1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

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
This commit is contained in:
Nick Krantz
2025-12-03 14:19:26 -06:00
committed by GitHub
parent 04d7744747
commit dab1a37bfe
25 changed files with 265 additions and 59 deletions

View File

@@ -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) } },

View File

@@ -141,7 +141,7 @@ export class ItemMoreOptionsComponent {
}),
);
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$();
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$;
protected canArchive$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,

View File

@@ -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(

View File

@@ -225,7 +225,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
switchMap((id) =>
combineLatest([
this.cipherArchiveService.userCanArchive$(id),
this.cipherArchiveService.hasArchiveFlagEnabled$(),
this.cipherArchiveService.hasArchiveFlagEnabled$,
]),
),
),

View File

@@ -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,
);
}

View File

@@ -203,10 +203,22 @@
{{ "eventLogs" | i18n }}
</button>
@if (showArchiveButton) {
<button bitMenuItem (click)="archive()" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
@if (userCanArchive) {
<button bitMenuItem (click)="archive()" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
}
@if (!userCanArchive) {
<button bitMenuItem (click)="badge.promptForPremium($event)" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
<!-- Hide app-premium badge from accessibility tools as it results in a button within a button -->
<div slot="end" class="-tw-mt-0.5" aria-hidden>
<app-premium-badge #badge></app-premium-badge>
</div>
</button>
}
}
@if (showUnArchiveButton) {

View File

@@ -72,6 +72,7 @@ describe("VaultCipherRowComponent", () => {
fixture = TestBed.createComponent(VaultCipherRowComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("archiveEnabled", false);
overlayContainer = TestBed.inject(OverlayContainer);
});

View File

@@ -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<C extends CipherViewLike> 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<boolean>();
/**
* 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<C extends CipherViewLike> 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);
}

View File

@@ -179,6 +179,7 @@
(onEvent)="event($event)"
[userCanArchive]="userCanArchive"
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy"
[archiveEnabled]="archiveFeatureEnabled$ | async"
></tr>
</ng-container>
</ng-template>

View File

@@ -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),
},
},
],
});

View File

@@ -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<C extends CipherViewLike> {
protected disableMenu$: Observable<boolean>;
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),

View File

@@ -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],

View File

@@ -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({

View File

@@ -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<void> {
@@ -252,14 +255,20 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
};
async buildAllFilters(): Promise<VaultFilterList> {
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<VaultFilterSection> {
protected async addArchiveFilter(userId: UserId): Promise<VaultFilterSection> {
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<VaultFilterType>) => Promise<void>,
premiumOptions: {
showBadgeForNonPremium: true,
blockFilterAction: promptForPremiumOnFilter
? async () => await this.premiumUpgradePromptService.promptForPremium()
: undefined,
},
};
return archiveFilterSection;
}

View File

@@ -105,6 +105,9 @@
*ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)"
></ng-container>
</ng-container>
<ng-container *ngIf="premiumFeature">
<app-premium-badge></app-premium-badge>
</ng-container>
</span>
</span>
<ul

View File

@@ -96,6 +96,11 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
}
async onFilterSelect(filterNode: TreeNode<VaultFilterType>) {
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;
}

View File

@@ -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<void>;
};
};
export type VaultFilterList = {

View File

@@ -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],
})

View File

@@ -34,6 +34,16 @@
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<bit-callout
type="info"
[title]="'premiumSubscriptionEnded' | i18n"
*ngIf="showSubscriptionEndedMessaging$ | async"
>
<p>{{ "premiumSubscriptionEndedDesc" | i18n }}</p>
<a routerLink="/settings/subscription/premium" bitButton buttonType="primary">{{
"restartPremium" | i18n
}}</a>
</bit-callout>
<app-vault-items
#vaultItems
[ciphers]="ciphers"

View File

@@ -84,7 +84,7 @@ import {
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal";
import {
AddEditFolderDialogComponent,
@@ -177,6 +177,7 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
VaultItemsModule,
SharedModule,
OrganizationWarningsModule,
BannerComponent,
],
providers: [
RoutedVaultFilterService,
@@ -230,13 +231,6 @@ export class VaultComponent<C extends CipherViewLike> 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<C extends CipherViewLike> 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<C extends CipherViewLike> 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];

View File

@@ -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)"
},

View File

@@ -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;

View File

@@ -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<boolean>;
abstract hasArchiveFlagEnabled$: Observable<boolean>;
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract showArchiveVault$(userId: UserId): Observable<boolean>;
abstract userHasPremium$(userId: UserId): Observable<boolean>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
}

View File

@@ -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<boolean>(true);
beforeEach(() => {
mockCipherService = mock<CipherService>();
mockApiService = mock<ApiService>();
mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
mockConfigService = mock<ConfigService>();
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: [

View File

@@ -27,10 +27,6 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
private configService: ConfigService,
) {}
hasArchiveFlagEnabled$(): Observable<boolean> {
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<boolean> {
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<boolean> = 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<boolean> {
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<boolean> {
return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe(
map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}