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 ff6e9b4065c3..38e6ffe88bf4 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 @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; -import { firstValueFrom, switchMap } from "rxjs"; +import { firstValueFrom, map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; @@ -45,7 +45,13 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy { // Check if user has archived items (does not check if user is premium) protected readonly showArchiveFilter = toSignal( - this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.showArchiveVault$(userId))), + this.userId$.pipe( + switchMap((userId) => + this.cipherArchiveService + .archivedCiphers$(userId) + .pipe(map((ciphers) => ciphers.length > 0)), + ), + ), ); protected emptyVaultImportBadge$ = this.accountService.activeAccount$.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 0034bd9a43cf..0ac12c928f21 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 01e61f0ab287..a253bb87c50c 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 c09553dab9c0..fcb5f5c35c70 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 @@ -201,10 +201,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.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 4ea062db8d1f..92c49ac218a5 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 cb2af9a64e59..70c44e80a394 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 902fc2eb5a23..1eccb4c49ced 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 3ab643927f1b..a935314eb3ae 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 a3a925598784..a7c264114b93 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 043ae900b401..d973fbcbbc7d 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 0326f8455a66..74bc61319c65 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 f7078d2a67a7..66f14dcf2f6d 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 f1e6222b57ae..d275b1251e9e 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 c8becac8ef5f..190ace6db638 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 711a34413b5b..522b63c21fd4 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 1b0460e2aa68..ee2eddbcb8b7 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3124,6 +3124,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 9b1d6286a9a9..591e37f80716 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 @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { firstValueFrom, Observable } from "rxjs"; +import { firstValueFrom, map, Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -91,11 +91,13 @@ export class VaultFilterComponent implements OnInit { const userCanArchive = await firstValueFrom( this.cipherArchiveService.userCanArchive$(this.activeUserId), ); - const showArchiveVault = await firstValueFrom( - this.cipherArchiveService.showArchiveVault$(this.activeUserId), + const hasArchiveItems = await firstValueFrom( + this.cipherArchiveService + .archivedCiphers$(this.activeUserId) + .pipe(map((ciphers) => ciphers.length > 0)), ); - this.showArchiveVaultFilter = userCanArchive || showArchiveVault; + this.showArchiveVaultFilter = userCanArchive || hasArchiveItems; 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 d33fc5e7cc7e..0969b7de1ac6 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 972b04d2c4ec..807311ca851d 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 a56a22474a34..8076735c9e2a 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 }), ); }