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

[PM-19300] Session timeout policy (#16583)

* Session timeout policy

* default "custom" is 8 hours, validation fixes

* ownership update

* default max allowed timeout is not selected

* adjusting defaults, fixing backwards compatibility, skip type confirmation dialog when switching between the never and on system lock

* unit test coverage

* wording update, custom hours, minutes jumping on errors

* wording update

* wrong session timeout action dropdown label

* show dialog as valid when opened first time, use @for loop, use controls instead of get

* dialog static opener

* easier to understand type value listener

* unit tests

* explicit maximum allowed timeout required error

* eslint revert
This commit is contained in:
Maciej Zieniuk
2025-10-28 20:28:34 +01:00
committed by GitHub
parent 69d5c533ef
commit ff30df3dd6
12 changed files with 853 additions and 122 deletions

View File

@@ -1,4 +1,3 @@
export { ActivateAutofillPolicy } from "./activate-autofill.component";
export { AutomaticAppLoginPolicy } from "./automatic-app-login.component";
export { DisablePersonalVaultExportPolicy } from "./disable-personal-vault-export.component";
export { MaximumVaultTimeoutPolicy } from "./maximum-vault-timeout.component";

View File

@@ -1,32 +0,0 @@
<bit-callout title="{{ 'prerequisite' | i18n }}">
{{ "requireSsoPolicyReq" | i18n }}
</bit-callout>
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div [formGroup]="data">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6 !tw-mb-0">
<bit-label>{{ "maximumVaultTimeoutLabel" | i18n }}</bit-label>
<input bitInput type="number" min="0" formControlName="hours" />
<bit-hint>{{ "hours" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-end !tw-mb-0">
<input bitInput type="number" min="0" max="59" formControlName="minutes" />
<bit-hint>{{ "minutes" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "vaultTimeoutAction" | i18n }}</bit-label>
<bit-select formControlName="action">
<bit-option
*ngFor="let option of vaultTimeoutActionOptions"
[value]="option.value"
[label]="option.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
</div>

View File

@@ -1,79 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { FormBuilder, FormControl } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
BasePolicyEditDefinition,
BasePolicyEditComponent,
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
export class MaximumVaultTimeoutPolicy extends BasePolicyEditDefinition {
name = "maximumVaultTimeout";
description = "maximumVaultTimeoutDesc";
type = PolicyType.MaximumVaultTimeout;
component = MaximumVaultTimeoutPolicyComponent;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "maximum-vault-timeout.component.html",
imports: [SharedModule],
})
export class MaximumVaultTimeoutPolicyComponent extends BasePolicyEditComponent {
vaultTimeoutActionOptions: { name: string; value: string }[];
data = this.formBuilder.group({
hours: new FormControl<number>(null),
minutes: new FormControl<number>(null),
action: new FormControl<string>(null),
});
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,
) {
super();
this.vaultTimeoutActionOptions = [
{ name: i18nService.t("userPreference"), value: null },
{ name: i18nService.t(VaultTimeoutAction.Lock), value: VaultTimeoutAction.Lock },
{ name: i18nService.t(VaultTimeoutAction.LogOut), value: VaultTimeoutAction.LogOut },
];
}
protected loadData() {
const minutes = this.policyResponse.data?.minutes;
const action = this.policyResponse.data?.action;
this.data.patchValue({
hours: minutes ? Math.floor(minutes / 60) : null,
minutes: minutes ? minutes % 60 : null,
action: action,
});
}
protected buildRequestData() {
if (this.data.value.hours == null && this.data.value.minutes == null) {
return null;
}
return {
minutes: this.data.value.hours * 60 + this.data.value.minutes,
action: this.data.value.action,
};
}
async buildRequest(): Promise<PolicyRequest> {
const request = await super.buildRequest();
if (request.data?.minutes == null || request.data?.minutes <= 0) {
throw new Error(this.i18nService.t("invalidMaximumVaultTimeout"));
}
return request;
}
}

View File

@@ -4,12 +4,12 @@ import {
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { FreeFamiliesSponsorshipPolicy } from "../../billing/policies/free-families-sponsorship.component";
import { SessionTimeoutPolicy } from "../../key-management/policies/session-timeout.component";
import {
ActivateAutofillPolicy,
AutomaticAppLoginPolicy,
DisablePersonalVaultExportPolicy,
MaximumVaultTimeoutPolicy,
} from "./policy-edit-definitions";
/**
@@ -18,7 +18,7 @@ import {
* It will not appear in the web vault when running in OSS mode.
*/
const policyEditRegister: BasePolicyEditDefinition[] = [
new MaximumVaultTimeoutPolicy(),
new SessionTimeoutPolicy(),
new DisablePersonalVaultExportPolicy(),
new FreeFamiliesSponsorshipPolicy(),
new ActivateAutofillPolicy(),

View File

@@ -0,0 +1,38 @@
<bit-dialog dialogSize="small">
<div bitDialogTitle class="tw-mt-4 tw-flex tw-flex-col tw-gap-2 tw-text-center">
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
<h1
bitTypography="h3"
class="tw-break-words tw-hyphens-auto tw-whitespace-normal tw-max-w-fit tw-inline-block"
>
{{ "sessionTimeoutConfirmationNeverTitle" | i18n }}
</h1>
</div>
<span
bitDialogContent
class="tw-flex tw-flex-col tw-gap-2 tw-items-center tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
>
<p>{{ "sessionTimeoutConfirmationNeverDescription" | i18n }}</p>
<a
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutDeviceProtection' | i18n }}"
href="https://bitwarden.com/help/vault-timeout/"
bitLink
class="tw-flex tw-flex-row tw-gap-1"
>
{{ "learnMoreAboutDeviceProtection" | i18n }}
<i class="bwi bwi-external-link" aria-hidden="true"></i>
</a>
</span>
<div bitDialogFooter class="tw-flex tw-flex-col tw-flex-grow tw-gap-2">
<button bitButton buttonType="primary" type="button" (click)="dialogRef.close(true)">
{{ "yes" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" (click)="dialogRef.close(false)">
{{ "no" | i18n }}
</button>
</div>
</bit-dialog>

View File

@@ -0,0 +1,79 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
describe("SessionTimeoutConfirmationNeverComponent", () => {
let component: SessionTimeoutConfirmationNeverComponent;
let fixture: ComponentFixture<SessionTimeoutConfirmationNeverComponent>;
let mockDialogRef: jest.Mocked<DialogRef>;
const mockI18nService = mock<I18nService>();
const mockDialogService = mock<DialogService>();
beforeEach(async () => {
mockDialogRef = mock<DialogRef>();
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
await TestBed.configureTestingModule({
imports: [SessionTimeoutConfirmationNeverComponent, NoopAnimationsModule],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
fixture = TestBed.createComponent(SessionTimeoutConfirmationNeverComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("open", () => {
it("should call dialogService.open with correct parameters", () => {
const mockResult = mock<DialogRef>();
mockDialogService.open.mockReturnValue(mockResult);
const result = SessionTimeoutConfirmationNeverComponent.open(mockDialogService);
expect(mockDialogService.open).toHaveBeenCalledWith(
SessionTimeoutConfirmationNeverComponent,
{
disableClose: true,
},
);
expect(result).toBe(mockResult);
});
});
describe("button clicks", () => {
it("should close dialog with true when Yes button is clicked", () => {
const yesButton = fixture.nativeElement.querySelector(
'button[buttonType="primary"]',
) as HTMLButtonElement;
yesButton.click();
expect(mockDialogRef.close).toHaveBeenCalledWith(true);
expect(yesButton.textContent?.trim()).toBe("yes-used-i18n");
});
it("should close dialog with false when No button is clicked", () => {
const noButton = fixture.nativeElement.querySelector(
'button[buttonType="secondary"]',
) as HTMLButtonElement;
noButton.click();
expect(mockDialogRef.close).toHaveBeenCalledWith(false);
expect(noButton.textContent?.trim()).toBe("no-used-i18n");
});
});
});

View File

@@ -0,0 +1,18 @@
import { Component } from "@angular/core";
import { DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
@Component({
imports: [SharedModule],
templateUrl: "./session-timeout-confirmation-never.component.html",
})
export class SessionTimeoutConfirmationNeverComponent {
constructor(public dialogRef: DialogRef) {}
static open(dialogService: DialogService) {
return dialogService.open<boolean>(SessionTimeoutConfirmationNeverComponent, {
disableClose: true,
});
}
}

View File

@@ -0,0 +1,39 @@
<bit-callout title="{{ 'prerequisite' | i18n }}">
{{ "requireSsoPolicyReq" | i18n }}
</bit-callout>
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div [formGroup]="data">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-12 !tw-mb-0">
<bit-label>{{ "maximumAllowedTimeout" | i18n }}</bit-label>
<bit-select formControlName="type">
@for (option of typeOptions; track option.value) {
<bit-option [value]="option.value" [label]="option.name"></bit-option>
}
</bit-select>
</bit-form-field>
@if (data.value.type === "custom") {
<bit-form-field class="tw-col-span-6 tw-self-start !tw-mb-0">
<bit-label>{{ "hours" | i18n }}</bit-label>
<input bitInput type="number" min="0" formControlName="hours" />
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-start !tw-mb-0">
<bit-label>{{ "minutes" | i18n }}</bit-label>
<input bitInput type="number" min="0" max="59" formControlName="minutes" />
</bit-form-field>
}
<bit-form-field class="tw-col-span-12">
<bit-label>{{ "sessionTimeoutAction" | i18n }}</bit-label>
<bit-select formControlName="action">
@for (option of actionOptions; track option.value) {
<bit-option [value]="option.value" [label]="option.name"></bit-option>
}
</bit-select>
</bit-form-field>
</div>
</div>

View File

@@ -0,0 +1,441 @@
import { DialogCloseOptions } from "@angular/cdk/dialog";
import { DebugElement } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
import {
SessionTimeoutAction,
SessionTimeoutPolicyComponent,
SessionTimeoutType,
} from "./session-timeout.component";
// Mock DialogRef, so we can mock "readonly closed" property.
class MockDialogRef extends DialogRef {
close(result: unknown | undefined, options: DialogCloseOptions | undefined): void {}
closed: Observable<unknown | undefined> = of();
componentInstance: unknown | null;
disableClose: boolean | undefined;
isDrawer: boolean = false;
}
describe("SessionTimeoutPolicyComponent", () => {
let component: SessionTimeoutPolicyComponent;
let fixture: ComponentFixture<SessionTimeoutPolicyComponent>;
const mockI18nService = mock<I18nService>();
const mockDialogService = mock<DialogService>();
const mockDialogRef = mock<MockDialogRef>();
beforeEach(async () => {
jest.resetAllMocks();
mockDialogRef.closed = of(true);
mockDialogService.open.mockReturnValue(mockDialogRef);
mockDialogService.openSimpleDialog.mockResolvedValue(true);
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
const testBed = TestBed.configureTestingModule({
imports: [SessionTimeoutPolicyComponent, ReactiveFormsModule],
providers: [FormBuilder, { provide: I18nService, useValue: mockI18nService }],
});
// Override DialogService provided from SharedModule (which includes DialogModule)
testBed.overrideProvider(DialogService, { useValue: mockDialogService });
await testBed.compileComponents();
fixture = TestBed.createComponent(SessionTimeoutPolicyComponent);
component = fixture.componentInstance;
});
function assertHoursAndMinutesInputsNotVisible() {
const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]');
const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]');
expect(hoursInput).toBeFalsy();
expect(minutesInput).toBeFalsy();
}
function assertHoursAndMinutesInputs(expectedHours: string, expectedMinutes: string) {
const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]');
const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]');
expect(hoursInput).toBeTruthy();
expect(minutesInput).toBeTruthy();
expect(hoursInput.disabled).toBe(false);
expect(minutesInput.disabled).toBe(false);
expect(hoursInput.value).toBe(expectedHours);
expect(minutesInput.value).toBe(expectedMinutes);
}
function setPolicyResponseType(type: SessionTimeoutType) {
component.policyResponse = new PolicyResponse({
Data: {
type,
minutes: 480,
action: null,
},
});
}
describe("initialization and data loading", () => {
function assertTypeAndActionSelectElementsVisible() {
// Type and action selects should always be present
const typeSelectDebug: DebugElement = fixture.debugElement.query(
By.css('bit-select[formControlName="type"]'),
);
const actionSelectDebug: DebugElement = fixture.debugElement.query(
By.css('bit-select[formControlName="action"]'),
);
expect(typeSelectDebug).toBeTruthy();
expect(actionSelectDebug).toBeTruthy();
}
it("should initialize with default state when policy have no value", () => {
component.policyResponse = undefined;
fixture.detectChanges();
expect(component.data.controls.type.value).toBeNull();
expect(component.data.controls.type.hasError("required")).toBe(true);
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.hours.disabled).toBe(true);
expect(component.data.controls.minutes.value).toBe(0);
expect(component.data.controls.minutes.disabled).toBe(true);
expect(component.data.controls.action.value).toBeNull();
assertTypeAndActionSelectElementsVisible();
assertHoursAndMinutesInputsNotVisible();
});
// This is for backward compatibility when type field did not exist
it("should load as custom type when type field does not exist but minutes does", () => {
component.policyResponse = new PolicyResponse({
Data: {
minutes: 500,
action: VaultTimeoutAction.Lock,
},
});
fixture.detectChanges();
expect(component.data.controls.type.value).toBe("custom");
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.hours.disabled).toBe(false);
expect(component.data.controls.minutes.value).toBe(20);
expect(component.data.controls.minutes.disabled).toBe(false);
expect(component.data.controls.action.value).toBe(VaultTimeoutAction.Lock);
assertTypeAndActionSelectElementsVisible();
assertHoursAndMinutesInputs("8", "20");
});
it.each([
["never", null],
["never", VaultTimeoutAction.Lock],
["never", VaultTimeoutAction.LogOut],
["onAppRestart", null],
["onAppRestart", VaultTimeoutAction.Lock],
["onAppRestart", VaultTimeoutAction.LogOut],
["onSystemLock", null],
["onSystemLock", VaultTimeoutAction.Lock],
["onSystemLock", VaultTimeoutAction.LogOut],
["immediately", null],
["immediately", VaultTimeoutAction.Lock],
["immediately", VaultTimeoutAction.LogOut],
["custom", null],
["custom", VaultTimeoutAction.Lock],
["custom", VaultTimeoutAction.LogOut],
])("should load correctly when policy type is %s and action is %s", (type, action) => {
component.policyResponse = new PolicyResponse({
Data: {
type,
minutes: 510,
action,
},
});
fixture.detectChanges();
expect(component.data.controls.type.value).toBe(type);
expect(component.data.controls.action.value).toBe(action);
assertTypeAndActionSelectElementsVisible();
if (type === "custom") {
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.minutes.value).toBe(30);
expect(component.data.controls.hours.disabled).toBe(false);
expect(component.data.controls.minutes.disabled).toBe(false);
assertHoursAndMinutesInputs("8", "30");
} else {
expect(component.data.controls.hours.disabled).toBe(true);
expect(component.data.controls.minutes.disabled).toBe(true);
assertHoursAndMinutesInputsNotVisible();
}
});
it("should have all type options and update form control when value changes", fakeAsync(() => {
expect(component.typeOptions.length).toBe(5);
expect(component.typeOptions[0].value).toBe("immediately");
expect(component.typeOptions[1].value).toBe("custom");
expect(component.typeOptions[2].value).toBe("onSystemLock");
expect(component.typeOptions[3].value).toBe("onAppRestart");
expect(component.typeOptions[4].value).toBe("never");
}));
it("should have all action options and update form control when value changes", () => {
expect(component.actionOptions.length).toBe(3);
expect(component.actionOptions[0].value).toBeNull();
expect(component.actionOptions[1].value).toBe(VaultTimeoutAction.Lock);
expect(component.actionOptions[2].value).toBe(VaultTimeoutAction.LogOut);
});
});
describe("form controls change detection", () => {
it.each(["never", "onAppRestart", "onSystemLock", "immediately"])(
"should disable hours and minutes inputs when type changes from custom to %s",
fakeAsync((newType: SessionTimeoutType) => {
setPolicyResponseType("custom");
fixture.detectChanges();
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.minutes.value).toBe(0);
expect(component.data.controls.hours.disabled).toBe(false);
expect(component.data.controls.minutes.disabled).toBe(false);
component.data.patchValue({ type: newType });
tick();
fixture.detectChanges();
expect(component.data.controls.hours.disabled).toBe(true);
expect(component.data.controls.minutes.disabled).toBe(true);
assertHoursAndMinutesInputsNotVisible();
}),
);
it.each(["never", "onAppRestart", "onSystemLock", "immediately"])(
"should enable hours and minutes inputs when type changes from %s to custom",
fakeAsync((oldType: SessionTimeoutType) => {
setPolicyResponseType(oldType);
fixture.detectChanges();
expect(component.data.controls.hours.disabled).toBe(true);
expect(component.data.controls.minutes.disabled).toBe(true);
component.data.patchValue({ type: "custom", hours: 8, minutes: 1 });
tick();
fixture.detectChanges();
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.minutes.value).toBe(1);
expect(component.data.controls.hours.disabled).toBe(false);
expect(component.data.controls.minutes.disabled).toBe(false);
assertHoursAndMinutesInputs("8", "1");
}),
);
it.each(["custom", "onAppRestart", "immediately"])(
"should not show confirmation dialog when changing to %s type",
fakeAsync((newType: SessionTimeoutType) => {
setPolicyResponseType(null);
fixture.detectChanges();
component.data.patchValue({ type: newType });
tick();
fixture.detectChanges();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
}),
);
it("should show never confirmation dialog when changing to never type", fakeAsync(() => {
setPolicyResponseType(null);
fixture.detectChanges();
component.data.patchValue({ type: "never" });
tick();
fixture.detectChanges();
expect(mockDialogService.open).toHaveBeenCalledWith(
SessionTimeoutConfirmationNeverComponent,
{
disableClose: true,
},
);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
}));
it("should show simple confirmation dialog when changing to onSystemLock type", fakeAsync(() => {
setPolicyResponseType(null);
fixture.detectChanges();
component.data.patchValue({ type: "onSystemLock" });
tick();
fixture.detectChanges();
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
type: "info",
title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" },
content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" },
acceptButtonText: { key: "continue" },
cancelButtonText: { key: "cancel" },
});
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(component.data.controls.type.value).toBe("onSystemLock");
}));
it("should revert to previous type when type changed to never and dialog not confirmed", fakeAsync(() => {
mockDialogRef.closed = of(false);
setPolicyResponseType("immediately");
fixture.detectChanges();
component.data.patchValue({ type: "never" });
tick();
fixture.detectChanges();
expect(mockDialogService.open).toHaveBeenCalled();
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(component.data.controls.type.value).toBe("immediately");
}));
it("should revert to previous type when type changed to onSystemLock and dialog not confirmed", fakeAsync(() => {
mockDialogService.openSimpleDialog.mockResolvedValue(false);
setPolicyResponseType("immediately");
fixture.detectChanges();
component.data.patchValue({ type: "onSystemLock" });
tick();
fixture.detectChanges();
expect(mockDialogService.openSimpleDialog).toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(component.data.controls.type.value).toBe("immediately");
}));
it("should revert to last confirmed type when canceling multiple times", fakeAsync(() => {
mockDialogRef.closed = of(false);
mockDialogService.openSimpleDialog.mockResolvedValue(false);
setPolicyResponseType("custom");
fixture.detectChanges();
// First attempt: custom -> never (cancel)
component.data.patchValue({ type: "never" });
tick();
fixture.detectChanges();
expect(component.data.controls.type.value).toBe("custom");
// Second attempt: custom -> onSystemLock (cancel)
component.data.patchValue({ type: "onSystemLock" });
tick();
fixture.detectChanges();
// Should revert to "custom", not "never"
expect(component.data.controls.type.value).toBe("custom");
}));
});
describe("buildRequestData", () => {
beforeEach(() => {
setPolicyResponseType("custom");
fixture.detectChanges();
});
it("should throw max allowed timeout required error when type is invalid", () => {
component.data.patchValue({ type: null });
expect(() => component["buildRequestData"]()).toThrow(
"maximumAllowedTimeoutRequired-used-i18n",
);
});
it.each([
[null, null],
[null, 0],
[0, null],
[0, 0],
])(
"should throw invalid time error when type is custom, hours is %o and minutes is %o ",
(hours, minutes) => {
component.data.patchValue({
type: "custom",
hours: hours,
minutes: minutes,
});
expect(() => component["buildRequestData"]()).toThrow(
"sessionTimeoutPolicyInvalidTime-used-i18n",
);
},
);
it("should return correct data when type is custom with valid time", () => {
component.data.patchValue({
type: "custom",
hours: 8,
minutes: 30,
action: VaultTimeoutAction.Lock,
});
const result = component["buildRequestData"]();
expect(result).toEqual({
type: "custom",
minutes: 510,
action: VaultTimeoutAction.Lock,
});
});
it.each([
["never", null],
["never", VaultTimeoutAction.Lock],
["never", VaultTimeoutAction.LogOut],
["immediately", null],
["immediately", VaultTimeoutAction.Lock],
["immediately", VaultTimeoutAction.LogOut],
["onSystemLock", null],
["onSystemLock", VaultTimeoutAction.Lock],
["onSystemLock", VaultTimeoutAction.LogOut],
["onAppRestart", null],
["onAppRestart", VaultTimeoutAction.Lock],
["onAppRestart", VaultTimeoutAction.LogOut],
])(
"should return default 8 hours for backward compatibility when type is %s and action is %s",
(type, action) => {
component.data.patchValue({
type: type as SessionTimeoutType,
hours: 5,
minutes: 25,
action: action as SessionTimeoutAction,
});
const result = component["buildRequestData"]();
expect(result).toEqual({
type,
minutes: 480,
action,
});
},
);
});
});

View File

@@ -0,0 +1,197 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import {
BehaviorSubject,
concatMap,
firstValueFrom,
Subject,
takeUntil,
withLatestFrom,
} from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
import {
BasePolicyEditDefinition,
BasePolicyEditComponent,
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
export type SessionTimeoutAction = null | "lock" | "logOut";
export type SessionTimeoutType =
| null
| "never"
| "onAppRestart"
| "onSystemLock"
| "immediately"
| "custom";
export class SessionTimeoutPolicy extends BasePolicyEditDefinition {
name = "sessionTimeoutPolicyTitle";
description = "sessionTimeoutPolicyDescription";
type = PolicyType.MaximumVaultTimeout;
component = SessionTimeoutPolicyComponent;
}
const DEFAULT_HOURS = 8;
const DEFAULT_MINUTES = 0;
@Component({
templateUrl: "session-timeout.component.html",
imports: [SharedModule],
})
export class SessionTimeoutPolicyComponent
extends BasePolicyEditComponent
implements OnInit, OnDestroy
{
private destroy$ = new Subject<void>();
private lastConfirmedType$ = new BehaviorSubject<SessionTimeoutType>(null);
actionOptions: { name: string; value: SessionTimeoutAction }[];
typeOptions: { name: string; value: SessionTimeoutType }[];
data = this.formBuilder.group({
type: new FormControl<SessionTimeoutType>(null, [Validators.required]),
hours: new FormControl<number>(
{
value: DEFAULT_HOURS,
disabled: true,
},
[Validators.required],
),
minutes: new FormControl<number>(
{
value: DEFAULT_MINUTES,
disabled: true,
},
[Validators.required],
),
action: new FormControl<SessionTimeoutAction>(null),
});
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,
private dialogService: DialogService,
) {
super();
this.actionOptions = [
{ name: i18nService.t("userPreference"), value: null },
{ name: i18nService.t("lock"), value: VaultTimeoutAction.Lock },
{ name: i18nService.t("logOut"), value: VaultTimeoutAction.LogOut },
];
this.typeOptions = [
{ name: i18nService.t("immediately"), value: "immediately" },
{ name: i18nService.t("custom"), value: "custom" },
{ name: i18nService.t("onSystemLock"), value: "onSystemLock" },
{ name: i18nService.t("onAppRestart"), value: "onAppRestart" },
{ name: i18nService.t("never"), value: "never" },
];
}
ngOnInit() {
super.ngOnInit();
const typeControl = this.data.controls.type;
this.lastConfirmedType$.next(typeControl.value ?? null);
typeControl.valueChanges
.pipe(
withLatestFrom(this.lastConfirmedType$),
concatMap(async ([newType, lastConfirmedType]) => {
const confirmed = await this.confirmTypeChange(newType);
if (confirmed) {
this.updateFormControls(newType);
this.lastConfirmedType$.next(newType);
} else {
typeControl.setValue(lastConfirmedType, { emitEvent: false });
}
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected override loadData() {
const minutes: number | null = this.policyResponse?.data?.minutes ?? null;
const action: SessionTimeoutAction =
this.policyResponse?.data?.action ?? (null satisfies SessionTimeoutAction);
// For backward compatibility, the "type" field might not exist, hence we initialize it based on the presence of "minutes"
const type: SessionTimeoutType =
this.policyResponse?.data?.type ?? ((minutes ? "custom" : null) satisfies SessionTimeoutType);
this.updateFormControls(type);
this.data.patchValue({
type: type,
hours: minutes ? Math.floor(minutes / 60) : DEFAULT_HOURS,
minutes: minutes ? minutes % 60 : DEFAULT_MINUTES,
action: action,
});
}
protected override buildRequestData() {
this.data.markAllAsTouched();
this.data.updateValueAndValidity();
if (this.data.invalid) {
if (this.data.controls.type.hasError("required")) {
throw new Error(this.i18nService.t("maximumAllowedTimeoutRequired"));
}
throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime"));
}
let minutes = this.data.value.hours! * 60 + this.data.value.minutes!;
const type = this.data.value.type;
if (type === "custom") {
if (minutes <= 0) {
throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime"));
}
} else {
// For backwards compatibility, we set minutes to 8 hours, so older client's vault timeout will not be broken
minutes = DEFAULT_HOURS * 60 + DEFAULT_MINUTES;
}
return {
type,
minutes,
action: this.data.value.action,
};
}
private async confirmTypeChange(newType: SessionTimeoutType): Promise<boolean> {
if (newType === "never") {
const dialogRef = SessionTimeoutConfirmationNeverComponent.open(this.dialogService);
return !!(await firstValueFrom(dialogRef.closed));
} else if (newType === "onSystemLock") {
return await this.dialogService.openSimpleDialog({
type: "info",
title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" },
content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" },
acceptButtonText: { key: "continue" },
cancelButtonText: { key: "cancel" },
});
}
return true;
}
private updateFormControls(type: SessionTimeoutType) {
const hoursControl = this.data.controls.hours;
const minutesControl = this.data.controls.minutes;
if (type === "custom") {
hoursControl.enable();
minutesControl.enable();
} else {
hoursControl.disable();
minutesControl.disable();
}
}
}