mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +00:00
[PM-12034] Remove usage of ActiveUserState from vault-banners.service (#11543)
* Migrated banner service from using active user state * Fixed unit tests for the vault banner service * Updated component to pass user id required by the banner service * Updated component tests * Added comments * Fixed unit tests * Updated vault banner service to use lastSync$ version and removed polling * Updated to use UserDecryptionOptions * Updated to use getKdfConfig$ * Updated shouldShowVerifyEmailBanner to use account observable * Added takewhile operator to only make calls when userId is present * Simplified to use sing userId * Simplified to use sing userId
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
import { TestBed } from "@angular/core/testing";
|
||||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import {
|
||||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
UserDecryptionOptions,
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
UserDecryptionOptionsServiceAbstraction,
|
||||||
|
} from "@bitwarden/auth/common";
|
||||||
|
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
|
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
@@ -22,18 +25,20 @@ describe("VaultBannersService", () => {
|
|||||||
let service: VaultBannersService;
|
let service: VaultBannersService;
|
||||||
const isSelfHost = jest.fn().mockReturnValue(false);
|
const isSelfHost = jest.fn().mockReturnValue(false);
|
||||||
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(false);
|
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(false);
|
||||||
const userId = "user-id" as UserId;
|
const userId = Utils.newGuid() as UserId;
|
||||||
const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
||||||
const getEmailVerified = jest.fn().mockResolvedValue(true);
|
const getEmailVerified = jest.fn().mockResolvedValue(true);
|
||||||
const hasMasterPassword = jest.fn().mockResolvedValue(true);
|
const lastSync$ = new BehaviorSubject<Date | null>(null);
|
||||||
const getKdfConfig = jest
|
const userDecryptionOptions$ = new BehaviorSubject<UserDecryptionOptions>({
|
||||||
.fn()
|
hasMasterPassword: true,
|
||||||
.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600000 });
|
});
|
||||||
const getLastSync = jest.fn().mockResolvedValue(null);
|
const kdfConfig$ = new BehaviorSubject({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600000 });
|
||||||
|
const accounts$ = new BehaviorSubject<Record<UserId, AccountInfo>>({
|
||||||
|
[userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
lastSync$.next(new Date("2024-05-14"));
|
||||||
getLastSync.mockClear().mockResolvedValue(new Date("2024-05-14"));
|
|
||||||
isSelfHost.mockClear();
|
isSelfHost.mockClear();
|
||||||
getEmailVerified.mockClear().mockResolvedValue(true);
|
getEmailVerified.mockClear().mockResolvedValue(true);
|
||||||
|
|
||||||
@@ -52,25 +57,27 @@ describe("VaultBannersService", () => {
|
|||||||
provide: StateProvider,
|
provide: StateProvider,
|
||||||
useValue: fakeStateProvider,
|
useValue: fakeStateProvider,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: PlatformUtilsService,
|
||||||
|
useValue: { isSelfHost },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: AccountService,
|
provide: AccountService,
|
||||||
useValue: mockAccountServiceWith(userId),
|
useValue: { accounts$ },
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: TokenService,
|
|
||||||
useValue: { getEmailVerified },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: UserVerificationService,
|
|
||||||
useValue: { hasMasterPassword },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: KdfConfigService,
|
provide: KdfConfigService,
|
||||||
useValue: { getKdfConfig },
|
useValue: { getKdfConfig$: () => kdfConfig$ },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: SyncService,
|
provide: SyncService,
|
||||||
useValue: { getLastSync },
|
useValue: { lastSync$: () => lastSync$ },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: UserDecryptionOptionsServiceAbstraction,
|
||||||
|
useValue: {
|
||||||
|
userDecryptionOptionsById$: () => userDecryptionOptions$,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -82,39 +89,38 @@ describe("VaultBannersService", () => {
|
|||||||
|
|
||||||
describe("Premium", () => {
|
describe("Premium", () => {
|
||||||
it("waits until sync is completed before showing premium banner", async () => {
|
it("waits until sync is completed before showing premium banner", async () => {
|
||||||
getLastSync.mockResolvedValue(new Date("2024-05-14"));
|
|
||||||
hasPremiumFromAnySource$.next(false);
|
hasPremiumFromAnySource$.next(false);
|
||||||
isSelfHost.mockReturnValue(false);
|
isSelfHost.mockReturnValue(false);
|
||||||
|
lastSync$.next(null);
|
||||||
|
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
jest.advanceTimersByTime(201);
|
const premiumBanner$ = service.shouldShowPremiumBanner$(userId);
|
||||||
|
|
||||||
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(true);
|
// Should not emit when sync is null
|
||||||
|
await expect(firstValueFrom(premiumBanner$.pipe(take(1), timeout(100)))).rejects.toThrow();
|
||||||
|
|
||||||
|
// Should emit when sync is completed
|
||||||
|
lastSync$.next(new Date("2024-05-14"));
|
||||||
|
expect(await firstValueFrom(premiumBanner$)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show a premium banner for self-hosted users", async () => {
|
it("does not show a premium banner for self-hosted users", async () => {
|
||||||
getLastSync.mockResolvedValue(new Date("2024-05-14"));
|
|
||||||
hasPremiumFromAnySource$.next(false);
|
hasPremiumFromAnySource$.next(false);
|
||||||
isSelfHost.mockReturnValue(true);
|
isSelfHost.mockReturnValue(true);
|
||||||
|
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
jest.advanceTimersByTime(201);
|
expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false);
|
||||||
|
|
||||||
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show a premium banner when they have access to premium", async () => {
|
it("does not show a premium banner when they have access to premium", async () => {
|
||||||
getLastSync.mockResolvedValue(new Date("2024-05-14"));
|
|
||||||
hasPremiumFromAnySource$.next(true);
|
hasPremiumFromAnySource$.next(true);
|
||||||
isSelfHost.mockReturnValue(false);
|
isSelfHost.mockReturnValue(false);
|
||||||
|
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
jest.advanceTimersByTime(201);
|
expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false);
|
||||||
|
|
||||||
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dismissing", () => {
|
describe("dismissing", () => {
|
||||||
@@ -125,7 +131,7 @@ describe("VaultBannersService", () => {
|
|||||||
jest.setSystemTime(date.getTime());
|
jest.setSystemTime(date.getTime());
|
||||||
|
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
await service.dismissBanner(VisibleVaultBanner.Premium);
|
await service.dismissBanner(userId, VisibleVaultBanner.Premium);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -134,7 +140,7 @@ describe("VaultBannersService", () => {
|
|||||||
|
|
||||||
it("updates state on first dismiss", async () => {
|
it("updates state on first dismiss", async () => {
|
||||||
const state = await firstValueFrom(
|
const state = await firstValueFrom(
|
||||||
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
|
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
|
||||||
);
|
);
|
||||||
|
|
||||||
const oneWeekLater = new Date("2023-06-15");
|
const oneWeekLater = new Date("2023-06-15");
|
||||||
@@ -148,7 +154,7 @@ describe("VaultBannersService", () => {
|
|||||||
|
|
||||||
it("updates state on second dismiss", async () => {
|
it("updates state on second dismiss", async () => {
|
||||||
const state = await firstValueFrom(
|
const state = await firstValueFrom(
|
||||||
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
|
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
|
||||||
);
|
);
|
||||||
|
|
||||||
const oneMonthLater = new Date("2023-07-08");
|
const oneMonthLater = new Date("2023-07-08");
|
||||||
@@ -162,7 +168,7 @@ describe("VaultBannersService", () => {
|
|||||||
|
|
||||||
it("updates state on third dismiss", async () => {
|
it("updates state on third dismiss", async () => {
|
||||||
const state = await firstValueFrom(
|
const state = await firstValueFrom(
|
||||||
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
|
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
|
||||||
);
|
);
|
||||||
|
|
||||||
const oneYearLater = new Date("2024-06-08");
|
const oneYearLater = new Date("2024-06-08");
|
||||||
@@ -178,40 +184,40 @@ describe("VaultBannersService", () => {
|
|||||||
|
|
||||||
describe("KDFSettings", () => {
|
describe("KDFSettings", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
hasMasterPassword.mockResolvedValue(true);
|
userDecryptionOptions$.next({ hasMasterPassword: true });
|
||||||
getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 599999 });
|
kdfConfig$.next({ kdfType: KdfType.PBKDF2_SHA256, iterations: 599999 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows low KDF iteration banner", async () => {
|
it("shows low KDF iteration banner", async () => {
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
expect(await service.shouldShowLowKDFBanner()).toBe(true);
|
expect(await service.shouldShowLowKDFBanner(userId)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show low KDF iteration banner if KDF type is not PBKDF2_SHA256", async () => {
|
it("does not show low KDF iteration banner if KDF type is not PBKDF2_SHA256", async () => {
|
||||||
getKdfConfig.mockResolvedValue({ kdfType: KdfType.Argon2id, iterations: 600001 });
|
kdfConfig$.next({ kdfType: KdfType.Argon2id, iterations: 600001 });
|
||||||
|
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
expect(await service.shouldShowLowKDFBanner()).toBe(false);
|
expect(await service.shouldShowLowKDFBanner(userId)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show low KDF for iterations about 600,000", async () => {
|
it("does not show low KDF for iterations about 600,000", async () => {
|
||||||
getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600001 });
|
kdfConfig$.next({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600001 });
|
||||||
|
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
expect(await service.shouldShowLowKDFBanner()).toBe(false);
|
expect(await service.shouldShowLowKDFBanner(userId)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dismisses low KDF iteration banner", async () => {
|
it("dismisses low KDF iteration banner", async () => {
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
expect(await service.shouldShowLowKDFBanner()).toBe(true);
|
expect(await service.shouldShowLowKDFBanner(userId)).toBe(true);
|
||||||
|
|
||||||
await service.dismissBanner(VisibleVaultBanner.KDFSettings);
|
await service.dismissBanner(userId, VisibleVaultBanner.KDFSettings);
|
||||||
|
|
||||||
expect(await service.shouldShowLowKDFBanner()).toBe(false);
|
expect(await service.shouldShowLowKDFBanner(userId)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,39 +234,44 @@ describe("VaultBannersService", () => {
|
|||||||
it("shows outdated browser banner", async () => {
|
it("shows outdated browser banner", async () => {
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
expect(await service.shouldShowUpdateBrowserBanner()).toBe(true);
|
expect(await service.shouldShowUpdateBrowserBanner(userId)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dismisses outdated browser banner", async () => {
|
it("dismisses outdated browser banner", async () => {
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
expect(await service.shouldShowUpdateBrowserBanner()).toBe(true);
|
expect(await service.shouldShowUpdateBrowserBanner(userId)).toBe(true);
|
||||||
|
|
||||||
await service.dismissBanner(VisibleVaultBanner.OutdatedBrowser);
|
await service.dismissBanner(userId, VisibleVaultBanner.OutdatedBrowser);
|
||||||
|
|
||||||
expect(await service.shouldShowUpdateBrowserBanner()).toBe(false);
|
expect(await service.shouldShowUpdateBrowserBanner(userId)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("VerifyEmail", () => {
|
describe("VerifyEmail", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
getEmailVerified.mockResolvedValue(false);
|
accounts$.next({
|
||||||
|
[userId]: {
|
||||||
|
...accounts$.value[userId],
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows verify email banner", async () => {
|
it("shows verify email banner", async () => {
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
expect(await service.shouldShowVerifyEmailBanner()).toBe(true);
|
expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dismisses verify email banner", async () => {
|
it("dismisses verify email banner", async () => {
|
||||||
service = TestBed.inject(VaultBannersService);
|
service = TestBed.inject(VaultBannersService);
|
||||||
|
|
||||||
expect(await service.shouldShowVerifyEmailBanner()).toBe(true);
|
expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(true);
|
||||||
|
|
||||||
await service.dismissBanner(VisibleVaultBanner.VerifyEmail);
|
await service.dismissBanner(userId, VisibleVaultBanner.VerifyEmail);
|
||||||
|
|
||||||
expect(await service.shouldShowVerifyEmailBanner()).toBe(false);
|
expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import {
|
import { Observable, combineLatest, firstValueFrom, map, filter, mergeMap, take } from "rxjs";
|
||||||
Subject,
|
|
||||||
Observable,
|
|
||||||
combineLatest,
|
|
||||||
firstValueFrom,
|
|
||||||
map,
|
|
||||||
mergeMap,
|
|
||||||
take,
|
|
||||||
switchMap,
|
|
||||||
of,
|
|
||||||
} from "rxjs";
|
|
||||||
|
|
||||||
|
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import {
|
import {
|
||||||
StateProvider,
|
StateProvider,
|
||||||
ActiveUserState,
|
|
||||||
PREMIUM_BANNER_DISK_LOCAL,
|
PREMIUM_BANNER_DISK_LOCAL,
|
||||||
BANNERS_DISMISSED_DISK,
|
BANNERS_DISMISSED_DISK,
|
||||||
UserKeyDefinition,
|
UserKeyDefinition,
|
||||||
|
SingleUserState,
|
||||||
} from "@bitwarden/common/platform/state";
|
} from "@bitwarden/common/platform/state";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
import { PBKDF2KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-management";
|
import { PBKDF2KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-management";
|
||||||
|
|
||||||
@@ -62,47 +52,25 @@ export const BANNERS_DISMISSED_DISK_KEY = new UserKeyDefinition<SessionBanners[]
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VaultBannersService {
|
export class VaultBannersService {
|
||||||
shouldShowPremiumBanner$: Observable<boolean>;
|
|
||||||
|
|
||||||
private premiumBannerState: ActiveUserState<PremiumBannerReprompt>;
|
|
||||||
private sessionBannerState: ActiveUserState<SessionBanners[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits when the sync service has completed a sync
|
|
||||||
*
|
|
||||||
* This is needed because `hasPremiumFromAnySource$` will emit false until the sync is completed
|
|
||||||
* resulting in the premium banner being shown briefly on startup when the user has access to
|
|
||||||
* premium features.
|
|
||||||
*/
|
|
||||||
private syncCompleted$ = new Subject<void>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tokenService: TokenService,
|
private accountService: AccountService,
|
||||||
private userVerificationService: UserVerificationService,
|
|
||||||
private stateProvider: StateProvider,
|
private stateProvider: StateProvider,
|
||||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private kdfConfigService: KdfConfigService,
|
private kdfConfigService: KdfConfigService,
|
||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
private accountService: AccountService,
|
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||||
) {
|
) {}
|
||||||
this.pollUntilSynced();
|
|
||||||
this.premiumBannerState = this.stateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY);
|
|
||||||
this.sessionBannerState = this.stateProvider.getActive(BANNERS_DISMISSED_DISK_KEY);
|
|
||||||
|
|
||||||
const premiumSources$ = this.accountService.activeAccount$.pipe(
|
shouldShowPremiumBanner$(userId: UserId): Observable<boolean> {
|
||||||
take(1),
|
const premiumBannerState = this.premiumBannerState(userId);
|
||||||
switchMap((account) => {
|
const premiumSources$ = combineLatest([
|
||||||
return combineLatest([
|
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
|
||||||
account
|
premiumBannerState.state$,
|
||||||
? this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id)
|
]);
|
||||||
: of(false),
|
|
||||||
this.premiumBannerState.state$,
|
|
||||||
]);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.shouldShowPremiumBanner$ = this.syncCompleted$.pipe(
|
return this.syncService.lastSync$(userId).pipe(
|
||||||
|
filter((lastSync) => lastSync !== null),
|
||||||
take(1), // Wait until the first sync is complete before considering the premium status
|
take(1), // Wait until the first sync is complete before considering the premium status
|
||||||
mergeMap(() => premiumSources$),
|
mergeMap(() => premiumSources$),
|
||||||
map(([canAccessPremium, dismissedState]) => {
|
map(([canAccessPremium, dismissedState]) => {
|
||||||
@@ -122,9 +90,9 @@ export class VaultBannersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true when the update browser banner should be shown */
|
/** Returns true when the update browser banner should be shown */
|
||||||
async shouldShowUpdateBrowserBanner(): Promise<boolean> {
|
async shouldShowUpdateBrowserBanner(userId: UserId): Promise<boolean> {
|
||||||
const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1;
|
const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1;
|
||||||
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
|
const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes(
|
||||||
VisibleVaultBanner.OutdatedBrowser,
|
VisibleVaultBanner.OutdatedBrowser,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -132,10 +100,12 @@ export class VaultBannersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true when the verify email banner should be shown */
|
/** Returns true when the verify email banner should be shown */
|
||||||
async shouldShowVerifyEmailBanner(): Promise<boolean> {
|
async shouldShowVerifyEmailBanner(userId: UserId): Promise<boolean> {
|
||||||
const needsVerification = !(await this.tokenService.getEmailVerified());
|
const needsVerification = !(
|
||||||
|
await firstValueFrom(this.accountService.accounts$.pipe(map((accounts) => accounts[userId])))
|
||||||
|
)?.emailVerified;
|
||||||
|
|
||||||
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
|
const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes(
|
||||||
VisibleVaultBanner.VerifyEmail,
|
VisibleVaultBanner.VerifyEmail,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -143,12 +113,14 @@ export class VaultBannersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true when the low KDF iteration banner should be shown */
|
/** Returns true when the low KDF iteration banner should be shown */
|
||||||
async shouldShowLowKDFBanner(): Promise<boolean> {
|
async shouldShowLowKDFBanner(userId: UserId): Promise<boolean> {
|
||||||
const hasLowKDF = (await this.userVerificationService.hasMasterPassword())
|
const hasLowKDF = (
|
||||||
? await this.isLowKdfIteration()
|
await firstValueFrom(this.userDecryptionOptionsService.userDecryptionOptionsById$(userId))
|
||||||
|
)?.hasMasterPassword
|
||||||
|
? await this.isLowKdfIteration(userId)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
|
const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes(
|
||||||
VisibleVaultBanner.KDFSettings,
|
VisibleVaultBanner.KDFSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -156,11 +128,11 @@ export class VaultBannersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Dismiss the given banner and perform any respective side effects */
|
/** Dismiss the given banner and perform any respective side effects */
|
||||||
async dismissBanner(banner: SessionBanners): Promise<void> {
|
async dismissBanner(userId: UserId, banner: SessionBanners): Promise<void> {
|
||||||
if (banner === VisibleVaultBanner.Premium) {
|
if (banner === VisibleVaultBanner.Premium) {
|
||||||
await this.dismissPremiumBanner();
|
await this.dismissPremiumBanner(userId);
|
||||||
} else {
|
} else {
|
||||||
await this.sessionBannerState.update((current) => {
|
await this.sessionBannerState(userId).update((current) => {
|
||||||
const bannersDismissed = current ?? [];
|
const bannersDismissed = current ?? [];
|
||||||
|
|
||||||
return [...bannersDismissed, banner];
|
return [...bannersDismissed, banner];
|
||||||
@@ -168,16 +140,32 @@ export class VaultBannersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns a SingleUserState for the premium banner reprompt state
|
||||||
|
*/
|
||||||
|
private premiumBannerState(userId: UserId): SingleUserState<PremiumBannerReprompt> {
|
||||||
|
return this.stateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns a SingleUserState for the session banners dismissed state
|
||||||
|
*/
|
||||||
|
private sessionBannerState(userId: UserId): SingleUserState<SessionBanners[]> {
|
||||||
|
return this.stateProvider.getUser(userId, BANNERS_DISMISSED_DISK_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns banners that have already been dismissed */
|
/** Returns banners that have already been dismissed */
|
||||||
private async getBannerDismissedState(): Promise<SessionBanners[]> {
|
private async getBannerDismissedState(userId: UserId): Promise<SessionBanners[]> {
|
||||||
// `state$` can emit null when a value has not been set yet,
|
// `state$` can emit null when a value has not been set yet,
|
||||||
// use nullish coalescing to default to an empty array
|
// use nullish coalescing to default to an empty array
|
||||||
return (await firstValueFrom(this.sessionBannerState.state$)) ?? [];
|
return (await firstValueFrom(this.sessionBannerState(userId).state$)) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Increment dismissal state of the premium banner */
|
/** Increment dismissal state of the premium banner */
|
||||||
private async dismissPremiumBanner(): Promise<void> {
|
private async dismissPremiumBanner(userId: UserId): Promise<void> {
|
||||||
await this.premiumBannerState.update((current) => {
|
await this.premiumBannerState(userId).update((current) => {
|
||||||
const numberOfDismissals = current?.numberOfDismissals ?? 0;
|
const numberOfDismissals = current?.numberOfDismissals ?? 0;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -213,22 +201,11 @@ export class VaultBannersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isLowKdfIteration() {
|
private async isLowKdfIteration(userId: UserId) {
|
||||||
const kdfConfig = await this.kdfConfigService.getKdfConfig();
|
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
|
||||||
return (
|
return (
|
||||||
kdfConfig.kdfType === KdfType.PBKDF2_SHA256 &&
|
kdfConfig.kdfType === KdfType.PBKDF2_SHA256 &&
|
||||||
kdfConfig.iterations < PBKDF2KdfConfig.ITERATIONS.defaultValue
|
kdfConfig.iterations < PBKDF2KdfConfig.ITERATIONS.defaultValue
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Poll the `syncService` until a sync is completed */
|
|
||||||
private pollUntilSynced() {
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
const lastSync = await this.syncService.getLastSync();
|
|
||||||
if (lastSync !== null) {
|
|
||||||
clearInterval(interval);
|
|
||||||
this.syncCompleted$.next();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|||||||
import { By } from "@angular/platform-browser";
|
import { By } from "@angular/platform-browser";
|
||||||
import { RouterTestingModule } from "@angular/router/testing";
|
import { RouterTestingModule } from "@angular/router/testing";
|
||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
|
||||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { BannerComponent, BannerModule } from "@bitwarden/components";
|
import { BannerComponent, BannerModule } from "@bitwarden/components";
|
||||||
|
|
||||||
import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
|
import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
|
||||||
@@ -21,21 +25,25 @@ describe("VaultBannersComponent", () => {
|
|||||||
let component: VaultBannersComponent;
|
let component: VaultBannersComponent;
|
||||||
let fixture: ComponentFixture<VaultBannersComponent>;
|
let fixture: ComponentFixture<VaultBannersComponent>;
|
||||||
const premiumBanner$ = new BehaviorSubject<boolean>(false);
|
const premiumBanner$ = new BehaviorSubject<boolean>(false);
|
||||||
|
const mockUserId = Utils.newGuid() as UserId;
|
||||||
|
|
||||||
const bannerService = mock<VaultBannersService>({
|
const bannerService = mock<VaultBannersService>({
|
||||||
shouldShowPremiumBanner$: premiumBanner$,
|
shouldShowPremiumBanner$: jest.fn((userId$: Observable<UserId>) => premiumBanner$),
|
||||||
shouldShowUpdateBrowserBanner: jest.fn(),
|
shouldShowUpdateBrowserBanner: jest.fn(),
|
||||||
shouldShowVerifyEmailBanner: jest.fn(),
|
shouldShowVerifyEmailBanner: jest.fn(),
|
||||||
shouldShowLowKDFBanner: jest.fn(),
|
shouldShowLowKDFBanner: jest.fn(),
|
||||||
dismissBanner: jest.fn(),
|
dismissBanner: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
bannerService.shouldShowPremiumBanner$ = premiumBanner$;
|
|
||||||
bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false);
|
bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false);
|
||||||
bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false);
|
bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false);
|
||||||
bannerService.shouldShowLowKDFBanner.mockResolvedValue(false);
|
bannerService.shouldShowLowKDFBanner.mockResolvedValue(false);
|
||||||
|
|
||||||
|
premiumBanner$.next(false);
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
BannerModule,
|
BannerModule,
|
||||||
@@ -62,6 +70,10 @@ describe("VaultBannersComponent", () => {
|
|||||||
provide: TokenService,
|
provide: TokenService,
|
||||||
useValue: mock<TokenService>(),
|
useValue: mock<TokenService>(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: AccountService,
|
||||||
|
useValue: accountService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideProvider(VaultBannersService, { useValue: bannerService })
|
.overrideProvider(VaultBannersService, { useValue: bannerService })
|
||||||
@@ -135,7 +147,7 @@ describe("VaultBannersComponent", () => {
|
|||||||
|
|
||||||
dismissButton.dispatchEvent(new Event("click"));
|
dismissButton.dispatchEvent(new Event("click"));
|
||||||
|
|
||||||
expect(bannerService.dismissBanner).toHaveBeenCalledWith(banner);
|
expect(bannerService.dismissBanner).toHaveBeenCalledWith(mockUserId, banner);
|
||||||
|
|
||||||
expect(component.visibleBanners).toEqual([]);
|
expect(component.visibleBanners).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { Component, Input, OnInit } from "@angular/core";
|
import { Component, Input, OnInit } from "@angular/core";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
import { Observable } from "rxjs";
|
import { firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { BannerModule } from "@bitwarden/components";
|
import { BannerModule } from "@bitwarden/components";
|
||||||
|
|
||||||
@@ -26,12 +27,17 @@ export class VaultBannersComponent implements OnInit {
|
|||||||
VisibleVaultBanner = VisibleVaultBanner;
|
VisibleVaultBanner = VisibleVaultBanner;
|
||||||
@Input() organizationsPaymentStatus: FreeTrial[] = [];
|
@Input() organizationsPaymentStatus: FreeTrial[] = [];
|
||||||
|
|
||||||
|
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private vaultBannerService: VaultBannersService,
|
private vaultBannerService: VaultBannersService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
|
private accountService: AccountService,
|
||||||
) {
|
) {
|
||||||
this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$;
|
this.premiumBannerVisible$ = this.activeUserId$.pipe(
|
||||||
|
switchMap((userId) => this.vaultBannerService.shouldShowPremiumBanner$(userId)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
@@ -39,7 +45,8 @@ export class VaultBannersComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dismissBanner(banner: VisibleVaultBanner): Promise<void> {
|
async dismissBanner(banner: VisibleVaultBanner): Promise<void> {
|
||||||
await this.vaultBannerService.dismissBanner(banner);
|
const activeUserId = await firstValueFrom(this.activeUserId$);
|
||||||
|
await this.vaultBannerService.dismissBanner(activeUserId, banner);
|
||||||
|
|
||||||
await this.determineVisibleBanners();
|
await this.determineVisibleBanners();
|
||||||
}
|
}
|
||||||
@@ -57,9 +64,12 @@ export class VaultBannersComponent implements OnInit {
|
|||||||
|
|
||||||
/** Determine which banners should be present */
|
/** Determine which banners should be present */
|
||||||
private async determineVisibleBanners(): Promise<void> {
|
private async determineVisibleBanners(): Promise<void> {
|
||||||
const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner();
|
const activeUserId = await firstValueFrom(this.activeUserId$);
|
||||||
const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner();
|
|
||||||
const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner();
|
const showBrowserOutdated =
|
||||||
|
await this.vaultBannerService.shouldShowUpdateBrowserBanner(activeUserId);
|
||||||
|
const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner(activeUserId);
|
||||||
|
const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner(activeUserId);
|
||||||
|
|
||||||
this.visibleBanners = [
|
this.visibleBanners = [
|
||||||
showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null,
|
showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null,
|
||||||
|
|||||||
Reference in New Issue
Block a user