diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 8affac3387b..676c4b4657b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -174,6 +174,7 @@ apps/desktop/src/key-management @bitwarden/team-key-management-dev
apps/web/src/app/key-management @bitwarden/team-key-management-dev
apps/browser/src/key-management @bitwarden/team-key-management-dev
apps/cli/src/key-management @bitwarden/team-key-management-dev
+bitwarden_license/bit-web/src/app/key-management @bitwarden/team-key-management-dev
libs/key-management @bitwarden/team-key-management-dev
libs/key-management-ui @bitwarden/team-key-management-dev
libs/common/src/key-management @bitwarden/team-key-management-dev
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 72ca4d73976..aa0353e754d 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -6495,17 +6495,32 @@
"tdeDisabledMasterPasswordRequired": {
"message": "Your organization has updated your decryption options. Please set a master password to access your vault."
},
- "maximumVaultTimeout": {
- "message": "Vault timeout"
+ "sessionTimeoutPolicyTitle": {
+ "message": "Session timeout"
},
- "maximumVaultTimeoutDesc": {
- "message": "Set a maximum vault timeout for members."
+ "sessionTimeoutPolicyDescription": {
+ "message": "Set a maximum session timeout for all members except owners."
},
- "maximumVaultTimeoutLabel": {
- "message": "Maximum vault timeout"
+ "maximumAllowedTimeout": {
+ "message": "Maximum allowed timeout"
},
- "invalidMaximumVaultTimeout": {
- "message": "Invalid maximum vault timeout."
+ "maximumAllowedTimeoutRequired": {
+ "message": "Maximum allowed timeout is required."
+ },
+ "sessionTimeoutPolicyInvalidTime": {
+ "message": "Time is invalid. Change at least one value."
+ },
+ "sessionTimeoutAction": {
+ "message": "Session timeout action"
+ },
+ "immediately": {
+ "message": "Immediately"
+ },
+ "onSystemLock": {
+ "message": "On system lock"
+ },
+ "onAppRestart": {
+ "message": "On app restart"
},
"hours": {
"message": "Hours"
@@ -6513,6 +6528,21 @@
"minutes": {
"message": "Minutes"
},
+ "sessionTimeoutConfirmationNeverTitle": {
+ "message": "Are you certain you want to allow a maximum timeout of \"Never\" for all members?"
+ },
+ "sessionTimeoutConfirmationNeverDescription": {
+ "message": "This option will save your members' encryption keys on their devices. If you choose this option, ensure that their devices are adequately protected."
+ },
+ "learnMoreAboutDeviceProtection": {
+ "message": "Learn more about device protection"
+ },
+ "sessionTimeoutConfirmationOnSystemLockTitle": {
+ "message": "\"System lock\" will only apply to the browser and desktop app"
+ },
+ "sessionTimeoutConfirmationOnSystemLockDescription": {
+ "message": "The mobile and web app will use \"on app restart\" as their maximum allowed timeout, since the option is not supported."
+ },
"vaultTimeoutPolicyInEffect": {
"message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
"placeholders": {
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts
index 8c4be2eeea1..52325eae160 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts
@@ -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";
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.html b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.html
deleted file mode 100644
index deb72cfb3b5..00000000000
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.html
+++ /dev/null
@@ -1,32 +0,0 @@
-
- {{ "requireSsoPolicyReq" | i18n }}
-
-
-
-
- {{ "turnOn" | i18n }}
-
-
-
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts
deleted file mode 100644
index 277388e2883..00000000000
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts
+++ /dev/null
@@ -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(null),
- minutes: new FormControl(null),
- action: new FormControl(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 {
- const request = await super.buildRequest();
- if (request.data?.minutes == null || request.data?.minutes <= 0) {
- throw new Error(this.i18nService.t("invalidMaximumVaultTimeout"));
- }
-
- return request;
- }
-}
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts
index 3438e706f10..015b4fc17be 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts
@@ -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(),
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html
new file mode 100644
index 00000000000..2b718990c30
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html
@@ -0,0 +1,38 @@
+
+
+
+
+ {{ "sessionTimeoutConfirmationNeverTitle" | i18n }}
+
+
+
+
+ {{ "sessionTimeoutConfirmationNeverDescription" | i18n }}
+
+ {{ "learnMoreAboutDeviceProtection" | i18n }}
+
+
+
+
+
+
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts
new file mode 100644
index 00000000000..332a0e323a7
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts
@@ -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;
+ let mockDialogRef: jest.Mocked;
+
+ const mockI18nService = mock();
+ const mockDialogService = mock();
+
+ beforeEach(async () => {
+ mockDialogRef = mock();
+ 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();
+ 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");
+ });
+ });
+});
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts
new file mode 100644
index 00000000000..a909baf1c77
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts
@@ -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(SessionTimeoutConfirmationNeverComponent, {
+ disableClose: true,
+ });
+ }
+}
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html
new file mode 100644
index 00000000000..22e9e07bea7
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html
@@ -0,0 +1,39 @@
+
+ {{ "requireSsoPolicyReq" | i18n }}
+
+
+
+
+ {{ "turnOn" | i18n }}
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts
new file mode 100644
index 00000000000..694b0f1d1a2
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts
@@ -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 = of();
+ componentInstance: unknown | null;
+ disableClose: boolean | undefined;
+ isDrawer: boolean = false;
+}
+
+describe("SessionTimeoutPolicyComponent", () => {
+ let component: SessionTimeoutPolicyComponent;
+ let fixture: ComponentFixture;
+
+ const mockI18nService = mock();
+ const mockDialogService = mock();
+ const mockDialogRef = mock();
+
+ 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,
+ });
+ },
+ );
+ });
+});
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts
new file mode 100644
index 00000000000..3e40b9f0d80
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts
@@ -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();
+ private lastConfirmedType$ = new BehaviorSubject(null);
+
+ actionOptions: { name: string; value: SessionTimeoutAction }[];
+ typeOptions: { name: string; value: SessionTimeoutType }[];
+ data = this.formBuilder.group({
+ type: new FormControl(null, [Validators.required]),
+ hours: new FormControl(
+ {
+ value: DEFAULT_HOURS,
+ disabled: true,
+ },
+ [Validators.required],
+ ),
+ minutes: new FormControl(
+ {
+ value: DEFAULT_MINUTES,
+ disabled: true,
+ },
+ [Validators.required],
+ ),
+ action: new FormControl(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 {
+ 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();
+ }
+ }
+}