1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-26057] Enforce session timeout policy (#17424)

* enforce session timeout policy

* better angular validation

* lint fix

* missing switch break

* fallback when timeout not supported with highest available timeout

* failing unit tests

* incorrect policy message

* vault timeout type adjustments

* fallback to "on browser refresh" for browser, when policy is set to "on system locked", but not available (Safari)

* docs, naming improvements

* fallback for current user session timeout to "on refresh", when policy is set to "on system locked", but not available.

* don't display policy message when the policy does not affect available timeout options

* 8 hours default when changing from non-numeric timeout to Custom.

* failing unit test

* missing locales, changing functions access to private, docs

* removal of redundant magic number

* missing await

* await once for available timeout options

* adjusted messaging

* unit test coverage

* vault timeout numeric module exports

* unit test coverage
This commit is contained in:
Maciej Zieniuk
2025-12-05 14:55:59 +01:00
committed by GitHub
parent c036ffd775
commit bbea11388a
48 changed files with 3344 additions and 569 deletions

View File

@@ -12,3 +12,4 @@ export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-
export { SessionTimeoutSettingsComponent } from "./session-timeout/components/session-timeout-settings.component";
export { SessionTimeoutSettingsComponentService } from "./session-timeout/services/session-timeout-settings-component.service";
export { SessionTimeoutInputComponent } from "./session-timeout/components/session-timeout-input.component";
export { SessionTimeoutInputLegacyComponent } from "./session-timeout/components/session-timeout-input-legacy.component";

View File

@@ -0,0 +1,47 @@
<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

@@ -0,0 +1,296 @@
// 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";
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"];
/**
* @deprecated Use {@link SessionTimeoutInputComponent} instead.
*
* TODO Cleanup once feature flag enabled: https://bitwarden.atlassian.net/browse/PM-27297
*/
// 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: "bit-session-timeout-input-legacy",
templateUrl: "session-timeout-input-legacy.component.html",
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: SessionTimeoutInputLegacyComponent,
},
{
provide: NG_VALIDATORS,
multi: true,
useExisting: SessionTimeoutInputLegacyComponent,
},
],
})
export class SessionTimeoutInputLegacyComponent
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
{
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 readonly VaultTimeoutAction = VaultTimeoutAction;
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,
) {}
get showCustom() {
return this.form.get("vaultTimeout").value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE;
}
get exceedsMinimumTimeout(): boolean {
return (
!this.showCustom ||
this.customTimeInMinutes() > SessionTimeoutInputLegacyComponent.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;
});
}
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 !== SessionTimeoutInputLegacyComponent.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 === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE,
)
) {
this.vaultTimeoutOptions.push({
name: this.i18nService.t("custom"),
value: SessionTimeoutInputLegacyComponent.CUSTOM_VALUE,
});
}
}
getVaultTimeout(value: VaultTimeoutFormValue) {
if (value.vaultTimeout !== SessionTimeoutInputLegacyComponent.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: SessionTimeoutInputLegacyComponent.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 === SessionTimeoutInputLegacyComponent.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();
}
}
}

View File

@@ -1,47 +1,51 @@
<div [formGroup]="form" class="tw-mb-4">
<bit-form-field [disableMargin]="!showCustom">
<bit-form-field [disableMargin]="!isCustomTimeoutType">
<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>
@for (option of availableTimeoutOptions(); track option.value) {
<bit-option [value]="option.value" [label]="option.name | i18n"></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>
@if (isCustomTimeoutType) {
<div class="tw-grid tw-grid-cols-12 tw-gap-4" formGroupName="custom">
<bit-form-field class="tw-col-span-6" disableMargin>
<input
bitInput
type="number"
min="0"
formControlName="hours"
aria-describedby="session-timeout-maximum-error"
/>
<bit-label>{{ "hours" | i18n }}</bit-label>
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-start" disableMargin>
<input
bitInput
type="number"
[min]="customMinutesMin"
max="59"
formControlName="minutes"
aria-describedby="session-timeout-maximum-error"
/>
<bit-label>{{ "minutes" | i18n }}</bit-label>
</bit-form-field>
</div>
}
@if (form.hasError("maxTimeoutError")) {
<div class="tw-mt-1 tw-text-danger tw-text-xs" id="session-timeout-maximum-error">
<i class="bwi bwi-error" aria-hidden="true"></i>
{{
"sessionTimeoutSettingsPolicyMaximumError"
| i18n: maxSessionTimeoutPolicyHours : maxSessionTimeoutPolicyMinutes
}}
</div>
} @else if (maxSessionTimeoutPolicyData != null) {
@let policyTimeoutMessage = policyTimeoutMessage$ | async;
@if (policyTimeoutMessage != null) {
<bit-hint class="tw-mb-1" id="session-timeout-maximum-error">
{{ policyTimeoutMessage }}
</bit-hint>
}
}
</div>

View File

@@ -1,90 +1,819 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { BehaviorSubject } from "rxjs";
import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
VaultTimeoutSettingsService,
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutOption,
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 { LogService } from "@bitwarden/logging";
import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service";
import { SessionTimeoutInputComponent } from "./session-timeout-input.component";
describe("SessionTimeoutInputComponent", () => {
let component: SessionTimeoutInputComponent;
let fixture: ComponentFixture<SessionTimeoutInputComponent>;
const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({}));
const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([]));
const mockUserId = Utils.newGuid() as UserId;
const accountService = mockAccountServiceWith(mockUserId);
// Test constants
const MOCK_USER_ID = "user-id" as UserId;
const ONE_MINUTE = 1;
const FIVE_MINUTES = 5;
const FIFTEEN_MINUTES = 15;
const THIRTY_MINUTES = 30;
const ONE_HOUR = 60;
const FOUR_HOURS = 240;
const NINETY_MINUTES = 90;
// Mock services
let mockPolicyService: MockProxy<PolicyService>;
let mockSessionTimeoutSettingsComponentService: MockProxy<SessionTimeoutSettingsComponentService>;
let mockSessionTimeoutTypeService: MockProxy<SessionTimeoutTypeService>;
let mockI18nService: MockProxy<I18nService>;
let mockLogService: MockProxy<LogService>;
let accountService: AccountService;
// BehaviorSubjects for reactive testing
let policies$: BehaviorSubject<Policy[]>;
let availableTimeoutOptions: VaultTimeoutOption[];
beforeEach(async () => {
// Initialize BehaviorSubjects
policies$ = new BehaviorSubject<Policy[]>([]);
// Initialize available timeout options
availableTimeoutOptions = [
{ name: "oneMinute-used-i18n", value: ONE_MINUTE },
{ name: "fiveMinutes-used-i18n", value: FIVE_MINUTES },
{ name: "fifteenMinutes-used-i18n", value: FIFTEEN_MINUTES },
{ name: "thirtyMinutes-used-i18n", value: THIRTY_MINUTES },
{ name: "oneHour-used-i18n", value: ONE_HOUR },
{ name: "fourHours-used-i18n", value: FOUR_HOURS },
{ name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart },
{ name: "onLocked-used-i18n", value: VaultTimeoutStringType.OnLocked },
{ name: "never-used-i18n", value: VaultTimeoutStringType.Never },
];
// Initialize mocks
mockPolicyService = mock<PolicyService>();
mockPolicyService.policiesByType$.mockReturnValue(policies$.asObservable());
accountService = mockAccountServiceWith(MOCK_USER_ID);
mockI18nService = mock<I18nService>();
mockI18nService.t.mockImplementation((key, ...args) => {
if (args.length > 0) {
return `${key}-used-i18n-${args.join("-")}`;
}
return `${key}-used-i18n`;
});
mockLogService = mock<LogService>();
mockSessionTimeoutSettingsComponentService = mock<SessionTimeoutSettingsComponentService>();
mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockReturnValue(
of(availableTimeoutOptions),
);
mockSessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true);
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation(
async (timeout: VaultTimeout) => timeout,
);
await TestBed.configureTestingModule({
imports: [SessionTimeoutInputComponent],
providers: [
{ provide: PolicyService, useValue: { policiesByType$ } },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: AccountService, useValue: accountService },
{ provide: VaultTimeoutSettingsService, useValue: { availableVaultTimeoutActions$ } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: I18nService, useValue: mockI18nService },
{ provide: LogService, useValue: mockLogService },
{
provide: SessionTimeoutSettingsComponentService,
useValue: mockSessionTimeoutSettingsComponentService,
},
{ provide: SessionTimeoutTypeService, useValue: mockSessionTimeoutTypeService },
],
}).compileComponents();
fixture = TestBed.createComponent(SessionTimeoutInputComponent);
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();
fixture.componentRef.setInput("availableTimeoutOptions", availableTimeoutOptions);
});
describe("form", () => {
beforeEach(async () => {
await component.ngOnInit();
it("should create", () => {
expect(component).toBeTruthy();
});
describe("ngOnInit", () => {
describe("policy data subscription and initialization", () => {
it("should initialize maxSessionTimeoutPolicyData to null when no policy exists", fakeAsync(() => {
fixture.detectChanges();
flush();
expect(component["maxSessionTimeoutPolicyData"]).toBeNull();
}));
it("should set maxSessionTimeoutPolicyData when policy exists", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: NINETY_MINUTES,
};
fixture.detectChanges();
flush();
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component["maxSessionTimeoutPolicyData"]).toEqual(policyData);
}));
it("should trigger validatorChange callback when policy data changes", fakeAsync(() => {
const validatorChangeFn = jest.fn();
component.registerOnValidatorChange(validatorChangeFn);
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: NINETY_MINUTES,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(validatorChangeFn).toHaveBeenCalled();
}));
it("should update form validation when policy data changes", fakeAsync(() => {
const updateSpy = jest.spyOn(component.form.controls.custom, "updateValueAndValidity");
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: FIFTEEN_MINUTES,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(updateSpy).toHaveBeenCalled();
}));
});
it("invokes the onChange associated with `ControlValueAccessor`", () => {
const onChange = jest.fn();
component.registerOnChange(onChange);
describe("policyTimeoutMessage$ observable", () => {
it("should emit custom timeout message when policy has custom type", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: NINETY_MINUTES,
};
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnRestart);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
expect(onChange).toHaveBeenCalledWith(VaultTimeoutStringType.OnRestart);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe(
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes-used-i18n-1-30",
);
}));
it("should emit immediately message when policy has immediately type", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "immediately",
minutes: 0,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe(
"sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately-used-i18n",
);
}));
it("should emit onLocked message when policy has onSystemLock type", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onSystemLock",
minutes: 0,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked-used-i18n");
}));
it("should emit onRestart message when policy has onAppRestart type", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onAppRestart",
minutes: 0,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n");
}));
it("should emit null when policy has never type and promoted value is Never", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "never",
minutes: 0,
};
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
VaultTimeoutStringType.Never,
);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = "initial";
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBeNull();
}));
it("should emit numeric timeout message when immediately is promoted to 1 minute", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "immediately",
minutes: 0,
};
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(ONE_MINUTE);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe(
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes-used-i18n-0-1",
);
}));
it("should emit onRestart message when onSystemLock is promoted to OnRestart", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onSystemLock",
minutes: 0,
};
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
VaultTimeoutStringType.OnRestart,
);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n");
}));
it("should emit onRestart message when never is promoted to OnRestart", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "never",
minutes: 0,
};
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
VaultTimeoutStringType.OnRestart,
);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n");
}));
});
it("updates custom value to match preset option", () => {
// 1 hour
component.form.controls.vaultTimeout.setValue(60);
describe("form value changes subscription", () => {
it("should call onChange with vault timeout when form is valid and in custom mode", fakeAsync(() => {
const onChange = jest.fn();
component.registerOnChange(onChange);
expect(component.form.value.custom).toEqual({ hours: 1, minutes: 0 });
fixture.detectChanges();
flush();
// 17 minutes
component.form.controls.vaultTimeout.setValue(17);
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 1, minutes: 30 });
flush();
expect(component.form.value.custom).toEqual({ hours: 0, minutes: 17 });
expect(onChange).toHaveBeenCalledWith(NINETY_MINUTES);
}));
// 2.25 hours
component.form.controls.vaultTimeout.setValue(135);
it("should call onChange when form changes to non-custom mode", fakeAsync(() => {
const onChange = jest.fn();
component.registerOnChange(onChange);
expect(component.form.value.custom).toEqual({ hours: 2, minutes: 15 });
fixture.detectChanges();
flush();
onChange.mockClear();
component.form.controls.vaultTimeout.setValue(FIFTEEN_MINUTES);
flush();
expect(onChange).toHaveBeenCalledWith(FIFTEEN_MINUTES);
}));
it("should not call onChange when custom controls are invalid", fakeAsync(() => {
const onChange = jest.fn();
component.registerOnChange(onChange);
fixture.detectChanges();
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
flush();
onChange.mockClear();
component.form.controls.custom.controls.hours.setValue(null);
flush();
expect(onChange).not.toHaveBeenCalled();
}));
it("should not call onChange when vaultTimeout is null", fakeAsync(() => {
const onChange = jest.fn();
component.registerOnChange(onChange);
fixture.detectChanges();
flush();
onChange.mockClear();
component.form.controls.vaultTimeout.setValue(null);
flush();
expect(onChange).not.toHaveBeenCalled();
}));
});
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 });
describe("custom fields initialization from vaultTimeout changes", () => {
it("should update custom fields when vaultTimeout changes to numeric value", fakeAsync(() => {
fixture.detectChanges();
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnLocked);
component.form.controls.vaultTimeout.setValue(NINETY_MINUTES);
flush();
expect(component.form.value.custom).toEqual({ hours: 0, minutes: 0 });
expect(component.form.value.custom).toEqual({ hours: 1, minutes: 30 });
}));
it.each([
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnIdle,
])(
"should set custom fields to 8 hours when vaultTimeout changes to %s",
fakeAsync((timeoutType: VaultTimeout) => {
fixture.detectChanges();
flush();
component.form.controls.custom.setValue({ hours: 1, minutes: 30 });
component.form.controls.vaultTimeout.setValue(timeoutType);
flush();
expect(component.form.value.custom).toEqual({ hours: 8, minutes: 0 });
}),
);
it("should mark custom fields as touched after update", fakeAsync(() => {
fixture.detectChanges();
flush();
component.form.controls.vaultTimeout.setValue(ONE_HOUR);
flush();
expect(component.form.controls.custom.controls.hours.touched).toBe(true);
expect(component.form.controls.custom.controls.minutes.touched).toBe(true);
}));
it("should not update custom fields when vaultTimeout changes to Custom", fakeAsync(() => {
fixture.detectChanges();
flush();
component.form.controls.custom.setValue({ hours: 5, minutes: 15 });
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
flush();
expect(component.form.value.custom).toEqual({ hours: 5, minutes: 15 });
}));
});
});
describe("isCustomTimeoutType", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return true when vaultTimeout is Custom", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
flush();
expect(component.isCustomTimeoutType).toBe(true);
}));
it.each([
ONE_MINUTE,
FIFTEEN_MINUTES,
ONE_HOUR,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Never,
])(
"should return false when vaultTimeout is %s",
fakeAsync((timeout: VaultTimeout) => {
component.form.controls.vaultTimeout.setValue(timeout);
flush();
expect(component.isCustomTimeoutType).toBe(false);
}),
);
});
describe("customMinutesMin", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return 1 when hours is 0", fakeAsync(() => {
component.form.controls.custom.controls.hours.setValue(0);
flush();
expect(component.customMinutesMin).toBe(1);
}));
it.each([1, 2, 5, 10])(
"should return 0 when hours is %s",
fakeAsync((hours: number) => {
component.form.controls.custom.controls.hours.setValue(hours);
flush();
expect(component.customMinutesMin).toBe(0);
}),
);
});
describe("maxSessionTimeoutPolicyHours", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return 0 when no policy exists", fakeAsync(() => {
expect(component.maxSessionTimeoutPolicyHours).toBe(0);
}));
it.each([
{ minutes: ONE_HOUR, expectedHours: 1 },
{ minutes: NINETY_MINUTES, expectedHours: 1 },
{ minutes: FOUR_HOURS, expectedHours: 4 },
{ minutes: 300, expectedHours: 5 },
])(
"should return $expectedHours when policy minutes is $minutes",
fakeAsync(({ minutes, expectedHours }: { minutes: number; expectedHours: number }) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.maxSessionTimeoutPolicyHours).toBe(expectedHours);
}),
);
});
describe("maxSessionTimeoutPolicyMinutes", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return 0 when no policy exists", fakeAsync(() => {
expect(component.maxSessionTimeoutPolicyMinutes).toBe(0);
}));
it.each([
{ minutes: ONE_HOUR, expectedMinutes: 0 },
{ minutes: NINETY_MINUTES, expectedMinutes: 30 },
{ minutes: 65, expectedMinutes: 5 },
{ minutes: 137, expectedMinutes: 17 },
])(
"should return $expectedMinutes when policy minutes is $minutes",
fakeAsync(({ minutes, expectedMinutes }: { minutes: number; expectedMinutes: number }) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.maxSessionTimeoutPolicyMinutes).toBe(expectedMinutes);
}),
);
});
describe("exceedsPolicyMaximumTimeout", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return true when custom timeout exceeds policy maximum", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: ONE_HOUR,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 2, minutes: 0 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(true);
}));
it("should return false when no policy exists", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 100, minutes: 0 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
it("should return false when policy type is not custom", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "immediately",
minutes: 0,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 10, minutes: 0 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
it("should return false when policy type is custom and form timeout is not custom", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: FIFTEEN_MINUTES,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(ONE_HOUR);
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
it("should return false when custom timeout equals policy maximum", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: ONE_HOUR,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 1, minutes: 0 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
it("should return false when custom timeout is below policy maximum", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: FOUR_HOURS,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 2, minutes: 30 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
});
describe("writeValue", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should do nothing when value is null", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(ONE_HOUR);
flush();
component.writeValue(null);
flush();
expect(component.form.controls.vaultTimeout.value).toBe(ONE_HOUR);
}));
it("should set form to custom mode when value doesn't match any available option", fakeAsync(() => {
component.writeValue(NINETY_MINUTES);
flush();
expect(component.form.controls.vaultTimeout.value).toBe(VaultTimeoutStringType.Custom);
expect(component.form.controls.custom.value).toEqual({ hours: 1, minutes: 30 });
}));
it.each([ONE_MINUTE, FIVE_MINUTES, FIFTEEN_MINUTES, THIRTY_MINUTES, ONE_HOUR, FOUR_HOURS])(
"should set vaultTimeout directly when numeric value %s matches preset option",
fakeAsync((timeout: number) => {
component.writeValue(timeout);
flush();
expect(component.form.controls.vaultTimeout.value).toBe(timeout);
}),
);
it.each([
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Never,
])(
"should set vaultTimeout directly when string value %s matches preset option",
fakeAsync((timeout: VaultTimeout) => {
component.writeValue(timeout);
flush();
expect(component.form.controls.vaultTimeout.value).toBe(timeout);
}),
);
});
describe("validate", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return null when vaultTimeout is not custom", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(ONE_HOUR);
flush();
const result = component.validate(component.form);
expect(result).toBeNull();
}));
it("should return required error when vaultTimeout is custom and hours is null", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.controls.hours.setValue(null);
component.form.controls.custom.controls.minutes.setValue(30);
flush();
const result = component.validate(component.form);
expect(result).toEqual({ required: true });
}));
it("should return required error when vaultTimeout is custom and minutes is null", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.controls.hours.setValue(1);
component.form.controls.custom.controls.minutes.setValue(null);
flush();
const result = component.validate(component.form);
expect(result).toEqual({ required: true });
}));
it("should return required error when vaultTimeout is custom and both hours and minutes are null", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.controls.hours.setValue(null);
component.form.controls.custom.controls.minutes.setValue(null);
flush();
const result = component.validate(component.form);
expect(result).toEqual({ required: true });
}));
it("should return minTimeoutError when total minutes is 0", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 0, minutes: 0 });
flush();
const result = component.validate(component.form);
expect(result).toEqual({ minTimeoutError: true });
}));
it("should return maxTimeoutError when exceeds policy maximum", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: ONE_HOUR,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 2, minutes: 0 });
flush();
const result = component.validate(component.form);
expect(result).toEqual({ maxTimeoutError: true });
}));
it("should return null when custom values are valid and within policy limit", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: FOUR_HOURS,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 2, minutes: 30 });
flush();
const result = component.validate(component.form);
expect(result).toBeNull();
}));
it("should return null when custom values are valid and no policy exists", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 5, minutes: 15 });
flush();
const result = component.validate(component.form);
expect(result).toBeNull();
}));
});
});

View File

@@ -1,9 +1,16 @@
// 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 {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
input,
OnInit,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
AbstractControl,
AbstractControlOptions,
ControlValueAccessor,
FormBuilder,
FormControl,
@@ -13,26 +20,32 @@ import {
ReactiveFormsModule,
ValidationErrors,
Validator,
Validators,
} from "@angular/forms";
import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
import { filter, map, Observable, switchMap } 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 {
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutSettingsService,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FormFieldModule, SelectModule } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
type VaultTimeoutForm = FormGroup<{
type SessionTimeoutForm = FormGroup<{
vaultTimeout: FormControl<VaultTimeout | null>;
custom: FormGroup<{
hours: FormControl<number | null>;
@@ -40,10 +53,8 @@ type VaultTimeoutForm = FormGroup<{
}>;
}>;
type VaultTimeoutFormValue = VaultTimeoutForm["value"];
type SessionTimeoutFormValue = SessionTimeoutForm["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: "bit-session-timeout-input",
templateUrl: "session-timeout-input.component.html",
@@ -60,111 +71,110 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"];
useExisting: SessionTimeoutInputComponent,
},
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SessionTimeoutInputComponent
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
{
static CUSTOM_VALUE = -100;
static MIN_CUSTOM_MINUTES = 0;
form: VaultTimeoutForm = this.formBuilder.group({
vaultTimeout: [null],
custom: this.formBuilder.group({
hours: [null],
minutes: [null],
}),
});
export class SessionTimeoutInputComponent implements ControlValueAccessor, Validator, OnInit {
static readonly MIN_CUSTOM_MINUTES = 0;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() vaultTimeoutOptions: VaultTimeoutOption[];
private readonly formBuilder = inject(FormBuilder);
private readonly policyService = inject(PolicyService);
private readonly i18nService = inject(I18nService);
private readonly accountService = inject(AccountService);
private readonly destroyRef = inject(DestroyRef);
private readonly sessionTimeoutTypeService = inject(SessionTimeoutTypeService);
private readonly logService = inject(LogService);
vaultTimeoutPolicy: Policy;
vaultTimeoutPolicyHours: number;
vaultTimeoutPolicyMinutes: number;
readonly availableTimeoutOptions = input.required<VaultTimeoutOption[]>();
protected readonly VaultTimeoutAction = VaultTimeoutAction;
protected maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null = null;
protected policyTimeoutMessage$!: Observable<string | null>;
protected canLockVault$: Observable<boolean>;
private onChange: (vaultTimeout: VaultTimeout) => void;
private validatorChange: () => void;
private destroy$ = new Subject<void>();
readonly form: SessionTimeoutForm = this.formBuilder.group(
{
vaultTimeout: [null as VaultTimeout | null],
custom: this.formBuilder.group({
hours: [0, [Validators.required, Validators.min(0)]],
minutes: [0, [Validators.required, Validators.min(0), Validators.max(59)]],
}),
},
{ validators: [this.formValidator.bind(this)] } as AbstractControlOptions,
);
constructor(
private formBuilder: FormBuilder,
private policyService: PolicyService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private i18nService: I18nService,
private accountService: AccountService,
) {}
private onChange: ((vaultTimeout: VaultTimeout) => void) | null = null;
private validatorChange: (() => void) | null = null;
get showCustom() {
return this.form.get("vaultTimeout").value === SessionTimeoutInputComponent.CUSTOM_VALUE;
get isCustomTimeoutType(): boolean {
return this.form.controls.vaultTimeout.value === VaultTimeoutStringType.Custom;
}
get exceedsMinimumTimeout(): boolean {
get customMinutesMin(): number {
return this.form.controls.custom.controls.hours.value === 0 ? 1 : 0;
}
get exceedsPolicyMaximumTimeout(): boolean {
return (
!this.showCustom ||
this.customTimeInMinutes() > SessionTimeoutInputComponent.MIN_CUSTOM_MINUTES
this.maxSessionTimeoutPolicyData?.type === VaultTimeoutStringType.Custom &&
this.isCustomTimeoutType &&
this.getTotalMinutesFromCustomValue(this.form.value.custom) >
this.maxSessionTimeoutPolicyMinutes + 60 * this.maxSessionTimeoutPolicyHours
);
}
get exceedsMaximumTimeout(): boolean {
return (
this.showCustom &&
this.customTimeInMinutes() >
this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours
ngOnInit(): void {
const maximumSessionTimeoutPolicyData$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
filter((policy) => policy != null),
map((policy) => policy.data as MaximumSessionTimeoutPolicyData),
);
}
get filteredVaultTimeoutOptions(): VaultTimeoutOption[] {
// by policy max value
if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) {
return this.vaultTimeoutOptions;
}
this.policyTimeoutMessage$ = maximumSessionTimeoutPolicyData$.pipe(
switchMap((policyData) => this.getPolicyTimeoutMessage(policyData)),
);
return this.vaultTimeoutOptions.filter((option) => {
if (typeof option.value === "number") {
return option.value <= this.vaultTimeoutPolicy.data.minutes;
}
return false;
});
}
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));
maximumSessionTimeoutPolicyData$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((policyData) => {
this.maxSessionTimeoutPolicyData = policyData;
// Re-validate custom form group with new policy data
this.form.controls.custom.updateValueAndValidity();
// Trigger validator change when policy data changes
if (this.validatorChange) {
this.validatorChange();
}
});
// Subscribe to form value changes
this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value) => {
if (this.onChange) {
const vaultTimeout = this.getVaultTimeout(value);
if (vaultTimeout != null) {
// Only call onChange if the form is valid
// For non-numeric values, we don't need to validate custom fields
const isValid = !this.isCustomTimeoutType || this.form.controls.custom.valid;
if (isValid) {
this.onChange(vaultTimeout);
}
}
}
});
// 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 !== SessionTimeoutInputComponent.CUSTOM_VALUE),
takeUntil(this.destroy$),
filter((value) => value != null && value !== VaultTimeoutStringType.Custom),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((value) => {
const current = typeof value === "string" ? 0 : Math.max(value, 0);
const current = isVaultTimeoutTypeNumeric(value)
? (value as number)
: VaultTimeoutNumberType.EightHours;
// 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
@@ -178,112 +188,169 @@ export class SessionTimeoutInputComponent
},
{ emitEvent: false },
);
this.form.controls.custom.markAllAsTouched();
});
this.canLockVault$ = this.vaultTimeoutSettingsService
.availableVaultTimeoutActions$()
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
get maxSessionTimeoutPolicyHours(): number {
return Math.floor((this.maxSessionTimeoutPolicyData?.minutes ?? 0) / 60);
}
ngOnChanges() {
if (
!this.vaultTimeoutOptions.find((p) => p.value === SessionTimeoutInputComponent.CUSTOM_VALUE)
) {
this.vaultTimeoutOptions.push({
name: this.i18nService.t("custom"),
value: SessionTimeoutInputComponent.CUSTOM_VALUE,
});
}
get maxSessionTimeoutPolicyMinutes(): number {
return (this.maxSessionTimeoutPolicyData?.minutes ?? 0) % 60;
}
getVaultTimeout(value: VaultTimeoutFormValue) {
if (value.vaultTimeout !== SessionTimeoutInputComponent.CUSTOM_VALUE) {
return value.vaultTimeout;
}
return value.custom.hours * 60 + value.custom.minutes;
}
writeValue(value: number): void {
writeValue(value: VaultTimeout | null): void {
if (value == null) {
return;
}
if (this.vaultTimeoutOptions.every((p) => p.value !== value)) {
// Normalize the custom numeric value to preset (i.e. 1 minute), otherwise set as custom
const options = this.availableTimeoutOptions();
const matchingOption = options.some((opt) => opt.value === value);
if (!matchingOption) {
this.logService.debug(
`[SessionTimeoutInputComponent] form control write value as custom ${value}`,
);
this.form.setValue({
vaultTimeout: SessionTimeoutInputComponent.CUSTOM_VALUE,
vaultTimeout: VaultTimeoutStringType.Custom,
custom: {
hours: Math.floor(value / 60),
minutes: value % 60,
hours: Math.floor((value as number) / 60),
minutes: (value as number) % 60,
},
});
return;
}
this.logService.debug(
`[SessionTimeoutInputComponent] form control write value as preset ${value}`,
);
// For string values (e.g., "onLocked", "never"), set directly
this.form.patchValue({
vaultTimeout: value,
});
}
registerOnChange(onChange: any): void {
registerOnChange(onChange: (vaultTimeout: VaultTimeout) => void): void {
this.onChange = onChange;
}
registerOnTouched(onTouched: any): void {
registerOnTouched(_onTouched: () => void): void {
// Empty
}
setDisabledState?(isDisabled: boolean): void {
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;
validate(_: AbstractControl): ValidationErrors | null {
return this.form.errors;
}
registerOnValidatorChange(fn: () => void): void {
this.validatorChange = fn;
}
private customTimeInMinutes() {
return this.form.value.custom.hours * 60 + this.form.value.custom.minutes;
private getTotalMinutesFromCustomValue(customValue: SessionTimeoutFormValue["custom"]): number {
const hours = customValue?.hours ?? 0;
const minutes = customValue?.minutes ?? 0;
return hours * 60 + minutes;
}
private applyVaultTimeoutPolicy() {
this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60);
this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60;
private formValidator(control: AbstractControl): ValidationErrors | null {
const formValue = control.value as SessionTimeoutFormValue;
const isCustomMode = formValue.vaultTimeout === VaultTimeoutStringType.Custom;
this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => {
// Always include the custom option
if (vaultTimeoutOption.value === SessionTimeoutInputComponent.CUSTOM_VALUE) {
return true;
// Only validate when in custom mode
if (!isCustomMode) {
return null;
}
const hours = formValue.custom?.hours;
const minutes = formValue.custom?.minutes;
if (hours == null || minutes == null) {
return { required: true };
}
const totalMinutes = this.getTotalMinutesFromCustomValue(formValue.custom);
if (totalMinutes === 0) {
return { minTimeoutError: true };
}
if (this.exceedsPolicyMaximumTimeout) {
return { maxTimeoutError: true };
}
return null;
}
private getVaultTimeout(value: SessionTimeoutFormValue): VaultTimeout | null {
if (value.vaultTimeout !== VaultTimeoutStringType.Custom) {
return value.vaultTimeout ?? null;
}
return this.getTotalMinutesFromCustomValue(value.custom);
}
private async getPolicyTimeoutMessage(
policyData: MaximumSessionTimeoutPolicyData,
): Promise<string | null> {
const timeout = await this.getPolicyAppliedTimeout(policyData);
switch (timeout) {
case null:
// Don't display the policy message
return null;
case VaultTimeoutNumberType.Immediately:
return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately");
case VaultTimeoutStringType.OnLocked:
return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked");
case VaultTimeoutStringType.OnRestart:
return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart");
default:
if (isVaultTimeoutTypeNumeric(timeout)) {
const hours = Math.floor((timeout as number) / 60);
const minutes = (timeout as number) % 60;
return this.i18nService.t(
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes",
hours,
minutes,
);
}
throw new Error("Invalid timeout parameter");
}
}
private async getPolicyAppliedTimeout(
policyData: MaximumSessionTimeoutPolicyData,
): Promise<VaultTimeout | null> {
switch (policyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
case "onSystemLock":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
case "onAppRestart":
return VaultTimeoutStringType.OnRestart;
case "never": {
const timeout = await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.Never,
);
if (timeout == VaultTimeoutStringType.Never) {
// Don't display policy message, when the policy doesn't change the available timeout options
return null;
}
return timeout;
}
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();
case "custom":
default:
return policyData.minutes;
}
}
}

View File

@@ -1,10 +1,11 @@
<div [formGroup]="formGroup">
<bit-session-timeout-input
[vaultTimeoutOptions]="availableTimeoutOptions$ | async"
[formControl]="formGroup.controls.timeout"
ngDefaultControl
>
</bit-session-timeout-input>
@if (availableTimeoutOptions$ | async; as options) {
<bit-session-timeout-input
[availableTimeoutOptions]="options"
[formControl]="formGroup.controls.timeout"
>
</bit-session-timeout-input>
}
<bit-form-field [disableMargin]="true">
<bit-label>{{ "sessionTimeoutSettingsAction" | i18n }}</bit-label>
@@ -18,14 +19,10 @@
}
</bit-select>
@if (!canLock) {
<bit-hint>{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br /></bit-hint>
@if (!canLock && supportsLock) {
<bit-hint>{{ "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction" | i18n }}</bit-hint>
} @else if ((sessionTimeoutActionFromPolicy$ | async) != null) {
<bit-hint>{{ "sessionTimeoutSettingsManagedByOrganization" | i18n }}</bit-hint>
}
</bit-form-field>
@if (hasVaultTimeoutPolicy$ | async) {
<bit-hint class="tw-mt-4">
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
</bit-hint>
}
</div>

View File

@@ -4,11 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
MaximumVaultTimeoutPolicyData,
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
@@ -16,6 +20,7 @@ import {
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -39,6 +44,8 @@ describe("SessionTimeoutSettingsComponent", () => {
let accountService: FakeAccountService;
let mockDialogService: MockProxy<DialogService>;
let mockLogService: MockProxy<LogService>;
const mockPlatformUtilsService = mock<PlatformUtilsService>();
const mockSessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
const mockUserId = "user-id" as UserId;
const mockEmail = "test@example.com";
@@ -46,6 +53,7 @@ describe("SessionTimeoutSettingsComponent", () => {
const mockInitialTimeoutAction = VaultTimeoutAction.Lock;
let refreshTimeoutActionSettings$: BehaviorSubject<void>;
let availableTimeoutOptions$: BehaviorSubject<VaultTimeoutOption[]>;
let policies$: BehaviorSubject<Policy[]>;
beforeEach(async () => {
refreshTimeoutActionSettings$ = new BehaviorSubject<void>(undefined);
@@ -58,6 +66,7 @@ describe("SessionTimeoutSettingsComponent", () => {
{ name: "onIdle-used-i18n", value: VaultTimeoutStringType.OnIdle },
{ name: "never-used-i18n", value: VaultTimeoutStringType.Never },
]);
policies$ = new BehaviorSubject<Policy[]>([]);
mockVaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
mockSessionTimeoutSettingsComponentService = mock<SessionTimeoutSettingsComponentService>();
@@ -79,9 +88,10 @@ describe("SessionTimeoutSettingsComponent", () => {
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
);
mockSessionTimeoutSettingsComponentService.availableTimeoutOptions$ =
availableTimeoutOptions$.asObservable();
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockImplementation(
(userId) => availableTimeoutOptions$.asObservable(),
);
mockPolicyService.policiesByType$.mockReturnValue(policies$.asObservable());
await TestBed.configureTestingModule({
imports: [
@@ -102,6 +112,8 @@ describe("SessionTimeoutSettingsComponent", () => {
{ provide: AccountService, useValue: accountService },
{ provide: LogService, useValue: mockLogService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: SessionTimeoutTypeService, useValue: mockSessionTimeoutTypeService },
],
})
.overrideComponent(SessionTimeoutSettingsComponent, {
@@ -145,6 +157,83 @@ describe("SessionTimeoutSettingsComponent", () => {
}));
});
describe("supportsLock", () => {
it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli])(
"should return true when client is %s and policy action is null",
fakeAsync((clientType: ClientType) => {
mockPlatformUtilsService.getClientType.mockReturnValue(clientType);
fixture.detectChanges();
flush();
expect(component.supportsLock).toBe(true);
}),
);
it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli])(
"should return true when client is %s and policy action is lock",
fakeAsync((clientType: ClientType) => {
mockPlatformUtilsService.getClientType.mockReturnValue(clientType);
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.Lock,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.supportsLock).toBe(true);
}),
);
it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli, ClientType.Web])(
"should return false when client is %s and policy action is logOut",
fakeAsync((clientType: ClientType) => {
mockPlatformUtilsService.getClientType.mockReturnValue(clientType);
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.LogOut,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.supportsLock).toBe(false);
}),
);
it("should return false when client is Web and policy action is null", fakeAsync(() => {
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
fixture.detectChanges();
flush();
expect(component.supportsLock).toBe(false);
}));
it("should return false when client is Web and policy action is lock", fakeAsync(() => {
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.Lock,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.supportsLock).toBe(false);
}));
});
describe("ngOnInit", () => {
it("should initialize available timeout options", fakeAsync(async () => {
fixture.detectChanges();
@@ -178,7 +267,7 @@ describe("SessionTimeoutSettingsComponent", () => {
});
}));
it("should initialize available timeout actions", fakeAsync(() => {
it("should initialize available timeout actions signal", fakeAsync(() => {
const expectedActions = [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut];
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
@@ -209,27 +298,55 @@ describe("SessionTimeoutSettingsComponent", () => {
expect(component.formGroup.value.timeoutAction).toBe(expectedAction);
}));
it("should fall back to OnRestart when current option is not available", fakeAsync(() => {
availableTimeoutOptions$.next([
{ name: "oneMinute-used-i18n", value: 1 },
{ name: "fiveMinutes-used-i18n", value: 5 },
{ name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart },
]);
it("should initialize userId from active account", fakeAsync(() => {
fixture.detectChanges();
flush();
const unavailableTimeout = VaultTimeoutStringType.Never;
expect(component["userId"]).toBe(mockUserId);
}));
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() =>
of(unavailableTimeout),
);
it("should initialize sessionTimeoutActionFromPolicy signal with null when no policy exists", fakeAsync(() => {
fixture.detectChanges();
flush();
expect(component["sessionTimeoutActionFromPolicy"]()).toBeNull();
}));
it.each([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut])(
"should initialize sessionTimeoutActionFromPolicy signal with policy action %s when policy exists",
fakeAsync((timeoutAction: VaultTimeoutAction) => {
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: timeoutAction,
};
fixture.detectChanges();
flush();
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component["sessionTimeoutActionFromPolicy"]()).toBe(timeoutAction);
}),
);
it("should initialize sessionTimeoutActionFromPolicy signal with null when policy exists and action is user preference", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: null,
};
fixture.detectChanges();
flush();
expect(component.formGroup.value.timeout).toBe(VaultTimeoutStringType.OnRestart);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component["sessionTimeoutActionFromPolicy"]()).toBeNull();
}));
it("should disable timeout action control when policy enforces action", fakeAsync(() => {
const policyData: MaximumVaultTimeoutPolicyData = {
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.LogOut,
};
@@ -273,7 +390,7 @@ describe("SessionTimeoutSettingsComponent", () => {
expect(component.formGroup.controls.timeoutAction.enabled).toBe(true);
const policyData: MaximumVaultTimeoutPolicyData = {
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.LogOut,
};
@@ -355,6 +472,56 @@ describe("SessionTimeoutSettingsComponent", () => {
expect(saveSpy).toHaveBeenCalledWith(VaultTimeoutAction.LogOut);
}));
it("should sync form timeout when service emits new timeout value", fakeAsync(() => {
const timeout$ = new BehaviorSubject<VaultTimeout>(mockInitialTimeout);
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(timeout$);
fixture.detectChanges();
flush();
expect(component.formGroup.controls.timeout.value).toBe(mockInitialTimeout);
const newTimeout = 30;
timeout$.next(newTimeout);
flush();
expect(component.formGroup.controls.timeout.value).toBe(newTimeout);
}));
it("should not sync form timeout when service emits same timeout value", fakeAsync(() => {
const timeout$ = new BehaviorSubject<VaultTimeout>(mockInitialTimeout);
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(timeout$);
fixture.detectChanges();
flush();
const setValueSpy = jest.spyOn(component.formGroup.controls.timeout, "setValue");
timeout$.next(mockInitialTimeout);
flush();
expect(setValueSpy).not.toHaveBeenCalled();
}));
it("should update availableTimeoutActions signal when service emits new actions", fakeAsync(() => {
const actions$ = new BehaviorSubject<VaultTimeoutAction[]>([VaultTimeoutAction.Lock]);
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue(actions$);
fixture.detectChanges();
flush();
expect(component["availableTimeoutActions"]()).toEqual([VaultTimeoutAction.Lock]);
actions$.next([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]);
refreshTimeoutActionSettings$.next(undefined);
flush();
expect(component["availableTimeoutActions"]()).toEqual([
VaultTimeoutAction.Lock,
VaultTimeoutAction.LogOut,
]);
}));
});
describe("saveTimeout", () => {

View File

@@ -1,6 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, input, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Component, DestroyRef, inject, input, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import {
FormControl,
FormGroup,
@@ -18,27 +18,28 @@ import {
firstValueFrom,
map,
Observable,
of,
pairwise,
startWith,
switchMap,
tap,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ClientType } from "@bitwarden/client-type";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
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 { MaximumSessionTimeoutPolicyData } from "@bitwarden/common/key-management/session-timeout";
import {
MaximumVaultTimeoutPolicyData,
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
CheckboxModule,
@@ -86,6 +87,19 @@ export class SessionTimeoutSettingsComponent implements OnInit {
new BehaviorSubject<void>(undefined),
);
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private readonly sessionTimeoutSettingsComponentService = inject(
SessionTimeoutSettingsComponentService,
);
private readonly i18nService = inject(I18nService);
private readonly toastService = inject(ToastService);
private readonly policyService = inject(PolicyService);
private readonly accountService = inject(AccountService);
private readonly dialogService = inject(DialogService);
private readonly logService = inject(LogService);
private readonly destroyRef = inject(DestroyRef);
private readonly platformUtilsService = inject(PlatformUtilsService);
formGroup = new FormGroup({
timeout: new FormControl<VaultTimeout | null>(null, [Validators.required]),
timeoutAction: new FormControl<VaultTimeoutAction>(VaultTimeoutAction.Lock, [
@@ -93,63 +107,48 @@ export class SessionTimeoutSettingsComponent implements OnInit {
]),
});
protected readonly availableTimeoutActions = signal<VaultTimeoutAction[]>([]);
protected readonly availableTimeoutOptions$ =
this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe(
startWith([] as VaultTimeoutOption[]),
);
protected hasVaultTimeoutPolicy$: Observable<boolean> = of(false);
protected readonly availableTimeoutOptions$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.sessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$(userId),
),
tap((options) => {
this.logService.debug("[SessionTimeoutSettings] Available timeout options", options);
}),
);
protected readonly sessionTimeoutActionFromPolicy$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
map((policy) => policy?.data as MaximumSessionTimeoutPolicyData | undefined),
map((data) => data?.action ?? null),
);
protected readonly sessionTimeoutActionFromPolicy = toSignal(
this.sessionTimeoutActionFromPolicy$,
);
private userId!: UserId;
constructor(
private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private readonly sessionTimeoutSettingsComponentService: SessionTimeoutSettingsComponentService,
private readonly i18nService: I18nService,
private readonly toastService: ToastService,
private readonly policyService: PolicyService,
private readonly accountService: AccountService,
private readonly dialogService: DialogService,
private readonly logService: LogService,
private readonly destroyRef: DestroyRef,
) {}
get canLock() {
return this.availableTimeoutActions().includes(VaultTimeoutAction.Lock);
}
get supportsLock() {
return (
this.platformUtilsService.getClientType() !== ClientType.Web &&
this.sessionTimeoutActionFromPolicy() !== "logOut"
);
}
async ngOnInit(): Promise<void> {
const availableTimeoutOptions = await firstValueFrom(
this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$,
);
this.logService.debug(
"[SessionTimeoutSettings] Available timeout options",
availableTimeoutOptions,
);
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const maximumVaultTimeoutPolicy$ = this.policyService
.policiesByType$(PolicyType.MaximumVaultTimeout, this.userId)
.pipe(getFirstPolicy);
this.hasVaultTimeoutPolicy$ = maximumVaultTimeoutPolicy$.pipe(map((policy) => policy != null));
let timeout = await firstValueFrom(
const timeout = await firstValueFrom(
this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(this.userId),
);
// Fallback if current timeout option is not available on this platform
// Only applies to string-based timeout types, not numeric values
const hasCurrentOption = availableTimeoutOptions.some((opt) => opt.value === timeout);
if (!hasCurrentOption && typeof timeout !== "number") {
this.logService.debug(
"[SessionTimeoutSettings] Current timeout option not available, falling back from",
{ timeout },
);
timeout = VaultTimeoutStringType.OnRestart;
}
this.formGroup.patchValue(
{
timeout: timeout,
@@ -160,6 +159,23 @@ export class SessionTimeoutSettingsComponent implements OnInit {
{ emitEvent: false },
);
// Sync form with reactive timeout updates to handle race condition where policies
// load asynchronously and may override the initially set timeout value
this.vaultTimeoutSettingsService
.getVaultTimeoutByUserId$(this.userId)
.pipe(
filter((timeout) => this.formGroup.controls.timeout.value !== timeout),
tap((timeout) =>
this.logService.debug(
`[SessionTimeoutSettings] Updating initial form timeout from ${this.formGroup.controls.timeout.value} to ${timeout}`,
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((timeout) => {
this.formGroup.controls.timeout.setValue(timeout, { emitEvent: false });
});
this.refreshTimeoutActionSettings()
.pipe(
startWith(undefined),
@@ -167,19 +183,17 @@ export class SessionTimeoutSettingsComponent implements OnInit {
combineLatest([
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId),
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
maximumVaultTimeoutPolicy$,
this.sessionTimeoutActionFromPolicy$,
]),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([availableActions, action, policy]) => {
.subscribe(([availableActions, action, sessionTimeoutActionFromPolicy]) => {
this.availableTimeoutActions.set(availableActions);
this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false });
const policyData = policy?.data as MaximumVaultTimeoutPolicyData | undefined;
// Enable/disable the action control based on policy or available actions
if (policyData?.action != null || availableActions.length <= 1) {
if (sessionTimeoutActionFromPolicy != null || availableActions.length <= 1) {
this.formGroup.controls.timeoutAction.disable({ emitEvent: false });
} else {
this.formGroup.controls.timeoutAction.enable({ emitEvent: false });

View File

@@ -0,0 +1,337 @@
import { fakeAsync, flush } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
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 {
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { SessionTimeoutSettingsComponentService } from "./session-timeout-settings-component.service";
describe("SessionTimeoutSettingsComponentService", () => {
let service: SessionTimeoutSettingsComponentService;
let mockI18nService: MockProxy<I18nService>;
let mockSessionTimeoutTypeService: MockProxy<SessionTimeoutTypeService>;
let mockPolicyService: MockProxy<PolicyService>;
const mockUserId = "test-user-id" as UserId;
beforeEach(() => {
mockI18nService = mock<I18nService>();
mockSessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
mockPolicyService = mock<PolicyService>();
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true);
mockPolicyService.policiesByType$.mockReturnValue(of([]));
service = new SessionTimeoutSettingsComponentService(
mockI18nService,
mockSessionTimeoutTypeService,
mockPolicyService,
);
});
it("should create", () => {
expect(service).toBeTruthy();
});
describe("availableTimeoutOptions$", () => {
it("should return all options when isAvailable returns true for all", fakeAsync(async () => {
mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true);
flush();
const options = await firstValueFrom(service["availableTimeoutOptions$"]);
assertAllTimeoutTypes(options);
}));
it("should filter options based on isAvailable() results", fakeAsync(async () => {
mockSessionTimeoutTypeService.isAvailable.mockImplementation(async (value: VaultTimeout) => {
return (
value === VaultTimeoutNumberType.OnMinute ||
value === 5 ||
value === VaultTimeoutStringType.OnLocked
);
});
flush();
const options = await firstValueFrom(service["availableTimeoutOptions$"]);
expect(options).toHaveLength(3);
expect(options).toContainEqual({ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute });
expect(options).toContainEqual({ name: "fiveMinutes", value: 5 });
expect(options).toContainEqual({ name: "onLocked", value: VaultTimeoutStringType.OnLocked });
expect(options).not.toContainEqual({
name: "immediately",
value: VaultTimeoutNumberType.Immediately,
});
}));
});
describe("policyFilteredTimeoutOptions$", () => {
it("should return all available options when no policy for user", fakeAsync(async () => {
mockPolicyService.policiesByType$.mockReturnValue(of([]));
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
assertAllTimeoutTypes(options);
}));
describe('policy type "immediately"', () => {
it.each([VaultTimeoutNumberType.Immediately, VaultTimeoutNumberType.OnMinute])(
"should only return immediately option or fallback",
fakeAsync(async (availableTimeoutOrPromoted: VaultTimeout) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "immediately",
minutes: 0,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
availableTimeoutOrPromoted,
);
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
expect(options).toHaveLength(1);
if (availableTimeoutOrPromoted === VaultTimeoutNumberType.Immediately) {
expect(options[0]).toEqual({
name: "immediately",
value: VaultTimeoutNumberType.Immediately,
});
} else {
expect(options[0]).toEqual({
name: "oneMinute",
value: VaultTimeoutNumberType.OnMinute,
});
}
}),
);
});
describe('policy type "onSystemLock"', () => {
it.each([VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnRestart])(
"should allow immediately, numeric, custom, onLocked, onIdle, onSleep or fallback",
fakeAsync(async (availableTimeoutOrPromoted: VaultTimeout) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onSystemLock",
minutes: 0,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
availableTimeoutOrPromoted,
);
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
assertNumericTimeoutTypes(options);
expect(options).toContainEqual({
name: "onLocked",
value: VaultTimeoutStringType.OnLocked,
});
expect(options).toContainEqual({ name: "onIdle", value: VaultTimeoutStringType.OnIdle });
expect(options).toContainEqual({
name: "onSleep",
value: VaultTimeoutStringType.OnSleep,
});
expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom });
expect(options).not.toContainEqual({
name: "never",
value: VaultTimeoutStringType.Never,
});
if (availableTimeoutOrPromoted === VaultTimeoutStringType.OnLocked) {
expect(options).not.toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
} else {
expect(options).toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
}
}),
);
});
describe('policy type "onAppRestart"', () => {
it("should allow immediately, numeric, custom, and onRestart", fakeAsync(async () => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onAppRestart",
minutes: 0,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
assertNumericTimeoutTypes(options);
expect(options).toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom });
expect(options).not.toContainEqual({
name: "onLocked",
value: VaultTimeoutStringType.OnLocked,
});
expect(options).not.toContainEqual({
name: "onIdle",
value: VaultTimeoutStringType.OnIdle,
});
expect(options).not.toContainEqual({
name: "onSleep",
value: VaultTimeoutStringType.OnSleep,
});
expect(options).not.toContainEqual({ name: "never", value: VaultTimeoutStringType.Never });
}));
});
describe('policy type "custom", null, or undefined', () => {
it.each(["custom", null, undefined])(
"should allow immediately, custom, and numeric values within policy limit when type is %s",
fakeAsync(async (policyType: "custom" | null | undefined) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: policyType as "custom" | null | undefined,
minutes: 15,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
expect(options).toContainEqual({
name: "immediately",
value: VaultTimeoutNumberType.Immediately,
});
expect(options).toContainEqual({
name: "oneMinute",
value: VaultTimeoutNumberType.OnMinute,
});
expect(options).toContainEqual({ name: "fiveMinutes", value: 5 });
expect(options).toContainEqual({ name: "fifteenMinutes", value: 15 });
expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom });
expect(options).not.toContainEqual({ name: "thirtyMinutes", value: 30 });
expect(options).not.toContainEqual({ name: "oneHour", value: 60 });
expect(options).not.toContainEqual({ name: "fourHours", value: 240 });
expect(options).not.toContainEqual({
name: "onLocked",
value: VaultTimeoutStringType.OnLocked,
});
expect(options).not.toContainEqual({
name: "onIdle",
value: VaultTimeoutStringType.OnIdle,
});
expect(options).not.toContainEqual({
name: "onSleep",
value: VaultTimeoutStringType.OnSleep,
});
expect(options).not.toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
expect(options).not.toContainEqual({
name: "never",
value: VaultTimeoutStringType.Never,
});
}),
);
});
describe('policy type "never"', () => {
it("should return all available options", fakeAsync(async () => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "never",
minutes: 0,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
assertAllTimeoutTypes(options);
}));
});
});
function assertAllTimeoutTypes(options: VaultTimeoutOption[]) {
assertNumericTimeoutTypes(options);
expect(options).toContainEqual({ name: "onIdle", value: VaultTimeoutStringType.OnIdle });
expect(options).toContainEqual({ name: "onSleep", value: VaultTimeoutStringType.OnSleep });
expect(options).toContainEqual({ name: "onLocked", value: VaultTimeoutStringType.OnLocked });
expect(options).toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
expect(options).toContainEqual({ name: "never", value: VaultTimeoutStringType.Never });
expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom });
}
function assertNumericTimeoutTypes(options: VaultTimeoutOption[]) {
expect(options).toContainEqual({
name: "immediately",
value: VaultTimeoutNumberType.Immediately,
});
expect(options).toContainEqual({ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute });
expect(options).toContainEqual({ name: "fiveMinutes", value: 5 });
expect(options).toContainEqual({ name: "fifteenMinutes", value: 15 });
expect(options).toContainEqual({ name: "thirtyMinutes", value: 30 });
expect(options).toContainEqual({ name: "oneHour", value: 60 });
expect(options).toContainEqual({ name: "fourHours", value: 240 });
}
});

View File

@@ -1,9 +1,158 @@
import { Observable } from "rxjs";
import { combineLatest, concatMap, defer, map, Observable } from "rxjs";
import { VaultTimeout, VaultTimeoutOption } from "@bitwarden/common/key-management/vault-timeout";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import {
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/user-core";
export abstract class SessionTimeoutSettingsComponentService {
abstract availableTimeoutOptions$: Observable<VaultTimeoutOption[]>;
export class SessionTimeoutSettingsComponentService {
private readonly availableTimeoutOptions$: Observable<VaultTimeoutOption[]>;
abstract onTimeoutSave(timeout: VaultTimeout): void;
constructor(
protected readonly i18nService: I18nService,
protected readonly sessionTimeoutTypeService: SessionTimeoutTypeService,
protected readonly policyService: PolicyService,
) {
this.availableTimeoutOptions$ = defer(async () => {
const allOptions = this.getAllTimeoutOptions();
const availabilityResults = await Promise.all(
allOptions.map(async (option) => ({
option,
available: await this.sessionTimeoutTypeService.isAvailable(option.value),
})),
);
return availabilityResults
.filter((result) => result.available)
.map((result) => result.option);
});
}
onTimeoutSave(_timeout: VaultTimeout): void {
// Default implementation does nothing, but other clients might want to override this
}
policyFilteredTimeoutOptions$(userId: UserId): Observable<VaultTimeoutOption[]> {
const policyData$ = this.policyService
.policiesByType$(PolicyType.MaximumVaultTimeout, userId)
.pipe(
getFirstPolicy,
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
);
return combineLatest([
this.availableTimeoutOptions$,
policyData$,
policyData$.pipe(
concatMap(async (policyData) => {
if (policyData == null) {
return null;
}
switch (policyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
case "onSystemLock":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
}
return null;
}),
),
]).pipe(
concatMap(
async ([availableOptions, policyData, highestAvailableEnforcedByPolicyTimeoutType]) => {
if (policyData == null) {
return availableOptions;
}
return availableOptions.filter((option) => {
switch (policyData.type) {
case "immediately": {
// Policy requires immediate lock.
return option.value === highestAvailableEnforcedByPolicyTimeoutType;
}
case "onSystemLock": {
// Allow immediately, numeric values, custom, and any system lock-related options.
if (
option.value === VaultTimeoutNumberType.Immediately ||
isVaultTimeoutTypeNumeric(option.value) ||
option.value === VaultTimeoutStringType.Custom ||
option.value === VaultTimeoutStringType.OnLocked ||
option.value === VaultTimeoutStringType.OnIdle ||
option.value === VaultTimeoutStringType.OnSleep
) {
return true;
}
// When on locked is not supported, fallback.
return option.value === highestAvailableEnforcedByPolicyTimeoutType;
}
case "onAppRestart":
// Allow immediately, numeric values, custom, and on app restart
return (
option.value === VaultTimeoutNumberType.Immediately ||
isVaultTimeoutTypeNumeric(option.value) ||
option.value === VaultTimeoutStringType.Custom ||
option.value === VaultTimeoutStringType.OnRestart
);
case "custom":
case null:
case undefined:
// Allow immediately, custom, and numeric values within policy limit
return (
option.value === VaultTimeoutNumberType.Immediately ||
option.value === VaultTimeoutStringType.Custom ||
(isVaultTimeoutTypeNumeric(option.value) &&
(option.value as number) <= policyData.minutes)
);
case "never":
// No policy restriction
return true;
default:
throw Error(`Unsupported policy type: ${policyData.type}`);
}
});
},
),
);
}
private getAllTimeoutOptions(): VaultTimeoutOption[] {
return [
{ name: "immediately", value: VaultTimeoutNumberType.Immediately },
{ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute },
{ name: "fiveMinutes", value: 5 },
{ name: "fifteenMinutes", value: 15 },
{ name: "thirtyMinutes", value: 30 },
{ name: "oneHour", value: 60 },
{ name: "fourHours", value: 240 },
{ name: "onIdle", value: VaultTimeoutStringType.OnIdle },
{ name: "onSleep", value: VaultTimeoutStringType.OnSleep },
{ name: "onLocked", value: VaultTimeoutStringType.OnLocked },
{ name: "sessionTimeoutOnRestart", value: VaultTimeoutStringType.OnRestart },
{ name: "never", value: VaultTimeoutStringType.Never },
{ name: "custom", value: VaultTimeoutStringType.Custom },
];
}
}