1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-21 11:35:34 +01:00

[AC-217] Migrate to Banner Component (#8899)

* convert premium card to banner component

- create VaultBanners component that will handle all banner logic

* move upgrade browser notice to banner component

* refactor verify email component to use the banner component

* add email banner to VaultBanners component

* move low KDF message to banner component

* remove unused KDF component

* allow multiple banners to be displayed at once

* use vault service to consolidate premium banner logic
- Implement prompt thresholds for premium banner
- Update dismiss logic to re-run visibility logic

* update variable name

* move all dismiss/show logic to vault banner service

* rename tense of methods for readability

* apply underline to send email button to match other banner actions

* fix dark mode styling across banners

* remove unused variable

* use bitLink directive for styling rather than tailwind

* move premium banner to a standalone observable

* update bootstrap styles to tailwind

* use new KDF service for vault banners

* move the VerifyEmailComponent to a standalone component

* convert premium banner to a singular observable

* remove unneeded import

* AC-2589 add unique id for each vault banner

* AC-2588 poll sync service to only show premium banner after a sync

* close subscription to syncCompleted$ after one emit

* remove unneeded ReplaySubject
This commit is contained in:
Nick Krantz 2024-05-20 16:06:35 -05:00 committed by GitHub
parent f2d24e036b
commit 6c61cd4f63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 756 additions and 126 deletions

View File

@ -1,11 +1,14 @@
<div class="tw-rounded tw-border tw-border-solid tw-border-warning-600 tw-bg-background">
<div class="tw-bg-warning-600 tw-px-5 tw-py-2.5 tw-font-bold tw-uppercase tw-text-contrast">
<i class="bwi bwi-envelope bwi-fw" aria-hidden="true"></i> {{ "verifyEmail" | i18n }}
</div>
<div class="tw-p-5">
<p>{{ "verifyEmailDesc" | i18n }}</p>
<button id="sendBtn" bitButton type="button" block [bitAction]="send">
{{ "sendEmail" | i18n }}
</button>
</div>
</div>
<bit-banner bannerType="warning" (onClose)="onDismiss.emit()">
{{ "verifyEmailDesc" | i18n }}
<button
id="sendBtn"
bitLink
linkType="contrast"
bitButton
type="button"
buttonType="unstyled"
[bitAction]="send"
>
{{ "sendEmail" | i18n }}
</button>
</bit-banner>

View File

@ -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<unknown>;
@Output() onVerified = new EventEmitter<boolean>();
@Output() onDismiss = new EventEmitter<void>();
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private tokenService: TokenService,
) {}

View File

@ -1,17 +0,0 @@
<div class="tw-rounded tw-border tw-border-solid tw-border-warning-600 tw-bg-background">
<div class="tw-bg-warning-600 tw-px-5 tw-py-2.5 tw-font-bold tw-uppercase tw-text-contrast">
<i class="bwi bwi-exclamation-triangle bwi-fw" aria-hidden="true"></i>
{{ "lowKdfIterations" | i18n }}
</div>
<div class="tw-p-5">
<p>{{ "updateLowKdfIterationsDesc" | i18n }}</p>
<a
bitButton
buttonType="secondary"
[block]="true"
routerLink="/settings/security/security-keys"
>
{{ "updateKdfSettings" | i18n }}
</a>
</div>
</div>

View File

@ -1,7 +0,0 @@
import { Component } from "@angular/core";
@Component({
selector: "app-low-kdf",
templateUrl: "low-kdf.component.html",
})
export class LowKdfComponent {}

View File

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

View File

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

View File

@ -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<VisibleVaultBanner, VisibleVaultBanner.Premium>;
export const PREMIUM_BANNER_REPROMPT_KEY = new KeyDefinition<PremiumBannerReprompt>(
PREMIUM_BANNER_DISK_LOCAL,
"bannerReprompt",
{
deserializer: (bannerReprompt) => bannerReprompt,
},
);
export const BANNERS_DISMISSED_DISK_KEY = new KeyDefinition<SessionBanners[]>(
BANNERS_DISMISSED_DISK,
"bannersDismissed",
{
deserializer: (bannersDismissed) => bannersDismissed,
},
);
@Injectable()
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(
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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<SessionBanners[]> {
// `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<void> {
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);
}
}

View File

@ -0,0 +1,52 @@
<bit-banner
id="update-browser-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="warning"
*ngIf="visibleBanners.includes(VisibleVaultBanner.OutdatedBrowser)"
(onClose)="dismissBanner(VisibleVaultBanner.OutdatedBrowser)"
>
{{ "updateBrowserDesc" | i18n }}
<a
bitLink
linkType="contrast"
target="_blank"
href="https://browser-update.org/update-browser.html"
rel="noreferrer noopener"
>
{{ "updateBrowser" | i18n }}
</a>
</bit-banner>
<bit-banner
id="kdf-settings-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="warning"
*ngIf="visibleBanners.includes(VisibleVaultBanner.KDFSettings)"
(onClose)="dismissBanner(VisibleVaultBanner.KDFSettings)"
>
{{ "lowKDFIterationsBanner" | i18n }}
<a bitLink linkType="contrast" routerLink="/settings/security/security-keys">
{{ "changeKDFSettings" | i18n }}
</a>
</bit-banner>
<app-verify-email
id="verify-email-banner"
*ngIf="visibleBanners.includes(VisibleVaultBanner.VerifyEmail)"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
(onDismiss)="dismissBanner(VisibleVaultBanner.VerifyEmail)"
(onVerified)="dismissBanner(VisibleVaultBanner.VerifyEmail)"
></app-verify-email>
<bit-banner
id="premium-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="premium"
*ngIf="premiumBannerVisible$ | async"
(onClose)="dismissBanner(VisibleVaultBanner.Premium)"
>
{{ "premiumUpgradeUnlockFeatures" | i18n }}
<a bitLink linkType="contrast" routerLink="/settings/subscription/premium">
{{ "goPremium" | i18n }}
</a>
</bit-banner>

View File

@ -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<VaultBannersComponent>;
const premiumBanner$ = new BehaviorSubject<boolean>(false);
const bannerService = mock<VaultBannersService>({
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<I18nService>({ t: (key) => key }),
},
{
provide: ApiService,
useValue: mock<ApiService>(),
},
{
provide: PlatformUtilsService,
useValue: mock<PlatformUtilsService>(),
},
{
provide: TokenService,
useValue: mock<TokenService>(),
},
],
}).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([]);
});
});
});
});
});

View File

@ -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<boolean>;
VisibleVaultBanner = VisibleVaultBanner;
constructor(private vaultBannerService: VaultBannersService) {
this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$;
}
async ngOnInit(): Promise<void> {
await this.determineVisibleBanners();
}
async dismissBanner(banner: VisibleVaultBanner): Promise<void> {
await this.vaultBannerService.dismissBanner(banner);
await this.determineVisibleBanners();
}
/** Determine which banners should be present */
private async determineVisibleBanners(): Promise<void> {
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
}
}

View File

@ -1,3 +1,5 @@
<app-vault-banners></app-vault-banners>
<app-vault-header
[filter]="filter"
[loading]="refreshing && !performingInitialLoad"
@ -14,8 +16,8 @@
<app-vault-onboarding [ciphers]="ciphers" [orgs]="allOrganizations" (onAddCipher)="addCipher()">
</app-vault-onboarding>
<div class="row">
<div class="col-3">
<div class="tw-flex tw-flex-row -tw-mx-2.5">
<div class="tw-basis-1/4 tw-max-w-1/4 tw-px-2.5">
<div class="groupings">
<div class="content">
<div class="inner-content">
@ -30,7 +32,7 @@
</div>
</div>
</div>
<div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }">
<div class="tw-basis-3/4 tw-max-w-3/4 tw-px-2.5">
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
{{ trashCleanupWarning }}
</app-callout>
@ -81,44 +83,6 @@
</button>
</div>
</div>
<div class="col-3">
<app-low-kdf class="d-block mb-4" *ngIf="showLowKdf"> </app-low-kdf>
<app-verify-email
*ngIf="showVerifyEmail"
class="d-block mb-4"
(onVerified)="emailVerified($event)"
></app-verify-email>
<div class="card border-warning mb-4" *ngIf="showBrowserOutdated">
<div class="card-header bg-warning text-white">
<i class="bwi bwi-exclamation-triangle bwi-fw" aria-hidden="true"></i>
{{ "updateBrowser" | i18n }}
</div>
<div class="card-body">
<p>{{ "updateBrowserDesc" | i18n }}</p>
<a
class="btn btn-block btn-outline-secondary"
target="_blank"
href="https://browser-update.org/update-browser.html"
rel="noreferrer"
>
{{ "updateBrowser" | i18n }}
</a>
</div>
</div>
<div class="card border-success mb-4" *ngIf="showPremiumCallout">
<div class="card-header bg-success text-white">
<i class="bwi bwi-star-f bwi-fw" aria-hidden="true"></i> {{ "goPremium" | i18n }}
</div>
<div class="card-body">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<a class="btn btn-block btn-outline-secondary" routerLink="/settings/subscription/premium">
{{ "goPremium" | i18n }}
</a>
</div>
</div>
</div>
</div>
<ng-template #attachments></ng-template>

View File

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

View File

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

View File

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

View File

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