1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

moving ownership of Auth vault timeout input component to KM (#17180)

(cherry picked from commit 5e595dabf71cdb312ae9e1e3bcc3121b3aebf19f)
This commit is contained in:
Maciej Zieniuk
2025-11-12 22:15:01 +01:00
committed by GitHub
parent e8ac3008c7
commit 3da3aa5e8c
14 changed files with 78 additions and 77 deletions

View File

@@ -43,9 +43,6 @@ export * from "./user-verification/user-verification-dialog.component";
export * from "./user-verification/user-verification-dialog.types";
export * from "./user-verification/user-verification-form-input.component";
// vault timeout
export * from "./vault-timeout-input/vault-timeout-input.component";
// sso
export * from "./sso/sso.component";
export * from "./sso/sso-component.service";

View File

@@ -1,47 +0,0 @@
<div [formGroup]="form" class="tw-mb-4">
<bit-form-field [disableMargin]="!showCustom">
<bit-label>{{ "vaultTimeout1" | i18n }}</bit-label>
<bit-select formControlName="vaultTimeout">
<bit-option
*ngFor="let o of filteredVaultTimeoutOptions"
[value]="o.value"
[label]="o.name"
></bit-option>
</bit-select>
</bit-form-field>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" *ngIf="showCustom" formGroupName="custom">
<bit-form-field class="tw-col-span-6" disableMargin>
<input
bitInput
type="number"
min="0"
formControlName="hours"
aria-labelledby="maximum-error"
/>
<bit-label>{{ "hours" | i18n }}</bit-label>
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-end" disableMargin>
<input
bitInput
type="number"
min="0"
name="minutes"
formControlName="minutes"
aria-labelledby="maximum-error"
/>
<bit-label>{{ "minutes" | i18n }}</bit-label>
</bit-form-field>
</div>
<bit-hint *ngIf="vaultTimeoutPolicy != null && !exceedsMaximumTimeout">
{{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }}
</bit-hint>
<small *ngIf="!exceedsMinimumTimeout" class="tw-text-danger">
<i class="bwi bwi-error" aria-hidden="true"></i> {{ "vaultCustomTimeoutMinimum" | i18n }}
</small>
<small class="tw-text-danger" *ngIf="exceedsMaximumTimeout" id="maximum-error">
<i class="bwi bwi-error" aria-hidden="true"></i>
{{
"vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes
}}
</small>
</div>

View File

@@ -1,90 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { BehaviorSubject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { VaultTimeoutInputComponent } from "./vault-timeout-input.component";
describe("VaultTimeoutInputComponent", () => {
let component: VaultTimeoutInputComponent;
let fixture: ComponentFixture<VaultTimeoutInputComponent>;
const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({}));
const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([]));
const mockUserId = Utils.newGuid() as UserId;
const accountService = mockAccountServiceWith(mockUserId);
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VaultTimeoutInputComponent],
providers: [
{ provide: PolicyService, useValue: { policiesByType$ } },
{ provide: AccountService, useValue: accountService },
{ provide: VaultTimeoutSettingsService, useValue: { availableVaultTimeoutActions$ } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
}).compileComponents();
fixture = TestBed.createComponent(VaultTimeoutInputComponent);
component = fixture.componentInstance;
component.vaultTimeoutOptions = [
{ name: "oneMinute", value: 1 },
{ name: "fiveMinutes", value: 5 },
{ name: "fifteenMinutes", value: 15 },
{ name: "thirtyMinutes", value: 30 },
{ name: "oneHour", value: 60 },
{ name: "fourHours", value: 240 },
{ name: "onRefresh", value: VaultTimeoutStringType.OnRestart },
];
fixture.detectChanges();
});
describe("form", () => {
beforeEach(async () => {
await component.ngOnInit();
});
it("invokes the onChange associated with `ControlValueAccessor`", () => {
const onChange = jest.fn();
component.registerOnChange(onChange);
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnRestart);
expect(onChange).toHaveBeenCalledWith(VaultTimeoutStringType.OnRestart);
});
it("updates custom value to match preset option", () => {
// 1 hour
component.form.controls.vaultTimeout.setValue(60);
expect(component.form.value.custom).toEqual({ hours: 1, minutes: 0 });
// 17 minutes
component.form.controls.vaultTimeout.setValue(17);
expect(component.form.value.custom).toEqual({ hours: 0, minutes: 17 });
// 2.25 hours
component.form.controls.vaultTimeout.setValue(135);
expect(component.form.value.custom).toEqual({ hours: 2, minutes: 15 });
});
it("sets custom timeout to 0 when a preset string option is selected", () => {
// Set custom value to random values
component.form.controls.custom.setValue({ hours: 1, minutes: 1 });
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnLocked);
expect(component.form.value.custom).toEqual({ hours: 0, minutes: 0 });
});
});
});

View File

@@ -1,292 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, OnDestroy, OnInit } from "@angular/core";
import {
AbstractControl,
ControlValueAccessor,
FormBuilder,
FormControl,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
ValidationErrors,
Validator,
} from "@angular/forms";
import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutSettingsService,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { FormFieldModule, SelectModule } from "@bitwarden/components";
type VaultTimeoutForm = FormGroup<{
vaultTimeout: FormControl<VaultTimeout | null>;
custom: FormGroup<{
hours: FormControl<number | null>;
minutes: FormControl<number | null>;
}>;
}>;
type VaultTimeoutFormValue = VaultTimeoutForm["value"];
// 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-vault-timeout-input",
templateUrl: "vault-timeout-input.component.html",
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: VaultTimeoutInputComponent,
},
{
provide: NG_VALIDATORS,
multi: true,
useExisting: VaultTimeoutInputComponent,
},
],
})
export class VaultTimeoutInputComponent
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
{
protected readonly VaultTimeoutAction = VaultTimeoutAction;
get showCustom() {
return this.form.get("vaultTimeout").value === VaultTimeoutInputComponent.CUSTOM_VALUE;
}
get exceedsMinimumTimeout(): boolean {
return (
!this.showCustom || this.customTimeInMinutes() > VaultTimeoutInputComponent.MIN_CUSTOM_MINUTES
);
}
get exceedsMaximumTimeout(): boolean {
return (
this.showCustom &&
this.customTimeInMinutes() >
this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours
);
}
get filteredVaultTimeoutOptions(): VaultTimeoutOption[] {
// by policy max value
if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) {
return this.vaultTimeoutOptions;
}
return this.vaultTimeoutOptions.filter((option) => {
if (typeof option.value === "number") {
return option.value <= this.vaultTimeoutPolicy.data.minutes;
}
return false;
});
}
static CUSTOM_VALUE = -100;
static MIN_CUSTOM_MINUTES = 0;
form: VaultTimeoutForm = this.formBuilder.group({
vaultTimeout: [null],
custom: this.formBuilder.group({
hours: [null],
minutes: [null],
}),
});
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() vaultTimeoutOptions: VaultTimeoutOption[];
vaultTimeoutPolicy: Policy;
vaultTimeoutPolicyHours: number;
vaultTimeoutPolicyMinutes: number;
protected canLockVault$: Observable<boolean>;
private onChange: (vaultTimeout: VaultTimeout) => void;
private validatorChange: () => void;
private destroy$ = new Subject<void>();
constructor(
private formBuilder: FormBuilder,
private policyService: PolicyService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private i18nService: I18nService,
private accountService: AccountService,
) {}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
filter((policy) => policy != null),
takeUntil(this.destroy$),
)
.subscribe((policy) => {
this.vaultTimeoutPolicy = policy;
this.applyVaultTimeoutPolicy();
});
this.form.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value: VaultTimeoutFormValue) => {
if (this.onChange) {
this.onChange(this.getVaultTimeout(value));
}
});
// Assign the current value to the custom fields
// so that if the user goes from a numeric value to custom
// we can initialize the custom fields with the current value
// ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields
this.form.controls.vaultTimeout.valueChanges
.pipe(
filter((value) => value !== VaultTimeoutInputComponent.CUSTOM_VALUE),
takeUntil(this.destroy$),
)
.subscribe((value) => {
const current = typeof value === "string" ? 0 : Math.max(value, 0);
// This cannot emit an event b/c it would cause form.valueChanges to fire again
// and we are already handling that above so just silently update
// custom fields when vaultTimeout changes to a non-custom value
this.form.patchValue(
{
custom: {
hours: Math.floor(current / 60),
minutes: current % 60,
},
},
{ emitEvent: false },
);
});
this.canLockVault$ = this.vaultTimeoutSettingsService
.availableVaultTimeoutActions$()
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
ngOnChanges() {
if (
!this.vaultTimeoutOptions.find((p) => p.value === VaultTimeoutInputComponent.CUSTOM_VALUE)
) {
this.vaultTimeoutOptions.push({
name: this.i18nService.t("custom"),
value: VaultTimeoutInputComponent.CUSTOM_VALUE,
});
}
}
getVaultTimeout(value: VaultTimeoutFormValue) {
if (value.vaultTimeout !== VaultTimeoutInputComponent.CUSTOM_VALUE) {
return value.vaultTimeout;
}
return value.custom.hours * 60 + value.custom.minutes;
}
writeValue(value: number): void {
if (value == null) {
return;
}
if (this.vaultTimeoutOptions.every((p) => p.value !== value)) {
this.form.setValue({
vaultTimeout: VaultTimeoutInputComponent.CUSTOM_VALUE,
custom: {
hours: Math.floor(value / 60),
minutes: value % 60,
},
});
return;
}
this.form.patchValue({
vaultTimeout: value,
});
}
registerOnChange(onChange: any): void {
this.onChange = onChange;
}
registerOnTouched(onTouched: any): void {
// Empty
}
setDisabledState?(isDisabled: boolean): void {
// Empty
}
validate(control: AbstractControl): ValidationErrors {
if (this.vaultTimeoutPolicy && this.vaultTimeoutPolicy?.data?.minutes < control.value) {
return { policyError: true };
}
if (!this.exceedsMinimumTimeout) {
return { minTimeoutError: true };
}
return null;
}
registerOnValidatorChange(fn: () => void): void {
this.validatorChange = fn;
}
private customTimeInMinutes() {
return this.form.value.custom.hours * 60 + this.form.value.custom.minutes;
}
private applyVaultTimeoutPolicy() {
this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60);
this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60;
this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => {
// Always include the custom option
if (vaultTimeoutOption.value === VaultTimeoutInputComponent.CUSTOM_VALUE) {
return true;
}
if (typeof vaultTimeoutOption.value === "number") {
// Include numeric values that are less than or equal to the policy minutes
return vaultTimeoutOption.value <= this.vaultTimeoutPolicy.data.minutes;
}
// Exclude all string cases when there's a numeric policy defined
return false;
});
// Only call validator change if it's been set
if (this.validatorChange) {
this.validatorChange();
}
}
}