Merge ffd0d7c2f7
into c21a58f2fb
This commit is contained in:
commit
5420cd6443
|
@ -1,6 +1,7 @@
|
|||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
import { EmergencyAccessModule } from "../emergency-access";
|
||||
|
@ -16,6 +17,7 @@ import { WebauthnLoginSettingsModule } from "./webauthn-login-settings";
|
|||
EmergencyAccessModule,
|
||||
PasswordCalloutComponent,
|
||||
UserKeyRotationModule,
|
||||
BannerModule,
|
||||
],
|
||||
declarations: [ChangePasswordComponent],
|
||||
providers: [],
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -3,7 +3,6 @@ import { Component, EventEmitter, Output } from "@angular/core";
|
|||
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";
|
||||
|
||||
@Component({
|
||||
|
@ -14,12 +13,12 @@ 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,
|
||||
) {}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -1,7 +0,0 @@
|
|||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "app-low-kdf",
|
||||
templateUrl: "low-kdf.component.html",
|
||||
})
|
||||
export class LowKdfComponent {}
|
|
@ -1,7 +1,7 @@
|
|||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
|
||||
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
import { BannerModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { OrganizationLayoutComponent } from "../admin-console/organizations/layouts/organization-layout.component";
|
||||
import { EventsComponent as OrgEventsComponent } from "../admin-console/organizations/manage/events.component";
|
||||
|
@ -67,7 +67,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";
|
||||
|
@ -113,6 +112,7 @@ import { SharedModule } from "./shared.module";
|
|||
OrganizationLayoutComponent,
|
||||
UserLayoutComponent,
|
||||
PaymentMethodWarningsModule,
|
||||
BannerModule,
|
||||
],
|
||||
declarations: [
|
||||
AcceptFamilySponsorshipComponent,
|
||||
|
@ -186,7 +186,6 @@ import { SharedModule } from "./shared.module";
|
|||
VerifyEmailTokenComponent,
|
||||
VerifyRecoverDeleteComponent,
|
||||
VerifyRecoverDeleteProviderComponent,
|
||||
LowKdfComponent,
|
||||
],
|
||||
exports: [
|
||||
UserVerificationModule,
|
||||
|
@ -264,7 +263,6 @@ import { SharedModule } from "./shared.module";
|
|||
VerifyEmailTokenComponent,
|
||||
VerifyRecoverDeleteComponent,
|
||||
VerifyRecoverDeleteProviderComponent,
|
||||
LowKdfComponent,
|
||||
HeaderModule,
|
||||
DangerZoneComponent,
|
||||
],
|
||||
|
|
|
@ -0,0 +1,226 @@
|
|||
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 {
|
||||
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 });
|
||||
|
||||
beforeEach(() => {
|
||||
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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("Premium", () => {
|
||||
it("shows premium banner when not self hosted and no premium", async () => {
|
||||
hasPremiumFromAnySource$.next(false);
|
||||
isSelfHost.mockReturnValue(false);
|
||||
|
||||
service = TestBed.inject(VaultBannersService);
|
||||
|
||||
expect(await firstValueFrom(service.shouldShowPremiumBanner())).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,188 @@
|
|||
import { Injectable } from "@angular/core";
|
||||
import { Observable, combineLatest, firstValueFrom, map } 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, PBKDF2_ITERATIONS } from "@bitwarden/common/platform/enums";
|
||||
import {
|
||||
StateProvider,
|
||||
ActiveUserState,
|
||||
KeyDefinition,
|
||||
PREMIUM_BANNER_DISK_LOCAL,
|
||||
BANNERS_DISMISSED_DISK,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
|
||||
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 {
|
||||
private premiumBannerState: ActiveUserState<PremiumBannerReprompt>;
|
||||
private sessionBannerState: ActiveUserState<SessionBanners[]>;
|
||||
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private stateProvider: StateProvider,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
) {
|
||||
this.premiumBannerState = this.stateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY);
|
||||
this.sessionBannerState = this.stateProvider.getActive(BANNERS_DISMISSED_DISK_KEY);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Returns true when the premium banner should be shown */
|
||||
shouldShowPremiumBanner(): Observable<boolean> {
|
||||
return combineLatest([
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$,
|
||||
this.premiumBannerState.state$,
|
||||
]).pipe(
|
||||
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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
<bit-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
|
||||
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
|
||||
*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
|
||||
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>
|
|
@ -0,0 +1,139 @@
|
|||
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 { 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: jest.fn(),
|
||||
shouldShowUpdateBrowserBanner: jest.fn(),
|
||||
shouldShowVerifyEmailBanner: jest.fn(),
|
||||
shouldShowLowKDFBanner: jest.fn(),
|
||||
dismissBanner: jest.fn(),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
bannerService.shouldShowPremiumBanner.mockReturnValue(premiumBanner$);
|
||||
bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false);
|
||||
bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false);
|
||||
bannerService.shouldShowLowKDFBanner.mockResolvedValue(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BannerModule, LooseComponentsModule],
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
@ -82,44 +84,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>
|
||||
|
|
|
@ -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,8 +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 { StateService } from "@bitwarden/common/platform/abstractions/state.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";
|
||||
|
@ -123,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();
|
||||
|
@ -163,12 +154,10 @@ 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,
|
||||
private ngZone: NgZone,
|
||||
private stateService: StateService,
|
||||
private organizationService: OrganizationService,
|
||||
private vaultFilterService: VaultFilterService,
|
||||
private routedVaultFilterService: RoutedVaultFilterService,
|
||||
|
@ -183,13 +172,10 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
private searchPipe: SearchPipe,
|
||||
private configService: ConfigService,
|
||||
private apiService: ApiService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected kdfConfigService: KdfConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.showBrowserOutdated = window.navigator.userAgent.indexOf("MSIE") !== -1;
|
||||
this.trashCleanupWarning = this.i18nService.t(
|
||||
this.platformUtilsService.isSelfHost()
|
||||
? "trashCleanupWarningSelfHosted"
|
||||
|
@ -199,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;
|
||||
|
@ -414,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();
|
||||
|
@ -973,14 +939,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);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BreadcrumbsModule } from "@bitwarden/components";
|
||||
import { BannerModule, BreadcrumbsModule } from "@bitwarden/components";
|
||||
|
||||
import { LooseComponentsModule, SharedModule } from "../../shared";
|
||||
import { CollectionDialogModule } from "../components/collection-dialog";
|
||||
|
@ -11,6 +11,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 +36,12 @@ import { VaultComponent } from "./vault.component";
|
|||
VaultItemsModule,
|
||||
CollectionDialogModule,
|
||||
VaultOnboardingComponent,
|
||||
BannerModule,
|
||||
],
|
||||
declarations: [VaultComponent, VaultHeaderComponent],
|
||||
declarations: [VaultComponent, VaultHeaderComponent, VaultBannersComponent],
|
||||
exports: [VaultComponent],
|
||||
providers: [
|
||||
VaultBannersService,
|
||||
{
|
||||
provide: VaultOnboardingServiceAbstraction,
|
||||
useClass: VaultOnboardingService,
|
||||
|
|
|
@ -8049,5 +8049,11 @@
|
|||
},
|
||||
"collectionItemSelect": {
|
||||
"message": "Select collection item"
|
||||
},
|
||||
"lowKDFIterationsBanner": {
|
||||
"message": "Low KDF iterations. Increase your iterations to improve the security of your account."
|
||||
},
|
||||
"changeKDFSettings": {
|
||||
"message": "Change KDF settings"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -150,3 +150,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");
|
||||
|
|
Loading…
Reference in New Issue