diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 8387c53e5e3..ae0972a6828 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -121,6 +121,22 @@ + + {{ "accountDeprovisioningNotification" | i18n }} + + {{ "learnMore" | i18n }} + + ; enterpriseOrganization$: Observable; + showAccountDeprovisioningBanner$: Observable; + constructor( private route: ActivatedRoute, private organizationService: OrganizationService, @@ -68,19 +73,36 @@ export class OrganizationLayoutComponent implements OnInit { private configService: ConfigService, private policyService: PolicyService, private providerService: ProviderService, + protected bannerService: AccountDeprovisioningBannerService, private accountService: AccountService, ) {} async ngOnInit() { document.body.classList.remove("layout_frontend"); - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.organization$ = this.route.params.pipe( map((p) => p.organizationId), - switchMap((id) => this.organizationService.organizations$(userId).pipe(getById(id))), + withLatestFrom(this.accountService.activeAccount$.pipe(getUserId)), + switchMap(([orgId, userId]) => + this.organizationService.organizations$(userId).pipe(getById(orgId)), + ), filter((org) => org != null), ); + this.showAccountDeprovisioningBanner$ = combineLatest([ + this.bannerService.showBanner$, + this.configService.getFeatureFlag$(FeatureFlag.AccountDeprovisioningBanner), + this.organization$, + ]).pipe( + map( + ([dismissedOrgs, featureFlagEnabled, organization]) => + organization.productTierType === ProductTierType.Enterprise && + organization.isAdmin && + !dismissedOrgs?.includes(organization.id) && + featureFlagEnabled, + ), + ); + this.canAccessExport$ = this.organization$.pipe(map((org) => org.canAccessExport)); this.showPaymentAndHistory$ = this.organization$.pipe( diff --git a/apps/web/src/app/admin-console/organizations/layouts/services/account-deprovisioning-banner.service.spec.ts b/apps/web/src/app/admin-console/organizations/layouts/services/account-deprovisioning-banner.service.spec.ts new file mode 100644 index 00000000000..414828969df --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/layouts/services/account-deprovisioning-banner.service.spec.ts @@ -0,0 +1,75 @@ +import { firstValueFrom } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + FakeAccountService, + FakeStateProvider, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { AccountDeprovisioningBannerService } from "./account-deprovisioning-banner.service"; + +describe("Account Deprovisioning Banner Service", () => { + const userId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; + let stateProvider: FakeStateProvider; + let bannerService: AccountDeprovisioningBannerService; + + beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + stateProvider = new FakeStateProvider(accountService); + bannerService = new AccountDeprovisioningBannerService(stateProvider); + }); + + it("updates state with single org", async () => { + const fakeOrg = new Organization(); + fakeOrg.id = "123"; + + await bannerService.hideBanner(fakeOrg); + const state = await firstValueFrom(bannerService.showBanner$); + + expect(state).toEqual([fakeOrg.id]); + }); + + it("updates state with multiple orgs", async () => { + const fakeOrg1 = new Organization(); + fakeOrg1.id = "123"; + const fakeOrg2 = new Organization(); + fakeOrg2.id = "234"; + const fakeOrg3 = new Organization(); + fakeOrg3.id = "987"; + + await bannerService.hideBanner(fakeOrg1); + await bannerService.hideBanner(fakeOrg2); + await bannerService.hideBanner(fakeOrg3); + + const state = await firstValueFrom(bannerService.showBanner$); + + expect(state).toContain(fakeOrg1.id); + expect(state).toContain(fakeOrg2.id); + expect(state).toContain(fakeOrg3.id); + }); + + it("does not add the same org id multiple times", async () => { + const fakeOrg = new Organization(); + fakeOrg.id = "123"; + + await bannerService.hideBanner(fakeOrg); + await bannerService.hideBanner(fakeOrg); + + const state = await firstValueFrom(bannerService.showBanner$); + + expect(state).toEqual([fakeOrg.id]); + }); + + it("does not add null to the state", async () => { + await bannerService.hideBanner(null as unknown as Organization); + await bannerService.hideBanner(undefined as unknown as Organization); + + const state = await firstValueFrom(bannerService.showBanner$); + + expect(state).toBeNull(); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/layouts/services/account-deprovisioning-banner.service.ts b/apps/web/src/app/admin-console/organizations/layouts/services/account-deprovisioning-banner.service.ts new file mode 100644 index 00000000000..86a6b7df3e2 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/layouts/services/account-deprovisioning-banner.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { + ACCOUNT_DEPROVISIONING_BANNER_DISK, + StateProvider, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; + +export const SHOW_BANNER_KEY = new UserKeyDefinition( + ACCOUNT_DEPROVISIONING_BANNER_DISK, + "accountDeprovisioningBanner", + { + deserializer: (b) => b, + clearOn: [], + }, +); + +@Injectable({ providedIn: "root" }) +export class AccountDeprovisioningBannerService { + private _showBanner = this.stateProvider.getActive(SHOW_BANNER_KEY); + + showBanner$ = this._showBanner.state$; + + constructor(private stateProvider: StateProvider) {} + + async hideBanner(organization: Organization) { + await this._showBanner.update((state) => { + if (!organization) { + return state; + } + if (!state) { + return [organization.id]; + } else if (!state.includes(organization.id)) { + return [...state, organization.id]; + } + return state; + }); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9e5b25a4abf..04e814d554b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1792,7 +1792,7 @@ }, "requestPending": { "message": "Request pending" - }, + }, "logBackInOthersToo": { "message": "Please log back in. If you are using other Bitwarden applications log out and back in to those as well." }, @@ -10285,5 +10285,8 @@ "example": "Acme c" } } + }, + "accountDeprovisioningNotification" : { + "message": "Administrators now have the ability to delete member accounts that belong to a claimed domain." } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index a20dd1379e2..bf82fbd160b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -37,25 +37,5 @@ > - - {{ "providerClientVaultPrivacyNotification" | i18n }} - - {{ "contactBitwardenSupport" | i18n }} . - diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts index 3f1a7ff3989..7e47da95e2b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts @@ -10,27 +10,15 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderStatusType } from "@bitwarden/common/admin-console/enums"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { BannerModule, IconModule, LinkModule } from "@bitwarden/components"; +import { IconModule } from "@bitwarden/components"; import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo"; import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module"; -import { ProviderClientVaultPrivacyBannerService } from "./services/provider-client-vault-privacy-banner.service"; - @Component({ selector: "providers-layout", templateUrl: "providers-layout.component.html", standalone: true, - imports: [ - CommonModule, - RouterModule, - JslibModule, - WebLayoutModule, - IconModule, - LinkModule, - BannerModule, - ], + imports: [CommonModule, RouterModule, JslibModule, WebLayoutModule, IconModule], }) export class ProvidersLayoutComponent implements OnInit, OnDestroy { protected readonly logo = ProviderPortalLogo; @@ -41,15 +29,9 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { protected isBillable: Observable; protected canAccessBilling$: Observable; - protected showProviderClientVaultPrivacyWarningBanner$ = this.configService.getFeatureFlag$( - FeatureFlag.ProviderClientVaultPrivacyBanner, - ); - constructor( private route: ActivatedRoute, private providerService: ProviderService, - private configService: ConfigService, - protected providerClientVaultPrivacyBannerService: ProviderClientVaultPrivacyBannerService, ) {} ngOnInit() { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/services/provider-client-vault-privacy-banner.service.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/services/provider-client-vault-privacy-banner.service.ts deleted file mode 100644 index c347f5c2aae..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/services/provider-client-vault-privacy-banner.service.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Injectable } from "@angular/core"; - -import { - StateProvider, - AC_BANNERS_DISMISSED_DISK, - UserKeyDefinition, -} from "@bitwarden/common/platform/state"; - -export const SHOW_BANNER_KEY = new UserKeyDefinition( - AC_BANNERS_DISMISSED_DISK, - "showProviderClientVaultPrivacyBanner", - { - deserializer: (b) => b, - clearOn: [], - }, -); - -/** Displays a banner warning provider users that client organization vaults - * will soon become inaccessible directly. */ -@Injectable({ providedIn: "root" }) -export class ProviderClientVaultPrivacyBannerService { - private _showBanner = this.stateProvider.getActive(SHOW_BANNER_KEY); - - showBanner$ = this._showBanner.state$; - - constructor(private stateProvider: StateProvider) {} - - async hideBanner() { - await this._showBanner.update(() => false); - } -} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index a988bdbf6a7..51d52f8fb3e 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -46,6 +46,7 @@ export enum FeatureFlag { PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", PrivateKeyRegeneration = "pm-12241-private-key-regeneration", ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs", + AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner", NewDeviceVerification = "new-device-verification", } @@ -103,6 +104,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE, [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.ResellerManagedOrgAlert]: FALSE, + [FeatureFlag.AccountDeprovisioningBanner]: FALSE, [FeatureFlag.NewDeviceVerification]: FALSE, } satisfies Record; diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index c83119d9ad4..6a96b483c3d 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -29,9 +29,13 @@ export const ORGANIZATION_MANAGEMENT_PREFERENCES_DISK = new StateDefinition( web: "disk-local", }, ); -export const AC_BANNERS_DISMISSED_DISK = new StateDefinition("acBannersDismissed", "disk", { - web: "disk-local", -}); +export const ACCOUNT_DEPROVISIONING_BANNER_DISK = new StateDefinition( + "showAccountDeprovisioningBanner", + "disk", + { + web: "disk-local", + }, +); // Billing export const BILLING_DISK = new StateDefinition("billing", "disk");