1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-04-12 19:46:17 +02:00

[PM-17448] add 1 time dialog when deleting managed members for admins (#13139)

* add 1 time dialog when deleting managed members for admins

* fix story

* refactor to show warning for each org. Add test
This commit is contained in:
Brandon Treston 2025-02-05 15:26:25 -05:00 committed by GitHub
parent 605a5fd14b
commit aedb899401
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 214 additions and 0 deletions

View File

@ -2,12 +2,17 @@
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
import { DeleteManagedMemberWarningService } from "../../services/delete-managed-member/delete-managed-member-warning.service";
import { BulkUserDetails } from "./bulk-status.component";
type BulkDeleteDialogParams = {
@ -31,12 +36,20 @@ export class BulkDeleteDialogComponent {
@Inject(DIALOG_DATA) protected dialogParams: BulkDeleteDialogParams,
protected i18nService: I18nService,
private organizationUserApiService: OrganizationUserApiService,
private configService: ConfigService,
private deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
) {
this.organizationId = dialogParams.organizationId;
this.users = dialogParams.users;
}
async submit() {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.AccountDeprovisioning))
) {
await this.deleteManagedMemberWarningService.acknowledgeWarning(this.organizationId);
}
try {
this.loading = true;
this.error = null;

View File

@ -53,6 +53,7 @@ import {
convertToSelectionView,
PermissionMode,
} from "../../../shared/components/access-selector";
import { DeleteManagedMemberWarningService } from "../../services/delete-managed-member/delete-managed-member-warning.service";
import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator";
import { inputEmailLimitValidator } from "./validators/input-email-limit.validator";
@ -176,6 +177,7 @@ export class MemberDialogComponent implements OnDestroy {
organizationService: OrganizationService,
private toastService: ToastService,
private configService: ConfigService,
private deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
) {
this.organization$ = accountService.activeAccount$.pipe(
switchMap((account) =>
@ -639,6 +641,27 @@ export class MemberDialogComponent implements OnDestroy {
return;
}
const showWarningDialog = combineLatest([
this.organization$,
this.deleteManagedMemberWarningService.warningAcknowledged(this.params.organizationId),
this.accountDeprovisioningEnabled$,
]).pipe(
map(
([organization, acknowledged, featureFlagEnabled]) =>
featureFlagEnabled &&
organization.isOwner &&
organization.productTierType === ProductTierType.Enterprise &&
!acknowledged,
),
);
if (await firstValueFrom(showWarningDialog)) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return;
}
}
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "deleteOrganizationUser",
@ -667,6 +690,10 @@ export class MemberDialogComponent implements OnDestroy {
title: null,
message: this.i18nService.t("organizationUserDeleted", this.params.name),
});
if (await firstValueFrom(this.accountDeprovisioningEnabled$)) {
await this.deleteManagedMemberWarningService.acknowledgeWarning(this.params.organizationId);
}
this.close(MemberDialogResult.Deleted);
};

View File

@ -81,6 +81,7 @@ import {
ResetPasswordComponent,
ResetPasswordDialogResult,
} from "./components/reset-password.component";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
@ -138,6 +139,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private collectionService: CollectionService,
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
) {
super(
apiService,
@ -585,6 +587,23 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async bulkDelete() {
if (this.accountDeprovisioningEnabled) {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(this.organization.id),
);
if (
!warningAcknowledged &&
this.organization.isOwner &&
this.organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return;
}
}
}
if (this.actionPromise != null) {
return;
}
@ -774,6 +793,23 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async deleteUser(user: OrganizationUserView) {
if (this.accountDeprovisioningEnabled) {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(this.organization.id),
);
if (
!warningAcknowledged &&
this.organization.isOwner &&
this.organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return false;
}
}
}
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "deleteOrganizationUser",
@ -792,6 +828,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return false;
}
if (this.accountDeprovisioningEnabled) {
await this.deleteManagedMemberWarningService.acknowledgeWarning(this.organization.id);
}
this.actionPromise = this.organizationUserApiService.deleteOrganizationUser(
this.organization.id,
user.id,

View File

@ -0,0 +1,51 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { DeleteManagedMemberWarningService } from "./delete-managed-member-warning.service";
describe("Delete managed member warning service", () => {
const userId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let stateProvider: FakeStateProvider;
let dialogService: MockProxy<DialogService>;
let warningService: DeleteManagedMemberWarningService;
beforeEach(() => {
accountService = mockAccountServiceWith(userId);
stateProvider = new FakeStateProvider(accountService);
dialogService = mock();
warningService = new DeleteManagedMemberWarningService(stateProvider, dialogService);
});
it("warningAcknowledged returns false for ids that have not acknowledged the warning", async () => {
const id = Utils.newGuid();
const acknowledged = await firstValueFrom(warningService.warningAcknowledged(id));
expect(acknowledged).toEqual(false);
});
it("warningAcknowledged returns true for ids that have acknowledged the warning", async () => {
const id1 = Utils.newGuid();
const id2 = Utils.newGuid();
const id3 = Utils.newGuid();
await warningService.acknowledgeWarning(id1);
await warningService.acknowledgeWarning(id3);
const acknowledged1 = await firstValueFrom(warningService.warningAcknowledged(id1));
const acknowledged2 = await firstValueFrom(warningService.warningAcknowledged(id2));
const acknowledged3 = await firstValueFrom(warningService.warningAcknowledged(id3));
expect(acknowledged1).toEqual(true);
expect(acknowledged2).toEqual(false);
expect(acknowledged3).toEqual(true);
});
});

View File

@ -0,0 +1,70 @@
import { Injectable } from "@angular/core";
import { map } from "rxjs";
import {
DELETE_MANAGED_USER_WARNING,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { DialogService } from "@bitwarden/components";
export const SHOW_WARNING_KEY = new UserKeyDefinition<string[]>(
DELETE_MANAGED_USER_WARNING,
"showDeleteManagedUserWarning",
{
deserializer: (b) => b,
clearOn: [],
},
);
@Injectable({ providedIn: "root" })
export class DeleteManagedMemberWarningService {
private _acknowledged = this.stateProvider.getActive(SHOW_WARNING_KEY);
private acknowledgedState$ = this._acknowledged.state$;
constructor(
private stateProvider: StateProvider,
private dialogService: DialogService,
) {}
async acknowledgeWarning(organizationId: string) {
await this._acknowledged.update((state) => {
if (!organizationId) {
return state;
}
if (!state) {
return [organizationId];
} else if (!state.includes(organizationId)) {
return [...state, organizationId];
}
return state;
});
}
async showWarning() {
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "deleteManagedUserWarning",
},
content: {
key: "deleteManagedUserWarningDesc",
},
type: "danger",
icon: "bwi-exclamation-circle",
acceptButtonText: { key: "continue" },
cancelButtonText: { key: "cancel" },
});
if (!confirmed) {
return false;
}
return confirmed;
}
warningAcknowledged(organizationId: string) {
return this.acknowledgedState$.pipe(
map((acknowledgedIds) => acknowledgedIds?.includes(organizationId) ?? false),
);
}
}

View File

@ -10346,6 +10346,12 @@
}
}
},
"deleteManagedUserWarningDesc": {
"message": "This action will delete the member account including all items in their vault. This replaces the previous Remove action."
},
"deleteManagedUserWarning": {
"message": "Delete is a new action!"
},
"seatsRemaining": {
"message": "You have $REMAINING$ seats remaining out of $TOTAL$ seats assigned to this organization. Contact your provider to manage your subscription.",
"placeholders": {

View File

@ -32,6 +32,13 @@ export const ORGANIZATION_MANAGEMENT_PREFERENCES_DISK = new StateDefinition(
export const AC_BANNERS_DISMISSED_DISK = new StateDefinition("acBannersDismissed", "disk", {
web: "disk-local",
});
export const DELETE_MANAGED_USER_WARNING = new StateDefinition(
"showDeleteManagedUserWarning",
"disk",
{
web: "disk-local",
},
);
// Billing
export const BILLING_DISK = new StateDefinition("billing", "disk");