diff --git a/apps/web/src/app/auth/settings/verify-email.component.html b/apps/web/src/app/auth/settings/verify-email.component.html index ccad78348c..60f7e52b9a 100644 --- a/apps/web/src/app/auth/settings/verify-email.component.html +++ b/apps/web/src/app/auth/settings/verify-email.component.html @@ -1,11 +1,14 @@ -
-
- {{ "verifyEmail" | i18n }} -
-
-

{{ "verifyEmailDesc" | i18n }}

- -
-
+ + {{ "verifyEmailDesc" | i18n }} + + diff --git a/apps/web/src/app/auth/settings/verify-email.component.ts b/apps/web/src/app/auth/settings/verify-email.component.ts index d9d2acb87b..e8809cd893 100644 --- a/apps/web/src/app/auth/settings/verify-email.component.ts +++ b/apps/web/src/app/auth/settings/verify-email.component.ts @@ -1,25 +1,29 @@ +import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Output } from "@angular/core"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { AsyncActionsModule, BannerModule, ButtonModule, LinkModule } from "@bitwarden/components"; @Component({ + standalone: true, selector: "app-verify-email", templateUrl: "verify-email.component.html", + imports: [AsyncActionsModule, BannerModule, ButtonModule, CommonModule, JslibModule, LinkModule], }) export class VerifyEmailComponent { actionPromise: Promise; @Output() onVerified = new EventEmitter(); + @Output() onDismiss = new EventEmitter(); constructor( private apiService: ApiService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private logService: LogService, private tokenService: TokenService, ) {} diff --git a/apps/web/src/app/settings/low-kdf.component.html b/apps/web/src/app/settings/low-kdf.component.html deleted file mode 100644 index fd191b21e8..0000000000 --- a/apps/web/src/app/settings/low-kdf.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
-
- - {{ "lowKdfIterations" | i18n }} -
-
-

{{ "updateLowKdfIterationsDesc" | i18n }}

- - {{ "updateKdfSettings" | i18n }} - -
-
diff --git a/apps/web/src/app/settings/low-kdf.component.ts b/apps/web/src/app/settings/low-kdf.component.ts deleted file mode 100644 index a411c1402f..0000000000 --- a/apps/web/src/app/settings/low-kdf.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-low-kdf", - templateUrl: "low-kdf.component.html", -}) -export class LowKdfComponent {} diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index b511f5d766..c7ae63f25c 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -53,7 +53,6 @@ import { TwoFactorSetupComponent } from "../auth/settings/two-factor-setup.compo import { TwoFactorVerifyComponent } from "../auth/settings/two-factor-verify.component"; import { TwoFactorWebAuthnComponent } from "../auth/settings/two-factor-webauthn.component"; import { TwoFactorYubiKeyComponent } from "../auth/settings/two-factor-yubikey.component"; -import { VerifyEmailComponent } from "../auth/settings/verify-email.component"; import { UserVerificationModule } from "../auth/shared/components/user-verification"; import { SsoComponent } from "../auth/sso.component"; import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; @@ -70,7 +69,6 @@ import { HeaderModule } from "../layouts/header/header.module"; import { ProductSwitcherModule } from "../layouts/product-switcher/product-switcher.module"; import { UserLayoutComponent } from "../layouts/user-layout.component"; import { DomainRulesComponent } from "../settings/domain-rules.component"; -import { LowKdfComponent } from "../settings/low-kdf.component"; import { PreferencesComponent } from "../settings/preferences.component"; import { VaultTimeoutInputComponent } from "../settings/vault-timeout-input.component"; import { GeneratorComponent } from "../tools/generator.component"; @@ -186,11 +184,9 @@ import { SharedModule } from "./shared.module"; UpdatePasswordComponent, UpdateTempPasswordComponent, VaultTimeoutInputComponent, - VerifyEmailComponent, VerifyEmailTokenComponent, VerifyRecoverDeleteComponent, VerifyRecoverDeleteProviderComponent, - LowKdfComponent, ], exports: [ UserVerificationModule, @@ -264,11 +260,9 @@ import { SharedModule } from "./shared.module"; UpdateTempPasswordComponent, UserLayoutComponent, VaultTimeoutInputComponent, - VerifyEmailComponent, VerifyEmailTokenComponent, VerifyRecoverDeleteComponent, VerifyRecoverDeleteProviderComponent, - LowKdfComponent, HeaderModule, DangerZoneComponent, ], diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts new file mode 100644 index 0000000000..c09451addd --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -0,0 +1,265 @@ +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KdfType } from "@bitwarden/common/platform/enums"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +import { + PREMIUM_BANNER_REPROMPT_KEY, + VaultBannersService, + VisibleVaultBanner, +} from "./vault-banners.service"; + +describe("VaultBannersService", () => { + let service: VaultBannersService; + const isSelfHost = jest.fn().mockReturnValue(false); + const hasPremiumFromAnySource$ = new BehaviorSubject(false); + const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); + const getEmailVerified = jest.fn().mockResolvedValue(true); + const hasMasterPassword = jest.fn().mockResolvedValue(true); + const getKdfConfig = jest + .fn() + .mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600000 }); + const getLastSync = jest.fn().mockResolvedValue(null); + + beforeEach(() => { + jest.useFakeTimers(); + getLastSync.mockClear().mockResolvedValue(new Date("2024-05-14")); + isSelfHost.mockClear(); + getEmailVerified.mockClear().mockResolvedValue(true); + + TestBed.configureTestingModule({ + providers: [ + VaultBannersService, + { + provide: PlatformUtilsService, + useValue: { isSelfHost }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { hasPremiumFromAnySource$: hasPremiumFromAnySource$ }, + }, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: PlatformUtilsService, + useValue: { isSelfHost }, + }, + { + provide: TokenService, + useValue: { getEmailVerified }, + }, + { + provide: UserVerificationService, + useValue: { hasMasterPassword }, + }, + { + provide: KdfConfigService, + useValue: { getKdfConfig }, + }, + { + provide: SyncService, + useValue: { getLastSync }, + }, + ], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("Premium", () => { + it("waits until sync is completed before showing premium banner", async () => { + getLastSync.mockResolvedValue(new Date("2024-05-14")); + hasPremiumFromAnySource$.next(false); + isSelfHost.mockReturnValue(false); + + service = TestBed.inject(VaultBannersService); + + jest.advanceTimersByTime(201); + + expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(true); + }); + + it("does not show a premium banner for self-hosted users", async () => { + getLastSync.mockResolvedValue(new Date("2024-05-14")); + hasPremiumFromAnySource$.next(false); + isSelfHost.mockReturnValue(true); + + service = TestBed.inject(VaultBannersService); + + jest.advanceTimersByTime(201); + + expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false); + }); + + it("does not show a premium banner when they have access to premium", async () => { + getLastSync.mockResolvedValue(new Date("2024-05-14")); + hasPremiumFromAnySource$.next(true); + isSelfHost.mockReturnValue(false); + + service = TestBed.inject(VaultBannersService); + + jest.advanceTimersByTime(201); + + expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false); + }); + + describe("dismissing", () => { + beforeEach(async () => { + jest.useFakeTimers(); + const date = new Date("2023-06-08"); + date.setHours(0, 0, 0, 0); + jest.setSystemTime(date.getTime()); + + service = TestBed.inject(VaultBannersService); + await service.dismissBanner(VisibleVaultBanner.Premium); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("updates state on first dismiss", async () => { + const state = await firstValueFrom( + fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$, + ); + + const oneWeekLater = new Date("2023-06-15"); + oneWeekLater.setHours(0, 0, 0, 0); + + expect(state).toEqual({ + numberOfDismissals: 1, + nextPromptDate: oneWeekLater.getTime(), + }); + }); + + it("updates state on second dismiss", async () => { + const state = await firstValueFrom( + fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$, + ); + + const oneMonthLater = new Date("2023-07-08"); + oneMonthLater.setHours(0, 0, 0, 0); + + expect(state).toEqual({ + numberOfDismissals: 2, + nextPromptDate: oneMonthLater.getTime(), + }); + }); + + it("updates state on third dismiss", async () => { + const state = await firstValueFrom( + fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$, + ); + + const oneYearLater = new Date("2024-06-08"); + oneYearLater.setHours(0, 0, 0, 0); + + expect(state).toEqual({ + numberOfDismissals: 3, + nextPromptDate: oneYearLater.getTime(), + }); + }); + }); + }); + + describe("KDFSettings", () => { + beforeEach(async () => { + hasMasterPassword.mockResolvedValue(true); + getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 599999 }); + }); + + it("shows low KDF iteration banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowLowKDFBanner()).toBe(true); + }); + + it("does not show low KDF iteration banner if KDF type is not PBKDF2_SHA256", async () => { + getKdfConfig.mockResolvedValue({ kdfType: KdfType.Argon2id, iterations: 600001 }); + + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowLowKDFBanner()).toBe(false); + }); + + it("does not show low KDF for iterations about 600,000", async () => { + getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600001 }); + + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowLowKDFBanner()).toBe(false); + }); + + it("dismisses low KDF iteration banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowLowKDFBanner()).toBe(true); + + await service.dismissBanner(VisibleVaultBanner.KDFSettings); + + expect(await service.shouldShowLowKDFBanner()).toBe(false); + }); + }); + + describe("OutdatedBrowser", () => { + beforeEach(async () => { + // Hardcode `MSIE` in userAgent string + const userAgent = "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 MSIE"; + Object.defineProperty(navigator, "userAgent", { + configurable: true, + get: () => userAgent, + }); + }); + + it("shows outdated browser banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowUpdateBrowserBanner()).toBe(true); + }); + + it("dismisses outdated browser banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowUpdateBrowserBanner()).toBe(true); + + await service.dismissBanner(VisibleVaultBanner.OutdatedBrowser); + + expect(await service.shouldShowUpdateBrowserBanner()).toBe(false); + }); + }); + + describe("VerifyEmail", () => { + beforeEach(async () => { + getEmailVerified.mockResolvedValue(false); + }); + + it("shows verify email banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowVerifyEmailBanner()).toBe(true); + }); + + it("dismisses verify email banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowVerifyEmailBanner()).toBe(true); + + await service.dismissBanner(VisibleVaultBanner.VerifyEmail); + + expect(await service.shouldShowVerifyEmailBanner()).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts new file mode 100644 index 0000000000..b54f2e9c11 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts @@ -0,0 +1,215 @@ +import { Injectable } from "@angular/core"; +import { Subject, Observable, combineLatest, firstValueFrom, map } from "rxjs"; +import { mergeMap, take } from "rxjs/operators"; + +import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KdfType, PBKDF2_ITERATIONS } from "@bitwarden/common/platform/enums"; +import { + StateProvider, + ActiveUserState, + KeyDefinition, + PREMIUM_BANNER_DISK_LOCAL, + BANNERS_DISMISSED_DISK, +} from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +export enum VisibleVaultBanner { + KDFSettings = "kdf-settings", + OutdatedBrowser = "outdated-browser", + Premium = "premium", + VerifyEmail = "verify-email", +} + +type PremiumBannerReprompt = { + numberOfDismissals: number; + /** Timestamp representing when to show the prompt next */ + nextPromptDate: number; +}; + +/** Banners that will be re-shown on a new session */ +type SessionBanners = Omit; + +export const PREMIUM_BANNER_REPROMPT_KEY = new KeyDefinition( + PREMIUM_BANNER_DISK_LOCAL, + "bannerReprompt", + { + deserializer: (bannerReprompt) => bannerReprompt, + }, +); + +export const BANNERS_DISMISSED_DISK_KEY = new KeyDefinition( + BANNERS_DISMISSED_DISK, + "bannersDismissed", + { + deserializer: (bannersDismissed) => bannersDismissed, + }, +); + +@Injectable() +export class VaultBannersService { + shouldShowPremiumBanner$: Observable; + + private premiumBannerState: ActiveUserState; + private sessionBannerState: ActiveUserState; + + /** + * 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(); + + constructor( + private tokenService: TokenService, + private userVerificationService: UserVerificationService, + private stateProvider: StateProvider, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private platformUtilsService: PlatformUtilsService, + private kdfConfigService: KdfConfigService, + private syncService: SyncService, + ) { + this.pollUntilSynced(); + this.premiumBannerState = this.stateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY); + this.sessionBannerState = this.stateProvider.getActive(BANNERS_DISMISSED_DISK_KEY); + + const premiumSources$ = combineLatest([ + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + this.premiumBannerState.state$, + ]); + + this.shouldShowPremiumBanner$ = this.syncCompleted$.pipe( + take(1), // Wait until the first sync is complete before considering the premium status + mergeMap(() => premiumSources$), + map(([canAccessPremium, dismissedState]) => { + const shouldShowPremiumBanner = + !canAccessPremium && !this.platformUtilsService.isSelfHost(); + + // Check if nextPromptDate is in the past passed + if (shouldShowPremiumBanner && dismissedState?.nextPromptDate) { + const nextPromptDate = new Date(dismissedState.nextPromptDate); + const now = new Date(); + return now >= nextPromptDate; + } + + return shouldShowPremiumBanner; + }), + ); + } + + /** Returns true when the update browser banner should be shown */ + async shouldShowUpdateBrowserBanner(): Promise { + const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1; + const alreadyDismissed = (await this.getBannerDismissedState()).includes( + VisibleVaultBanner.OutdatedBrowser, + ); + + return outdatedBrowser && !alreadyDismissed; + } + + /** Returns true when the verify email banner should be shown */ + async shouldShowVerifyEmailBanner(): Promise { + const needsVerification = !(await this.tokenService.getEmailVerified()); + + const alreadyDismissed = (await this.getBannerDismissedState()).includes( + VisibleVaultBanner.VerifyEmail, + ); + + return needsVerification && !alreadyDismissed; + } + + /** Returns true when the low KDF iteration banner should be shown */ + async shouldShowLowKDFBanner(): Promise { + const hasLowKDF = (await this.userVerificationService.hasMasterPassword()) + ? await this.isLowKdfIteration() + : false; + + const alreadyDismissed = (await this.getBannerDismissedState()).includes( + VisibleVaultBanner.KDFSettings, + ); + + return hasLowKDF && !alreadyDismissed; + } + + /** Dismiss the given banner and perform any respective side effects */ + async dismissBanner(banner: SessionBanners): Promise { + if (banner === VisibleVaultBanner.Premium) { + await this.dismissPremiumBanner(); + } else { + await this.sessionBannerState.update((current) => { + const bannersDismissed = current ?? []; + + return [...bannersDismissed, banner]; + }); + } + } + + /** Returns banners that have already been dismissed */ + private async getBannerDismissedState(): Promise { + // `state$` can emit null when a value has not been set yet, + // use nullish coalescing to default to an empty array + return (await firstValueFrom(this.sessionBannerState.state$)) ?? []; + } + + /** Increment dismissal state of the premium banner */ + private async dismissPremiumBanner(): Promise { + await this.premiumBannerState.update((current) => { + const numberOfDismissals = current?.numberOfDismissals ?? 0; + const now = new Date(); + + // Set midnight of the current day + now.setHours(0, 0, 0, 0); + + // First dismissal, re-prompt in 1 week + if (numberOfDismissals === 0) { + now.setDate(now.getDate() + 7); + return { + numberOfDismissals: 1, + nextPromptDate: now.getTime(), + }; + } + + // Second dismissal, re-prompt in 1 month + if (numberOfDismissals === 1) { + now.setMonth(now.getMonth() + 1); + return { + numberOfDismissals: 2, + nextPromptDate: now.getTime(), + }; + } + + // 3+ dismissals, re-prompt each year + // Avoid day/month edge cases and only increment year + const nextYear = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()); + nextYear.setHours(0, 0, 0, 0); + return { + numberOfDismissals: numberOfDismissals + 1, + nextPromptDate: nextYear.getTime(), + }; + }); + } + + private async isLowKdfIteration() { + const kdfConfig = await this.kdfConfigService.getKdfConfig(); + return ( + kdfConfig.kdfType === KdfType.PBKDF2_SHA256 && + kdfConfig.iterations < PBKDF2_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); + } +} diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html new file mode 100644 index 0000000000..bcbe424b46 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html @@ -0,0 +1,52 @@ + + {{ "updateBrowserDesc" | i18n }} + + {{ "updateBrowser" | i18n }} + + + + + {{ "lowKDFIterationsBanner" | i18n }} + + {{ "changeKDFSettings" | i18n }} + + + + + + + {{ "premiumUpgradeUnlockFeatures" | i18n }} + + {{ "goPremium" | i18n }} + + diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts new file mode 100644 index 0000000000..8c637d22b1 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts @@ -0,0 +1,140 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { BannerComponent, BannerModule } from "@bitwarden/components"; + +import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component"; +import { LooseComponentsModule } from "../../../shared"; + +import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service"; +import { VaultBannersComponent } from "./vault-banners.component"; + +describe("VaultBannersComponent", () => { + let component: VaultBannersComponent; + let fixture: ComponentFixture; + const premiumBanner$ = new BehaviorSubject(false); + + const bannerService = mock({ + shouldShowPremiumBanner$: premiumBanner$, + shouldShowUpdateBrowserBanner: jest.fn(), + shouldShowVerifyEmailBanner: jest.fn(), + shouldShowLowKDFBanner: jest.fn(), + dismissBanner: jest.fn(), + }); + + beforeEach(async () => { + bannerService.shouldShowPremiumBanner$ = premiumBanner$; + bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false); + bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false); + bannerService.shouldShowLowKDFBanner.mockResolvedValue(false); + + await TestBed.configureTestingModule({ + imports: [BannerModule, LooseComponentsModule, VerifyEmailComponent], + declarations: [VaultBannersComponent, I18nPipe], + providers: [ + { + provide: VaultBannersService, + useValue: bannerService, + }, + { + provide: I18nService, + useValue: mock({ t: (key) => key }), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: PlatformUtilsService, + useValue: mock(), + }, + { + provide: TokenService, + useValue: mock(), + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(VaultBannersComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + describe("premiumBannerVisible$", () => { + it("shows premium banner", async () => { + premiumBanner$.next(true); + + fixture.detectChanges(); + + const banner = fixture.debugElement.query(By.directive(BannerComponent)); + expect(banner.componentInstance.bannerType).toBe("premium"); + }); + + it("dismisses premium banner", async () => { + premiumBanner$.next(false); + + fixture.detectChanges(); + + const banner = fixture.debugElement.query(By.directive(BannerComponent)); + expect(banner).toBeNull(); + }); + }); + + describe("determineVisibleBanner", () => { + [ + { + name: "OutdatedBrowser", + method: bannerService.shouldShowUpdateBrowserBanner, + banner: VisibleVaultBanner.OutdatedBrowser, + }, + { + name: "VerifyEmail", + method: bannerService.shouldShowVerifyEmailBanner, + banner: VisibleVaultBanner.VerifyEmail, + }, + { + name: "LowKDF", + method: bannerService.shouldShowLowKDFBanner, + banner: VisibleVaultBanner.KDFSettings, + }, + ].forEach(({ name, method, banner }) => { + describe(name, () => { + beforeEach(async () => { + method.mockResolvedValue(true); + + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it(`shows ${name} banner`, async () => { + expect(component.visibleBanners).toEqual([banner]); + }); + + it(`dismisses ${name} banner`, async () => { + const dismissButton = fixture.debugElement.nativeElement.querySelector( + 'button[biticonbutton="bwi-close"]', + ); + + // Mock out the banner service returning false after dismissing + method.mockResolvedValue(false); + + dismissButton.dispatchEvent(new Event("click")); + + expect(bannerService.dismissBanner).toHaveBeenCalledWith(banner); + + expect(component.visibleBanners).toEqual([]); + }); + }); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts new file mode 100644 index 0000000000..e612bc231d --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -0,0 +1,41 @@ +import { Component, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; + +import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service"; + +@Component({ + selector: "app-vault-banners", + templateUrl: "./vault-banners.component.html", +}) +export class VaultBannersComponent implements OnInit { + visibleBanners: VisibleVaultBanner[] = []; + premiumBannerVisible$: Observable; + VisibleVaultBanner = VisibleVaultBanner; + + constructor(private vaultBannerService: VaultBannersService) { + this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$; + } + + async ngOnInit(): Promise { + await this.determineVisibleBanners(); + } + + async dismissBanner(banner: VisibleVaultBanner): Promise { + await this.vaultBannerService.dismissBanner(banner); + + await this.determineVisibleBanners(); + } + + /** Determine which banners should be present */ + private async determineVisibleBanners(): Promise { + const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(); + const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner(); + const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner(); + + this.visibleBanners = [ + showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null, + showVerifyEmail ? VisibleVaultBanner.VerifyEmail : null, + showLowKdf ? VisibleVaultBanner.KDFSettings : null, + ].filter(Boolean); // remove all falsy values, i.e. null + } +} 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 3f95665f37..f39ec3378d 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -1,3 +1,5 @@ + + -
-
+
+
@@ -30,7 +32,7 @@
-
+
{{ trashCleanupWarning }} @@ -81,44 +83,6 @@
-
- - - - -
-
- - {{ "updateBrowser" | i18n }} -
-
-

{{ "updateBrowserDesc" | i18n }}

- - {{ "updateBrowser" | i18n }} - -
-
-
-
- {{ "goPremium" | i18n }} -
-
-

{{ "premiumUpgradeUnlockFeatures" | i18n }}

- - {{ "goPremium" | i18n }} - -
-
-
diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 474e9045d0..ca04b3aa51 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -35,9 +35,6 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.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 { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -47,7 +44,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { KdfType, PBKDF2_ITERATIONS } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; @@ -122,10 +118,6 @@ export class VaultComponent implements OnInit, OnDestroy { @ViewChild("collectionsModal", { read: ViewContainerRef, static: true }) collectionsModalRef: ViewContainerRef; - showVerifyEmail = false; - showBrowserOutdated = false; - showPremiumCallout = false; - showLowKdf = false; trashCleanupWarning: string = null; kdfIterations: number; activeFilter: VaultFilter = new VaultFilter(); @@ -161,7 +153,6 @@ export class VaultComponent implements OnInit, OnDestroy { private i18nService: I18nService, private modalService: ModalService, private dialogService: DialogService, - private tokenService: TokenService, private messagingService: MessagingService, private platformUtilsService: PlatformUtilsService, private broadcasterService: BroadcasterService, @@ -180,14 +171,11 @@ export class VaultComponent implements OnInit, OnDestroy { private searchPipe: SearchPipe, private configService: ConfigService, private apiService: ApiService, - private userVerificationService: UserVerificationService, private billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, - protected kdfConfigService: KdfConfigService, ) {} async ngOnInit() { - this.showBrowserOutdated = window.navigator.userAgent.indexOf("MSIE") !== -1; this.trashCleanupWarning = this.i18nService.t( this.platformUtilsService.isSelfHost() ? "trashCleanupWarningSelfHosted" @@ -197,18 +185,8 @@ export class VaultComponent implements OnInit, OnDestroy { const firstSetup$ = this.route.queryParams.pipe( first(), switchMap(async (params: Params) => { - this.showVerifyEmail = !(await this.tokenService.getEmailVerified()); - this.showLowKdf = (await this.userVerificationService.hasMasterPassword()) - ? await this.isLowKdfIteration() - : false; await this.syncService.fullSync(false); - const canAccessPremium = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumFromAnySource$, - ); - this.showPremiumCallout = - !this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost(); - const cipherId = getCipherIdFromParams(params); if (!cipherId) { return; @@ -412,16 +390,6 @@ export class VaultComponent implements OnInit, OnDestroy { ); } - get isShowingCards() { - return ( - this.showBrowserOutdated || this.showPremiumCallout || this.showVerifyEmail || this.showLowKdf - ); - } - - emailVerified(verified: boolean) { - this.showVerifyEmail = !verified; - } - ngOnDestroy() { this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); this.destroy$.next(); @@ -1005,14 +973,6 @@ export class VaultComponent implements OnInit, OnDestroy { : this.cipherService.softDeleteWithServer(id); } - async isLowKdfIteration() { - const kdfConfig = await this.kdfConfigService.getKdfConfig(); - return ( - kdfConfig.kdfType === KdfType.PBKDF2_SHA256 && - kdfConfig.iterations < PBKDF2_ITERATIONS.defaultValue - ); - } - protected async repromptCipher(ciphers: CipherView[]) { const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); diff --git a/apps/web/src/app/vault/individual-vault/vault.module.ts b/apps/web/src/app/vault/individual-vault/vault.module.ts index 81fc38eda1..c79c64c1eb 100644 --- a/apps/web/src/app/vault/individual-vault/vault.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault.module.ts @@ -1,7 +1,8 @@ import { NgModule } from "@angular/core"; -import { BreadcrumbsModule } from "@bitwarden/components"; +import { BannerModule, BreadcrumbsModule } from "@bitwarden/components"; +import { VerifyEmailComponent } from "../../auth/settings/verify-email.component"; import { LooseComponentsModule, SharedModule } from "../../shared"; import { CollectionDialogModule } from "../components/collection-dialog"; import { VaultItemsModule } from "../components/vault-items/vault-items.module"; @@ -11,6 +12,8 @@ import { GroupBadgeModule } from "../org-vault/group-badge/group-badge.module"; import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module"; import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module"; import { PipesModule } from "./pipes/pipes.module"; +import { VaultBannersService } from "./vault-banners/services/vault-banners.service"; +import { VaultBannersComponent } from "./vault-banners/vault-banners.component"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./vault-onboarding/services/abstraction/vault-onboarding.service"; @@ -34,10 +37,13 @@ import { VaultComponent } from "./vault.component"; VaultItemsModule, CollectionDialogModule, VaultOnboardingComponent, + BannerModule, + VerifyEmailComponent, ], - declarations: [VaultComponent, VaultHeaderComponent], + declarations: [VaultComponent, VaultHeaderComponent, VaultBannersComponent], exports: [VaultComponent], providers: [ + VaultBannersService, { provide: VaultOnboardingServiceAbstraction, useClass: VaultOnboardingService, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3f67df15be..e79497d25e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8223,6 +8223,12 @@ } } }, + "lowKDFIterationsBanner": { + "message": "Low KDF iterations. Increase your iterations to improve the security of your account." + }, + "changeKDFSettings": { + "message": "Change KDF settings" + }, "secureYourInfrastructure": { "message": "Secure your infrastructure" }, diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 6f225f6c2f..986c51f4b7 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -160,3 +160,7 @@ export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", { export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory", { browser: "memory-large-object", }); +export const PREMIUM_BANNER_DISK_LOCAL = new StateDefinition("premiumBannerReprompt", "disk", { + web: "disk-local", +}); +export const BANNERS_DISMISSED_DISK = new StateDefinition("bannersDismissed", "disk");