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 }} - - -
-
- - {{ "maximumVaultTimeoutLabel" | i18n }} - - {{ "hours" | i18n }} - - - - {{ "minutes" | i18n }} - - - {{ "vaultTimeoutAction" | 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 }} + + +
+
+ + {{ "maximumAllowedTimeout" | i18n }} + + @for (option of typeOptions; track option.value) { + + } + + + @if (data.value.type === "custom") { + + {{ "hours" | i18n }} + + + + {{ "minutes" | i18n }} + + + } + + {{ "sessionTimeoutAction" | i18n }} + + @for (option of actionOptions; track option.value) { + + } + + +
+
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(); + } + } +}