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:
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user