From aedb89940158e92144ba5d9c712378ad5c049b93 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 5 Feb 2025 15:26:25 -0500 Subject: [PATCH] [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 --- .../bulk/bulk-delete-dialog.component.ts | 13 ++++ .../member-dialog/member-dialog.component.ts | 27 +++++++ .../members/members.component.ts | 40 +++++++++++ ...ete-managed-member-warning.service.spec.ts | 51 ++++++++++++++ .../delete-managed-member-warning.service.ts | 70 +++++++++++++++++++ apps/web/src/locales/en/messages.json | 6 ++ .../src/platform/state/state-definitions.ts | 7 ++ 7 files changed, 214 insertions(+) create mode 100644 apps/web/src/app/admin-console/organizations/members/services/delete-managed-member/delete-managed-member-warning.service.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/delete-managed-member/delete-managed-member-warning.service.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts index 704a94b0dd3..9d7752cde84 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts @@ -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; diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index 7a30eba9e12..34926e1d379 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -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); }; diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 1c83b66d20d..9fc27f2f199 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -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 { protected statusType = OrganizationUserStatusType; @@ -138,6 +139,7 @@ export class MembersComponent extends BaseMembersComponent private collectionService: CollectionService, private billingApiService: BillingApiServiceAbstraction, private configService: ConfigService, + protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, ) { super( apiService, @@ -585,6 +587,23 @@ export class MembersComponent extends BaseMembersComponent } 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 } 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 return false; } + if (this.accountDeprovisioningEnabled) { + await this.deleteManagedMemberWarningService.acknowledgeWarning(this.organization.id); + } + this.actionPromise = this.organizationUserApiService.deleteOrganizationUser( this.organization.id, user.id, diff --git a/apps/web/src/app/admin-console/organizations/members/services/delete-managed-member/delete-managed-member-warning.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/delete-managed-member/delete-managed-member-warning.service.spec.ts new file mode 100644 index 00000000000..45271f6f78b --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/delete-managed-member/delete-managed-member-warning.service.spec.ts @@ -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; + 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); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/delete-managed-member/delete-managed-member-warning.service.ts b/apps/web/src/app/admin-console/organizations/members/services/delete-managed-member/delete-managed-member-warning.service.ts new file mode 100644 index 00000000000..53ca0bbcaf8 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/delete-managed-member/delete-managed-member-warning.service.ts @@ -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( + 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), + ); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index bb6fd62755a..32f7dd1e978 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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": { diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index c83119d9ad4..f98fad72e08 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -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");