From dab1a37bfe4b3a51aa7426533fa52795b2371474 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:19:26 -0600 Subject: [PATCH] PM-24535 Web premium upgrade path for archive (#16854) * add premium badge to web filter when the user does not have access to premium * remove feature flag pass through in favor of showing/hiding archive vault observable * refactor archive observable to be more generic * add archive premium badge for the web * show premium badge inline for archive filter * show premium subscription ended message when user has archived ciphers * fix missing refactor * remove unneeded can archive check * reference observable directly * reduce the number of firstValueFroms by combining observables into a single stream * fix failing tests * add import to storybook * update variable naming for premium filters * pass event to `promptForPremium` * remove check for organization * fix footer variable reference * refactor back to `hasArchiveFlagEnabled$` - more straight forward to the underlying logic * update archive service test with new feature flag format --- .../item-more-options.component.spec.ts | 2 +- .../item-more-options.component.ts | 2 +- .../settings/vault-settings-v2.component.ts | 2 +- .../vault/app/vault/item-footer.component.ts | 2 +- .../vault-filter/vault-filter.component.ts | 3 + .../vault-cipher-row.component.html | 20 +++- .../vault-cipher-row.component.spec.ts | 1 + .../vault-items/vault-cipher-row.component.ts | 18 +++- .../vault-items/vault-items.component.html | 1 + .../vault-items/vault-items.component.spec.ts | 7 ++ .../vault-items/vault-items.component.ts | 4 + .../vault-items/vault-items.module.ts | 2 + .../vault-items/vault-items.stories.ts | 7 ++ .../components/vault-filter.component.ts | 34 ++++++- .../vault-filter-section.component.html | 3 + .../vault-filter-section.component.ts | 9 ++ .../models/vault-filter-section.type.ts | 10 ++ .../shared/vault-filter-shared.module.ts | 3 +- .../individual-vault/vault.component.html | 10 ++ .../vault/individual-vault/vault.component.ts | 36 ++++--- apps/web/src/locales/en/messages.json | 9 ++ .../components/vault-filter.component.ts | 2 +- .../abstractions/cipher-archive.service.ts | 5 +- .../default-cipher-archive.service.spec.ts | 95 ++++++++++++++++++- .../default-cipher-archive.service.ts | 37 ++++---- 25 files changed, 265 insertions(+), 59 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index 577b7d9677..b9f48b7407 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -108,7 +108,7 @@ describe("ItemMoreOptionsComponent", () => { { provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } }, { provide: CipherArchiveService, - useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) }, + useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: of(true) }, }, { provide: ToastService, useValue: { showToast: () => {} } }, { provide: Router, useValue: { navigate: () => Promise.resolve(true) } }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 4dfaf7bc66..b65acc6ca8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -141,7 +141,7 @@ export class ItemMoreOptionsComponent { }), ); - protected showArchive$: Observable = this.cipherArchiveService.hasArchiveFlagEnabled$(); + protected showArchive$: Observable = this.cipherArchiveService.hasArchiveFlagEnabled$; protected canArchive$: Observable = this.accountService.activeAccount$.pipe( getUserId, diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index c6db820c23..e085cb21c2 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -49,7 +49,7 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy { this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))), ); - protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$()); + protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$); protected readonly userHasArchivedItems = toSignal( this.userId$.pipe( diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index 0034bd9a43..0ac12c928f 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -225,7 +225,7 @@ export class ItemFooterComponent implements OnInit, OnChanges { switchMap((id) => combineLatest([ this.cipherArchiveService.userCanArchive$(id), - this.cipherArchiveService.hasArchiveFlagEnabled$(), + this.cipherArchiveService.hasArchiveFlagEnabled$, ]), ), ), diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 01e61f0ab2..a253bb87c5 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -59,6 +60,7 @@ export class VaultFilterComponent protected restrictedItemTypesService: RestrictedItemTypesService, protected cipherService: CipherService, protected cipherArchiveService: CipherArchiveService, + premiumUpgradePromptService: PremiumUpgradePromptService, ) { super( vaultFilterService, @@ -72,6 +74,7 @@ export class VaultFilterComponent restrictedItemTypesService, cipherService, cipherArchiveService, + premiumUpgradePromptService, ); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index c8732154ef..d56c9d15cf 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -203,10 +203,22 @@ {{ "eventLogs" | i18n }} @if (showArchiveButton) { - + @if (userCanArchive) { + + } + @if (!userCanArchive) { + + } } @if (showUnArchiveButton) { diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts index d5f7b54f37..9378ee54e5 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts @@ -72,6 +72,7 @@ describe("VaultCipherRowComponent", () => { fixture = TestBed.createComponent(VaultCipherRowComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("archiveEnabled", false); overlayContainer = TestBed.inject(OverlayContainer); }); diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 4ea062db8d..92c49ac218 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -8,6 +8,7 @@ import { OnInit, Output, ViewChild, + input, } from "@angular/core"; import { CollectionView } from "@bitwarden/admin-console/common"; @@ -101,8 +102,10 @@ export class VaultCipherRowComponent implements OnInit // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() userCanArchive: boolean; + /** Archive feature is enabled */ + readonly archiveEnabled = input.required(); /** - * Enforge Org Data Ownership Policy Status + * Enforce Org Data Ownership Policy Status */ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -142,16 +145,21 @@ export class VaultCipherRowComponent implements OnInit } protected get showArchiveButton() { + if (!this.archiveEnabled()) { + return false; + } + return ( - this.userCanArchive && - !CipherViewLikeUtils.isArchived(this.cipher) && - !CipherViewLikeUtils.isDeleted(this.cipher) && - !this.cipher.organizationId + !CipherViewLikeUtils.isArchived(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher) ); } // If item is archived always show unarchive button, even if user is not premium protected get showUnArchiveButton() { + if (!this.archiveEnabled()) { + return false; + } + return CipherViewLikeUtils.isArchived(this.cipher); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index cb2af9a64e..70c44e80a3 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -179,6 +179,7 @@ (onEvent)="event($event)" [userCanArchive]="userCanArchive" [enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy" + [archiveEnabled]="archiveFeatureEnabled$ | async" > diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts index 902fc2eb5a..1eccb4c49c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts @@ -4,6 +4,7 @@ import { of } from "rxjs"; import { CollectionView } from "@bitwarden/admin-console/common"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -54,6 +55,12 @@ describe("VaultItemsComponent", () => { t: (key: string) => key, }, }, + { + provide: CipherArchiveService, + useValue: { + hasArchiveFlagEnabled$: of(true), + }, + }, ], }); diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 3ab643927f..a935314eb3 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -7,6 +7,7 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedCipherType, @@ -145,9 +146,12 @@ export class VaultItemsComponent { protected disableMenu$: Observable; private restrictedTypes: RestrictedCipherType[] = []; + protected archiveFeatureEnabled$ = this.cipherArchiveService.hasArchiveFlagEnabled$; + constructor( protected cipherAuthorizationService: CipherAuthorizationService, protected restrictedItemTypesService: RestrictedItemTypesService, + protected cipherArchiveService: CipherArchiveService, ) { this.canDeleteSelected$ = this.selection.changed.pipe( startWith(null), diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts index a3a9255987..a7c264114b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { ScrollLayoutDirective, TableModule } from "@bitwarden/components"; import { CopyCipherFieldDirective } from "@bitwarden/vault"; @@ -29,6 +30,7 @@ import { VaultItemsComponent } from "./vault-items.component"; PipesModule, CopyCipherFieldDirective, ScrollLayoutDirective, + PremiumBadgeComponent, ], declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent], exports: [VaultItemsComponent], diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 043ae900b4..d973fbcbbc 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -30,6 +30,7 @@ import { import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -143,6 +144,12 @@ export default { isCipherRestricted: () => false, // No restrictions for this story }, }, + { + provide: CipherArchiveService, + useValue: { + hasArchiveFlagEnabled$: of(true), + }, + }, ], }), applicationConfig({ diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index e40a32dc8b..8839fa5039 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -19,8 +19,10 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -170,6 +172,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected restrictedItemTypesService: RestrictedItemTypesService, protected cipherService: CipherService, protected cipherArchiveService: CipherArchiveService, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) {} async ngOnInit(): Promise { @@ -252,14 +255,20 @@ export class VaultFilterComponent implements OnInit, OnDestroy { }; async buildAllFilters(): Promise { - const hasArchiveFlag = await firstValueFrom(this.cipherArchiveService.hasArchiveFlagEnabled$()); + const [userId, showArchive] = await firstValueFrom( + combineLatest([ + this.accountService.activeAccount$.pipe(getUserId), + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]), + ); + const builderFilter = {} as VaultFilterList; builderFilter.organizationFilter = await this.addOrganizationFilter(); builderFilter.typeFilter = await this.addTypeFilter(); builderFilter.folderFilter = await this.addFolderFilter(); builderFilter.collectionFilter = await this.addCollectionFilter(); - if (hasArchiveFlag) { - builderFilter.archiveFilter = await this.addArchiveFilter(); + if (showArchive) { + builderFilter.archiveFilter = await this.addArchiveFilter(userId); } builderFilter.trashFilter = await this.addTrashFilter(); return builderFilter; @@ -419,7 +428,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy { return trashFilterSection; } - protected async addArchiveFilter(): Promise { + protected async addArchiveFilter(userId: UserId): Promise { + const [hasArchivedCiphers, userHasPremium] = await firstValueFrom( + combineLatest([ + this.cipherArchiveService + .archivedCiphers$(userId) + .pipe(map((archivedCiphers) => archivedCiphers.length > 0)), + this.cipherArchiveService.userHasPremium$(userId), + ]), + ); + + const promptForPremiumOnFilter = !userHasPremium && !hasArchivedCiphers; + const archiveFilterSection: VaultFilterSection = { data$: this.vaultFilterService.buildTypeTree( { @@ -442,6 +462,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy { isSelectable: true, }, action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, + premiumOptions: { + showBadgeForNonPremium: true, + blockFilterAction: promptForPremiumOnFilter + ? async () => await this.premiumUpgradePromptService.promptForPremium() + : undefined, + }, }; return archiveFilterSection; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html index f7078d2a67..66f14dcf2f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html @@ -105,6 +105,9 @@ *ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)" > + + +
    ) { + if (this.section?.premiumOptions?.blockFilterAction) { + await this.section.premiumOptions.blockFilterAction(); + return; + } + await this.section?.action(filterNode); } @@ -123,6 +128,10 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy { return this.section?.options; } + get premiumFeature() { + return this.section?.premiumOptions?.showBadgeForNonPremium; + } + get divider() { return this.section?.divider; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts index f1e6222b57..d275b1251e 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts @@ -47,6 +47,16 @@ export type VaultFilterSection = { component: any; }; divider?: boolean; + premiumOptions?: { + /** When true, the premium badge will show on the filter for non-premium users. */ + showBadgeForNonPremium?: true; + /** + * Action to be called instead of applying the filter. + * Useful when the user does not have access to a filter (e.g., premium feature) + * and custom behavior is needed when invoking the filter. + */ + blockFilterAction?: () => Promise; + }; }; export type VaultFilterList = { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/vault-filter-shared.module.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/vault-filter-shared.module.ts index c8becac8ef..190ace6db6 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/vault-filter-shared.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/vault-filter-shared.module.ts @@ -1,5 +1,6 @@ import { NgModule } from "@angular/core"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { SearchModule } from "@bitwarden/components"; import { SharedModule } from "../../../../shared"; @@ -7,7 +8,7 @@ import { SharedModule } from "../../../../shared"; import { VaultFilterSectionComponent } from "./components/vault-filter-section.component"; @NgModule({ - imports: [SharedModule, SearchModule], + imports: [SharedModule, SearchModule, PremiumBadgeComponent], declarations: [VaultFilterSectionComponent], exports: [SharedModule, VaultFilterSectionComponent, SearchModule], }) diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 711a34413b..522b63c21f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -34,6 +34,16 @@ {{ trashCleanupWarning }} + +

    {{ "premiumSubscriptionEndedDesc" | i18n }}

    + {{ + "restartPremium" | i18n + }} +
    ; VaultItemsModule, SharedModule, OrganizationWarningsModule, + BannerComponent, ], providers: [ RoutedVaultFilterService, @@ -230,13 +231,6 @@ export class VaultComponent implements OnInit, OnDestr .pipe(map((a) => a?.id)) .pipe(switchMap((id) => this.organizationService.organizations$(id))); - protected userCanArchive$ = this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => { - return this.cipherArchiveService.userCanArchive$(userId); - }), - ); - emptyState$ = combineLatest([ this.currentSearchText$, this.routedVaultFilterService.filter$, @@ -295,14 +289,28 @@ export class VaultComponent implements OnInit, OnDestr }), ); - protected enforceOrgDataOwnershipPolicy$ = this.accountService.activeAccount$.pipe( - getUserId, + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + + protected enforceOrgDataOwnershipPolicy$ = this.userId$.pipe( switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId), ), ); - private userId$ = this.accountService.activeAccount$.pipe(getUserId); + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => { + return this.cipherArchiveService.userCanArchive$(userId); + }), + ); + + protected showSubscriptionEndedMessaging$ = this.userId$.pipe( + switchMap((userId) => + combineLatest([ + this.routedVaultFilterBridgeService.activeFilter$, + this.cipherArchiveService.showSubscriptionEndedMessaging$(userId), + ]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)), + ), + ); constructor( private syncService: SyncService, @@ -438,13 +446,13 @@ export class VaultComponent implements OnInit, OnDestr allowedCiphers$, filter$, this.currentSearchText$, - this.cipherArchiveService.hasArchiveFlagEnabled$(), + this.cipherArchiveService.hasArchiveFlagEnabled$, ]).pipe( filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), - concatMap(async ([ciphers, filter, searchText, archiveEnabled]) => { + concatMap(async ([ciphers, filter, searchText, showArchiveVault]) => { const failedCiphers = (await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? []; - const filterFunction = createFilterFunction(filter, archiveEnabled); + const filterFunction = createFilterFunction(filter, showArchiveVault); // Append any failed to decrypt ciphers to the top of the cipher list const allCiphers = [...failedCiphers, ...ciphers]; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 19eec24588..4ed0ac639b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3133,6 +3133,15 @@ } } }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "additionalStorageGb": { "message": "Additional storage (GB)" }, diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts index 659db1bb92..f664cff2e8 100644 --- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts @@ -89,7 +89,7 @@ export class VaultFilterComponent implements OnInit { this.collections = await this.initCollections(); this.showArchiveVaultFilter = await firstValueFrom( - this.cipherArchiveService.hasArchiveFlagEnabled$(), + this.cipherArchiveService.hasArchiveFlagEnabled$, ); this.isLoaded = true; diff --git a/libs/common/src/vault/abstractions/cipher-archive.service.ts b/libs/common/src/vault/abstractions/cipher-archive.service.ts index d33fc5e7cc..0969b7de1a 100644 --- a/libs/common/src/vault/abstractions/cipher-archive.service.ts +++ b/libs/common/src/vault/abstractions/cipher-archive.service.ts @@ -4,10 +4,11 @@ import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; export abstract class CipherArchiveService { - abstract hasArchiveFlagEnabled$(): Observable; + abstract hasArchiveFlagEnabled$: Observable; abstract archivedCiphers$(userId: UserId): Observable; abstract userCanArchive$(userId: UserId): Observable; - abstract showArchiveVault$(userId: UserId): Observable; + abstract userHasPremium$(userId: UserId): Observable; abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract showSubscriptionEndedMessaging$(userId: UserId): Observable; } diff --git a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts index 972b04d2c4..807311ca85 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts @@ -1,5 +1,5 @@ import { mock } from "jest-mock-extended"; -import { of, firstValueFrom } from "rxjs"; +import { of, firstValueFrom, BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -24,12 +24,14 @@ describe("DefaultCipherArchiveService", () => { const userId = "user-id" as UserId; const cipherId = "123" as CipherId; + const featureFlag = new BehaviorSubject(true); beforeEach(() => { mockCipherService = mock(); mockApiService = mock(); mockBillingAccountProfileStateService = mock(); mockConfigService = mock(); + mockConfigService.getFeatureFlag$.mockReturnValue(featureFlag.asObservable()); service = new DefaultCipherArchiveService( mockCipherService, @@ -86,7 +88,7 @@ describe("DefaultCipherArchiveService", () => { describe("userCanArchive$", () => { it("should return true when user has premium and feature flag is enabled", async () => { mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + featureFlag.next(true); const result = await firstValueFrom(service.userCanArchive$(userId)); @@ -101,7 +103,7 @@ describe("DefaultCipherArchiveService", () => { it("should return false when feature flag is disabled", async () => { mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); - mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + featureFlag.next(false); const result = await firstValueFrom(service.userCanArchive$(userId)); @@ -109,6 +111,93 @@ describe("DefaultCipherArchiveService", () => { }); }); + describe("hasArchiveFlagEnabled$", () => { + it("returns true when feature flag is enabled", async () => { + featureFlag.next(true); + + const result = await firstValueFrom(service.hasArchiveFlagEnabled$); + + expect(result).toBe(true); + expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM19148_InnovationArchive, + ); + }); + + it("returns false when feature flag is disabled", async () => { + featureFlag.next(false); + + const result = await firstValueFrom(service.hasArchiveFlagEnabled$); + + expect(result).toBe(false); + }); + }); + + describe("userHasPremium$", () => { + it("returns true when user has premium", async () => { + mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + + const result = await firstValueFrom(service.userHasPremium$(userId)); + + expect(result).toBe(true); + expect(mockBillingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( + userId, + ); + }); + + it("returns false when user does not have premium", async () => { + mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + const result = await firstValueFrom(service.userHasPremium$(userId)); + + expect(result).toBe(false); + }); + }); + + describe("showSubscriptionEndedMessaging$", () => { + it("returns true when user has archived ciphers but no premium", async () => { + const mockCiphers: CipherListView[] = [ + { + id: "1", + archivedDate: "2024-01-15T10:30:00.000Z", + type: "identity", + } as unknown as CipherListView, + ]; + + mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers)); + mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId)); + + expect(result).toBe(true); + }); + + it("returns false when user has archived ciphers and has premium", async () => { + const mockCiphers: CipherListView[] = [ + { + id: "1", + archivedDate: "2024-01-15T10:30:00.000Z", + type: "identity", + } as unknown as CipherListView, + ]; + + mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers)); + mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + + const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId)); + + expect(result).toBe(false); + }); + + it("returns false when user has no archived ciphers and no premium", async () => { + mockCipherService.cipherListViews$.mockReturnValue(of([])); + mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId)); + + expect(result).toBe(false); + }); + }); + describe("archiveWithServer", () => { const mockResponse = { data: [ diff --git a/libs/common/src/vault/services/default-cipher-archive.service.ts b/libs/common/src/vault/services/default-cipher-archive.service.ts index a56a22474a..8076735c9e 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -27,10 +27,6 @@ export class DefaultCipherArchiveService implements CipherArchiveService { private configService: ConfigService, ) {} - hasArchiveFlagEnabled$(): Observable { - return this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive); - } - /** * Observable that contains the list of ciphers that have been archived. */ @@ -61,23 +57,22 @@ export class DefaultCipherArchiveService implements CipherArchiveService { ); } - /** - * User can access the archive vault if: - * Feature Flag is enabled - * There is at least one archived item - * ///////////// NOTE ///////////// - * This is separated from userCanArchive because a user that loses premium status, but has archived items, - * should still be able to access their archive vault. The items will be read-only, and can be restored. - */ - showArchiveVault$(userId: UserId): Observable { - return combineLatest([ - this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive), - this.archivedCiphers$(userId), - ]).pipe( - map( - ([archiveFlagEnabled, hasArchivedItems]) => - archiveFlagEnabled && hasArchivedItems.length > 0, - ), + /** Returns true when the archive features should be shown. */ + hasArchiveFlagEnabled$: Observable = this.configService + .getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive) + .pipe(shareReplay({ refCount: true, bufferSize: 1 })); + + /** Returns true when the user has premium from any means. */ + userHasPremium$(userId: UserId): Observable { + return this.billingAccountProfileStateService + .hasPremiumFromAnySource$(userId) + .pipe(shareReplay({ refCount: true, bufferSize: 1 })); + } + + /** Returns true when the user has previously archived ciphers but lost their premium membership. */ + showSubscriptionEndedMessaging$(userId: UserId): Observable { + return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe( + map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium), shareReplay({ refCount: true, bufferSize: 1 }), ); }