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:
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export {
|
||||
vNextOrganizationDataOwnershipPolicy,
|
||||
vNextOrganizationDataOwnershipPolicyComponent,
|
||||
} from "./vnext-organization-data-ownership.component";
|
||||
export { AutoConfirmPolicy } from "./auto-confirm-policy.component";
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ×
|
||||
{{ 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 ×
|
||||
{{ 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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 admin’s 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 admin’s 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 organization’s 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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user