diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index bbdea838e6..5c8c351e58 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5881,5 +5881,49 @@ }, "sessionTimeoutSettingsAction": { "message": "Timeout action" + }, + "sessionTimeoutSettingsManagedByOrganization": { + "message": "This setting is managed by your organization." + }, + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { + "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "placeholders": { + "hours": { + "content": "$1", + "example": "8" + }, + "minutes": { + "content": "$2", + "example": "2" + } + } + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately": { + "message": "Your organization has set the default session timeout to Immediately." + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked": { + "message": "Your organization has set the default session timeout to On system lock." + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart": { + "message": "Your organization has set the default session timeout to On browser restart." + }, + "sessionTimeoutSettingsPolicyMaximumError": { + "message": "Maximum timeout cannot exceed $HOURS$ hour(s) and $MINUTES$ minute(s)", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, + "sessionTimeoutOnRestart": { + "message": "On browser restart" + }, + "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { + "message": "Set an unlock method to change your timeout action" } } diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index 37efcee901..b5d725b4a8 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -86,12 +86,12 @@ - - + {{ "vaultTimeoutAction1" | i18n }} diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index e6e7be96c0..4ff29c8853 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -70,7 +70,7 @@ import { BiometricsStatus, } from "@bitwarden/key-management"; import { - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, SessionTimeoutSettingsComponent, } from "@bitwarden/key-management-ui"; @@ -109,7 +109,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; SessionTimeoutSettingsComponent, SpotlightComponent, TypographyModule, - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, ], }) export class AccountSecurityComponent implements OnInit, OnDestroy { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 143f5d1f6b..1bd4718691 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -297,6 +297,7 @@ import { SafariApp } from "../browser/safariApp"; import { PhishingDataService } from "../dirt/phishing-detection/services/phishing-data.service"; import { PhishingDetectionService } from "../dirt/phishing-detection/services/phishing-detection.service"; import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service"; +import { BrowserSessionTimeoutTypeService } from "../key-management/session-timeout/services/browser-session-timeout-type.service"; import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service"; import { BrowserActionsService } from "../platform/actions/browser-actions.service"; import { DefaultBadgeBrowserApi } from "../platform/badge/badge-browser-api"; @@ -738,6 +739,10 @@ export default class MainBackground { this.accountService, ); + const sessionTimeoutTypeService = new BrowserSessionTimeoutTypeService( + this.platformUtilsService, + ); + this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, pinStateService, @@ -749,6 +754,7 @@ export default class MainBackground { this.stateProvider, this.logService, VaultTimeoutStringType.OnRestart, // default vault timeout + sessionTimeoutTypeService, ); this.apiService = new ApiService( diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts new file mode 100644 index 0000000000..cf5d556a55 --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts @@ -0,0 +1,57 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { BrowserSessionTimeoutSettingsComponentService } from "./browser-session-timeout-settings-component.service"; + +describe("BrowserSessionTimeoutSettingsComponentService", () => { + let service: BrowserSessionTimeoutSettingsComponentService; + let mockI18nService: jest.Mocked; + let mockSessionTimeoutTypeService: jest.Mocked; + let mockPolicyService: jest.Mocked; + let mockMessagingService: jest.Mocked; + + beforeEach(() => { + mockI18nService = mock(); + mockSessionTimeoutTypeService = mock(); + mockPolicyService = mock(); + mockMessagingService = mock(); + + service = new BrowserSessionTimeoutSettingsComponentService( + mockI18nService, + mockSessionTimeoutTypeService, + mockPolicyService, + mockMessagingService, + ); + }); + + describe("onTimeoutSave", () => { + it("should call messagingService.send with 'bgReseedStorage' when timeout is Never", () => { + service.onTimeoutSave(VaultTimeoutStringType.Never); + + expect(mockMessagingService.send).toHaveBeenCalledWith("bgReseedStorage"); + }); + + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Custom, + ])("should not call messagingService.send when timeout is %s", (timeoutValue) => { + service.onTimeoutSave(timeoutValue); + + expect(mockMessagingService.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts index 297718687e..24925e25e2 100644 --- a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts @@ -1,56 +1,24 @@ -import { defer, Observable, of } from "rxjs"; - +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeout, - VaultTimeoutOption, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; -export class BrowserSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("immediately"), value: 0 }, - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - ]; - - const showOnLocked = - !this.platformUtilsService.isFirefox() && - !this.platformUtilsService.isSafari() && - !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel"); - - if (showOnLocked) { - options.push({ - name: this.i18nService.t("onLocked"), - value: VaultTimeoutStringType.OnLocked, - }); - } - - options.push( - { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, - { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, - ); - - return of(options); - }); - +export class BrowserSessionTimeoutSettingsComponentService extends SessionTimeoutSettingsComponentService { constructor( - private readonly i18nService: I18nService, - private readonly platformUtilsService: PlatformUtilsService, + i18nService: I18nService, + sessionTimeoutTypeService: SessionTimeoutTypeService, + policyService: PolicyService, private readonly messagingService: MessagingService, - ) {} + ) { + super(i18nService, sessionTimeoutTypeService, policyService); + } - onTimeoutSave(timeout: VaultTimeout): void { + override onTimeoutSave(timeout: VaultTimeout): void { if (timeout === VaultTimeoutStringType.Never) { this.messagingService.send("bgReseedStorage"); } diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts new file mode 100644 index 0000000000..83de5c51a4 --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts @@ -0,0 +1,139 @@ +import { mock } from "jest-mock-extended"; + +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { BrowserSessionTimeoutTypeService } from "./browser-session-timeout-type.service"; + +describe("BrowserSessionTimeoutTypeService", () => { + let service: BrowserSessionTimeoutTypeService; + let mockPlatformUtilsService: jest.Mocked; + + beforeEach(() => { + mockPlatformUtilsService = mock(); + service = new BrowserSessionTimeoutTypeService(mockPlatformUtilsService); + }); + + describe("isAvailable", () => { + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return true for always available type: %s", async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }); + + it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])( + "should return true for numeric timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + describe("OnLocked availability", () => { + const mockNavigatorPlatform = (platform: string) => { + Object.defineProperty(navigator, "platform", { + value: platform, + writable: true, + configurable: true, + }); + }; + + beforeEach(() => { + mockNavigatorPlatform("Linux x86_64"); + mockPlatformUtilsService.isFirefox.mockReturnValue(false); + mockPlatformUtilsService.isSafari.mockReturnValue(false); + mockPlatformUtilsService.isOpera.mockReturnValue(false); + }); + + it("should return true when not Firefox, Safari, or Opera on Mac", async () => { + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + }); + + it("should return true when Opera on non-Mac platform", async () => { + mockNavigatorPlatform("Win32"); + mockPlatformUtilsService.isOpera.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + }); + + it("should return false when Opera on Mac", async () => { + mockNavigatorPlatform("MacIntel"); + mockPlatformUtilsService.isOpera.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + + it("should return false when Firefox", async () => { + mockPlatformUtilsService.isFirefox.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + + it("should return false when Safari", async () => { + mockPlatformUtilsService.isSafari.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + }); + + it.each([VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnSleep])( + "should return false for unavailable timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(false); + }, + ); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Custom, + ])("should return the original type when it is available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(true); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(timeoutType); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + 5, + ])("should return OnRestart when type is not available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(VaultTimeoutStringType.OnRestart); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + }); +}); diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts new file mode 100644 index 0000000000..33ac3e356d --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts @@ -0,0 +1,43 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +export class BrowserSessionTimeoutTypeService implements SessionTimeoutTypeService { + constructor(private readonly platformUtilsService: PlatformUtilsService) {} + + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Never: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.OnLocked: + return ( + !this.platformUtilsService.isFirefox() && + !this.platformUtilsService.isSafari() && + !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel") + ); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + return VaultTimeoutStringType.OnRestart; + } + return type; + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c462319dc2..0a82a07b72 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -76,6 +76,7 @@ import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutService, VaultTimeoutStringType, @@ -170,6 +171,7 @@ import { InlineMenuFieldQualificationService } from "../../autofill/services/inl import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics"; import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service"; import { BrowserSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/browser-session-timeout-settings-component.service"; +import { BrowserSessionTimeoutTypeService } from "../../key-management/session-timeout/services/browser-session-timeout-type.service"; import { ForegroundVaultTimeoutService } from "../../key-management/vault-timeout/foreground-vault-timeout.service"; import { BrowserActionsService } from "../../platform/actions/browser-actions.service"; import { BrowserApi } from "../../platform/browser/browser-api"; @@ -723,10 +725,20 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: BrowserSessionTimeoutTypeService, + deps: [PlatformUtilsService], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, useClass: BrowserSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction, PlatformUtilsService, MessagingServiceAbstraction], + deps: [ + I18nServiceAbstraction, + SessionTimeoutTypeService, + PolicyService, + MessagingServiceAbstraction, + ], }), ]; diff --git a/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts b/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts new file mode 100644 index 0000000000..8143b37b8a --- /dev/null +++ b/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts @@ -0,0 +1,15 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeout, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +export class CliSessionTimeoutTypeService implements SessionTimeoutTypeService { + async isAvailable(timeout: VaultTimeout): Promise { + return timeout === VaultTimeoutStringType.Never; + } + + async getOrPromoteToAvailable(_: VaultTimeout): Promise { + return VaultTimeoutStringType.Never; + } +} diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index e29bc517f2..83c64c6142 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -211,6 +211,7 @@ import { import { CliBiometricsService } from "../key-management/cli-biometrics-service"; import { CliProcessReloadService } from "../key-management/cli-process-reload.service"; +import { CliSessionTimeoutTypeService } from "../key-management/session-timeout/services/cli-session-timeout-type.service"; import { flagEnabled } from "../platform/flags"; import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service"; import { CliSdkLoadService } from "../platform/services/cli-sdk-load.service"; @@ -529,6 +530,8 @@ export class ServiceContainer { this.accountService, ); + const sessionTimeoutTypeService = new CliSessionTimeoutTypeService(); + this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, pinStateService, @@ -540,6 +543,7 @@ export class ServiceContainer { this.stateProvider, this.logService, VaultTimeoutStringType.Never, // default vault timeout + sessionTimeoutTypeService, ); const refreshAccessTokenErrorCallback = () => { diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 8abd84ee39..d5042918d2 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -44,12 +44,12 @@

{{ "vaultTimeoutHeader" | i18n }}

- - + {{ diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 68863312ff..e302242842 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -55,7 +55,7 @@ import { } from "@bitwarden/components"; import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; import { - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, SessionTimeoutSettingsComponent, } from "@bitwarden/key-management-ui"; import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; @@ -97,7 +97,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man SectionHeaderComponent, SelectModule, TypographyModule, - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 03d6eb5c90..04f5e8026c 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -62,6 +62,7 @@ import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypt import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutSettingsService, VaultTimeoutStringType, @@ -128,7 +129,7 @@ import { DesktopBiometricsService } from "../../key-management/biometrics/deskto import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service"; import { ElectronKeyService } from "../../key-management/electron-key.service"; import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service"; -import { DesktopSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/desktop-session-timeout-settings-component.service"; +import { DesktopSessionTimeoutTypeService } from "../../key-management/session-timeout/services/desktop-session-timeout-type.service"; import { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; @@ -484,10 +485,15 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopAutotypeDefaultSettingPolicy, deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: DesktopSessionTimeoutTypeService, + deps: [], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, - useClass: DesktopSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction], + useClass: SessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction], }), ]; diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts deleted file mode 100644 index 91c8126cdd..0000000000 --- a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { defer, from, map, Observable } from "rxjs"; - -import { - VaultTimeout, - VaultTimeoutOption, - VaultTimeoutStringType, -} from "@bitwarden/common/key-management/vault-timeout"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; - -export class DesktopSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => - from(ipc.platform.powermonitor.isLockMonitorAvailable()).pipe( - map((isLockMonitorAvailable) => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - { name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle }, - { name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep }, - ]; - - if (isLockMonitorAvailable) { - options.push({ - name: this.i18nService.t("onLocked"), - value: VaultTimeoutStringType.OnLocked, - }); - } - - options.push( - { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, - { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, - ); - - return options; - }), - ), - ); - - constructor(private readonly i18nService: I18nService) {} - - onTimeoutSave(_: VaultTimeout): void {} -} diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts new file mode 100644 index 0000000000..d3ece8842b --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts @@ -0,0 +1,125 @@ +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +import { DesktopSessionTimeoutTypeService } from "./desktop-session-timeout-type.service"; + +describe("DesktopSessionTimeoutTypeService", () => { + let service: DesktopSessionTimeoutTypeService; + let mockIsLockMonitorAvailable: jest.Mock; + + beforeEach(() => { + mockIsLockMonitorAvailable = jest.fn(); + + (global as any).ipc = { + platform: { + powermonitor: { + isLockMonitorAvailable: mockIsLockMonitorAvailable, + }, + }, + }; + + service = new DesktopSessionTimeoutTypeService(); + }); + + describe("isAvailable", () => { + it("should return false for Immediately", async () => { + const result = await service.isAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(false); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return true for always available type: %s", async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }); + + it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])( + "should return true for numeric timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + describe("OnLocked availability", () => { + it("should return true when lock monitor is available", async () => { + mockIsLockMonitorAvailable.mockResolvedValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + expect(mockIsLockMonitorAvailable).toHaveBeenCalled(); + }); + + it("should return false when lock monitor is not available", async () => { + mockIsLockMonitorAvailable.mockResolvedValue(false); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + expect(mockIsLockMonitorAvailable).toHaveBeenCalled(); + }); + }); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.OnMinute, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return the original type when it is available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(true); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(timeoutType); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + + it("should return OnMinute when Immediately is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(VaultTimeoutNumberType.OnMinute); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately); + }); + + it("should return OnSleep when OnLocked is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(VaultTimeoutStringType.OnSleep); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutStringType.OnLocked); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutNumberType.OnMinute, + 5, + ])("should return OnRestart when type is not available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(VaultTimeoutStringType.OnRestart); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + }); +}); diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts new file mode 100644 index 0000000000..1f09e83b0f --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts @@ -0,0 +1,46 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +export class DesktopSessionTimeoutTypeService implements SessionTimeoutTypeService { + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return false; + case VaultTimeoutStringType.OnIdle: + case VaultTimeoutStringType.OnSleep: + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Never: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.OnLocked: + return await ipc.platform.powermonitor.isLockMonitorAvailable(); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return VaultTimeoutNumberType.OnMinute; + case VaultTimeoutStringType.OnLocked: + return VaultTimeoutStringType.OnSleep; + default: + return VaultTimeoutStringType.OnRestart; + } + } + return type; + } +} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 8da3ba5484..86df61940d 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4255,5 +4255,46 @@ }, "sessionTimeoutHeader": { "message": "Session timeout" + }, + "sessionTimeoutSettingsManagedByOrganization": { + "message": "This setting is managed by your organization." + }, + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { + "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "placeholders": { + "hours": { + "content": "$1", + "example": "8" + }, + "minutes": { + "content": "$2", + "example": "2" + } + } + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked": { + "message": "Your organization has set the default session timeout to On system lock." + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart": { + "message": "Your organization has set the default session timeout to On restart." + }, + "sessionTimeoutSettingsPolicyMaximumError": { + "message": "Maximum timeout cannot exceed $HOURS$ hour(s) and $MINUTES$ minute(s)", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, + "sessionTimeoutOnRestart": { + "message": "On restart" + }, + "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { + "message": "Set an unlock method to change your timeout action" } } diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index c0716d9971..ab8f06dcf3 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -69,6 +69,7 @@ import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-managemen import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeout, VaultTimeoutStringType, @@ -124,7 +125,6 @@ import { import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service"; -import { WebSessionTimeoutSettingsComponentService } from "@bitwarden/web-vault/app/key-management/session-timeout/services/web-session-timeout-settings-component.service"; import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service"; import { flagEnabled } from "../../utils/flags"; @@ -149,6 +149,7 @@ import { WebFileDownloadService } from "../core/web-file-download.service"; import { UserKeyRotationService } from "../key-management/key-rotation/user-key-rotation.service"; import { WebLockComponentService } from "../key-management/lock/services/web-lock-component.service"; import { WebProcessReloadService } from "../key-management/services/web-process-reload.service"; +import { WebSessionTimeoutTypeService } from "../key-management/session-timeout/services/web-session-timeout-type.service"; import { WebBiometricsService } from "../key-management/web-biometric.service"; import { WebIpcService } from "../platform/ipc/web-ipc.service"; import { WebEnvironmentService } from "../platform/web-environment.service"; @@ -469,10 +470,15 @@ const safeProviders: SafeProvider[] = [ useClass: WebSystemService, deps: [], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: WebSessionTimeoutTypeService, + deps: [PlatformUtilsService], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, - useClass: WebSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction, PlatformUtilsService], + useClass: SessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyService], }), ]; diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts deleted file mode 100644 index 61836c9825..0000000000 --- a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { defer, Observable, of } from "rxjs"; - -import { - VaultTimeout, - VaultTimeoutOption, - VaultTimeoutStringType, -} from "@bitwarden/common/key-management/vault-timeout"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; - -export class WebSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - { name: this.i18nService.t("onRefresh"), value: VaultTimeoutStringType.OnRestart }, - ]; - - if (this.platformUtilsService.isDev()) { - options.push({ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }); - } - - return of(options); - }); - - constructor( - private readonly i18nService: I18nService, - private readonly platformUtilsService: PlatformUtilsService, - ) {} - - onTimeoutSave(_: VaultTimeout): void {} -} diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts new file mode 100644 index 0000000000..40eb3e77d4 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts @@ -0,0 +1,115 @@ +import { mock } from "jest-mock-extended"; + +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { WebSessionTimeoutTypeService } from "./web-session-timeout-type.service"; + +describe("WebSessionTimeoutTypeService", () => { + let service: WebSessionTimeoutTypeService; + let mockPlatformUtilsService: jest.Mocked; + + beforeEach(() => { + mockPlatformUtilsService = mock(); + service = new WebSessionTimeoutTypeService(mockPlatformUtilsService); + }); + + describe("isAvailable", () => { + it("should return false for Immediately", async () => { + const result = await service.isAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(false); + }); + + it.each([VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.Custom])( + "should return true for always available type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])( + "should return true for numeric timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + ])("should return false for unavailable timeout type: %s", async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(false); + }); + + describe("Never availability", () => { + it("should return true when in dev mode", async () => { + mockPlatformUtilsService.isDev.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.Never); + + expect(result).toBe(true); + expect(mockPlatformUtilsService.isDev).toHaveBeenCalled(); + }); + + it("should return false when not in dev mode", async () => { + mockPlatformUtilsService.isDev.mockReturnValue(false); + + const result = await service.isAvailable(VaultTimeoutStringType.Never); + + expect(result).toBe(false); + expect(mockPlatformUtilsService.isDev).toHaveBeenCalled(); + }); + }); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return the original type when it is available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(true); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(timeoutType); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + + it("should return OnMinute when Immediately is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(VaultTimeoutNumberType.OnMinute); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + ])("should return OnRestart when type is not available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(VaultTimeoutStringType.OnRestart); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + }); +}); diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts new file mode 100644 index 0000000000..458befc29a --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts @@ -0,0 +1,44 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +export class WebSessionTimeoutTypeService implements SessionTimeoutTypeService { + constructor(private readonly platformUtilsService: PlatformUtilsService) {} + + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return false; + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.Never: + return this.platformUtilsService.isDev(); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return VaultTimeoutNumberType.OnMinute; + default: + return VaultTimeoutStringType.OnRestart; + } + } + return type; + } +} diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index a2e90dd588..cdcb897360 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -17,12 +17,12 @@ {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} - - + (); - private lastConfirmedType$ = new BehaviorSubject(null); - actionOptions: { name: string; value: SessionTimeoutAction }[]; typeOptions: { name: string; value: SessionTimeoutType }[]; data = this.formBuilder.group({ @@ -74,6 +67,9 @@ export class SessionTimeoutPolicyComponent action: new FormControl(null), }); + private destroy$ = new Subject(); + private lastConfirmedType$ = new BehaviorSubject(null); + constructor( private formBuilder: FormBuilder, private i18nService: I18nService, @@ -123,12 +119,10 @@ export class SessionTimeoutPolicyComponent } protected override loadData() { - const minutes: number | null = this.policyResponse?.data?.minutes ?? null; - const action: SessionTimeoutAction = - this.policyResponse?.data?.action ?? (null satisfies SessionTimeoutAction); + const minutes: number | null = this.policyData?.minutes ?? null; + const action: SessionTimeoutAction = this.policyData?.action ?? null; // 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); + const type: SessionTimeoutType = this.policyData?.type ?? (minutes ? "custom" : null); this.updateFormControls(type); this.data.patchValue({ @@ -165,7 +159,11 @@ export class SessionTimeoutPolicyComponent type, minutes, action: this.data.value.action, - }; + } satisfies MaximumSessionTimeoutPolicyData; + } + + private get policyData(): MaximumSessionTimeoutPolicyData | null { + return this.policyResponse?.data ?? null; } private async confirmTypeChange(newType: SessionTimeoutType): Promise { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 13935beab1..c2d4440b44 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -207,6 +207,7 @@ import { SendPasswordService, DefaultSendPasswordService, } from "@bitwarden/common/key-management/sends"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -912,6 +913,7 @@ const safeProviders: SafeProvider[] = [ StateProvider, LogService, DEFAULT_VAULT_TIMEOUT, + SessionTimeoutTypeService, ], }), safeProvider({ diff --git a/libs/common/src/key-management/session-timeout/abstractions/session-timeout-type.service.ts b/libs/common/src/key-management/session-timeout/abstractions/session-timeout-type.service.ts new file mode 100644 index 0000000000..6ed230d9b7 --- /dev/null +++ b/libs/common/src/key-management/session-timeout/abstractions/session-timeout-type.service.ts @@ -0,0 +1,15 @@ +import { VaultTimeout } from "../../vault-timeout"; + +export abstract class SessionTimeoutTypeService { + /** + * Is provided timeout type available on this client type, OS ? + * @param timeout the timeout type + */ + abstract isAvailable(timeout: VaultTimeout): Promise; + + /** + * Returns the highest available and permissive timeout type, that is higher than or equals the provided timeout type. + * @param timeout the provided timeout type + */ + abstract getOrPromoteToAvailable(timeout: VaultTimeout): Promise; +} diff --git a/libs/common/src/key-management/session-timeout/index.ts b/libs/common/src/key-management/session-timeout/index.ts new file mode 100644 index 0000000000..4768534e7c --- /dev/null +++ b/libs/common/src/key-management/session-timeout/index.ts @@ -0,0 +1,3 @@ +export { SessionTimeoutTypeService } from "./abstractions/session-timeout-type.service"; +export { MaximumSessionTimeoutPolicyData } from "./types/maximum-session-timeout-policy.type"; +export { SessionTimeoutAction, SessionTimeoutType } from "./types/session-timeout.type"; diff --git a/libs/common/src/key-management/session-timeout/types/maximum-session-timeout-policy.type.ts b/libs/common/src/key-management/session-timeout/types/maximum-session-timeout-policy.type.ts new file mode 100644 index 0000000000..de416be151 --- /dev/null +++ b/libs/common/src/key-management/session-timeout/types/maximum-session-timeout-policy.type.ts @@ -0,0 +1,7 @@ +import { SessionTimeoutAction, SessionTimeoutType } from "./session-timeout.type"; + +export interface MaximumSessionTimeoutPolicyData { + type?: SessionTimeoutType; + minutes: number; + action?: SessionTimeoutAction; +} diff --git a/libs/common/src/key-management/session-timeout/types/session-timeout.type.ts b/libs/common/src/key-management/session-timeout/types/session-timeout.type.ts new file mode 100644 index 0000000000..3dd1a546aa --- /dev/null +++ b/libs/common/src/key-management/session-timeout/types/session-timeout.type.ts @@ -0,0 +1,8 @@ +export type SessionTimeoutAction = null | "lock" | "logOut"; +export type SessionTimeoutType = + | null + | "never" + | "onAppRestart" + | "onSystemLock" + | "immediately" + | "custom"; diff --git a/libs/common/src/key-management/vault-timeout/index.ts b/libs/common/src/key-management/vault-timeout/index.ts index 02e49dceaa..ba32c12c9f 100644 --- a/libs/common/src/key-management/vault-timeout/index.ts +++ b/libs/common/src/key-management/vault-timeout/index.ts @@ -4,8 +4,9 @@ export { VaultTimeoutService } from "./abstractions/vault-timeout.service"; export { VaultTimeoutService as DefaultVaultTimeoutService } from "./services/vault-timeout.service"; export { VaultTimeoutAction } from "./enums/vault-timeout-action.enum"; export { + isVaultTimeoutTypeNumeric, VaultTimeout, VaultTimeoutOption, + VaultTimeoutNumberType, VaultTimeoutStringType, } from "./types/vault-timeout.type"; -export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type"; diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts index ba58fa8092..ccb66a4dff 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts @@ -21,9 +21,14 @@ import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { UserId } from "../../../types/guid"; import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction"; +import { SessionTimeoutTypeService } from "../../session-timeout"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service"; import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; -import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type"; +import { + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "../types/vault-timeout.type"; import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service"; import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state"; @@ -40,9 +45,11 @@ describe("VaultTimeoutSettingsService", () => { let userDecryptionOptionsSubject: BehaviorSubject; + const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout const mockUserId = Utils.newGuid() as UserId; let stateProvider: FakeStateProvider; let logService: MockProxy; + let sessionTimeoutTypeService: MockProxy; beforeEach(() => { accountService = mockAccountServiceWith(mockUserId); @@ -67,8 +74,8 @@ describe("VaultTimeoutSettingsService", () => { stateProvider = new FakeStateProvider(accountService); logService = mock(); + sessionTimeoutTypeService = mock(); - const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout); biometricStateService.biometricUnlockEnabled$ = of(false); @@ -259,40 +266,276 @@ describe("VaultTimeoutSettingsService", () => { ); }); - it.each([ - // policy, vaultTimeout, expected - [null, null, 15], // no policy, no vault timeout, falls back to default - [30, 90, 30], // policy overrides vault timeout - [30, 15, 15], // policy doesn't override vault timeout when it's within acceptable range - [90, VaultTimeoutStringType.Never, 90], // policy overrides vault timeout when it's "never" - [null, VaultTimeoutStringType.Never, VaultTimeoutStringType.Never], // no policy, persist "never" vault timeout - [90, 0, 0], // policy doesn't override vault timeout when it's 0 (immediate) - [null, 0, 0], // no policy, persist 0 (immediate) vault timeout - [90, VaultTimeoutStringType.OnRestart, 90], // policy overrides vault timeout when it's "onRestart" - [null, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnRestart], // no policy, persist "onRestart" vault timeout - [90, VaultTimeoutStringType.OnLocked, 90], // policy overrides vault timeout when it's "onLocked" - [null, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnLocked], // no policy, persist "onLocked" vault timeout - [90, VaultTimeoutStringType.OnSleep, 90], // policy overrides vault timeout when it's "onSleep" - [null, VaultTimeoutStringType.OnSleep, VaultTimeoutStringType.OnSleep], // no policy, persist "onSleep" vault timeout - [90, VaultTimeoutStringType.OnIdle, 90], // policy overrides vault timeout when it's "onIdle" - [null, VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnIdle], // no policy, persist "onIdle" vault timeout - ])( - "when policy is %s, and vault timeout is %s, returns %s", - async (policy, vaultTimeout, expected) => { + describe("no policy", () => { + it("when vault timeout is null, returns default", async () => { userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); - policyService.policiesByType$.mockReturnValue( - of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])), + policyService.policiesByType$.mockReturnValue(of([])); + + await stateProvider.setUserState(VAULT_TIMEOUT, null, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(result).toBe(defaultVaultTimeout); + }); + + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnIdle, + ])("when vault timeout is %s, returns unchanged", async (vaultTimeout) => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue(of([])); + await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId); const result = await firstValueFrom( vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(result).toBe(expected); - }, - ); + expect(result).toBe(vaultTimeout); + }); + }); + + describe("policy type: custom", () => { + const policyMinutes = 30; + + it.each([ + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnIdle, + ])( + "when vault timeout is %s and exceeds policy max, returns policy minutes", + async (vaultTimeout) => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(result).toBe(policyMinutes); + }, + ); + + it.each([VaultTimeoutNumberType.OnMinute, policyMinutes])( + "when vault timeout is %s and within policy max, returns unchanged", + async (vaultTimeout) => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(result).toBe(vaultTimeout); + }, + ); + + it("when vault timeout is Immediately, returns Immediately", async () => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutNumberType.Immediately, + mockUserId, + ); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(result).toBe(VaultTimeoutNumberType.Immediately); + }); + }); + + describe("policy type: immediately", () => { + it.each([ + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + ])( + "when current timeout is %s, returns immediately or promoted value", + async (currentTimeout) => { + const expectedTimeout = VaultTimeoutNumberType.Immediately; + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "immediately" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); + expect(result).toBe(expectedTimeout); + }, + ); + }); + + describe("policy type: onSystemLock", () => { + it.each([ + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + ])( + "when current timeout is %s, returns onLocked or promoted value", + async (currentTimeout) => { + const expectedTimeout = VaultTimeoutStringType.OnLocked; + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnLocked, + ); + expect(result).toBe(expectedTimeout); + }, + ); + + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + ])("when current timeout is numeric %s, returns unchanged", async (currentTimeout) => { + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(result).toBe(currentTimeout); + }); + }); + + describe("policy type: onAppRestart", () => { + it.each([ + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + ])("when current timeout is %s, returns onRestart", async (currentTimeout) => { + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(result).toBe(VaultTimeoutStringType.OnRestart); + }); + + it.each([ + VaultTimeoutStringType.OnRestart, + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + ])("when current timeout is %s, returns unchanged", async (currentTimeout) => { + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(result).toBe(currentTimeout); + }); + }); + + describe("policy type: never", () => { + it("when current timeout is never, returns never or promoted value", async () => { + const expectedTimeout = VaultTimeoutStringType.Never; + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "never" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.Never, + ); + expect(result).toBe(expectedTimeout); + }); + + it.each([ + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + ])("when current timeout is %s, returns unchanged", async (currentTimeout) => { + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "never" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(result).toBe(currentTimeout); + }); + }); }); describe("setVaultTimeoutOptions", () => { @@ -405,6 +648,7 @@ describe("VaultTimeoutSettingsService", () => { stateProvider, logService, defaultVaultTimeout, + sessionTimeoutTypeService, ); } }); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index 00e53596de..dc0c562051 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -1,14 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { - EMPTY, - Observable, catchError, combineLatest, defer, distinctUntilChanged, + EMPTY, firstValueFrom, from, + map, + Observable, shareReplay, switchMap, tap, @@ -23,7 +24,6 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management"; import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service"; import { AccountService } from "../../../auth/abstractions/account.service"; import { TokenService } from "../../../auth/abstractions/token.service"; @@ -31,9 +31,15 @@ import { LogService } from "../../../platform/abstractions/log.service"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction"; +import { MaximumSessionTimeoutPolicyData, SessionTimeoutTypeService } from "../../session-timeout"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service"; import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; -import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "../types/vault-timeout.type"; import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state"; @@ -49,6 +55,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private stateProvider: StateProvider, private logService: LogService, private defaultVaultTimeout: VaultTimeout, + private sessionTimeoutTypeService: SessionTimeoutTypeService, ) {} async setVaultTimeoutOptions( @@ -131,11 +138,25 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return combineLatest([ this.stateProvider.getUserState$(VAULT_TIMEOUT, userId), - this.getMaxVaultTimeoutPolicyByUserId$(userId), + this.getMaxSessionTimeoutPolicyDataByUserId$(userId), ]).pipe( - switchMap(([currentVaultTimeout, maxVaultTimeoutPolicy]) => { - return from(this.determineVaultTimeout(currentVaultTimeout, maxVaultTimeoutPolicy)).pipe( + switchMap(([currentVaultTimeout, maxSessionTimeoutPolicyData]) => { + this.logService.debug( + "[VaultTimeoutSettingsService] Current vault timeout is %o for user id %s, max session policy %o", + currentVaultTimeout, + userId, + maxSessionTimeoutPolicyData, + ); + return from( + this.determineVaultTimeout(currentVaultTimeout, maxSessionTimeoutPolicyData), + ).pipe( tap((vaultTimeout: VaultTimeout) => { + this.logService.debug( + "[VaultTimeoutSettingsService] Determined vault timeout is %o for user id %s", + vaultTimeout, + userId, + ); + // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current if (vaultTimeout !== currentVaultTimeout) { return this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId); @@ -155,28 +176,63 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private async determineVaultTimeout( currentVaultTimeout: VaultTimeout | null, - maxVaultTimeoutPolicy: Policy | null, + maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, ): Promise { // if current vault timeout is null, apply the client specific default currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout; // If no policy applies, return the current vault timeout - if (!maxVaultTimeoutPolicy) { + if (maxSessionTimeoutPolicyData == null) { return currentVaultTimeout; } - // User is subject to a max vault timeout policy - const maxVaultTimeoutPolicyData = maxVaultTimeoutPolicy.data; - - // If the current vault timeout is not numeric, change it to the policy compliant value - if (typeof currentVaultTimeout === "string") { - return maxVaultTimeoutPolicyData.minutes; + switch (maxSessionTimeoutPolicyData.type) { + case "immediately": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutNumberType.Immediately, + ); + case "custom": + case null: + case undefined: + if (currentVaultTimeout === VaultTimeoutNumberType.Immediately) { + return currentVaultTimeout; + } + if (isVaultTimeoutTypeNumeric(currentVaultTimeout)) { + return Math.min(currentVaultTimeout as number, maxSessionTimeoutPolicyData.minutes); + } + return maxSessionTimeoutPolicyData.minutes; + case "onSystemLock": + if ( + currentVaultTimeout === VaultTimeoutStringType.Never || + currentVaultTimeout === VaultTimeoutStringType.OnRestart || + currentVaultTimeout === VaultTimeoutStringType.OnLocked || + currentVaultTimeout === VaultTimeoutStringType.OnIdle || + currentVaultTimeout === VaultTimeoutStringType.OnSleep + ) { + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.OnLocked, + ); + } + break; + case "onAppRestart": + if ( + currentVaultTimeout === VaultTimeoutStringType.Never || + currentVaultTimeout === VaultTimeoutStringType.OnLocked || + currentVaultTimeout === VaultTimeoutStringType.OnIdle || + currentVaultTimeout === VaultTimeoutStringType.OnSleep + ) { + return VaultTimeoutStringType.OnRestart; + } + break; + case "never": + if (currentVaultTimeout === VaultTimeoutStringType.Never) { + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.Never, + ); + } + break; } - - // For numeric vault timeouts, ensure they are smaller than maximum allowed value according to policy - const policyCompliantTimeout = Math.min(currentVaultTimeout, maxVaultTimeoutPolicyData.minutes); - - return policyCompliantTimeout; + return currentVaultTimeout; } private async setVaultTimeoutAction(userId: UserId, action: VaultTimeoutAction): Promise { @@ -198,14 +254,14 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return combineLatest([ this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId), - this.getMaxVaultTimeoutPolicyByUserId$(userId), + this.getMaxSessionTimeoutPolicyDataByUserId$(userId), ]).pipe( - switchMap(([currentVaultTimeoutAction, maxVaultTimeoutPolicy]) => { + switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => { return from( this.determineVaultTimeoutAction( userId, currentVaultTimeoutAction, - maxVaultTimeoutPolicy, + maxSessionTimeoutPolicyData, ), ).pipe( tap((vaultTimeoutAction: VaultTimeoutAction) => { @@ -235,7 +291,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private async determineVaultTimeoutAction( userId: string, currentVaultTimeoutAction: VaultTimeoutAction | null, - maxVaultTimeoutPolicy: Policy | null, + maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, ): Promise { const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId); if (availableVaultTimeoutActions.length === 1) { @@ -243,11 +299,13 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA } if ( - maxVaultTimeoutPolicy?.data?.action && - availableVaultTimeoutActions.includes(maxVaultTimeoutPolicy.data.action) + maxSessionTimeoutPolicyData?.action && + availableVaultTimeoutActions.includes( + maxSessionTimeoutPolicyData.action as VaultTimeoutAction, + ) ) { - // return policy defined vault timeout action - return maxVaultTimeoutPolicy.data.action; + // return policy defined session timeout action + return maxSessionTimeoutPolicyData.action as VaultTimeoutAction; } // No policy applies from here on @@ -262,14 +320,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return currentVaultTimeoutAction; } - private getMaxVaultTimeoutPolicyByUserId$(userId: UserId): Observable { + private getMaxSessionTimeoutPolicyDataByUserId$( + userId: UserId, + ): Observable { if (!userId) { - throw new Error("User id required. Cannot get max vault timeout policy."); + throw new Error("User id required. Cannot get max session timeout policy."); } - return this.policyService - .policiesByType$(PolicyType.MaximumVaultTimeout, userId) - .pipe(getFirstPolicy); + return this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId).pipe( + getFirstPolicy, + map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null), + ); } private async getAvailableVaultTimeoutActions(userId?: string): Promise { diff --git a/libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts b/libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts deleted file mode 100644 index c254e823fe..0000000000 --- a/libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; - -export interface MaximumVaultTimeoutPolicyData { - minutes: number; - action?: VaultTimeoutAction; -} diff --git a/libs/common/src/key-management/vault-timeout/types/vault-timeout.type.ts b/libs/common/src/key-management/vault-timeout/types/vault-timeout.type.ts index e5a2e7f182..a8757f6100 100644 --- a/libs/common/src/key-management/vault-timeout/types/vault-timeout.type.ts +++ b/libs/common/src/key-management/vault-timeout/types/vault-timeout.type.ts @@ -5,13 +5,25 @@ export const VaultTimeoutStringType = { OnLocked: "onLocked", // -2 OnSleep: "onSleep", // -3 OnIdle: "onIdle", // -4 + Custom: "custom", // -100 +} as const; + +export const VaultTimeoutNumberType = { + Immediately: 0, + OnMinute: 1, + EightHours: 480, } as const; export type VaultTimeout = - | number // 0 or positive numbers only + | (typeof VaultTimeoutNumberType)[keyof typeof VaultTimeoutNumberType] + | number // 0 or positive numbers (in minutes). See VaultTimeoutNumberType for common numeric presets | (typeof VaultTimeoutStringType)[keyof typeof VaultTimeoutStringType]; export interface VaultTimeoutOption { name: string; value: VaultTimeout; } + +export function isVaultTimeoutTypeNumeric(timeout: VaultTimeout): boolean { + return typeof timeout === "number"; +} diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index d681d46912..b273b49cb7 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -12,3 +12,4 @@ export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key- export { SessionTimeoutSettingsComponent } from "./session-timeout/components/session-timeout-settings.component"; export { SessionTimeoutSettingsComponentService } from "./session-timeout/services/session-timeout-settings-component.service"; export { SessionTimeoutInputComponent } from "./session-timeout/components/session-timeout-input.component"; +export { SessionTimeoutInputLegacyComponent } from "./session-timeout/components/session-timeout-input-legacy.component"; diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.html b/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.html new file mode 100644 index 0000000000..4f1b27a812 --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.html @@ -0,0 +1,47 @@ +
+ + {{ "vaultTimeout1" | i18n }} + + + + +
+ + + {{ "hours" | i18n }} + + + + {{ "minutes" | i18n }} + +
+ + {{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }} + + + {{ "vaultCustomTimeoutMinimum" | i18n }} + + + + {{ + "vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes + }} + +
diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.ts new file mode 100644 index 0000000000..22a53f6a53 --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.ts @@ -0,0 +1,296 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, Input, OnChanges, OnDestroy, OnInit } from "@angular/core"; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + ValidationErrors, + Validator, +} from "@angular/forms"; +import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { + VaultTimeout, + VaultTimeoutAction, + VaultTimeoutOption, + VaultTimeoutSettingsService, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FormFieldModule, SelectModule } from "@bitwarden/components"; + +type VaultTimeoutForm = FormGroup<{ + vaultTimeout: FormControl; + custom: FormGroup<{ + hours: FormControl; + minutes: FormControl; + }>; +}>; + +type VaultTimeoutFormValue = VaultTimeoutForm["value"]; + +/** + * @deprecated Use {@link SessionTimeoutInputComponent} instead. + * + * TODO Cleanup once feature flag enabled: https://bitwarden.atlassian.net/browse/PM-27297 + */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-session-timeout-input-legacy", + templateUrl: "session-timeout-input-legacy.component.html", + imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: SessionTimeoutInputLegacyComponent, + }, + { + provide: NG_VALIDATORS, + multi: true, + useExisting: SessionTimeoutInputLegacyComponent, + }, + ], +}) +export class SessionTimeoutInputLegacyComponent + implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges +{ + static CUSTOM_VALUE = -100; + static MIN_CUSTOM_MINUTES = 0; + form: VaultTimeoutForm = this.formBuilder.group({ + vaultTimeout: [null], + custom: this.formBuilder.group({ + hours: [null], + minutes: [null], + }), + }); + + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() vaultTimeoutOptions: VaultTimeoutOption[]; + + vaultTimeoutPolicy: Policy; + vaultTimeoutPolicyHours: number; + vaultTimeoutPolicyMinutes: number; + + protected readonly VaultTimeoutAction = VaultTimeoutAction; + + protected canLockVault$: Observable; + private onChange: (vaultTimeout: VaultTimeout) => void; + private validatorChange: () => void; + private destroy$ = new Subject(); + + constructor( + private formBuilder: FormBuilder, + private policyService: PolicyService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private i18nService: I18nService, + private accountService: AccountService, + ) {} + + get showCustom() { + return this.form.get("vaultTimeout").value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE; + } + + get exceedsMinimumTimeout(): boolean { + return ( + !this.showCustom || + this.customTimeInMinutes() > SessionTimeoutInputLegacyComponent.MIN_CUSTOM_MINUTES + ); + } + + get exceedsMaximumTimeout(): boolean { + return ( + this.showCustom && + this.customTimeInMinutes() > + this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours + ); + } + + get filteredVaultTimeoutOptions(): VaultTimeoutOption[] { + // by policy max value + if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) { + return this.vaultTimeoutOptions; + } + + return this.vaultTimeoutOptions.filter((option) => { + if (typeof option.value === "number") { + return option.value <= this.vaultTimeoutPolicy.data.minutes; + } + + return false; + }); + } + + async ngOnInit() { + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, + filter((policy) => policy != null), + takeUntil(this.destroy$), + ) + .subscribe((policy) => { + this.vaultTimeoutPolicy = policy; + this.applyVaultTimeoutPolicy(); + }); + this.form.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((value: VaultTimeoutFormValue) => { + if (this.onChange) { + this.onChange(this.getVaultTimeout(value)); + } + }); + + // Assign the current value to the custom fields + // so that if the user goes from a numeric value to custom + // we can initialize the custom fields with the current value + // ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields + this.form.controls.vaultTimeout.valueChanges + .pipe( + filter((value) => value !== SessionTimeoutInputLegacyComponent.CUSTOM_VALUE), + takeUntil(this.destroy$), + ) + .subscribe((value) => { + const current = typeof value === "string" ? 0 : Math.max(value, 0); + + // This cannot emit an event b/c it would cause form.valueChanges to fire again + // and we are already handling that above so just silently update + // custom fields when vaultTimeout changes to a non-custom value + this.form.patchValue( + { + custom: { + hours: Math.floor(current / 60), + minutes: current % 60, + }, + }, + { emitEvent: false }, + ); + }); + + this.canLockVault$ = this.vaultTimeoutSettingsService + .availableVaultTimeoutActions$() + .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngOnChanges() { + if ( + !this.vaultTimeoutOptions.find( + (p) => p.value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE, + ) + ) { + this.vaultTimeoutOptions.push({ + name: this.i18nService.t("custom"), + value: SessionTimeoutInputLegacyComponent.CUSTOM_VALUE, + }); + } + } + + getVaultTimeout(value: VaultTimeoutFormValue) { + if (value.vaultTimeout !== SessionTimeoutInputLegacyComponent.CUSTOM_VALUE) { + return value.vaultTimeout; + } + + return value.custom.hours * 60 + value.custom.minutes; + } + + writeValue(value: number): void { + if (value == null) { + return; + } + + if (this.vaultTimeoutOptions.every((p) => p.value !== value)) { + this.form.setValue({ + vaultTimeout: SessionTimeoutInputLegacyComponent.CUSTOM_VALUE, + custom: { + hours: Math.floor(value / 60), + minutes: value % 60, + }, + }); + return; + } + + this.form.patchValue({ + vaultTimeout: value, + }); + } + + registerOnChange(onChange: any): void { + this.onChange = onChange; + } + + registerOnTouched(onTouched: any): void { + // Empty + } + + setDisabledState?(isDisabled: boolean): void { + // Empty + } + + validate(control: AbstractControl): ValidationErrors { + if (this.vaultTimeoutPolicy && this.vaultTimeoutPolicy?.data?.minutes < control.value) { + return { policyError: true }; + } + + if (!this.exceedsMinimumTimeout) { + return { minTimeoutError: true }; + } + + return null; + } + + registerOnValidatorChange(fn: () => void): void { + this.validatorChange = fn; + } + + private customTimeInMinutes() { + return this.form.value.custom.hours * 60 + this.form.value.custom.minutes; + } + + private applyVaultTimeoutPolicy() { + this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60); + this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60; + + this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => { + // Always include the custom option + if (vaultTimeoutOption.value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE) { + return true; + } + + if (typeof vaultTimeoutOption.value === "number") { + // Include numeric values that are less than or equal to the policy minutes + return vaultTimeoutOption.value <= this.vaultTimeoutPolicy.data.minutes; + } + + // Exclude all string cases when there's a numeric policy defined + return false; + }); + + // Only call validator change if it's been set + if (this.validatorChange) { + this.validatorChange(); + } + } +} diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.html b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.html index 4f1b27a812..9a8de253d5 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.html +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.html @@ -1,47 +1,51 @@
- + {{ "vaultTimeout1" | i18n }} - + @for (option of availableTimeoutOptions(); track option.value) { + + } -
- - - {{ "hours" | i18n }} - - - - {{ "minutes" | i18n }} - -
- - {{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }} - - - {{ "vaultCustomTimeoutMinimum" | i18n }} - - - - {{ - "vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes - }} - + @if (isCustomTimeoutType) { +
+ + + {{ "hours" | i18n }} + + + + {{ "minutes" | i18n }} + +
+ } + @if (form.hasError("maxTimeoutError")) { +
+ + {{ + "sessionTimeoutSettingsPolicyMaximumError" + | i18n: maxSessionTimeoutPolicyHours : maxSessionTimeoutPolicyMinutes + }} +
+ } @else if (maxSessionTimeoutPolicyData != null) { + @let policyTimeoutMessage = policyTimeoutMessage$ | async; + @if (policyTimeoutMessage != null) { + + {{ policyTimeoutMessage }} + + } + }
diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.spec.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.spec.ts index ca9b1230be..cbe532de5b 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.spec.ts +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.spec.ts @@ -1,90 +1,819 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { BehaviorSubject } from "rxjs"; +import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { - VaultTimeoutSettingsService, + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeout, + VaultTimeoutOption, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { LogService } from "@bitwarden/logging"; + +import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service"; import { SessionTimeoutInputComponent } from "./session-timeout-input.component"; describe("SessionTimeoutInputComponent", () => { let component: SessionTimeoutInputComponent; let fixture: ComponentFixture; - const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({})); - const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([])); - const mockUserId = Utils.newGuid() as UserId; - const accountService = mockAccountServiceWith(mockUserId); + + // Test constants + const MOCK_USER_ID = "user-id" as UserId; + const ONE_MINUTE = 1; + const FIVE_MINUTES = 5; + const FIFTEEN_MINUTES = 15; + const THIRTY_MINUTES = 30; + const ONE_HOUR = 60; + const FOUR_HOURS = 240; + const NINETY_MINUTES = 90; + + // Mock services + let mockPolicyService: MockProxy; + let mockSessionTimeoutSettingsComponentService: MockProxy; + let mockSessionTimeoutTypeService: MockProxy; + let mockI18nService: MockProxy; + let mockLogService: MockProxy; + let accountService: AccountService; + + // BehaviorSubjects for reactive testing + let policies$: BehaviorSubject; + let availableTimeoutOptions: VaultTimeoutOption[]; beforeEach(async () => { + // Initialize BehaviorSubjects + policies$ = new BehaviorSubject([]); + + // Initialize available timeout options + availableTimeoutOptions = [ + { name: "oneMinute-used-i18n", value: ONE_MINUTE }, + { name: "fiveMinutes-used-i18n", value: FIVE_MINUTES }, + { name: "fifteenMinutes-used-i18n", value: FIFTEEN_MINUTES }, + { name: "thirtyMinutes-used-i18n", value: THIRTY_MINUTES }, + { name: "oneHour-used-i18n", value: ONE_HOUR }, + { name: "fourHours-used-i18n", value: FOUR_HOURS }, + { name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart }, + { name: "onLocked-used-i18n", value: VaultTimeoutStringType.OnLocked }, + { name: "never-used-i18n", value: VaultTimeoutStringType.Never }, + ]; + + // Initialize mocks + mockPolicyService = mock(); + mockPolicyService.policiesByType$.mockReturnValue(policies$.asObservable()); + + accountService = mockAccountServiceWith(MOCK_USER_ID); + + mockI18nService = mock(); + mockI18nService.t.mockImplementation((key, ...args) => { + if (args.length > 0) { + return `${key}-used-i18n-${args.join("-")}`; + } + return `${key}-used-i18n`; + }); + + mockLogService = mock(); + + mockSessionTimeoutSettingsComponentService = mock(); + mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockReturnValue( + of(availableTimeoutOptions), + ); + + mockSessionTimeoutTypeService = mock(); + mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true); + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation( + async (timeout: VaultTimeout) => timeout, + ); + await TestBed.configureTestingModule({ imports: [SessionTimeoutInputComponent], providers: [ - { provide: PolicyService, useValue: { policiesByType$ } }, + { provide: PolicyService, useValue: mockPolicyService }, { provide: AccountService, useValue: accountService }, - { provide: VaultTimeoutSettingsService, useValue: { availableVaultTimeoutActions$ } }, - { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: I18nService, useValue: mockI18nService }, + { provide: LogService, useValue: mockLogService }, + { + provide: SessionTimeoutSettingsComponentService, + useValue: mockSessionTimeoutSettingsComponentService, + }, + { provide: SessionTimeoutTypeService, useValue: mockSessionTimeoutTypeService }, ], }).compileComponents(); fixture = TestBed.createComponent(SessionTimeoutInputComponent); component = fixture.componentInstance; - component.vaultTimeoutOptions = [ - { name: "oneMinute", value: 1 }, - { name: "fiveMinutes", value: 5 }, - { name: "fifteenMinutes", value: 15 }, - { name: "thirtyMinutes", value: 30 }, - { name: "oneHour", value: 60 }, - { name: "fourHours", value: 240 }, - { name: "onRefresh", value: VaultTimeoutStringType.OnRestart }, - ]; - fixture.detectChanges(); + fixture.componentRef.setInput("availableTimeoutOptions", availableTimeoutOptions); }); - describe("form", () => { - beforeEach(async () => { - await component.ngOnInit(); + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + describe("policy data subscription and initialization", () => { + it("should initialize maxSessionTimeoutPolicyData to null when no policy exists", fakeAsync(() => { + fixture.detectChanges(); + flush(); + + expect(component["maxSessionTimeoutPolicyData"]).toBeNull(); + })); + + it("should set maxSessionTimeoutPolicyData when policy exists", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: NINETY_MINUTES, + }; + + fixture.detectChanges(); + flush(); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component["maxSessionTimeoutPolicyData"]).toEqual(policyData); + })); + + it("should trigger validatorChange callback when policy data changes", fakeAsync(() => { + const validatorChangeFn = jest.fn(); + component.registerOnValidatorChange(validatorChangeFn); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: NINETY_MINUTES, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(validatorChangeFn).toHaveBeenCalled(); + })); + + it("should update form validation when policy data changes", fakeAsync(() => { + const updateSpy = jest.spyOn(component.form.controls.custom, "updateValueAndValidity"); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: FIFTEEN_MINUTES, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(updateSpy).toHaveBeenCalled(); + })); }); - it("invokes the onChange associated with `ControlValueAccessor`", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); + describe("policyTimeoutMessage$ observable", () => { + it("should emit custom timeout message when policy has custom type", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: NINETY_MINUTES, + }; - component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnRestart); + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); - expect(onChange).toHaveBeenCalledWith(VaultTimeoutStringType.OnRestart); + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe( + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes-used-i18n-1-30", + ); + })); + + it("should emit immediately message when policy has immediately type", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "immediately", + minutes: 0, + }; + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe( + "sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately-used-i18n", + ); + })); + + it("should emit onLocked message when policy has onSystemLock type", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onSystemLock", + minutes: 0, + }; + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked-used-i18n"); + })); + + it("should emit onRestart message when policy has onAppRestart type", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onAppRestart", + minutes: 0, + }; + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n"); + })); + + it("should emit null when policy has never type and promoted value is Never", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "never", + minutes: 0, + }; + + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + VaultTimeoutStringType.Never, + ); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = "initial"; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBeNull(); + })); + + it("should emit numeric timeout message when immediately is promoted to 1 minute", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "immediately", + minutes: 0, + }; + + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(ONE_MINUTE); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe( + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes-used-i18n-0-1", + ); + })); + + it("should emit onRestart message when onSystemLock is promoted to OnRestart", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onSystemLock", + minutes: 0, + }; + + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + VaultTimeoutStringType.OnRestart, + ); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n"); + })); + + it("should emit onRestart message when never is promoted to OnRestart", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "never", + minutes: 0, + }; + + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + VaultTimeoutStringType.OnRestart, + ); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n"); + })); }); - it("updates custom value to match preset option", () => { - // 1 hour - component.form.controls.vaultTimeout.setValue(60); + describe("form value changes subscription", () => { + it("should call onChange with vault timeout when form is valid and in custom mode", fakeAsync(() => { + const onChange = jest.fn(); + component.registerOnChange(onChange); - expect(component.form.value.custom).toEqual({ hours: 1, minutes: 0 }); + fixture.detectChanges(); + flush(); - // 17 minutes - component.form.controls.vaultTimeout.setValue(17); + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 1, minutes: 30 }); + flush(); - expect(component.form.value.custom).toEqual({ hours: 0, minutes: 17 }); + expect(onChange).toHaveBeenCalledWith(NINETY_MINUTES); + })); - // 2.25 hours - component.form.controls.vaultTimeout.setValue(135); + it("should call onChange when form changes to non-custom mode", fakeAsync(() => { + const onChange = jest.fn(); + component.registerOnChange(onChange); - expect(component.form.value.custom).toEqual({ hours: 2, minutes: 15 }); + fixture.detectChanges(); + flush(); + + onChange.mockClear(); + + component.form.controls.vaultTimeout.setValue(FIFTEEN_MINUTES); + flush(); + + expect(onChange).toHaveBeenCalledWith(FIFTEEN_MINUTES); + })); + + it("should not call onChange when custom controls are invalid", fakeAsync(() => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + fixture.detectChanges(); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + flush(); + + onChange.mockClear(); + + component.form.controls.custom.controls.hours.setValue(null); + flush(); + + expect(onChange).not.toHaveBeenCalled(); + })); + + it("should not call onChange when vaultTimeout is null", fakeAsync(() => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + fixture.detectChanges(); + flush(); + + onChange.mockClear(); + + component.form.controls.vaultTimeout.setValue(null); + flush(); + + expect(onChange).not.toHaveBeenCalled(); + })); }); - it("sets custom timeout to 0 when a preset string option is selected", () => { - // Set custom value to random values - component.form.controls.custom.setValue({ hours: 1, minutes: 1 }); + describe("custom fields initialization from vaultTimeout changes", () => { + it("should update custom fields when vaultTimeout changes to numeric value", fakeAsync(() => { + fixture.detectChanges(); + flush(); - component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnLocked); + component.form.controls.vaultTimeout.setValue(NINETY_MINUTES); + flush(); - expect(component.form.value.custom).toEqual({ hours: 0, minutes: 0 }); + expect(component.form.value.custom).toEqual({ hours: 1, minutes: 30 }); + })); + + it.each([ + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnIdle, + ])( + "should set custom fields to 8 hours when vaultTimeout changes to %s", + fakeAsync((timeoutType: VaultTimeout) => { + fixture.detectChanges(); + flush(); + + component.form.controls.custom.setValue({ hours: 1, minutes: 30 }); + component.form.controls.vaultTimeout.setValue(timeoutType); + flush(); + + expect(component.form.value.custom).toEqual({ hours: 8, minutes: 0 }); + }), + ); + + it("should mark custom fields as touched after update", fakeAsync(() => { + fixture.detectChanges(); + flush(); + + component.form.controls.vaultTimeout.setValue(ONE_HOUR); + flush(); + + expect(component.form.controls.custom.controls.hours.touched).toBe(true); + expect(component.form.controls.custom.controls.minutes.touched).toBe(true); + })); + + it("should not update custom fields when vaultTimeout changes to Custom", fakeAsync(() => { + fixture.detectChanges(); + flush(); + + component.form.controls.custom.setValue({ hours: 5, minutes: 15 }); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + flush(); + + expect(component.form.value.custom).toEqual({ hours: 5, minutes: 15 }); + })); }); }); + + describe("isCustomTimeoutType", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return true when vaultTimeout is Custom", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + flush(); + + expect(component.isCustomTimeoutType).toBe(true); + })); + + it.each([ + ONE_MINUTE, + FIFTEEN_MINUTES, + ONE_HOUR, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + ])( + "should return false when vaultTimeout is %s", + fakeAsync((timeout: VaultTimeout) => { + component.form.controls.vaultTimeout.setValue(timeout); + flush(); + + expect(component.isCustomTimeoutType).toBe(false); + }), + ); + }); + + describe("customMinutesMin", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return 1 when hours is 0", fakeAsync(() => { + component.form.controls.custom.controls.hours.setValue(0); + flush(); + + expect(component.customMinutesMin).toBe(1); + })); + + it.each([1, 2, 5, 10])( + "should return 0 when hours is %s", + fakeAsync((hours: number) => { + component.form.controls.custom.controls.hours.setValue(hours); + flush(); + + expect(component.customMinutesMin).toBe(0); + }), + ); + }); + + describe("maxSessionTimeoutPolicyHours", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return 0 when no policy exists", fakeAsync(() => { + expect(component.maxSessionTimeoutPolicyHours).toBe(0); + })); + + it.each([ + { minutes: ONE_HOUR, expectedHours: 1 }, + { minutes: NINETY_MINUTES, expectedHours: 1 }, + { minutes: FOUR_HOURS, expectedHours: 4 }, + { minutes: 300, expectedHours: 5 }, + ])( + "should return $expectedHours when policy minutes is $minutes", + fakeAsync(({ minutes, expectedHours }: { minutes: number; expectedHours: number }) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.maxSessionTimeoutPolicyHours).toBe(expectedHours); + }), + ); + }); + + describe("maxSessionTimeoutPolicyMinutes", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return 0 when no policy exists", fakeAsync(() => { + expect(component.maxSessionTimeoutPolicyMinutes).toBe(0); + })); + + it.each([ + { minutes: ONE_HOUR, expectedMinutes: 0 }, + { minutes: NINETY_MINUTES, expectedMinutes: 30 }, + { minutes: 65, expectedMinutes: 5 }, + { minutes: 137, expectedMinutes: 17 }, + ])( + "should return $expectedMinutes when policy minutes is $minutes", + fakeAsync(({ minutes, expectedMinutes }: { minutes: number; expectedMinutes: number }) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.maxSessionTimeoutPolicyMinutes).toBe(expectedMinutes); + }), + ); + }); + + describe("exceedsPolicyMaximumTimeout", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return true when custom timeout exceeds policy maximum", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: ONE_HOUR, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 2, minutes: 0 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(true); + })); + + it("should return false when no policy exists", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 100, minutes: 0 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + + it("should return false when policy type is not custom", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "immediately", + minutes: 0, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 10, minutes: 0 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + + it("should return false when policy type is custom and form timeout is not custom", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: FIFTEEN_MINUTES, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(ONE_HOUR); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + + it("should return false when custom timeout equals policy maximum", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: ONE_HOUR, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 1, minutes: 0 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + + it("should return false when custom timeout is below policy maximum", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: FOUR_HOURS, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 2, minutes: 30 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + }); + + describe("writeValue", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should do nothing when value is null", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(ONE_HOUR); + flush(); + + component.writeValue(null); + flush(); + + expect(component.form.controls.vaultTimeout.value).toBe(ONE_HOUR); + })); + + it("should set form to custom mode when value doesn't match any available option", fakeAsync(() => { + component.writeValue(NINETY_MINUTES); + flush(); + + expect(component.form.controls.vaultTimeout.value).toBe(VaultTimeoutStringType.Custom); + expect(component.form.controls.custom.value).toEqual({ hours: 1, minutes: 30 }); + })); + + it.each([ONE_MINUTE, FIVE_MINUTES, FIFTEEN_MINUTES, THIRTY_MINUTES, ONE_HOUR, FOUR_HOURS])( + "should set vaultTimeout directly when numeric value %s matches preset option", + fakeAsync((timeout: number) => { + component.writeValue(timeout); + flush(); + + expect(component.form.controls.vaultTimeout.value).toBe(timeout); + }), + ); + + it.each([ + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + ])( + "should set vaultTimeout directly when string value %s matches preset option", + fakeAsync((timeout: VaultTimeout) => { + component.writeValue(timeout); + flush(); + + expect(component.form.controls.vaultTimeout.value).toBe(timeout); + }), + ); + }); + + describe("validate", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return null when vaultTimeout is not custom", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(ONE_HOUR); + flush(); + + const result = component.validate(component.form); + + expect(result).toBeNull(); + })); + + it("should return required error when vaultTimeout is custom and hours is null", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.controls.hours.setValue(null); + component.form.controls.custom.controls.minutes.setValue(30); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ required: true }); + })); + + it("should return required error when vaultTimeout is custom and minutes is null", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.controls.hours.setValue(1); + component.form.controls.custom.controls.minutes.setValue(null); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ required: true }); + })); + + it("should return required error when vaultTimeout is custom and both hours and minutes are null", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.controls.hours.setValue(null); + component.form.controls.custom.controls.minutes.setValue(null); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ required: true }); + })); + + it("should return minTimeoutError when total minutes is 0", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 0, minutes: 0 }); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ minTimeoutError: true }); + })); + + it("should return maxTimeoutError when exceeds policy maximum", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: ONE_HOUR, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 2, minutes: 0 }); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ maxTimeoutError: true }); + })); + + it("should return null when custom values are valid and within policy limit", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: FOUR_HOURS, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 2, minutes: 30 }); + flush(); + + const result = component.validate(component.form); + + expect(result).toBeNull(); + })); + + it("should return null when custom values are valid and no policy exists", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 5, minutes: 15 }); + flush(); + + const result = component.validate(component.form); + + expect(result).toBeNull(); + })); + }); }); diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.ts index e9a5722ff4..f8bbf9c215 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.ts +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.ts @@ -1,9 +1,16 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, Input, OnChanges, OnDestroy, OnInit } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + input, + OnInit, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { AbstractControl, + AbstractControlOptions, ControlValueAccessor, FormBuilder, FormControl, @@ -13,26 +20,32 @@ import { ReactiveFormsModule, ValidationErrors, Validator, + Validators, } from "@angular/forms"; -import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs"; +import { filter, map, Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, VaultTimeout, - VaultTimeoutAction, VaultTimeoutOption, - VaultTimeoutSettingsService, + VaultTimeoutNumberType, + VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FormFieldModule, SelectModule } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; -type VaultTimeoutForm = FormGroup<{ +type SessionTimeoutForm = FormGroup<{ vaultTimeout: FormControl; custom: FormGroup<{ hours: FormControl; @@ -40,10 +53,8 @@ type VaultTimeoutForm = FormGroup<{ }>; }>; -type VaultTimeoutFormValue = VaultTimeoutForm["value"]; +type SessionTimeoutFormValue = SessionTimeoutForm["value"]; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-session-timeout-input", templateUrl: "session-timeout-input.component.html", @@ -60,111 +71,110 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"]; useExisting: SessionTimeoutInputComponent, }, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SessionTimeoutInputComponent - implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges -{ - static CUSTOM_VALUE = -100; - static MIN_CUSTOM_MINUTES = 0; - form: VaultTimeoutForm = this.formBuilder.group({ - vaultTimeout: [null], - custom: this.formBuilder.group({ - hours: [null], - minutes: [null], - }), - }); +export class SessionTimeoutInputComponent implements ControlValueAccessor, Validator, OnInit { + static readonly MIN_CUSTOM_MINUTES = 0; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() vaultTimeoutOptions: VaultTimeoutOption[]; + private readonly formBuilder = inject(FormBuilder); + private readonly policyService = inject(PolicyService); + private readonly i18nService = inject(I18nService); + private readonly accountService = inject(AccountService); + private readonly destroyRef = inject(DestroyRef); + private readonly sessionTimeoutTypeService = inject(SessionTimeoutTypeService); + private readonly logService = inject(LogService); - vaultTimeoutPolicy: Policy; - vaultTimeoutPolicyHours: number; - vaultTimeoutPolicyMinutes: number; + readonly availableTimeoutOptions = input.required(); - protected readonly VaultTimeoutAction = VaultTimeoutAction; + protected maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null = null; + protected policyTimeoutMessage$!: Observable; - protected canLockVault$: Observable; - private onChange: (vaultTimeout: VaultTimeout) => void; - private validatorChange: () => void; - private destroy$ = new Subject(); + readonly form: SessionTimeoutForm = this.formBuilder.group( + { + vaultTimeout: [null as VaultTimeout | null], + custom: this.formBuilder.group({ + hours: [0, [Validators.required, Validators.min(0)]], + minutes: [0, [Validators.required, Validators.min(0), Validators.max(59)]], + }), + }, + { validators: [this.formValidator.bind(this)] } as AbstractControlOptions, + ); - constructor( - private formBuilder: FormBuilder, - private policyService: PolicyService, - private vaultTimeoutSettingsService: VaultTimeoutSettingsService, - private i18nService: I18nService, - private accountService: AccountService, - ) {} + private onChange: ((vaultTimeout: VaultTimeout) => void) | null = null; + private validatorChange: (() => void) | null = null; - get showCustom() { - return this.form.get("vaultTimeout").value === SessionTimeoutInputComponent.CUSTOM_VALUE; + get isCustomTimeoutType(): boolean { + return this.form.controls.vaultTimeout.value === VaultTimeoutStringType.Custom; } - get exceedsMinimumTimeout(): boolean { + get customMinutesMin(): number { + return this.form.controls.custom.controls.hours.value === 0 ? 1 : 0; + } + + get exceedsPolicyMaximumTimeout(): boolean { return ( - !this.showCustom || - this.customTimeInMinutes() > SessionTimeoutInputComponent.MIN_CUSTOM_MINUTES + this.maxSessionTimeoutPolicyData?.type === VaultTimeoutStringType.Custom && + this.isCustomTimeoutType && + this.getTotalMinutesFromCustomValue(this.form.value.custom) > + this.maxSessionTimeoutPolicyMinutes + 60 * this.maxSessionTimeoutPolicyHours ); } - get exceedsMaximumTimeout(): boolean { - return ( - this.showCustom && - this.customTimeInMinutes() > - this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours + ngOnInit(): void { + const maximumSessionTimeoutPolicyData$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, + filter((policy) => policy != null), + map((policy) => policy.data as MaximumSessionTimeoutPolicyData), ); - } - get filteredVaultTimeoutOptions(): VaultTimeoutOption[] { - // by policy max value - if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) { - return this.vaultTimeoutOptions; - } + this.policyTimeoutMessage$ = maximumSessionTimeoutPolicyData$.pipe( + switchMap((policyData) => this.getPolicyTimeoutMessage(policyData)), + ); - return this.vaultTimeoutOptions.filter((option) => { - if (typeof option.value === "number") { - return option.value <= this.vaultTimeoutPolicy.data.minutes; - } - - return false; - }); - } - - async ngOnInit() { - this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => - this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), - ), - getFirstPolicy, - filter((policy) => policy != null), - takeUntil(this.destroy$), - ) - .subscribe((policy) => { - this.vaultTimeoutPolicy = policy; - this.applyVaultTimeoutPolicy(); - }); - this.form.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((value: VaultTimeoutFormValue) => { - if (this.onChange) { - this.onChange(this.getVaultTimeout(value)); + maximumSessionTimeoutPolicyData$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((policyData) => { + this.maxSessionTimeoutPolicyData = policyData; + // Re-validate custom form group with new policy data + this.form.controls.custom.updateValueAndValidity(); + // Trigger validator change when policy data changes + if (this.validatorChange) { + this.validatorChange(); } }); + // Subscribe to form value changes + this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value) => { + if (this.onChange) { + const vaultTimeout = this.getVaultTimeout(value); + if (vaultTimeout != null) { + // Only call onChange if the form is valid + // For non-numeric values, we don't need to validate custom fields + const isValid = !this.isCustomTimeoutType || this.form.controls.custom.valid; + if (isValid) { + this.onChange(vaultTimeout); + } + } + } + }); + // Assign the current value to the custom fields // so that if the user goes from a numeric value to custom // we can initialize the custom fields with the current value // ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields this.form.controls.vaultTimeout.valueChanges .pipe( - filter((value) => value !== SessionTimeoutInputComponent.CUSTOM_VALUE), - takeUntil(this.destroy$), + filter((value) => value != null && value !== VaultTimeoutStringType.Custom), + takeUntilDestroyed(this.destroyRef), ) .subscribe((value) => { - const current = typeof value === "string" ? 0 : Math.max(value, 0); + const current = isVaultTimeoutTypeNumeric(value) + ? (value as number) + : VaultTimeoutNumberType.EightHours; // This cannot emit an event b/c it would cause form.valueChanges to fire again // and we are already handling that above so just silently update @@ -178,112 +188,169 @@ export class SessionTimeoutInputComponent }, { emitEvent: false }, ); + + this.form.controls.custom.markAllAsTouched(); }); - - this.canLockVault$ = this.vaultTimeoutSettingsService - .availableVaultTimeoutActions$() - .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); } - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); + get maxSessionTimeoutPolicyHours(): number { + return Math.floor((this.maxSessionTimeoutPolicyData?.minutes ?? 0) / 60); } - ngOnChanges() { - if ( - !this.vaultTimeoutOptions.find((p) => p.value === SessionTimeoutInputComponent.CUSTOM_VALUE) - ) { - this.vaultTimeoutOptions.push({ - name: this.i18nService.t("custom"), - value: SessionTimeoutInputComponent.CUSTOM_VALUE, - }); - } + get maxSessionTimeoutPolicyMinutes(): number { + return (this.maxSessionTimeoutPolicyData?.minutes ?? 0) % 60; } - getVaultTimeout(value: VaultTimeoutFormValue) { - if (value.vaultTimeout !== SessionTimeoutInputComponent.CUSTOM_VALUE) { - return value.vaultTimeout; - } - - return value.custom.hours * 60 + value.custom.minutes; - } - - writeValue(value: number): void { + writeValue(value: VaultTimeout | null): void { if (value == null) { return; } - if (this.vaultTimeoutOptions.every((p) => p.value !== value)) { + // Normalize the custom numeric value to preset (i.e. 1 minute), otherwise set as custom + const options = this.availableTimeoutOptions(); + const matchingOption = options.some((opt) => opt.value === value); + if (!matchingOption) { + this.logService.debug( + `[SessionTimeoutInputComponent] form control write value as custom ${value}`, + ); this.form.setValue({ - vaultTimeout: SessionTimeoutInputComponent.CUSTOM_VALUE, + vaultTimeout: VaultTimeoutStringType.Custom, custom: { - hours: Math.floor(value / 60), - minutes: value % 60, + hours: Math.floor((value as number) / 60), + minutes: (value as number) % 60, }, }); return; } + this.logService.debug( + `[SessionTimeoutInputComponent] form control write value as preset ${value}`, + ); + + // For string values (e.g., "onLocked", "never"), set directly this.form.patchValue({ vaultTimeout: value, }); } - registerOnChange(onChange: any): void { + registerOnChange(onChange: (vaultTimeout: VaultTimeout) => void): void { this.onChange = onChange; } - registerOnTouched(onTouched: any): void { + registerOnTouched(_onTouched: () => void): void { // Empty } - setDisabledState?(isDisabled: boolean): void { + setDisabledState?(_isDisabled: boolean): void { // Empty } - validate(control: AbstractControl): ValidationErrors { - if (this.vaultTimeoutPolicy && this.vaultTimeoutPolicy?.data?.minutes < control.value) { - return { policyError: true }; - } - - if (!this.exceedsMinimumTimeout) { - return { minTimeoutError: true }; - } - - return null; + validate(_: AbstractControl): ValidationErrors | null { + return this.form.errors; } registerOnValidatorChange(fn: () => void): void { this.validatorChange = fn; } - private customTimeInMinutes() { - return this.form.value.custom.hours * 60 + this.form.value.custom.minutes; + private getTotalMinutesFromCustomValue(customValue: SessionTimeoutFormValue["custom"]): number { + const hours = customValue?.hours ?? 0; + const minutes = customValue?.minutes ?? 0; + return hours * 60 + minutes; } - private applyVaultTimeoutPolicy() { - this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60); - this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60; + private formValidator(control: AbstractControl): ValidationErrors | null { + const formValue = control.value as SessionTimeoutFormValue; + const isCustomMode = formValue.vaultTimeout === VaultTimeoutStringType.Custom; - this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => { - // Always include the custom option - if (vaultTimeoutOption.value === SessionTimeoutInputComponent.CUSTOM_VALUE) { - return true; + // Only validate when in custom mode + if (!isCustomMode) { + return null; + } + + const hours = formValue.custom?.hours; + const minutes = formValue.custom?.minutes; + + if (hours == null || minutes == null) { + return { required: true }; + } + + const totalMinutes = this.getTotalMinutesFromCustomValue(formValue.custom); + if (totalMinutes === 0) { + return { minTimeoutError: true }; + } + + if (this.exceedsPolicyMaximumTimeout) { + return { maxTimeoutError: true }; + } + + return null; + } + + private getVaultTimeout(value: SessionTimeoutFormValue): VaultTimeout | null { + if (value.vaultTimeout !== VaultTimeoutStringType.Custom) { + return value.vaultTimeout ?? null; + } + + return this.getTotalMinutesFromCustomValue(value.custom); + } + + private async getPolicyTimeoutMessage( + policyData: MaximumSessionTimeoutPolicyData, + ): Promise { + const timeout = await this.getPolicyAppliedTimeout(policyData); + + switch (timeout) { + case null: + // Don't display the policy message + return null; + case VaultTimeoutNumberType.Immediately: + return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately"); + case VaultTimeoutStringType.OnLocked: + return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked"); + case VaultTimeoutStringType.OnRestart: + return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart"); + default: + if (isVaultTimeoutTypeNumeric(timeout)) { + const hours = Math.floor((timeout as number) / 60); + const minutes = (timeout as number) % 60; + return this.i18nService.t( + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes", + hours, + minutes, + ); + } + throw new Error("Invalid timeout parameter"); + } + } + + private async getPolicyAppliedTimeout( + policyData: MaximumSessionTimeoutPolicyData, + ): Promise { + switch (policyData.type) { + case "immediately": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutNumberType.Immediately, + ); + case "onSystemLock": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.OnLocked, + ); + case "onAppRestart": + return VaultTimeoutStringType.OnRestart; + case "never": { + const timeout = await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.Never, + ); + if (timeout == VaultTimeoutStringType.Never) { + // Don't display policy message, when the policy doesn't change the available timeout options + return null; + } + return timeout; } - - if (typeof vaultTimeoutOption.value === "number") { - // Include numeric values that are less than or equal to the policy minutes - return vaultTimeoutOption.value <= this.vaultTimeoutPolicy.data.minutes; - } - - // Exclude all string cases when there's a numeric policy defined - return false; - }); - - // Only call validator change if it's been set - if (this.validatorChange) { - this.validatorChange(); + case "custom": + default: + return policyData.minutes; } } } diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html index 4d607ce5d7..e6de54dc38 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html @@ -1,10 +1,11 @@
- - + @if (availableTimeoutOptions$ | async; as options) { + + + } {{ "sessionTimeoutSettingsAction" | i18n }} @@ -18,14 +19,10 @@ } - @if (!canLock) { - {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+ @if (!canLock && supportsLock) { + {{ "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction" | i18n }} + } @else if ((sessionTimeoutActionFromPolicy$ | async) != null) { + {{ "sessionTimeoutSettingsManagedByOrganization" | i18n }} }
- - @if (hasVaultTimeoutPolicy$ | async) { - - {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - - }
diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts index 4c3f41d738..7b69ee13dc 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts @@ -4,11 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { - MaximumVaultTimeoutPolicyData, + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { VaultTimeout, VaultTimeoutAction, VaultTimeoutOption, @@ -16,6 +20,7 @@ import { VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -39,6 +44,8 @@ describe("SessionTimeoutSettingsComponent", () => { let accountService: FakeAccountService; let mockDialogService: MockProxy; let mockLogService: MockProxy; + const mockPlatformUtilsService = mock(); + const mockSessionTimeoutTypeService = mock(); const mockUserId = "user-id" as UserId; const mockEmail = "test@example.com"; @@ -46,6 +53,7 @@ describe("SessionTimeoutSettingsComponent", () => { const mockInitialTimeoutAction = VaultTimeoutAction.Lock; let refreshTimeoutActionSettings$: BehaviorSubject; let availableTimeoutOptions$: BehaviorSubject; + let policies$: BehaviorSubject; beforeEach(async () => { refreshTimeoutActionSettings$ = new BehaviorSubject(undefined); @@ -58,6 +66,7 @@ describe("SessionTimeoutSettingsComponent", () => { { name: "onIdle-used-i18n", value: VaultTimeoutStringType.OnIdle }, { name: "never-used-i18n", value: VaultTimeoutStringType.Never }, ]); + policies$ = new BehaviorSubject([]); mockVaultTimeoutSettingsService = mock(); mockSessionTimeoutSettingsComponentService = mock(); @@ -79,9 +88,10 @@ describe("SessionTimeoutSettingsComponent", () => { mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), ); - mockSessionTimeoutSettingsComponentService.availableTimeoutOptions$ = - availableTimeoutOptions$.asObservable(); - mockPolicyService.policiesByType$.mockImplementation(() => of([])); + mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockImplementation( + (userId) => availableTimeoutOptions$.asObservable(), + ); + mockPolicyService.policiesByType$.mockReturnValue(policies$.asObservable()); await TestBed.configureTestingModule({ imports: [ @@ -102,6 +112,8 @@ describe("SessionTimeoutSettingsComponent", () => { { provide: AccountService, useValue: accountService }, { provide: LogService, useValue: mockLogService }, { provide: DialogService, useValue: mockDialogService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: SessionTimeoutTypeService, useValue: mockSessionTimeoutTypeService }, ], }) .overrideComponent(SessionTimeoutSettingsComponent, { @@ -145,6 +157,83 @@ describe("SessionTimeoutSettingsComponent", () => { })); }); + describe("supportsLock", () => { + it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli])( + "should return true when client is %s and policy action is null", + fakeAsync((clientType: ClientType) => { + mockPlatformUtilsService.getClientType.mockReturnValue(clientType); + + fixture.detectChanges(); + flush(); + + expect(component.supportsLock).toBe(true); + }), + ); + + it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli])( + "should return true when client is %s and policy action is lock", + fakeAsync((clientType: ClientType) => { + mockPlatformUtilsService.getClientType.mockReturnValue(clientType); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.Lock, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.supportsLock).toBe(true); + }), + ); + + it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli, ClientType.Web])( + "should return false when client is %s and policy action is logOut", + fakeAsync((clientType: ClientType) => { + mockPlatformUtilsService.getClientType.mockReturnValue(clientType); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.LogOut, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.supportsLock).toBe(false); + }), + ); + + it("should return false when client is Web and policy action is null", fakeAsync(() => { + mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); + + fixture.detectChanges(); + flush(); + + expect(component.supportsLock).toBe(false); + })); + + it("should return false when client is Web and policy action is lock", fakeAsync(() => { + mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.Lock, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.supportsLock).toBe(false); + })); + }); + describe("ngOnInit", () => { it("should initialize available timeout options", fakeAsync(async () => { fixture.detectChanges(); @@ -178,7 +267,7 @@ describe("SessionTimeoutSettingsComponent", () => { }); })); - it("should initialize available timeout actions", fakeAsync(() => { + it("should initialize available timeout actions signal", fakeAsync(() => { const expectedActions = [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]; mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => @@ -209,27 +298,55 @@ describe("SessionTimeoutSettingsComponent", () => { expect(component.formGroup.value.timeoutAction).toBe(expectedAction); })); - it("should fall back to OnRestart when current option is not available", fakeAsync(() => { - availableTimeoutOptions$.next([ - { name: "oneMinute-used-i18n", value: 1 }, - { name: "fiveMinutes-used-i18n", value: 5 }, - { name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart }, - ]); + it("should initialize userId from active account", fakeAsync(() => { + fixture.detectChanges(); + flush(); - const unavailableTimeout = VaultTimeoutStringType.Never; + expect(component["userId"]).toBe(mockUserId); + })); - mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() => - of(unavailableTimeout), - ); + it("should initialize sessionTimeoutActionFromPolicy signal with null when no policy exists", fakeAsync(() => { + fixture.detectChanges(); + flush(); + + expect(component["sessionTimeoutActionFromPolicy"]()).toBeNull(); + })); + + it.each([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut])( + "should initialize sessionTimeoutActionFromPolicy signal with policy action %s when policy exists", + fakeAsync((timeoutAction: VaultTimeoutAction) => { + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: timeoutAction, + }; + + fixture.detectChanges(); + flush(); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component["sessionTimeoutActionFromPolicy"]()).toBe(timeoutAction); + }), + ); + + it("should initialize sessionTimeoutActionFromPolicy signal with null when policy exists and action is user preference", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: null, + }; fixture.detectChanges(); flush(); - expect(component.formGroup.value.timeout).toBe(VaultTimeoutStringType.OnRestart); + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component["sessionTimeoutActionFromPolicy"]()).toBeNull(); })); it("should disable timeout action control when policy enforces action", fakeAsync(() => { - const policyData: MaximumVaultTimeoutPolicyData = { + const policyData: MaximumSessionTimeoutPolicyData = { minutes: 15, action: VaultTimeoutAction.LogOut, }; @@ -273,7 +390,7 @@ describe("SessionTimeoutSettingsComponent", () => { expect(component.formGroup.controls.timeoutAction.enabled).toBe(true); - const policyData: MaximumVaultTimeoutPolicyData = { + const policyData: MaximumSessionTimeoutPolicyData = { minutes: 15, action: VaultTimeoutAction.LogOut, }; @@ -355,6 +472,56 @@ describe("SessionTimeoutSettingsComponent", () => { expect(saveSpy).toHaveBeenCalledWith(VaultTimeoutAction.LogOut); })); + + it("should sync form timeout when service emits new timeout value", fakeAsync(() => { + const timeout$ = new BehaviorSubject(mockInitialTimeout); + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(timeout$); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeout.value).toBe(mockInitialTimeout); + + const newTimeout = 30; + timeout$.next(newTimeout); + flush(); + + expect(component.formGroup.controls.timeout.value).toBe(newTimeout); + })); + + it("should not sync form timeout when service emits same timeout value", fakeAsync(() => { + const timeout$ = new BehaviorSubject(mockInitialTimeout); + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(timeout$); + + fixture.detectChanges(); + flush(); + + const setValueSpy = jest.spyOn(component.formGroup.controls.timeout, "setValue"); + + timeout$.next(mockInitialTimeout); + flush(); + + expect(setValueSpy).not.toHaveBeenCalled(); + })); + + it("should update availableTimeoutActions signal when service emits new actions", fakeAsync(() => { + const actions$ = new BehaviorSubject([VaultTimeoutAction.Lock]); + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue(actions$); + + fixture.detectChanges(); + flush(); + + expect(component["availableTimeoutActions"]()).toEqual([VaultTimeoutAction.Lock]); + + actions$.next([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]); + refreshTimeoutActionSettings$.next(undefined); + flush(); + + expect(component["availableTimeoutActions"]()).toEqual([ + VaultTimeoutAction.Lock, + VaultTimeoutAction.LogOut, + ]); + })); }); describe("saveTimeout", () => { diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts index ecdd0ee89a..89f085e512 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, input, OnInit, signal } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Component, DestroyRef, inject, input, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, @@ -18,27 +18,28 @@ import { firstValueFrom, map, Observable, - of, pairwise, startWith, switchMap, + tap, } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/client-type"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { MaximumSessionTimeoutPolicyData } from "@bitwarden/common/key-management/session-timeout"; import { - MaximumVaultTimeoutPolicyData, VaultTimeout, VaultTimeoutAction, - VaultTimeoutOption, VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CheckboxModule, @@ -86,6 +87,19 @@ export class SessionTimeoutSettingsComponent implements OnInit { new BehaviorSubject(undefined), ); + private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); + private readonly sessionTimeoutSettingsComponentService = inject( + SessionTimeoutSettingsComponentService, + ); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + private readonly policyService = inject(PolicyService); + private readonly accountService = inject(AccountService); + private readonly dialogService = inject(DialogService); + private readonly logService = inject(LogService); + private readonly destroyRef = inject(DestroyRef); + private readonly platformUtilsService = inject(PlatformUtilsService); + formGroup = new FormGroup({ timeout: new FormControl(null, [Validators.required]), timeoutAction: new FormControl(VaultTimeoutAction.Lock, [ @@ -93,63 +107,48 @@ export class SessionTimeoutSettingsComponent implements OnInit { ]), }); protected readonly availableTimeoutActions = signal([]); - protected readonly availableTimeoutOptions$ = - this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe( - startWith([] as VaultTimeoutOption[]), - ); - protected hasVaultTimeoutPolicy$: Observable = of(false); + protected readonly availableTimeoutOptions$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.sessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$(userId), + ), + tap((options) => { + this.logService.debug("[SessionTimeoutSettings] Available timeout options", options); + }), + ); + protected readonly sessionTimeoutActionFromPolicy$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, + map((policy) => policy?.data as MaximumSessionTimeoutPolicyData | undefined), + map((data) => data?.action ?? null), + ); + protected readonly sessionTimeoutActionFromPolicy = toSignal( + this.sessionTimeoutActionFromPolicy$, + ); private userId!: UserId; - constructor( - private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService, - private readonly sessionTimeoutSettingsComponentService: SessionTimeoutSettingsComponentService, - private readonly i18nService: I18nService, - private readonly toastService: ToastService, - private readonly policyService: PolicyService, - private readonly accountService: AccountService, - private readonly dialogService: DialogService, - private readonly logService: LogService, - private readonly destroyRef: DestroyRef, - ) {} - get canLock() { return this.availableTimeoutActions().includes(VaultTimeoutAction.Lock); } + get supportsLock() { + return ( + this.platformUtilsService.getClientType() !== ClientType.Web && + this.sessionTimeoutActionFromPolicy() !== "logOut" + ); + } + async ngOnInit(): Promise { - const availableTimeoutOptions = await firstValueFrom( - this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$, - ); - - this.logService.debug( - "[SessionTimeoutSettings] Available timeout options", - availableTimeoutOptions, - ); - this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const maximumVaultTimeoutPolicy$ = this.policyService - .policiesByType$(PolicyType.MaximumVaultTimeout, this.userId) - .pipe(getFirstPolicy); - - this.hasVaultTimeoutPolicy$ = maximumVaultTimeoutPolicy$.pipe(map((policy) => policy != null)); - - let timeout = await firstValueFrom( + const timeout = await firstValueFrom( this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(this.userId), ); - // Fallback if current timeout option is not available on this platform - // Only applies to string-based timeout types, not numeric values - const hasCurrentOption = availableTimeoutOptions.some((opt) => opt.value === timeout); - if (!hasCurrentOption && typeof timeout !== "number") { - this.logService.debug( - "[SessionTimeoutSettings] Current timeout option not available, falling back from", - { timeout }, - ); - timeout = VaultTimeoutStringType.OnRestart; - } - this.formGroup.patchValue( { timeout: timeout, @@ -160,6 +159,23 @@ export class SessionTimeoutSettingsComponent implements OnInit { { emitEvent: false }, ); + // Sync form with reactive timeout updates to handle race condition where policies + // load asynchronously and may override the initially set timeout value + this.vaultTimeoutSettingsService + .getVaultTimeoutByUserId$(this.userId) + .pipe( + filter((timeout) => this.formGroup.controls.timeout.value !== timeout), + tap((timeout) => + this.logService.debug( + `[SessionTimeoutSettings] Updating initial form timeout from ${this.formGroup.controls.timeout.value} to ${timeout}`, + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((timeout) => { + this.formGroup.controls.timeout.setValue(timeout, { emitEvent: false }); + }); + this.refreshTimeoutActionSettings() .pipe( startWith(undefined), @@ -167,19 +183,17 @@ export class SessionTimeoutSettingsComponent implements OnInit { combineLatest([ this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId), this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), - maximumVaultTimeoutPolicy$, + this.sessionTimeoutActionFromPolicy$, ]), ), takeUntilDestroyed(this.destroyRef), ) - .subscribe(([availableActions, action, policy]) => { + .subscribe(([availableActions, action, sessionTimeoutActionFromPolicy]) => { this.availableTimeoutActions.set(availableActions); this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false }); - const policyData = policy?.data as MaximumVaultTimeoutPolicyData | undefined; - // Enable/disable the action control based on policy or available actions - if (policyData?.action != null || availableActions.length <= 1) { + if (sessionTimeoutActionFromPolicy != null || availableActions.length <= 1) { this.formGroup.controls.timeoutAction.disable({ emitEvent: false }); } else { this.formGroup.controls.timeoutAction.enable({ emitEvent: false }); diff --git a/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.spec.ts b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.spec.ts new file mode 100644 index 0000000000..9fe8ba6747 --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.spec.ts @@ -0,0 +1,337 @@ +import { fakeAsync, flush } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { SessionTimeoutSettingsComponentService } from "./session-timeout-settings-component.service"; + +describe("SessionTimeoutSettingsComponentService", () => { + let service: SessionTimeoutSettingsComponentService; + let mockI18nService: MockProxy; + let mockSessionTimeoutTypeService: MockProxy; + let mockPolicyService: MockProxy; + + const mockUserId = "test-user-id" as UserId; + + beforeEach(() => { + mockI18nService = mock(); + mockSessionTimeoutTypeService = mock(); + mockPolicyService = mock(); + + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true); + mockPolicyService.policiesByType$.mockReturnValue(of([])); + + service = new SessionTimeoutSettingsComponentService( + mockI18nService, + mockSessionTimeoutTypeService, + mockPolicyService, + ); + }); + + it("should create", () => { + expect(service).toBeTruthy(); + }); + + describe("availableTimeoutOptions$", () => { + it("should return all options when isAvailable returns true for all", fakeAsync(async () => { + mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true); + flush(); + + const options = await firstValueFrom(service["availableTimeoutOptions$"]); + + assertAllTimeoutTypes(options); + })); + + it("should filter options based on isAvailable() results", fakeAsync(async () => { + mockSessionTimeoutTypeService.isAvailable.mockImplementation(async (value: VaultTimeout) => { + return ( + value === VaultTimeoutNumberType.OnMinute || + value === 5 || + value === VaultTimeoutStringType.OnLocked + ); + }); + flush(); + + const options = await firstValueFrom(service["availableTimeoutOptions$"]); + + expect(options).toHaveLength(3); + expect(options).toContainEqual({ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute }); + expect(options).toContainEqual({ name: "fiveMinutes", value: 5 }); + expect(options).toContainEqual({ name: "onLocked", value: VaultTimeoutStringType.OnLocked }); + expect(options).not.toContainEqual({ + name: "immediately", + value: VaultTimeoutNumberType.Immediately, + }); + })); + }); + + describe("policyFilteredTimeoutOptions$", () => { + it("should return all available options when no policy for user", fakeAsync(async () => { + mockPolicyService.policiesByType$.mockReturnValue(of([])); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + assertAllTimeoutTypes(options); + })); + + describe('policy type "immediately"', () => { + it.each([VaultTimeoutNumberType.Immediately, VaultTimeoutNumberType.OnMinute])( + "should only return immediately option or fallback", + fakeAsync(async (availableTimeoutOrPromoted: VaultTimeout) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "immediately", + minutes: 0, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + availableTimeoutOrPromoted, + ); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + expect(options).toHaveLength(1); + if (availableTimeoutOrPromoted === VaultTimeoutNumberType.Immediately) { + expect(options[0]).toEqual({ + name: "immediately", + value: VaultTimeoutNumberType.Immediately, + }); + } else { + expect(options[0]).toEqual({ + name: "oneMinute", + value: VaultTimeoutNumberType.OnMinute, + }); + } + }), + ); + }); + + describe('policy type "onSystemLock"', () => { + it.each([VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnRestart])( + "should allow immediately, numeric, custom, onLocked, onIdle, onSleep or fallback", + fakeAsync(async (availableTimeoutOrPromoted: VaultTimeout) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onSystemLock", + minutes: 0, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + availableTimeoutOrPromoted, + ); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + assertNumericTimeoutTypes(options); + expect(options).toContainEqual({ + name: "onLocked", + value: VaultTimeoutStringType.OnLocked, + }); + expect(options).toContainEqual({ name: "onIdle", value: VaultTimeoutStringType.OnIdle }); + expect(options).toContainEqual({ + name: "onSleep", + value: VaultTimeoutStringType.OnSleep, + }); + expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom }); + expect(options).not.toContainEqual({ + name: "never", + value: VaultTimeoutStringType.Never, + }); + if (availableTimeoutOrPromoted === VaultTimeoutStringType.OnLocked) { + expect(options).not.toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + } else { + expect(options).toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + } + }), + ); + }); + + describe('policy type "onAppRestart"', () => { + it("should allow immediately, numeric, custom, and onRestart", fakeAsync(async () => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onAppRestart", + minutes: 0, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + assertNumericTimeoutTypes(options); + expect(options).toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom }); + expect(options).not.toContainEqual({ + name: "onLocked", + value: VaultTimeoutStringType.OnLocked, + }); + expect(options).not.toContainEqual({ + name: "onIdle", + value: VaultTimeoutStringType.OnIdle, + }); + expect(options).not.toContainEqual({ + name: "onSleep", + value: VaultTimeoutStringType.OnSleep, + }); + expect(options).not.toContainEqual({ name: "never", value: VaultTimeoutStringType.Never }); + })); + }); + + describe('policy type "custom", null, or undefined', () => { + it.each(["custom", null, undefined])( + "should allow immediately, custom, and numeric values within policy limit when type is %s", + fakeAsync(async (policyType: "custom" | null | undefined) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: policyType as "custom" | null | undefined, + minutes: 15, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + expect(options).toContainEqual({ + name: "immediately", + value: VaultTimeoutNumberType.Immediately, + }); + expect(options).toContainEqual({ + name: "oneMinute", + value: VaultTimeoutNumberType.OnMinute, + }); + expect(options).toContainEqual({ name: "fiveMinutes", value: 5 }); + expect(options).toContainEqual({ name: "fifteenMinutes", value: 15 }); + expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom }); + expect(options).not.toContainEqual({ name: "thirtyMinutes", value: 30 }); + expect(options).not.toContainEqual({ name: "oneHour", value: 60 }); + expect(options).not.toContainEqual({ name: "fourHours", value: 240 }); + expect(options).not.toContainEqual({ + name: "onLocked", + value: VaultTimeoutStringType.OnLocked, + }); + expect(options).not.toContainEqual({ + name: "onIdle", + value: VaultTimeoutStringType.OnIdle, + }); + expect(options).not.toContainEqual({ + name: "onSleep", + value: VaultTimeoutStringType.OnSleep, + }); + expect(options).not.toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + expect(options).not.toContainEqual({ + name: "never", + value: VaultTimeoutStringType.Never, + }); + }), + ); + }); + + describe('policy type "never"', () => { + it("should return all available options", fakeAsync(async () => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "never", + minutes: 0, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + assertAllTimeoutTypes(options); + })); + }); + }); + + function assertAllTimeoutTypes(options: VaultTimeoutOption[]) { + assertNumericTimeoutTypes(options); + expect(options).toContainEqual({ name: "onIdle", value: VaultTimeoutStringType.OnIdle }); + expect(options).toContainEqual({ name: "onSleep", value: VaultTimeoutStringType.OnSleep }); + expect(options).toContainEqual({ name: "onLocked", value: VaultTimeoutStringType.OnLocked }); + expect(options).toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + expect(options).toContainEqual({ name: "never", value: VaultTimeoutStringType.Never }); + expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom }); + } + + function assertNumericTimeoutTypes(options: VaultTimeoutOption[]) { + expect(options).toContainEqual({ + name: "immediately", + value: VaultTimeoutNumberType.Immediately, + }); + expect(options).toContainEqual({ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute }); + expect(options).toContainEqual({ name: "fiveMinutes", value: 5 }); + expect(options).toContainEqual({ name: "fifteenMinutes", value: 15 }); + expect(options).toContainEqual({ name: "thirtyMinutes", value: 30 }); + expect(options).toContainEqual({ name: "oneHour", value: 60 }); + expect(options).toContainEqual({ name: "fourHours", value: 240 }); + } +}); diff --git a/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts index 7b9efeac9c..d974eb2546 100644 --- a/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts +++ b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts @@ -1,9 +1,158 @@ -import { Observable } from "rxjs"; +import { combineLatest, concatMap, defer, map, Observable } from "rxjs"; -import { VaultTimeout, VaultTimeoutOption } from "@bitwarden/common/key-management/vault-timeout"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/user-core"; -export abstract class SessionTimeoutSettingsComponentService { - abstract availableTimeoutOptions$: Observable; +export class SessionTimeoutSettingsComponentService { + private readonly availableTimeoutOptions$: Observable; - abstract onTimeoutSave(timeout: VaultTimeout): void; + constructor( + protected readonly i18nService: I18nService, + protected readonly sessionTimeoutTypeService: SessionTimeoutTypeService, + protected readonly policyService: PolicyService, + ) { + this.availableTimeoutOptions$ = defer(async () => { + const allOptions = this.getAllTimeoutOptions(); + const availabilityResults = await Promise.all( + allOptions.map(async (option) => ({ + option, + available: await this.sessionTimeoutTypeService.isAvailable(option.value), + })), + ); + + return availabilityResults + .filter((result) => result.available) + .map((result) => result.option); + }); + } + + onTimeoutSave(_timeout: VaultTimeout): void { + // Default implementation does nothing, but other clients might want to override this + } + + policyFilteredTimeoutOptions$(userId: UserId): Observable { + const policyData$ = this.policyService + .policiesByType$(PolicyType.MaximumVaultTimeout, userId) + .pipe( + getFirstPolicy, + map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null), + ); + + return combineLatest([ + this.availableTimeoutOptions$, + policyData$, + policyData$.pipe( + concatMap(async (policyData) => { + if (policyData == null) { + return null; + } + switch (policyData.type) { + case "immediately": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutNumberType.Immediately, + ); + case "onSystemLock": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.OnLocked, + ); + } + + return null; + }), + ), + ]).pipe( + concatMap( + async ([availableOptions, policyData, highestAvailableEnforcedByPolicyTimeoutType]) => { + if (policyData == null) { + return availableOptions; + } + + return availableOptions.filter((option) => { + switch (policyData.type) { + case "immediately": { + // Policy requires immediate lock. + return option.value === highestAvailableEnforcedByPolicyTimeoutType; + } + + case "onSystemLock": { + // Allow immediately, numeric values, custom, and any system lock-related options. + if ( + option.value === VaultTimeoutNumberType.Immediately || + isVaultTimeoutTypeNumeric(option.value) || + option.value === VaultTimeoutStringType.Custom || + option.value === VaultTimeoutStringType.OnLocked || + option.value === VaultTimeoutStringType.OnIdle || + option.value === VaultTimeoutStringType.OnSleep + ) { + return true; + } + + // When on locked is not supported, fallback. + return option.value === highestAvailableEnforcedByPolicyTimeoutType; + } + + case "onAppRestart": + // Allow immediately, numeric values, custom, and on app restart + return ( + option.value === VaultTimeoutNumberType.Immediately || + isVaultTimeoutTypeNumeric(option.value) || + option.value === VaultTimeoutStringType.Custom || + option.value === VaultTimeoutStringType.OnRestart + ); + + case "custom": + case null: + case undefined: + // Allow immediately, custom, and numeric values within policy limit + return ( + option.value === VaultTimeoutNumberType.Immediately || + option.value === VaultTimeoutStringType.Custom || + (isVaultTimeoutTypeNumeric(option.value) && + (option.value as number) <= policyData.minutes) + ); + + case "never": + // No policy restriction + return true; + + default: + throw Error(`Unsupported policy type: ${policyData.type}`); + } + }); + }, + ), + ); + } + + private getAllTimeoutOptions(): VaultTimeoutOption[] { + return [ + { name: "immediately", value: VaultTimeoutNumberType.Immediately }, + { name: "oneMinute", value: VaultTimeoutNumberType.OnMinute }, + { name: "fiveMinutes", value: 5 }, + { name: "fifteenMinutes", value: 15 }, + { name: "thirtyMinutes", value: 30 }, + { name: "oneHour", value: 60 }, + { name: "fourHours", value: 240 }, + { name: "onIdle", value: VaultTimeoutStringType.OnIdle }, + { name: "onSleep", value: VaultTimeoutStringType.OnSleep }, + { name: "onLocked", value: VaultTimeoutStringType.OnLocked }, + { name: "sessionTimeoutOnRestart", value: VaultTimeoutStringType.OnRestart }, + { name: "never", value: VaultTimeoutStringType.Never }, + { name: "custom", value: VaultTimeoutStringType.Custom }, + ]; + } }