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

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