1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 18:09:17 +00:00

Merge branch 'main' into km/auto-kdf

This commit is contained in:
Bernd Schoolmann
2025-10-23 16:30:32 +02:00
256 changed files with 6426 additions and 2484 deletions

View File

@@ -0,0 +1,91 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading">
<ng-container bitDialogTitle>
@let title = (multiStepSubmit | async)[currentStep()]?.titleContent();
@if (title) {
<ng-container [ngTemplateOutlet]="title"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (loading) {
<div>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
}
<div [hidden]="loading">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
</div>
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footer = (multiStepSubmit | async)[currentStep()]?.footerContent();
@if (footer) {
<ng-container [ngTemplateOutlet]="footer"></ng-container>
}
</ng-container>
</bit-dialog>
</form>
<ng-template #step0Title>
<div class="tw-flex tw-flex-col">
@let showBadge = firstTimeDialog();
@if (showBadge) {
<span bitBadge variant="info" class="tw-w-28 tw-my-2"> {{ "availableNow" | i18n }}</span>
}
<span>
{{ (firstTimeDialog ? "autoConfirm" : "editPolicy") | i18n }}
@if (!firstTimeDialog) {
<span class="tw-text-muted tw-font-normal tw-text-sm">
{{ policy.name | i18n }}
</span>
}
</span>
</div>
</ng-template>
<ng-template #step1Title>
{{ "howToTurnOnAutoConfirm" | i18n }}
</ng-template>
<ng-template #step0>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
@if (autoConfirmEnabled$ | async) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}
}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>
<ng-template #step1>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
{{ "openExtension" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "close" | i18n }}
</button>
</ng-template>

View File

@@ -0,0 +1,249 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
Inject,
signal,
Signal,
TemplateRef,
viewChild,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
map,
Observable,
of,
shareReplay,
startWith,
switchMap,
tap,
} from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "./policy-edit-dialog.component";
export type MultiStepSubmit = {
sideEffect: () => Promise<void>;
footerContent: Signal<TemplateRef<unknown> | undefined>;
titleContent: Signal<TemplateRef<unknown> | undefined>;
};
export type AutoConfirmPolicyDialogData = PolicyEditDialogData & {
firstTimeDialog?: boolean;
};
/**
* Custom policy dialog component for Auto-Confirm policy.
* Satisfies the PolicyDialogComponent interface structurally
* via its static open() function.
*/
@Component({
templateUrl: "auto-confirm-edit-policy-dialog.component.html",
imports: [SharedModule],
})
export class AutoConfirmPolicyDialogComponent
extends PolicyEditDialogComponent
implements AfterViewInit
{
policyType = PolicyType;
protected readonly firstTimeDialog = signal(false);
protected readonly currentStep = signal(0);
protected multiStepSubmit: Observable<MultiStepSubmit[]> = of([]);
protected autoConfirmEnabled$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false),
);
private readonly submitPolicy: Signal<TemplateRef<unknown> | undefined> = viewChild("step0");
private readonly openExtension: Signal<TemplateRef<unknown> | undefined> = viewChild("step1");
private readonly submitPolicyTitle: Signal<TemplateRef<unknown> | undefined> = viewChild("step0Title");
private readonly openExtensionTitle: Signal<TemplateRef<unknown> | undefined> = viewChild("step1Title");
override policyComponent: AutoConfirmPolicyEditComponent | undefined;
constructor(
@Inject(DIALOG_DATA) protected data: AutoConfirmPolicyDialogData,
accountService: AccountService,
policyApiService: PolicyApiServiceAbstraction,
i18nService: I18nService,
cdr: ChangeDetectorRef,
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private policyService: PolicyService,
private router: Router,
) {
super(
data,
accountService,
policyApiService,
i18nService,
cdr,
formBuilder,
dialogRef,
toastService,
configService,
keyService,
);
this.firstTimeDialog.set(data.firstTimeDialog ?? false);
}
/**
* Instantiates the child policy component and inserts it into the view.
*/
async ngAfterViewInit() {
await super.ngAfterViewInit();
if (this.policyComponent) {
this.saveDisabled$ = combineLatest([
this.autoConfirmEnabled$,
this.policyComponent.enabled.valueChanges.pipe(
startWith(this.policyComponent.enabled.value),
),
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
}
this.multiStepSubmit = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false),
tap((singleOrgPolicyEnabled) =>
this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled),
),
map((singleOrgPolicyEnabled) => [
{
sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false),
footerContent: this.submitPolicy,
titleContent: this.submitPolicyTitle,
},
{
sideEffect: () => this.openBrowserExtension(),
footerContent: this.openExtension,
titleContent: this.openExtensionTitle,
},
]),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
private async handleSubmit(singleOrgEnabled: boolean) {
if (!singleOrgEnabled) {
await this.submitSingleOrg();
}
await this.submitAutoConfirm();
}
/**
* Triggers policy submission for auto confirm.
* @returns boolean: true if multi-submit workflow should continue, false otherwise.
*/
private async submitAutoConfirm() {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
const autoConfirmRequest = await this.policyComponent.buildRequest();
await this.policyApiService.putPolicy(
this.data.organizationId,
this.data.policy.type,
autoConfirmRequest,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
});
if (!this.policyComponent.enabled.value) {
this.dialogRef.close("saved");
}
}
private async submitSingleOrg(): Promise<void> {
const singleOrgRequest: PolicyRequest = {
type: PolicyType.SingleOrg,
enabled: true,
data: null,
};
await this.policyApiService.putPolicy(
this.data.organizationId,
PolicyType.SingleOrg,
singleOrgRequest,
);
}
private async openBrowserExtension() {
await this.router.navigate(["/browser-extension-prompt"], {
queryParams: { url: "AutoConfirm" },
});
}
submit = async () => {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
if ((await this.policyComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
try {
const multiStepSubmit = await firstValueFrom(this.multiStepSubmit);
await multiStepSubmit[this.currentStep()].sideEffect();
if (this.currentStep() === multiStepSubmit.length - 1) {
this.dialogRef.close("saved");
return;
}
this.currentStep.update((value) => value + 1);
this.policyComponent.setStep(this.currentStep());
} catch (error: any) {
this.toastService.showToast({
variant: "error",
message: error.message,
});
}
};
static open = (
dialogService: DialogService,
config: DialogConfig<AutoConfirmPolicyDialogData>,
) => {
return dialogService.open<PolicyEditDialogResult>(AutoConfirmPolicyDialogComponent, config);
};
}

View File

@@ -8,8 +8,20 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import type { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import type { PolicyEditDialogData, PolicyEditDialogResult } from "./policy-edit-dialog.component";
/**
* Interface for policy dialog components.
* Any component that implements this interface can be used as a custom policy edit dialog.
*/
export interface PolicyDialogComponent {
open: (
dialogService: DialogService,
config: DialogConfig<PolicyEditDialogData>,
) => DialogRef<PolicyEditDialogResult>;
}
/**
* A metadata class that defines how a policy is displayed in the Admin Console Policies page for editing.
@@ -37,9 +49,8 @@ export abstract class BasePolicyEditDefinition {
/**
* The dialog component that will be opened when editing this policy.
* This allows customizing the look and feel of each policy's dialog contents.
* If not specified, defaults to {@link PolicyEditDialogComponent}.
*/
editDialogComponent?: typeof PolicyEditDialogComponent;
editDialogComponent?: PolicyDialogComponent;
/**
* If true, the {@link description} will be reused in the policy edit modal. Set this to false if you

View File

@@ -1,17 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
firstValueFrom,
lastValueFrom,
Observable,
of,
switchMap,
first,
map,
withLatestFrom,
tap,
} from "rxjs";
import {
@@ -19,9 +20,11 @@ import {
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { safeProvider } from "@bitwarden/ui-common";
@@ -29,7 +32,7 @@ import { safeProvider } from "@bitwarden/ui-common";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component";
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
@@ -59,8 +62,18 @@ export class PoliciesComponent implements OnInit {
private policyApiService: PolicyApiServiceAbstraction,
private policyListService: PolicyListService,
private dialogService: DialogService,
private policyService: PolicyService,
protected configService: ConfigService,
) {}
) {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
tap(async () => await this.load()),
takeUntilDestroyed(),
)
.subscribe();
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
@@ -127,17 +140,13 @@ export class PoliciesComponent implements OnInit {
}
async edit(policy: BasePolicyEditDefinition) {
const dialogComponent = policy.editDialogComponent ?? PolicyEditDialogComponent;
const dialogRef = dialogComponent.open(this.dialogService, {
const dialogComponent: PolicyDialogComponent =
policy.editDialogComponent ?? PolicyEditDialogComponent;
dialogComponent.open(this.dialogService, {
data: {
policy: policy,
organizationId: this.organizationId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result == "saved") {
await this.load();
}
}
}

View File

@@ -0,0 +1,59 @@
<ng-container [ngTemplateOutlet]="steps[step]()"></ng-container>
<ng-template #step0>
<p class="tw-mb-6">
{{ "autoConfirmPolicyEditDescription" | i18n }}
</p>
<ul class="tw-mb-6 tw-pl-6">
<li>
<span class="tw-font-bold">
{{ "autoConfirmAcceptSecurityRiskTitle" | i18n }}
</span>
{{ "autoConfirmAcceptSecurityRiskDescription" | i18n }}
<a bitLink href="https://bitwarden.com/help/automatic-confirmation/" target="_blank">
{{ "autoConfirmAcceptSecurityRiskLearnMore" | i18n }}
<i class="bwi bwi-external-link bwi-fw"></i>
</a>
</li>
<li>
@if (singleOrgEnabled$ | async) {
<span class="tw-font-bold">
{{ "autoConfirmSingleOrgExemption" | i18n }}
</span>
} @else {
<span class="tw-font-bold">
{{ "autoConfirmSingleOrgRequired" | i18n }}
</span>
}
{{ "autoConfirmSingleOrgRequiredDescription" | i18n }}
</li>
<li>
<span class="tw-font-bold">
{{ "autoConfirmNoEmergencyAccess" | i18n }}
</span>
{{ "autoConfirmNoEmergencyAccessDescription" | i18n }}
</li>
</ul>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "autoConfirmCheckBoxLabel" | i18n }}</bit-label>
</ng-template>
<ng-template #step1>
<div class="tw-flex tw-justify-center tw-mb-6">
<bit-icon class="tw-w-[233px]" [icon]="autoConfirmSvg"></bit-icon>
</div>
<ol>
<li>1. {{ "autoConfirmStep1" | i18n }}</li>
<li>
2. {{ "autoConfirmStep2a" | i18n }}
<strong>
{{ "autoConfirmStep2b" | i18n }}
</strong>
</li>
</ol>
</ng-template>

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core";
import { BehaviorSubject, map, Observable } from "rxjs";
import { AutoConfirmSvg } from "@bitwarden/assets/svg";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SharedModule } from "../../../../shared";
import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
export class AutoConfirmPolicy extends BasePolicyEditDefinition {
name = "autoConfirm";
description = "autoConfirmDescription";
type = PolicyType.AutoConfirm;
component = AutoConfirmPolicyEditComponent;
showDescription = false;
editDialogComponent = AutoConfirmPolicyDialogComponent;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.AutoConfirm)
.pipe(map((enabled) => enabled && organization.useAutomaticUserConfirmation));
}
}
@Component({
templateUrl: "auto-confirm-policy.component.html",
imports: [SharedModule],
})
export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit {
protected readonly autoConfirmSvg = AutoConfirmSvg;
private readonly policyForm: Signal<TemplateRef<any> | undefined> = viewChild("step0");
private readonly extensionButton: Signal<TemplateRef<any> | undefined> = viewChild("step1");
protected step: number = 0;
protected steps = [this.policyForm, this.extensionButton];
protected singleOrgEnabled$: BehaviorSubject<boolean> = new BehaviorSubject(false);
setSingleOrgEnabled(enabled: boolean) {
this.singleOrgEnabled$.next(enabled);
}
setStep(step: number) {
this.step = step;
}
}

View File

@@ -14,3 +14,4 @@ export {
vNextOrganizationDataOwnershipPolicy,
vNextOrganizationDataOwnershipPolicyComponent,
} from "./vnext-organization-data-ownership.component";
export { AutoConfirmPolicy } from "./auto-confirm-policy.component";

View File

@@ -30,7 +30,7 @@ import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions";
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component";
export type PolicyEditDialogData = {
/**
@@ -64,13 +64,13 @@ export class PolicyEditDialogComponent implements AfterViewInit {
});
constructor(
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
private accountService: AccountService,
private policyApiService: PolicyApiServiceAbstraction,
private i18nService: I18nService,
protected accountService: AccountService,
protected policyApiService: PolicyApiServiceAbstraction,
protected i18nService: I18nService,
private cdr: ChangeDetectorRef,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<PolicyEditDialogResult>,
private toastService: ToastService,
protected dialogRef: DialogRef<PolicyEditDialogResult>,
protected toastService: ToastService,
private configService: ConfigService,
private keyService: KeyService,
) {}

View File

@@ -1,5 +1,6 @@
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
import {
AutoConfirmPolicy,
DesktopAutotypeDefaultSettingPolicy,
DisableSendPolicy,
MasterPasswordPolicy,
@@ -33,4 +34,5 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [
new SendOptionsPolicy(),
new RestrictedItemTypesPolicy(),
new DesktopAutotypeDefaultSettingPolicy(),
new AutoConfirmPolicy(),
];

View File

@@ -12,6 +12,8 @@ import { SharedModule } from "../../../shared";
import { EmergencyAccessModule } from "../emergency-access.module";
import { EmergencyAccessService } from "../services/emergency-access.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
imports: [SharedModule, EmergencyAccessModule],
templateUrl: "accept-emergency.component.html",

View File

@@ -11,18 +11,24 @@ import { RouterService } from "../../../core/router.service";
import { deepLinkGuard } from "./deep-link.guard";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,
})
export class GuardedRouteTestComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,
})
export class LockTestComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,

View File

@@ -16,6 +16,8 @@ import { BaseAcceptComponent } from "../../common/base.accept.component";
import { AcceptOrganizationInviteService } from "./accept-organization.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "accept-organization.component.html",
standalone: false,

View File

@@ -10,6 +10,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-delete",
templateUrl: "recover-delete.component.html",

View File

@@ -16,6 +16,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-two-factor",
templateUrl: "recover-two-factor.component.html",

View File

@@ -19,6 +19,8 @@ import { DeleteAccountDialogComponent } from "./delete-account-dialog.component"
import { ProfileComponent } from "./profile.component";
import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "account.component.html",
imports: [

View File

@@ -32,6 +32,8 @@ type ChangeAvatarDialogData = {
profile: ProfileResponse;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "change-avatar-dialog.component.html",
encapsulation: ViewEncapsulation.None,
@@ -40,6 +42,8 @@ type ChangeAvatarDialogData = {
export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
profile: ProfileResponse;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("colorPicker") colorPickerElement: ElementRef<HTMLElement>;
loading = false;

View File

@@ -17,6 +17,8 @@ import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-change-email",
templateUrl: "change-email.component.html",

View File

@@ -9,6 +9,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
/**
* Component for the Danger Zone section of the Account/Organization Settings page.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-danger-zone",
templateUrl: "danger-zone.component.html",

View File

@@ -12,6 +12,8 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "deauthorize-sessions.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -12,6 +12,8 @@ import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "delete-account-dialog.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -23,6 +23,8 @@ import { AccountFingerprintComponent } from "../../../shared/components/account-
import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-profile",
templateUrl: "profile.component.html",

View File

@@ -5,6 +5,8 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
import { AvatarModule } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "selectable-avatar",
template: `<span
@@ -30,12 +32,26 @@ import { AvatarModule } from "@bitwarden/components";
imports: [NgClass, AvatarModule],
})
export class SelectableAvatarComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() id: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() text: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() title: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() color: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() border = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() selected = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() select = new EventEmitter<string>();
onFire() {

View File

@@ -27,6 +27,8 @@ import {
ToastService,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./set-account-verify-devices-dialog.component.html",
imports: [

View File

@@ -25,6 +25,8 @@ type EmergencyAccessConfirmDialogData = {
/** user public key */
publicKey: Uint8Array;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-confirm.component.html",
imports: [SharedModule],

View File

@@ -35,6 +35,8 @@ export enum EmergencyAccessAddEditDialogResult {
Canceled = "canceled",
Deleted = "deleted",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-add-edit.component.html",
imports: [SharedModule, PremiumBadgeComponent],

View File

@@ -42,6 +42,8 @@ import {
EmergencyAccessTakeoverDialogResultType,
} from "./takeover/emergency-access-takeover-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access.component.html",
imports: [SharedModule, HeaderModule, PremiumBadgeComponent],

View File

@@ -48,6 +48,8 @@ export type EmergencyAccessTakeoverDialogResultType =
*
* @link https://bitwarden.com/help/emergency-access/
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "auth-emergency-access-takeover-dialog",
templateUrl: "./emergency-access-takeover-dialog.component.html",
@@ -61,6 +63,8 @@ export type EmergencyAccessTakeoverDialogResultType =
],
})
export class EmergencyAccessTakeoverDialogComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(InputPasswordComponent)
inputPasswordComponent: InputPasswordComponent | undefined = undefined;

View File

@@ -14,6 +14,8 @@ import { EmergencyAccessService } from "../../../emergency-access";
import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-view.component.html",
providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }],

View File

@@ -35,6 +35,8 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-emergency-view-dialog",
templateUrl: "emergency-view-dialog.component.html",

View File

@@ -23,6 +23,8 @@ export type ApiKeyDialogData = {
apiKeyWarning: string;
apiKeyDescription: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "api-key.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -10,6 +10,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { WebauthnLoginSettingsModule } from "../../webauthn-login-settings";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-password-settings",
templateUrl: "password-settings.component.html",

View File

@@ -13,6 +13,8 @@ import { SharedModule } from "../../../shared";
import { ApiKeyComponent } from "./api-key.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "security-keys.component.html",
imports: [SharedModule, ChangeKdfModule],

View File

@@ -5,6 +5,8 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "security.component.html",
imports: [SharedModule, HeaderModule],

View File

@@ -15,6 +15,8 @@ import {
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-recovery",
templateUrl: "two-factor-recovery.component.html",

View File

@@ -53,6 +53,8 @@ declare global {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-authenticator",
templateUrl: "two-factor-setup-authenticator.component.html",
@@ -76,6 +78,8 @@ export class TwoFactorSetupAuthenticatorComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit, OnDestroy
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus = new EventEmitter<boolean>();
type = TwoFactorProviderType.Authenticator;
key: string;

View File

@@ -30,6 +30,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-duo",
templateUrl: "two-factor-setup-duo.component.html",
@@ -51,6 +53,8 @@ export class TwoFactorSetupDuoComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus: EventEmitter<boolean> = new EventEmitter();
type = TwoFactorProviderType.Duo;

View File

@@ -33,6 +33,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-email",
templateUrl: "two-factor-setup-email.component.html",
@@ -54,6 +56,8 @@ export class TwoFactorSetupEmailComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus: EventEmitter<boolean> = new EventEmitter();
type = TwoFactorProviderType.Email;
sentEmail: string = "";

View File

@@ -17,6 +17,8 @@ import { DialogService, ToastService } from "@bitwarden/components";
*/
@Directive({})
export abstract class TwoFactorSetupMethodBaseComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUpdated = new EventEmitter<boolean>();
type: TwoFactorProviderType | undefined;

View File

@@ -43,6 +43,8 @@ interface Key {
removePromise: Promise<TwoFactorWebAuthnResponse> | null;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-webauthn",
templateUrl: "two-factor-setup-webauthn.component.html",

View File

@@ -44,6 +44,8 @@ interface Key {
existingKey: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-yubikey",
templateUrl: "two-factor-setup-yubikey.component.html",

View File

@@ -45,6 +45,8 @@ import { TwoFactorSetupWebAuthnComponent } from "./two-factor-setup-webauthn.com
import { TwoFactorSetupYubiKeyComponent } from "./two-factor-setup-yubikey.component";
import { TwoFactorVerifyComponent } from "./two-factor-verify.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup",
templateUrl: "two-factor-setup.component.html",

View File

@@ -28,6 +28,8 @@ type TwoFactorVerifyDialogData = {
organizationId: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-verify",
templateUrl: "two-factor-verify.component.html",
@@ -43,6 +45,8 @@ type TwoFactorVerifyDialogData = {
export class TwoFactorVerifyComponent {
type: TwoFactorProviderType;
organizationId: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onAuthed = new EventEmitter<AuthResponse<TwoFactorResponse>>();
formPromise: Promise<TwoFactorResponse> | undefined;

View File

@@ -16,6 +16,8 @@ import {
ToastService,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-email",
templateUrl: "verify-email.component.html",
@@ -24,7 +26,11 @@ import {
export class VerifyEmailComponent {
actionPromise: Promise<unknown>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onVerified = new EventEmitter<boolean>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onDismiss = new EventEmitter<void>();
constructor(

View File

@@ -32,6 +32,8 @@ type Step =
| "credentialCreationFailed"
| "credentialNaming";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "create-credential-dialog.component.html",
standalone: false,

View File

@@ -24,6 +24,8 @@ export interface DeleteCredentialDialogParams {
credentialId: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "delete-credential-dialog.component.html",
standalone: false,

View File

@@ -21,6 +21,8 @@ export interface EnableEncryptionDialogParams {
credentialId: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "enable-encryption-dialog.component.html",
standalone: false,

View File

@@ -17,6 +17,8 @@ import { openCreateCredentialDialog } from "./create-credential-dialog/create-cr
import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
import { openEnableCredentialDialogComponent } from "./enable-encryption-dialog/enable-encryption-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-webauthn-login-settings",
templateUrl: "webauthn-login-settings.component.html",

View File

@@ -21,6 +21,8 @@ import {
/**
* @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent instead.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "user-verification-prompt.component.html",
standalone: false,

View File

@@ -8,6 +8,8 @@ import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/a
* @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead.
* Each client specific component should eventually be converted over to use one of these new components.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-user-verification",
templateUrl: "user-verification.component.html",

View File

@@ -13,6 +13,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-email-token",
templateUrl: "verify-email-token.component.html",

View File

@@ -11,6 +11,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-recover-delete",
templateUrl: "verify-recover-delete.component.html",

View File

@@ -1,21 +1,29 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { ActivatedRoute, Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
from,
map,
Observable,
of,
shareReplay,
switchMap,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import {
DialogService,
ToastService,
SectionComponent,
BadgeModule,
TypographyModule,
DialogService,
LinkModule,
SectionComponent,
TypographyModule,
} from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -69,14 +77,14 @@ export class PremiumVNextComponent {
constructor(
private accountService: AccountService,
private i18nService: I18nService,
private apiService: ApiService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private toastService: ToastService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingService,
private router: Router,
private activatedRoute: ActivatedRoute,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
@@ -107,6 +115,23 @@ export class PremiumVNextComponent {
this.hasPremiumPersonally$,
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
// redirect to user subscription page if they already have premium personally
// redirect to individual vault if they already have premium from an org
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => {
if (hasPremiumPersonally) {
return from(this.navigateToSubscriptionPage());
}
if (hasPremiumFromOrg) {
return from(this.navigateToIndividualVault());
}
return of(true);
}),
)
.subscribe();
this.personalPricingTiers$ =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
@@ -141,6 +166,11 @@ export class PremiumVNextComponent {
);
}
private navigateToSubscriptionPage = (): Promise<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
private navigateToIndividualVault = (): Promise<boolean> => this.router.navigate(["/vault"]);
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);

View File

@@ -1,132 +1,153 @@
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
@if (isLoadingPrices$ | async) {
<ng-container>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
} @else {
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storagePrice$ | async | currency: "$" }} =
{{ storageCost$ | async | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
[showAccountCredit]="true"
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total$ | async | currency: "USD $" }}/{{
"year" | i18n
}}
</p>
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
[disabled]="!(hasEnoughAccountCredit$ | async)"
>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>
}

View File

@@ -4,7 +4,19 @@ import { Component, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, concatMap, from, map, Observable, of, startWith, switchMap } from "rxjs";
import {
combineLatest,
concatMap,
filter,
from,
map,
Observable,
of,
startWith,
switchMap,
catchError,
shareReplay,
} from "rxjs";
import { debounceTime } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -26,7 +38,9 @@ import {
tokenizablePaymentMethodToLegacyEnum,
NonTokenizablePaymentMethods,
} from "@bitwarden/web-vault/app/billing/payment/types";
import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service";
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
@Component({
templateUrl: "./premium.component.html",
@@ -37,7 +51,6 @@ export class PremiumComponent {
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected accountCredit$: Observable<number>;
protected hasEnoughAccountCredit$: Observable<boolean>;
protected formGroup = new FormGroup({
@@ -46,13 +59,66 @@ export class PremiumComponent {
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
premiumPrices$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
map((tiers) => {
const premiumPlan = tiers.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
);
if (!premiumPlan) {
throw new Error("Could not find Premium plan");
}
return {
seat: premiumPlan.passwordManager.annualPrice,
storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB,
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
premiumPrice$ = this.premiumPrices$.pipe(map((prices) => prices.seat));
storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage));
protected isLoadingPrices$ = this.premiumPrices$.pipe(
map(() => false),
startWith(true),
catchError(() => of(false)),
);
storageCost$ = combineLatest([
this.storagePrice$,
this.formGroup.controls.additionalStorage.valueChanges.pipe(
startWith(this.formGroup.value.additionalStorage),
),
]).pipe(map(([storagePrice, additionalStorage]) => storagePrice * additionalStorage));
subtotal$ = combineLatest([this.premiumPrice$, this.storageCost$]).pipe(
map(([premiumPrice, storageCost]) => premiumPrice + storageCost),
);
tax$ = this.formGroup.valueChanges.pipe(
filter(() => this.formGroup.valid),
debounceTime(1000),
switchMap(async () => {
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
this.formGroup.value.additionalStorage,
billingAddress,
);
return taxAmounts.tax;
}),
startWith(0),
);
total$ = combineLatest([this.subtotal$, this.tax$]).pipe(
map(([subtotal, tax]) => subtotal + tax),
);
protected cloudWebVaultURL: string;
protected isSelfHost = false;
protected estimatedTax: number = 0;
protected readonly familyPlanMaxUserCount = 6;
protected readonly premiumPrice = 10;
protected readonly storageGBPrice = 4;
constructor(
private activatedRoute: ActivatedRoute,
@@ -67,6 +133,7 @@ export class PremiumComponent {
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private subscriptionPricingService: SubscriptionPricingService,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
@@ -76,23 +143,23 @@ export class PremiumComponent {
),
);
// Fetch account credit
this.accountCredit$ = this.accountService.activeAccount$.pipe(
const accountCredit$ = this.accountService.activeAccount$.pipe(
mapAccountToSubscriber,
switchMap((account) => this.subscriberBillingClient.getCredit(account)),
);
// Check if user has enough account credit for the purchase
this.hasEnoughAccountCredit$ = combineLatest([
this.accountCredit$,
this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)),
accountCredit$,
this.total$,
this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe(
startWith(this.formGroup.value.paymentMethod.type),
),
]).pipe(
map(([credit, formValue]) => {
const selectedPaymentType = formValue.paymentMethod?.type;
if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) {
return true; // Not using account credit, so this check doesn't apply
map(([credit, total, paymentMethod]) => {
if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) {
return true;
}
return credit >= this.total;
return credit >= total;
}),
);
@@ -116,14 +183,6 @@ export class PremiumComponent {
}),
)
.subscribe();
this.formGroup.valueChanges
.pipe(
debounceTime(1000),
switchMap(async () => await this.refreshSalesTax()),
takeUntilDestroyed(),
)
.subscribe();
}
finalizeUpgrade = async () => {
@@ -177,38 +236,11 @@ export class PremiumComponent {
await this.postFinalizeUpgrade();
};
protected get additionalStorageCost(): number {
return this.storageGBPrice * this.formGroup.value.additionalStorage;
}
protected get premiumURL(): string {
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
}
protected get subtotal(): number {
return this.premiumPrice + this.additionalStorageCost;
}
protected get total(): number {
return this.subtotal + this.estimatedTax;
}
protected async onLicenseFileSelectedChanged(): Promise<void> {
await this.postFinalizeUpgrade();
}
private async refreshSalesTax(): Promise<void> {
if (this.formGroup.invalid) {
return;
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
this.formGroup.value.additionalStorage,
billingAddress,
);
this.estimatedTax = taxAmounts.tax;
}
}

View File

@@ -1,9 +1,12 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@@ -18,7 +21,8 @@ import { SubscriptionPricingService } from "./subscription-pricing.service";
describe("SubscriptionPricingService", () => {
let service: SubscriptionPricingService;
let apiService: MockProxy<ApiService>;
let billingApiService: MockProxy<BillingApiServiceAbstraction>;
let configService: MockProxy<ConfigService>;
let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>;
let toastService: MockProxy<ToastService>;
@@ -217,6 +221,15 @@ describe("SubscriptionPricingService", () => {
continuationToken: null,
};
const mockPremiumPlanResponse: PremiumPlanResponse = {
seat: {
price: 10,
},
storage: {
price: 4,
},
} as PremiumPlanResponse;
beforeAll(() => {
i18nService = mock<I18nService>();
logService = mock<LogService>();
@@ -320,14 +333,18 @@ describe("SubscriptionPricingService", () => {
});
beforeEach(() => {
apiService = mock<ApiService>();
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
apiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
TestBed.configureTestingModule({
providers: [
SubscriptionPricingService,
{ provide: ApiService, useValue: apiService },
{ provide: BillingApiServiceAbstraction, useValue: billingApiService },
{ provide: ConfigService, useValue: configService },
{ provide: I18nService, useValue: i18nService },
{ provide: LogService, useValue: logService },
{ provide: ToastService, useValue: toastService },
@@ -406,13 +423,16 @@ describe("SubscriptionPricingService", () => {
});
it("should handle API errors by logging and showing toast", (done) => {
const errorApiService = mock<ApiService>();
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>();
const testError = new Error("API error");
errorApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") {
@@ -422,7 +442,8 @@ describe("SubscriptionPricingService", () => {
});
const errorService = new SubscriptionPricingService(
errorApiService,
errorBillingApiService,
errorConfigService,
errorI18nService,
errorLogService,
errorToastService,
@@ -591,13 +612,16 @@ describe("SubscriptionPricingService", () => {
});
it("should handle API errors by logging and showing toast", (done) => {
const errorApiService = mock<ApiService>();
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>();
const testError = new Error("API error");
errorApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") {
@@ -607,7 +631,8 @@ describe("SubscriptionPricingService", () => {
});
const errorService = new SubscriptionPricingService(
errorApiService,
errorBillingApiService,
errorConfigService,
errorI18nService,
errorLogService,
errorToastService,
@@ -831,13 +856,16 @@ describe("SubscriptionPricingService", () => {
});
it("should handle API errors by logging and showing toast", (done) => {
const errorApiService = mock<ApiService>();
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>();
const testError = new Error("API error");
errorApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") {
@@ -847,7 +875,8 @@ describe("SubscriptionPricingService", () => {
});
const errorService = new SubscriptionPricingService(
errorApiService,
errorBillingApiService,
errorConfigService,
errorI18nService,
errorLogService,
errorToastService,
@@ -871,9 +900,137 @@ describe("SubscriptionPricingService", () => {
});
});
describe("Edge case handling", () => {
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new Error("Premium plan API error");
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
const errorService = new SubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to error in premium plan fetch
expect(tiers).toEqual([]);
expect(logService.error).toHaveBeenCalledWith(
"Failed to fetch premium plan from API",
testError,
);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
},
error: () => {
fail("Observable should not error, it should return empty array");
},
});
});
it("should handle malformed premium plan API response", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
// Malformed response missing the Seat property
const malformedResponse = {
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new SubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to validation error
expect(tiers).toEqual([]);
expect(logService.error).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
},
error: () => {
fail("Observable should not error, it should return empty array");
},
});
});
it("should handle malformed premium plan with invalid price types", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
// Malformed response with price as string instead of number
const malformedResponse = {
Seat: {
StripePriceId: "price_seat",
Price: "10", // Should be a number
},
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new SubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to validation error
expect(tiers).toEqual([]);
expect(logService.error).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
},
error: () => {
fail("Observable should not error, it should return empty array");
},
});
});
});
describe("Observable behavior and caching", () => {
it("should share API response between multiple subscriptions", () => {
const getPlansResponse = jest.spyOn(apiService, "getPlans");
const getPlansResponse = jest.spyOn(billingApiService, "getPlans");
// Subscribe to multiple observables
service.getPersonalSubscriptionPricingTiers$().subscribe();
@@ -883,5 +1040,67 @@ describe("SubscriptionPricingService", () => {
// API should only be called once due to shareReplay
expect(getPlansResponse).toHaveBeenCalledTimes(1);
});
it("should share premium plan API response between multiple subscriptions when feature flag is enabled", () => {
// Create a new mock to avoid conflicts with beforeEach setup
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
// Create a new service instance with the feature flag enabled
const newService = new SubscriptionPricingService(
newBillingApiService,
newConfigService,
i18nService,
logService,
toastService,
);
// Subscribe to the premium pricing tier multiple times
newService.getPersonalSubscriptionPricingTiers$().subscribe();
newService.getPersonalSubscriptionPricingTiers$().subscribe();
// API should only be called once due to shareReplay on premiumPlanResponse$
expect(getPremiumPlanSpy).toHaveBeenCalledTimes(1);
});
it("should use hardcoded premium price when feature flag is disabled", (done) => {
// Create a new mock to test from scratch
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue({
seat: { price: 999 }, // Different price to verify hardcoded value is used
storage: { price: 999 },
} as PremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
// Create a new service instance with the feature flag disabled
const newService = new SubscriptionPricingService(
newBillingApiService,
newConfigService,
i18nService,
logService,
toastService,
);
// Subscribe with feature flag disabled
newService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
const premiumTier = tiers.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
);
// Should use hardcoded value of 10, not the API response value of 999
expect(premiumTier!.passwordManager.annualPrice).toBe(10);
expect(premiumTier!.passwordManager.annualPricePerAdditionalStorageGB).toBe(4);
done();
});
});
});
});

View File

@@ -1,11 +1,14 @@
import { Injectable } from "@angular/core";
import { combineLatest, from, map, Observable, of, shareReplay } from "rxjs";
import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs";
import { catchError } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@@ -20,8 +23,18 @@ import {
@Injectable({ providedIn: BillingServicesModule })
export class SubscriptionPricingService {
/**
* Fallback premium pricing used when the feature flag is disabled.
* These values represent the legacy pricing model and will not reflect
* server-side price changes. They are retained for backward compatibility
* during the feature flag rollout period.
*/
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
constructor(
private apiService: ApiService,
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private i18nService: I18nService,
private logService: LogService,
private toastService: ToastService,
@@ -55,34 +68,56 @@ export class SubscriptionPricingService {
);
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
this.apiService.getPlans(),
this.billingApiService.getPlans(),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
private premium$: Observable<PersonalSubscriptionPricingTier> = of({
// premium plan is not configured server-side so for now, hardcode it
basePrice: 10,
additionalStoragePricePerGb: 4,
}).pipe(
map((details) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("planDescPremium"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: details.basePrice,
annualPricePerAdditionalStorageGB: details.additionalStoragePricePerGb,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
this.featureTranslations.emergencyAccess(),
this.featureTranslations.breachMonitoring(),
this.featureTranslations.andMoreFeatures(),
],
},
})),
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
this.billingApiService.getPremiumPlan(),
).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
throw error; // Re-throw to propagate to higher-level error handler
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
.pipe(
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
switchMap((fetchPremiumFromPricingService) =>
fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
seat: premiumPlan.seat.price,
storage: premiumPlan.storage.price,
})),
)
: of({
seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
}),
),
map((premiumPrices) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("planDescPremium"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: premiumPrices.seat,
annualPricePerAdditionalStorageGB: premiumPrices.storage,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
this.featureTranslations.emergencyAccess(),
this.featureTranslations.breachMonitoring(),
this.featureTranslations.andMoreFeatures(),
],
},
})),
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;

View File

@@ -1,12 +1,14 @@
<form [formGroup]="form" [bitSubmit]="submit" autocomplete="off">
<bit-dialog>
<span bitDialogTitle>
{{ "changeKdf" | i18n }}
{{ "updateYourEncryptionSettings" | i18n }}
</span>
<span bitDialogContent>
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
<bit-form-field>
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
}
<bit-form-field disableMargin>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input bitInput type="password" formControlName="masterPassword" appAutofocus />
<button
@@ -18,12 +20,12 @@
></button>
<bit-hint>
{{ "confirmIdentity" | i18n }}
</bit-hint></bit-form-field
>
</bit-hint>
</bit-form-field>
</span>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="submit" bitFormButton>
<span>{{ "changeKdf" | i18n }}</span>
<span>{{ "updateSettings" | i18n }}</span>
</button>
<button bitButton buttonType="secondary" type="button" bitFormButton bitDialogClose>
{{ "cancel" | i18n }}

View File

@@ -0,0 +1,243 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormControl } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
import { KdfType, PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
import { SharedModule } from "../../shared";
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
describe("ChangeKdfConfirmationComponent", () => {
let component: ChangeKdfConfirmationComponent;
let fixture: ComponentFixture<ChangeKdfConfirmationComponent>;
// Mock Services
let mockI18nService: MockProxy<I18nService>;
let mockMessagingService: MockProxy<MessagingService>;
let mockToastService: MockProxy<ToastService>;
let mockDialogRef: MockProxy<DialogRef<ChangeKdfConfirmationComponent>>;
let mockConfigService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
let mockChangeKdfService: MockProxy<ChangeKdfService>;
const mockUserId = "user-id" as UserId;
const mockEmail = "email";
const mockMasterPassword = "master-password";
const mockDialogData = jest.fn();
const kdfConfig = new PBKDF2KdfConfig(600_001);
beforeEach(() => {
mockI18nService = mock<I18nService>();
mockMessagingService = mock<MessagingService>();
mockToastService = mock<ToastService>();
mockDialogRef = mock<DialogRef<ChangeKdfConfirmationComponent>>();
mockConfigService = mock<ConfigService>();
accountService = mockAccountServiceWith(mockUserId, { email: mockEmail });
mockChangeKdfService = mock<ChangeKdfService>();
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
// Mock config service feature flag
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockDialogData.mockReturnValue({
kdf: KdfType.PBKDF2_SHA256,
kdfConfig,
});
TestBed.configureTestingModule({
declarations: [ChangeKdfConfirmationComponent],
imports: [SharedModule],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: MessagingService, useValue: mockMessagingService },
{ provide: AccountService, useValue: accountService },
{ provide: ToastService, useValue: mockToastService },
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: ChangeKdfService, useValue: mockChangeKdfService },
{
provide: DIALOG_DATA,
useFactory: mockDialogData,
},
],
});
});
describe("Component Initialization", () => {
it("should create component with PBKDF2 config", () => {
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
expect(component).toBeTruthy();
expect(component.kdfConfig).toBeInstanceOf(PBKDF2KdfConfig);
expect(component.kdfConfig.iterations).toBe(600_001);
});
it("should create component with Argon2id config", () => {
mockDialogData.mockReturnValue({
kdf: KdfType.Argon2id,
kdfConfig: new Argon2KdfConfig(4, 65, 5),
});
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
expect(component).toBeTruthy();
expect(component.kdfConfig).toBeInstanceOf(Argon2KdfConfig);
const kdfConfig = component.kdfConfig as Argon2KdfConfig;
expect(kdfConfig.iterations).toBe(4);
expect(kdfConfig.memory).toBe(65);
expect(kdfConfig.parallelism).toBe(5);
});
it("should initialize form with required master password field", () => {
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
expect(component.form.controls.masterPassword).toBeInstanceOf(FormControl);
expect(component.form.controls.masterPassword.value).toEqual(null);
expect(component.form.controls.masterPassword.hasError("required")).toBe(true);
});
});
describe("Form Validation", () => {
beforeEach(() => {
fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
component = fixture.componentInstance;
});
it("should be invalid when master password is empty", () => {
component.form.controls.masterPassword.setValue("");
expect(component.form.invalid).toBe(true);
});
it("should be valid when master password is provided", () => {
component.form.controls.masterPassword.setValue(mockMasterPassword);
expect(component.form.valid).toBe(true);
});
});
describe("submit method", () => {
describe("should not update kdf and not show success toast", () => {
beforeEach(() => {
fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
component = fixture.componentInstance;
component.form.controls.masterPassword.setValue(mockMasterPassword);
});
it("when form is invalid", async () => {
// Arrange
component.form.controls.masterPassword.setValue("");
expect(component.form.invalid).toBe(true);
// Act
await component.submit();
// Assert
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
});
it("when no active account", async () => {
accountService.activeAccount$ = of(null);
await expect(component.submit()).rejects.toThrow("Null or undefined account");
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
});
it("when kdf is invalid", async () => {
// Arrange
component.kdfConfig = new PBKDF2KdfConfig(1);
// Act
await expect(component.submit()).rejects.toThrow();
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
});
});
describe("should update kdf and show success toast", () => {
it("should set loading to true during submission", async () => {
// Arrange
let loadingDuringExecution = false;
mockChangeKdfService.updateUserKdfParams.mockImplementation(async () => {
loadingDuringExecution = component.loading;
});
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
component.form.controls.masterPassword.setValue(mockMasterPassword);
// Act
await component.submit();
expect(loadingDuringExecution).toBe(true);
expect(component.loading).toBe(false);
});
it("doesn't logout and closes the dialog when feature flag is enabled", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
component.form.controls.masterPassword.setValue(mockMasterPassword);
// Act
await component.submit();
// Assert
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
kdfConfig,
mockUserId,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "encKeySettingsChanged-used-i18n",
});
expect(mockDialogRef.close).toHaveBeenCalled();
expect(mockMessagingService.send).not.toHaveBeenCalled();
});
it("sends a logout and displays a log back in toast when feature flag is disabled", async () => {
// Arrange
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
component.form.controls.masterPassword.setValue(mockMasterPassword);
// Act
await component.submit();
// Assert
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
kdfConfig,
mockUserId,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: "encKeySettingsChanged-used-i18n",
message: "logBackIn-used-i18n",
});
expect(mockMessagingService.send).toHaveBeenCalledWith("logout");
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,15 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Inject } from "@angular/core";
import { FormGroup, FormControl, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DIALOG_DATA, ToastService } from "@bitwarden/components";
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
import { KdfConfig, KdfType } from "@bitwarden/key-management";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -23,12 +23,13 @@ export class ChangeKdfConfirmationComponent {
kdfConfig: KdfConfig;
form = new FormGroup({
masterPassword: new FormControl(null, Validators.required),
masterPassword: new FormControl<string | null>(null, Validators.required),
});
showPassword = false;
masterPassword: string;
loading = false;
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
constructor(
private i18nService: I18nService,
private messagingService: MessagingService,
@@ -36,9 +37,13 @@ export class ChangeKdfConfirmationComponent {
private accountService: AccountService,
private toastService: ToastService,
private changeKdfService: ChangeKdfService,
private dialogRef: DialogRef<ChangeKdfConfirmationComponent>,
configService: ConfigService,
) {
this.kdfConfig = params.kdfConfig;
this.masterPassword = null;
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
FeatureFlag.NoLogoutOnKdfChange,
);
}
submit = async () => {
@@ -46,24 +51,32 @@ export class ChangeKdfConfirmationComponent {
return;
}
this.loading = true;
await this.makeKeyAndSaveAsync();
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("encKeySettingsChanged"),
message: this.i18nService.t("logBackIn"),
});
this.messagingService.send("logout");
await this.makeKeyAndSave();
if (await firstValueFrom(this.noLogoutOnKdfChangeFeatureFlag$)) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("encKeySettingsChanged"),
});
this.dialogRef.close();
} else {
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("encKeySettingsChanged"),
message: this.i18nService.t("logBackIn"),
});
this.messagingService.send("logout");
}
this.loading = false;
};
private async makeKeyAndSaveAsync() {
const masterPassword = this.form.value.masterPassword;
private async makeKeyAndSave() {
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const masterPassword = this.form.value.masterPassword!;
// Ensure the KDF config is valid.
this.kdfConfig.validateKdfConfigForSetting();
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.changeKdfService.updateUserKdfParams(
masterPassword,
this.kdfConfig,

View File

@@ -1,31 +1,30 @@
<h2 bitTypography="h2">{{ "encKeySettings" | i18n }}</h2>
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
<p bitTypography="body1">
{{ "higherKDFIterations" | i18n }}
<h2 bitTypography="h2" class="tw-mt-6">
{{ "encKeySettings" | i18n }}
</h2>
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
}
<p bitTypography="body1" class="tw-mt-4">
{{ "encryptionKeySettingsHowShouldWeEncryptYourData" | i18n }}
</p>
<p bitTypography="body1">
{{
"kdfToHighWarningIncreaseInIncrements"
| i18n: (isPBKDF2(kdfConfig) ? ("incrementsOf100,000" | i18n) : ("smallIncrements" | i18n))
}}
{{ "encryptionKeySettingsIncreaseImproveSecurity" | i18n }}
</p>
<form [formGroup]="formGroup" autocomplete="off">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-grid tw-grid-cols-12 tw-gap-x-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label
>{{ "kdfAlgorithm" | i18n }}
<a
class="tw-ml-auto"
<bit-label>
{{ "algorithm" | i18n }}
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
[bitPopoverTriggerFor]="algorithmPopover"
appA11yTitle="{{ 'encryptionKeySettingsAlgorithmPopoverTitle' | i18n }}"
bitLink
href="https://bitwarden.com/help/kdf-algorithms"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</button>
</bit-label>
<bit-select formControlName="kdf">
<bit-option
@@ -35,33 +34,12 @@
></bit-option>
</bit-select>
</bit-form-field>
<bit-form-field formGroupName="kdfConfig" *ngIf="isArgon2(kdfConfig)">
<bit-label>{{ "kdfMemory" | i18n }}</bit-label>
<input
bitInput
formControlName="memory"
type="number"
[min]="ARGON2_MEMORY.min"
[max]="ARGON2_MEMORY.max"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<div class="tw-mb-0">
<bit-form-field formGroupName="kdfConfig" *ngIf="isPBKDF2(kdfConfig)">
@if (isPBKDF2(kdfConfig)) {
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfIterations" | i18n }}
<a
bitLink
class="tw-ml-auto"
href="https://bitwarden.com/help/what-encryption-is-used/#changing-kdf-iterations"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutKDFIterations' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<input
bitInput
@@ -72,34 +50,49 @@
/>
<bit-hint>{{ "kdfIterationRecommends" | i18n }}</bit-hint>
</bit-form-field>
<ng-container *ngIf="isArgon2(kdfConfig)">
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfIterations" | i18n }}
</bit-label>
<input
bitInput
type="number"
formControlName="iterations"
[min]="ARGON2_ITERATIONS.min"
[max]="ARGON2_ITERATIONS.max"
/>
</bit-form-field>
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfParallelism" | i18n }}
</bit-label>
<input
bitInput
type="number"
formControlName="parallelism"
[min]="ARGON2_PARALLELISM.min"
[max]="ARGON2_PARALLELISM.max"
/>
</bit-form-field>
</ng-container>
</div>
} @else if (isArgon2(kdfConfig)) {
<bit-form-field formGroupName="kdfConfig">
<bit-label>{{ "kdfMemory" | i18n }}</bit-label>
<input
bitInput
formControlName="memory"
type="number"
[min]="ARGON2_MEMORY.min"
[max]="ARGON2_MEMORY.max"
/>
</bit-form-field>
}
</div>
@if (isArgon2(kdfConfig)) {
<div class="tw-col-span-6">
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfIterations" | i18n }}
</bit-label>
<input
bitInput
type="number"
formControlName="iterations"
[min]="ARGON2_ITERATIONS.min"
[max]="ARGON2_ITERATIONS.max"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfParallelism" | i18n }}
</bit-label>
<input
bitInput
type="number"
formControlName="parallelism"
[min]="ARGON2_PARALLELISM.min"
[max]="ARGON2_PARALLELISM.max"
/>
</bit-form-field>
</div>
}
</div>
<button
(click)="openConfirmationModal()"
@@ -107,7 +100,27 @@
buttonType="primary"
bitButton
bitFormButton
class="tw-mt-2"
>
{{ "changeKdf" | i18n }}
{{ "updateEncryptionSettings" | i18n }}
</button>
</form>
<bit-popover [title]="'encryptionKeySettingsAlgorithmPopoverTitle' | i18n" #algorithmPopover>
<ul class="tw-mt-2 tw-mb-0 tw-ps-4">
<li class="tw-mb-2">{{ "encryptionKeySettingsAlgorithmPopoverPBKDF2" | i18n }}</li>
<li>{{ "encryptionKeySettingsAlgorithmPopoverArgon2Id" | i18n }}</li>
</ul>
<div class="tw-mt-4 tw-mb-1">
<a
href="https://bitwarden.com/help/kdf-algorithms/"
bitLink
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</bit-popover>

View File

@@ -0,0 +1,365 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, FormControl } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, PopoverModule, CalloutModule } from "@bitwarden/components";
import {
KdfConfigService,
Argon2KdfConfig,
PBKDF2KdfConfig,
KdfType,
} from "@bitwarden/key-management";
import { SharedModule } from "../../shared";
import { ChangeKdfComponent } from "./change-kdf.component";
describe("ChangeKdfComponent", () => {
let component: ChangeKdfComponent;
let fixture: ComponentFixture<ChangeKdfComponent>;
// Mock Services
let mockDialogService: MockProxy<DialogService>;
let mockKdfConfigService: MockProxy<KdfConfigService>;
let mockConfigService: MockProxy<ConfigService>;
let mockI18nService: MockProxy<I18nService>;
let accountService: FakeAccountService;
let formBuilder: FormBuilder;
const mockUserId = "user-id" as UserId;
// Helper functions for validation testing
function expectPBKDF2Validation(
iterationsControl: FormControl<number | null>,
memoryControl: FormControl<number | null>,
parallelismControl: FormControl<number | null>,
) {
// Assert current validators state
expect(iterationsControl.hasError("required")).toBe(false);
expect(iterationsControl.hasError("min")).toBe(false);
expect(iterationsControl.hasError("max")).toBe(false);
expect(memoryControl.validator).toBeNull();
expect(parallelismControl.validator).toBeNull();
// Test validation boundaries
iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.min - 1);
expect(iterationsControl.hasError("min")).toBe(true);
iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.max + 1);
expect(iterationsControl.hasError("max")).toBe(true);
}
function expectArgon2Validation(
iterationsControl: FormControl<number | null>,
memoryControl: FormControl<number | null>,
parallelismControl: FormControl<number | null>,
) {
// Assert current validators state
expect(iterationsControl.hasError("required")).toBe(false);
expect(memoryControl.hasError("required")).toBe(false);
expect(parallelismControl.hasError("required")).toBe(false);
// Test validation boundaries - min values
iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.min - 1);
expect(iterationsControl.hasError("min")).toBe(true);
memoryControl.setValue(Argon2KdfConfig.MEMORY.min - 1);
expect(memoryControl.hasError("min")).toBe(true);
parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.min - 1);
expect(parallelismControl.hasError("min")).toBe(true);
// Test validation boundaries - max values
iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.max + 1);
expect(iterationsControl.hasError("max")).toBe(true);
memoryControl.setValue(Argon2KdfConfig.MEMORY.max + 1);
expect(memoryControl.hasError("max")).toBe(true);
parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.max + 1);
expect(parallelismControl.hasError("max")).toBe(true);
}
beforeEach(() => {
mockDialogService = mock<DialogService>();
mockKdfConfigService = mock<KdfConfigService>();
mockConfigService = mock<ConfigService>();
mockI18nService = mock<I18nService>();
accountService = mockAccountServiceWith(mockUserId);
formBuilder = new FormBuilder();
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
TestBed.configureTestingModule({
declarations: [ChangeKdfComponent],
imports: [SharedModule, PopoverModule, CalloutModule],
providers: [
{ provide: DialogService, useValue: mockDialogService },
{ provide: KdfConfigService, useValue: mockKdfConfigService },
{ provide: AccountService, useValue: accountService },
{ provide: FormBuilder, useValue: formBuilder },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
],
});
});
describe("Component Initialization", () => {
describe("given PBKDF2 configuration", () => {
it("should initialize form with PBKDF2 values and validators when component loads", async () => {
// Arrange
const mockPBKDF2Config = new PBKDF2KdfConfig(600_000);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
// Act
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Extract form controls
const formGroup = component["formGroup"];
// Assert form values
expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256);
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000);
expect(kdfConfigFormGroup.controls.memory.value).toBeNull();
expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull();
expect(component.kdfConfig).toEqual(mockPBKDF2Config);
// Assert validators
expectPBKDF2Validation(
kdfConfigFormGroup.controls.iterations,
kdfConfigFormGroup.controls.memory,
kdfConfigFormGroup.controls.parallelism,
);
});
});
describe("given Argon2id configuration", () => {
it("should initialize form with Argon2id values and validators when component loads", async () => {
// Arrange
const mockArgon2Config = new Argon2KdfConfig(3, 64, 4);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config);
// Act
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Extract form controls
const formGroup = component["formGroup"];
// Assert form values
expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id);
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expect(kdfConfigFormGroup.controls.iterations.value).toBe(3);
expect(kdfConfigFormGroup.controls.memory.value).toBe(64);
expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4);
expect(component.kdfConfig).toEqual(mockArgon2Config);
// Assert validators
expectArgon2Validation(
kdfConfigFormGroup.controls.iterations,
kdfConfigFormGroup.controls.memory,
kdfConfigFormGroup.controls.parallelism,
);
});
});
it.each([
[true, false],
[false, true],
])(
"should show log out banner = %s when feature flag observable is %s",
async (showLogOutBanner, forceUpgradeKdfFeatureFlag) => {
// Arrange
const mockPBKDF2Config = new PBKDF2KdfConfig(600_000);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
mockConfigService.getFeatureFlag$.mockReturnValue(of(forceUpgradeKdfFeatureFlag));
// Act
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
fixture.detectChanges();
// Assert
const calloutElement = fixture.debugElement.query((el) =>
el.nativeElement.textContent?.includes("kdfSettingsChangeLogoutWarning"),
);
if (showLogOutBanner) {
expect(calloutElement).not.toBeNull();
expect(calloutElement.nativeElement.textContent).toContain(
"kdfSettingsChangeLogoutWarning-used-i18n",
);
} else {
expect(calloutElement).toBeNull();
}
},
);
});
describe("KDF Type Switching", () => {
describe("switching from PBKDF2 to Argon2id", () => {
beforeEach(async () => {
// Setup component with initial PBKDF2 configuration
const mockPBKDF2Config = new PBKDF2KdfConfig(600_001);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
});
it("should update form structure and default values when KDF type changes to Argon2id", () => {
// Arrange
const formGroup = component["formGroup"];
// Act - change KDF type to Argon2id
formGroup.controls.kdf.setValue(KdfType.Argon2id);
// Assert form values update to Argon2id defaults
expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id);
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expect(kdfConfigFormGroup.controls.iterations.value).toBe(3); // Argon2id default
expect(kdfConfigFormGroup.controls.memory.value).toBe(64); // Argon2id default
expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4); // Argon2id default
});
it("should update validators when KDF type changes to Argon2id", () => {
// Arrange
const formGroup = component["formGroup"];
// Act - change KDF type to Argon2id
formGroup.controls.kdf.setValue(KdfType.Argon2id);
// Assert validators update to Argon2id validation rules
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expectArgon2Validation(
kdfConfigFormGroup.controls.iterations,
kdfConfigFormGroup.controls.memory,
kdfConfigFormGroup.controls.parallelism,
);
});
});
describe("switching from Argon2id to PBKDF2", () => {
beforeEach(async () => {
// Setup component with initial Argon2id configuration
const mockArgon2IdConfig = new Argon2KdfConfig(4, 65, 5);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2IdConfig);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
});
it("should update form structure and default values when KDF type changes to PBKDF2", () => {
// Arrange
const formGroup = component["formGroup"];
// Act - change KDF type back to PBKDF2
formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256);
// Assert form values update to PBKDF2 defaults
expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256);
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000); // PBKDF2 default
expect(kdfConfigFormGroup.controls.memory.value).toBeNull(); // PBKDF2 doesn't use memory
expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull(); // PBKDF2 doesn't use parallelism
});
it("should update validators when KDF type changes to PBKDF2", () => {
// Arrange
const formGroup = component["formGroup"];
// Act - change KDF type back to PBKDF2
formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256);
// Assert validators update to PBKDF2 validation rules
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expectPBKDF2Validation(
kdfConfigFormGroup.controls.iterations,
kdfConfigFormGroup.controls.memory,
kdfConfigFormGroup.controls.parallelism,
);
});
});
});
describe("openConfirmationModal", () => {
describe("when form is valid", () => {
it("should open confirmation modal with PBKDF2 config when form is submitted", async () => {
// Arrange
const mockPBKDF2Config = new PBKDF2KdfConfig(600_001);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Act
await component.openConfirmationModal();
// Assert
expect(mockDialogService.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
data: expect.objectContaining({
kdfConfig: mockPBKDF2Config,
}),
}),
);
});
it("should open confirmation modal with Argon2id config when form is submitted", async () => {
// Arrange
const mockArgon2Config = new Argon2KdfConfig(4, 65, 5);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Act
await component.openConfirmationModal();
// Assert
expect(mockDialogService.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
data: expect.objectContaining({
kdfConfig: mockArgon2Config,
}),
}),
);
});
it("should not open modal when form is invalid", async () => {
// Arrange
const mockPBKDF2Config = new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.min - 1);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Act
await component.openConfirmationModal();
// Assert
expect(mockDialogService.open).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,11 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { Subject, firstValueFrom, takeUntil, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import {
KdfConfigService,
@@ -31,11 +31,11 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected formGroup = this.formBuilder.group({
kdf: new FormControl(KdfType.PBKDF2_SHA256, [Validators.required]),
kdf: new FormControl<KdfType>(KdfType.PBKDF2_SHA256, [Validators.required]),
kdfConfig: this.formBuilder.group({
iterations: [this.kdfConfig.iterations],
memory: [null as number],
parallelism: [null as number],
iterations: new FormControl<number | null>(null),
memory: new FormControl<number | null>(null),
parallelism: new FormControl<number | null>(null),
}),
});
@@ -45,95 +45,102 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
protected ARGON2_MEMORY = Argon2KdfConfig.MEMORY;
protected ARGON2_PARALLELISM = Argon2KdfConfig.PARALLELISM;
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
constructor(
private dialogService: DialogService,
private kdfConfigService: KdfConfigService,
private accountService: AccountService,
private formBuilder: FormBuilder,
configService: ConfigService,
) {
this.kdfOptions = [
{ name: "PBKDF2 SHA-256", value: KdfType.PBKDF2_SHA256 },
{ name: "Argon2id", value: KdfType.Argon2id },
];
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
FeatureFlag.NoLogoutOnKdfChange,
);
}
async ngOnInit() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
this.formGroup.get("kdf").setValue(this.kdfConfig.kdfType);
this.formGroup.controls.kdf.setValue(this.kdfConfig.kdfType);
this.setFormControlValues(this.kdfConfig);
this.setFormValidators(this.kdfConfig.kdfType);
this.formGroup
.get("kdf")
.valueChanges.pipe(takeUntil(this.destroy$))
this.formGroup.controls.kdf.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((newValue) => {
this.updateKdfConfig(newValue);
this.updateKdfConfig(newValue!);
});
}
private updateKdfConfig(newValue: KdfType) {
let config: KdfConfig;
const validators: { [key: string]: ValidatorFn[] } = {
iterations: [],
memory: [],
parallelism: [],
};
switch (newValue) {
case KdfType.PBKDF2_SHA256:
config = new PBKDF2KdfConfig();
validators.iterations = [
Validators.required,
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
];
break;
case KdfType.Argon2id:
config = new Argon2KdfConfig();
validators.iterations = [
Validators.required,
Validators.min(Argon2KdfConfig.ITERATIONS.min),
Validators.max(Argon2KdfConfig.ITERATIONS.max),
];
validators.memory = [
Validators.required,
Validators.min(Argon2KdfConfig.MEMORY.min),
Validators.max(Argon2KdfConfig.MEMORY.max),
];
validators.parallelism = [
Validators.required,
Validators.min(Argon2KdfConfig.PARALLELISM.min),
Validators.max(Argon2KdfConfig.PARALLELISM.max),
];
break;
default:
throw new Error("Unknown KDF type.");
}
this.kdfConfig = config;
this.setFormValidators(validators);
this.setFormValidators(newValue);
this.setFormControlValues(this.kdfConfig);
}
private setFormValidators(validators: { [key: string]: ValidatorFn[] }) {
this.setValidators("kdfConfig.iterations", validators.iterations);
this.setValidators("kdfConfig.memory", validators.memory);
this.setValidators("kdfConfig.parallelism", validators.parallelism);
}
private setValidators(controlName: string, validators: ValidatorFn[]) {
const control = this.formGroup.get(controlName);
if (control) {
control.setValidators(validators);
control.updateValueAndValidity();
private setFormValidators(kdfType: KdfType) {
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
switch (kdfType) {
case KdfType.PBKDF2_SHA256:
kdfConfigFormGroup.controls.iterations.setValidators([
Validators.required,
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
]);
kdfConfigFormGroup.controls.memory.setValidators([]);
kdfConfigFormGroup.controls.parallelism.setValidators([]);
break;
case KdfType.Argon2id:
kdfConfigFormGroup.controls.iterations.setValidators([
Validators.required,
Validators.min(Argon2KdfConfig.ITERATIONS.min),
Validators.max(Argon2KdfConfig.ITERATIONS.max),
]);
kdfConfigFormGroup.controls.memory.setValidators([
Validators.required,
Validators.min(Argon2KdfConfig.MEMORY.min),
Validators.max(Argon2KdfConfig.MEMORY.max),
]);
kdfConfigFormGroup.controls.parallelism.setValidators([
Validators.required,
Validators.min(Argon2KdfConfig.PARALLELISM.min),
Validators.max(Argon2KdfConfig.PARALLELISM.max),
]);
break;
default:
throw new Error("Unknown KDF type.");
}
kdfConfigFormGroup.controls.iterations.updateValueAndValidity();
kdfConfigFormGroup.controls.memory.updateValueAndValidity();
kdfConfigFormGroup.controls.parallelism.updateValueAndValidity();
}
private setFormControlValues(kdfConfig: KdfConfig) {
this.formGroup.get("kdfConfig").reset();
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
kdfConfigFormGroup.reset();
if (kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
} else if (kdfConfig.kdfType === KdfType.Argon2id) {
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
this.formGroup.get("kdfConfig.memory").setValue(kdfConfig.memory);
this.formGroup.get("kdfConfig.parallelism").setValue(kdfConfig.parallelism);
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
kdfConfigFormGroup.controls.memory.setValue(kdfConfig.memory);
kdfConfigFormGroup.controls.parallelism.setValue(kdfConfig.parallelism);
}
}
@@ -155,12 +162,14 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
if (this.formGroup.invalid) {
return;
}
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
if (this.kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
} else if (this.kdfConfig.kdfType === KdfType.Argon2id) {
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
this.kdfConfig.memory = this.formGroup.get("kdfConfig.memory").value;
this.kdfConfig.parallelism = this.formGroup.get("kdfConfig.parallelism").value;
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
this.kdfConfig.memory = kdfConfigFormGroup.controls.memory.value!;
this.kdfConfig.parallelism = kdfConfigFormGroup.controls.parallelism.value!;
}
this.dialogService.open(ChangeKdfConfirmationComponent, {
data: {

View File

@@ -1,13 +1,15 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { PopoverModule } from "@bitwarden/components";
import { SharedModule } from "../../shared";
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
import { ChangeKdfComponent } from "./change-kdf.component";
@NgModule({
imports: [CommonModule, SharedModule],
imports: [CommonModule, SharedModule, PopoverModule],
declarations: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
exports: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
})

View File

@@ -4,13 +4,14 @@
<p bitTypography="body1" class="tw-mb-0 tw-mt-2">{{ "openingExtension" | i18n }}</p>
</ng-container>
@let page = extensionPage$ | async;
<ng-container *ngIf="pageState === BrowserPromptState.Error">
<p bitTypography="body1" class="tw-mb-4 tw-text-xl">{{ "openingExtensionError" | i18n }}</p>
<button
bitButton
buttonType="primary"
type="button"
(click)="openExtension()"
(click)="openExtension(page)"
id="bw-extension-prompt-button"
>
{{ "openExtension" | i18n }}
@@ -21,7 +22,7 @@
<ng-container *ngIf="pageState === BrowserPromptState.Success">
<i class="bwi tw-text-2xl bwi-check-circle tw-text-success-700" aria-hidden="true"></i>
<p bitTypography="body1" class="tw-mb-4 tw-text-xl">
{{ "openedExtensionViewAtRiskPasswords" | i18n }}
{{ pageToI18nKeyMap[page] | i18n }}
</p>
</ng-container>

View File

@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { BehaviorSubject } from "rxjs";
import { ActivatedRoute } from "@angular/router";
import { BehaviorSubject, of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -16,6 +17,7 @@ describe("BrowserExtensionPromptComponent", () => {
let component: BrowserExtensionPromptComponent;
const start = jest.fn();
const openExtension = jest.fn();
const registerPopupUrl = jest.fn();
const pageState$ = new BehaviorSubject<BrowserPromptState>(BrowserPromptState.Loading);
const setAttribute = jest.fn();
const getAttribute = jest.fn().mockReturnValue("width=1010");
@@ -23,6 +25,7 @@ describe("BrowserExtensionPromptComponent", () => {
beforeEach(async () => {
start.mockClear();
openExtension.mockClear();
registerPopupUrl.mockClear();
setAttribute.mockClear();
getAttribute.mockClear();
@@ -41,12 +44,20 @@ describe("BrowserExtensionPromptComponent", () => {
providers: [
{
provide: BrowserExtensionPromptService,
useValue: { start, openExtension, pageState$ },
useValue: { start, openExtension, registerPopupUrl, pageState$ },
},
{
provide: I18nService,
useValue: { t: (key: string) => key },
},
{
provide: ActivatedRoute,
useValue: {
queryParamMap: of({
get: (key: string) => null,
}),
},
},
],
}).compileComponents();
@@ -92,7 +103,7 @@ describe("BrowserExtensionPromptComponent", () => {
button.click();
expect(openExtension).toHaveBeenCalledTimes(1);
expect(openExtension).toHaveBeenCalledWith(true);
expect(openExtension).toHaveBeenCalledWith("openAtRiskPasswords", true);
});
});

View File

@@ -1,6 +1,10 @@
import { CommonModule, DOCUMENT } from "@angular/common";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { map, Observable, of, tap } from "rxjs";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { ButtonComponent, IconModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -16,6 +20,8 @@ import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manua
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent],
})
export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
protected VaultMessages = VaultMessages;
/** Current state of the prompt page */
protected pageState$ = this.browserExtensionPromptService.pageState$;
@@ -25,14 +31,33 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
/** Content of the meta[name="viewport"] element */
private viewportContent: string | null = null;
/** Map of extension page identifiers to their i18n keys */
protected readonly pageToI18nKeyMap: Record<string, string> = {
[VaultMessages.OpenAtRiskPasswords]: "openedExtensionViewAtRiskPasswords",
AutoConfirm: "autoConfirmExtensionOpened",
} as const;
protected extensionPage$: Observable<string> = of(VaultMessages.OpenAtRiskPasswords);
constructor(
private browserExtensionPromptService: BrowserExtensionPromptService,
private route: ActivatedRoute,
@Inject(DOCUMENT) private document: Document,
) {}
) {
this.extensionPage$ = this.route.queryParamMap.pipe(
map((params) => params.get("url") ?? VaultMessages.OpenAtRiskPasswords),
);
this.extensionPage$
.pipe(
tap((url) => this.browserExtensionPromptService.registerPopupUrl(url)),
takeUntilDestroyed(),
)
.subscribe();
}
ngOnInit(): void {
this.browserExtensionPromptService.start();
// It is not be uncommon for users to hit this page from a mobile device.
// There are global styles and the viewport meta tag that set a min-width
// for the page which cause it to render poorly. Remove them here.
@@ -57,7 +82,7 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
}
}
openExtension(): void {
this.browserExtensionPromptService.openExtension(true);
async openExtension(command: string) {
await this.browserExtensionPromptService.openExtension(command, true);
}
}

View File

@@ -692,6 +692,12 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
async archive(cipher: C) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "archiveItem" },
content: { key: "archiveItemConfirmDesc" },
@@ -702,10 +708,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
return;
}
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;
}
const activeUserId = await firstValueFrom(this.userId$);
try {
await this.cipherArchiveService.archiveWithServer(cipher.id as CipherId, activeUserId);
@@ -724,6 +726,10 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
async bulkArchive(ciphers: C[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "archiveBulkItems" },
content: { key: "archiveBulkItemsConfirmDesc" },
@@ -734,10 +740,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
return;
}
if (!(await this.repromptCipher(ciphers))) {
return;
}
const activeUserId = await firstValueFrom(this.userId$);
const cipherIds = ciphers.map((c) => c.id as CipherId);
try {

View File

@@ -1,4 +1,5 @@
import { TestBed } from "@angular/core/testing";
import { firstValueFrom, Observable } from "rxjs";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -9,11 +10,13 @@ import {
BrowserExtensionPromptService,
BrowserPromptState,
} from "./browser-extension-prompt.service";
import { WebBrowserInteractionService } from "./web-browser-interaction.service";
describe("BrowserExtensionPromptService", () => {
let service: BrowserExtensionPromptService;
const setAnonLayoutWrapperData = jest.fn();
const isFirefox = jest.fn().mockReturnValue(false);
const openExtensionMock = jest.fn().mockResolvedValue(undefined);
const postMessage = jest.fn();
window.postMessage = postMessage;
@@ -21,12 +24,14 @@ describe("BrowserExtensionPromptService", () => {
setAnonLayoutWrapperData.mockClear();
postMessage.mockClear();
isFirefox.mockClear();
openExtensionMock.mockClear();
TestBed.configureTestingModule({
providers: [
BrowserExtensionPromptService,
{ provide: AnonLayoutWrapperDataService, useValue: { setAnonLayoutWrapperData } },
{ provide: PlatformUtilsService, useValue: { isFirefox } },
{ provide: WebBrowserInteractionService, useValue: { openExtension: openExtensionMock } },
],
});
jest.useFakeTimers();
@@ -45,38 +50,42 @@ describe("BrowserExtensionPromptService", () => {
});
});
describe("start", () => {
describe("registerPopupUrl", () => {
it("posts message to check for extension", () => {
service.start();
service.registerPopupUrl(VaultMessages.OpenAtRiskPasswords);
expect(window.postMessage).toHaveBeenCalledWith({
command: VaultMessages.checkBwInstalled,
});
});
it("sets timeout for error state", () => {
service.start();
expect(service["extensionCheckTimeout"]).not.toBeNull();
});
it("attempts to open the extension when installed", () => {
service.start();
service.registerPopupUrl(VaultMessages.OpenAtRiskPasswords);
window.dispatchEvent(
new MessageEvent("message", { data: { command: VaultMessages.HasBwInstalled } }),
);
expect(window.postMessage).toHaveBeenCalledTimes(2);
expect(window.postMessage).toHaveBeenCalledWith({
command: VaultMessages.checkBwInstalled,
});
expect(window.postMessage).toHaveBeenCalledWith({
command: VaultMessages.OpenAtRiskPasswords,
});
});
});
describe("success state", () => {
beforeEach(() => {
describe("start", () => {
it("sets timeout for error state", () => {
service.start();
expect(service["extensionCheckTimeout"]).not.toBeNull();
});
});
describe("success registerPopupUrl", () => {
beforeEach(() => {
service.registerPopupUrl(VaultMessages.OpenAtRiskPasswords);
window.dispatchEvent(
new MessageEvent("message", { data: { command: VaultMessages.PopupOpened } }),
@@ -155,7 +164,7 @@ describe("BrowserExtensionPromptService", () => {
describe("error state", () => {
beforeEach(() => {
service.start();
service.registerPopupUrl(VaultMessages.OpenAtRiskPasswords);
jest.advanceTimersByTime(1000);
});
@@ -172,19 +181,17 @@ describe("BrowserExtensionPromptService", () => {
});
});
it("sets manual open state when open extension is called", (done) => {
service.openExtension(true);
it("sets manual open state when open extension is called", async () => {
const pageState$: Observable<BrowserPromptState> = service.pageState$;
await service.openExtension(VaultMessages.OpenAtRiskPasswords, true);
jest.advanceTimersByTime(1000);
service.pageState$.subscribe((state) => {
expect(state).toBe(BrowserPromptState.ManualOpen);
done();
});
expect(await firstValueFrom(pageState$)).toBe(BrowserPromptState.ManualOpen);
});
it("shows success state when extension auto opens", (done) => {
service.openExtension(true);
it("shows success state when extension auto opens", async () => {
await service.openExtension(VaultMessages.OpenAtRiskPasswords, true);
jest.advanceTimersByTime(500); // don't let timeout occur
@@ -192,11 +199,9 @@ describe("BrowserExtensionPromptService", () => {
new MessageEvent("message", { data: { command: VaultMessages.PopupOpened } }),
);
service.pageState$.subscribe((state) => {
expect(state).toBe(BrowserPromptState.Success);
expect(service["extensionCheckTimeout"]).toBeUndefined();
done();
});
const pageState$: Observable<BrowserPromptState> = service.pageState$;
expect(await firstValueFrom(pageState$)).toBe(BrowserPromptState.Success);
expect(service["extensionCheckTimeout"]).toBeUndefined();
});
});
});

View File

@@ -4,10 +4,13 @@ import { BehaviorSubject, fromEvent } from "rxjs";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { AnonLayoutWrapperDataService } from "@bitwarden/components";
import { WebBrowserInteractionService } from "./web-browser-interaction.service";
export const BrowserPromptState = {
Loading: "loading",
Error: "error",
@@ -36,6 +39,7 @@ export class BrowserExtensionPromptService {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private destroyRef: DestroyRef,
private platformUtilsService: PlatformUtilsService,
private webBrowserInteractionService: WebBrowserInteractionService,
) {}
start(): void {
@@ -52,14 +56,19 @@ export class BrowserExtensionPromptService {
this.setErrorState(BrowserPromptState.ManualOpen);
return;
}
}
this.checkForBrowserExtension();
registerPopupUrl(url: string) {
this.checkForBrowserExtension(url);
}
/** Post a message to the extension to open */
openExtension(setManualErrorTimeout = false) {
window.postMessage({ command: VaultMessages.OpenAtRiskPasswords });
async openExtension(url: string, setManualErrorTimeout = false) {
if (url == VaultMessages.OpenAtRiskPasswords) {
window.postMessage({ command: url });
} else {
await this.webBrowserInteractionService.openExtension(ExtensionPageUrls[url]);
}
// Optionally, configure timeout to show the manual open error state if
// the extension does not open within one second.
if (setManualErrorTimeout) {
@@ -72,11 +81,11 @@ export class BrowserExtensionPromptService {
}
/** Send message checking for the browser extension */
private checkForBrowserExtension() {
private checkForBrowserExtension(url: string) {
fromEvent<MessageEvent>(window, "message")
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
void this.getMessages(event);
void this.getMessages(event, url);
});
window.postMessage({ command: VaultMessages.checkBwInstalled });
@@ -88,9 +97,9 @@ export class BrowserExtensionPromptService {
}
/** Handle window message events */
private getMessages(event: any) {
private async getMessages(event: any, url: string) {
if (event.data.command === VaultMessages.HasBwInstalled) {
this.openExtension();
await this.openExtension(url);
}
if (event.data.command === VaultMessages.PopupOpened) {

View File

@@ -11,7 +11,7 @@
"criticalApplications": {
"message": "Critical applications"
},
"noCriticalAppsAtRisk":{
"noCriticalAppsAtRisk": {
"message": "No critical applications at risk"
},
"accessIntelligence": {
@@ -1719,7 +1719,6 @@
}
}
},
"dontAskAgainOnThisDeviceFor30Days": {
"message": "Don't ask again on this device for 30 days"
},
@@ -2090,9 +2089,6 @@
"encKeySettings": {
"message": "Encryption key settings"
},
"kdfAlgorithm": {
"message": "KDF algorithm"
},
"kdfIterations": {
"message": "KDF iterations"
},
@@ -2127,9 +2123,6 @@
"argon2Desc": {
"message": "Higher KDF iterations, memory, and parallelism can help protect your master password from being brute forced by an attacker."
},
"changeKdf": {
"message": "Change KDF"
},
"encKeySettingsChanged": {
"message": "Encryption key settings saved"
},
@@ -2146,22 +2139,22 @@
"message": "Proceeding will also log you out of your current session, requiring you to log back in. You will also be prompted for two-step login again, if set up. Active sessions on other devices may continue to remain active for up to one hour."
},
"newDeviceLoginProtection": {
"message":"New device login"
"message": "New device login"
},
"turnOffNewDeviceLoginProtection": {
"message":"Turn off new device login protection"
"message": "Turn off new device login protection"
},
"turnOnNewDeviceLoginProtection": {
"message":"Turn on new device login protection"
"message": "Turn on new device login protection"
},
"turnOffNewDeviceLoginProtectionModalDesc": {
"message":"Proceed below to turn off the verification emails bitwarden sends when you login from a new device."
"message": "Proceed below to turn off the verification emails bitwarden sends when you login from a new device."
},
"turnOnNewDeviceLoginProtectionModalDesc": {
"message":"Proceed below to have bitwarden send you verification emails when you login from a new device."
"message": "Proceed below to have bitwarden send you verification emails when you login from a new device."
},
"turnOffNewDeviceLoginProtectionWarning": {
"message":"With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login."
"message": "With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login."
},
"accountNewDeviceLoginProtectionSaved": {
"message": "New device login protection changes saved"
@@ -2297,7 +2290,7 @@
"selectImportCollection": {
"message": "Select a collection"
},
"importTargetHintCollection": {
"importTargetHintCollection": {
"message": "Select this option if you want the imported file contents moved to a collection"
},
"importTargetHintFolder": {
@@ -5718,7 +5711,7 @@
"message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'"
},
"organizationDataOwnershipContentAnchor":{
"organizationDataOwnershipContentAnchor": {
"message": "credential lifecycle",
"description": "This will be used as a hyperlink"
},
@@ -5741,6 +5734,65 @@
"message": "Learn more about the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'"
},
"availableNow": {
"message": "Available now"
},
"autoConfirm": {
"message": "Automatic confirmation of new users"
},
"autoConfirmDescription": {
"message": "New users invited to the organization will be automatically confirmed when an admins device is unlocked.",
"description": "This is the description of the policy as it appears in the 'Policies' page"
},
"howToTurnOnAutoConfirm": {
"message": "How to turn on automatic user confirmation"
},
"autoConfirmStep1": {
"message": "Open your Bitwarden extension."
},
"autoConfirmStep2a": {
"message": "Select",
"description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on.'"
},
"autoConfirmStep2b": {
"message": " Turn on.",
"description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on.'"
},
"autoConfirmExtensionOpened": {
"message": "Successfully opened the Bitwarden browser extension. You can now activate the automatic user confirmation setting."
},
"autoConfirmPolicyEditDescription": {
"message": "New users invited to the organization will be automatically confirmed when an admins device is unlocked. Before turning on this policy, please review and agree to the following: ",
"description": "This is the description of the policy as it appears inside the policy edit dialog"
},
"autoConfirmAcceptSecurityRiskTitle": {
"message": "Potential security risk. "
},
"autoConfirmAcceptSecurityRiskDescription": {
"message": "Automatic user confirmation could pose a security risk to your organizations data."
},
"autoConfirmAcceptSecurityRiskLearnMore": {
"message": "Learn about the risks",
"description": "The is the link copy for the first check box option in the edit policy dialog"
},
"autoConfirmSingleOrgRequired": {
"message": "Single organization policy required. "
},
"autoConfirmSingleOrgRequiredDescription": {
"message": "Anyone part of more than one organization will have their access revoked until they leave the other organizations."
},
"autoConfirmSingleOrgExemption": {
"message": "Single organization policy will extend to all roles. "
},
"autoConfirmNoEmergencyAccess": {
"message": "No emergency access. "
},
"autoConfirmNoEmergencyAccessDescription": {
"message": "Emergency Access will be removed."
},
"autoConfirmCheckBoxLabel": {
"message": "I accept these risks and policy updates"
},
"personalOwnership": {
"message": "Remove individual vault"
},
@@ -10392,27 +10444,9 @@
"memberAccessReportAuthenticationEnabledFalse": {
"message": "Off"
},
"higherKDFIterations": {
"message": "Higher KDF iterations can help protect your master password from being brute forced by an attacker."
},
"incrementsOf100,000": {
"message": "increments of 100,000"
},
"smallIncrements": {
"message": "small increments"
},
"kdfIterationRecommends": {
"message": "We recommend 600,000 or more"
},
"kdfToHighWarningIncreaseInIncrements": {
"message": "For older devices, setting your KDF too high may lead to performance issues. Increase the value in $VALUE$ and test your devices.",
"placeholders": {
"value": {
"content": "$1",
"example": "increments of 100,000"
}
}
},
"providerReinstate": {
"message": " Contact Customer Support to reinstate your subscription."
},
@@ -11097,7 +11131,7 @@
"orgTrustWarning1": {
"message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint."
},
"trustUser":{
"trustUser": {
"message": "Trust user"
},
"sshKeyWrongPassword": {
@@ -11133,7 +11167,7 @@
"openingExtension": {
"message": "Opening the Bitwarden browser extension"
},
"somethingWentWrong":{
"somethingWentWrong": {
"message": "Something went wrong..."
},
"openingExtensionError": {
@@ -11220,7 +11254,7 @@
}
}
},
"accountDeprovisioningNotification" : {
"accountDeprovisioningNotification": {
"message": "Administrators now have the ability to delete member accounts that belong to a claimed domain."
},
"deleteManagedUserWarningDesc": {
@@ -11311,14 +11345,14 @@
"upgradeForFullEventsMessage": {
"message": "Event logs are not stored for your organization. Upgrade to a Teams or Enterprise plan to get full access to organization event logs."
},
"upgradeEventLogTitleMessage" : {
"message" : "Upgrade to see event logs from your organization."
"upgradeEventLogTitleMessage": {
"message": "Upgrade to see event logs from your organization."
},
"upgradeEventLogMessage":{
"message" : "These events are examples only and do not reflect real events within your Bitwarden organization."
"upgradeEventLogMessage": {
"message": "These events are examples only and do not reflect real events within your Bitwarden organization."
},
"viewEvents":{
"message" : "View Events"
"viewEvents": {
"message": "View Events"
},
"cannotCreateCollection": {
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
@@ -11637,14 +11671,14 @@
}
}
},
"unlimitedSecretsAndProjects": {
"unlimitedSecretsAndProjects": {
"message": "Unlimited secrets and projects"
},
"providersubscriptionCanceled": {
"providersubscriptionCanceled": {
"message": "Subscription canceled"
},
"providersubCanceledmessage": {
"message" : "To resubscribe, contact Bitwarden Customer Support."
"message": "To resubscribe, contact Bitwarden Customer Support."
},
"showMore": {
"message": "Show more"
@@ -11896,5 +11930,32 @@
},
"viewbusinessplans": {
"message": "View business plans"
},
"updateEncryptionSettings": {
"message": "Update encryption settings"
},
"updateYourEncryptionSettings": {
"message": "Update your encryption settings"
},
"updateSettings": {
"message": "Update settings"
},
"algorithm": {
"message": "Algorithm"
},
"encryptionKeySettingsHowShouldWeEncryptYourData": {
"message": "Choose how Bitwarden should encrypt your vault data. All options are secure, but stronger methods offer better protection - especially against brute-force attacks. Bitwarden recommends the default setting for most users."
},
"encryptionKeySettingsIncreaseImproveSecurity": {
"message": "Increasing the values above the default will improve security, but your vault may take longer to unlock as a result."
},
"encryptionKeySettingsAlgorithmPopoverTitle": {
"message": "About encryption algorithms"
},
"encryptionKeySettingsAlgorithmPopoverPBKDF2": {
"message": "PBKDF2-SHA256 is a well-tested encryption method that balances security and performance. Good for all users."
},
"encryptionKeySettingsAlgorithmPopoverArgon2Id": {
"message": "Argon2id offers stronger protection against modern attacks. Best for advanced users with powerful devices."
}
}