From 021d3e53aad5cf84f7c95b089ed0ab71975a7a93 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:15:36 +0100 Subject: [PATCH] [PM-26056] Consolidated session timeout component (#16988) * consolidated session timeout settings component * rename preferences to appearance * race condition bug on computed signal * outdated header for browser * unnecessary padding * remove required on action, fix build * rename localization key * missing user id * required * cleanup task * eslint fix signals rollback * takeUntilDestroyed, null checks * move browser specific logic outside shared component * explicit input type * input name * takeUntilDestroyed, no toast * unit tests * cleanup * cleanup, correct link to deprecation jira * tech debt todo with jira * missing web localization key when policy is on * relative import * extracting timeout options to component service * duplicate localization key * fix failing test * subsequent timeout action selecting opening without dialog on first dialog cancellation * default locale can be null * unit tests failing * rename, simplifications * one if else feature flag * timeout input component rendering before async pipe completion --- apps/browser/src/_locales/en/messages.json | 9 + .../settings/account-security.component.html | 88 +-- .../account-security.component.spec.ts | 3 + .../settings/account-security.component.ts | 16 +- ...sion-timeout-settings-component.service.ts | 58 ++ .../src/popup/services/services.module.ts | 11 +- .../src/app/accounts/settings.component.html | 64 ++- .../app/accounts/settings.component.spec.ts | 2 +- .../src/app/accounts/settings.component.ts | 13 +- .../src/app/services/services.module.ts | 11 +- ...sion-timeout-settings-component.service.ts | 48 ++ apps/desktop/src/locales/en/messages.json | 6 + .../security/security-routing.module.ts | 18 +- .../settings/security/security.component.html | 7 +- .../settings/security/security.component.ts | 13 +- apps/web/src/app/core/core.module.ts | 11 +- ...sion-timeout-settings-component.service.ts | 39 ++ .../session-timeout.component.html | 5 + .../session-timeout.component.ts | 11 + .../app/layouts/user-layout.component.html | 6 +- .../src/app/layouts/user-layout.component.ts | 5 + apps/web/src/app/oss-routing.module.ts | 24 + .../app/settings/appearance.component.html | 48 ++ .../app/settings/appearance.component.spec.ts | 215 ++++++++ .../src/app/settings/appearance.component.ts | 107 ++++ .../app/settings/preferences.component.html | 4 +- .../src/app/settings/preferences.component.ts | 7 + apps/web/src/locales/en/messages.json | 36 +- libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/key-management/vault-timeout/index.ts | 1 + .../maximum-vault-timeout-policy.type.ts | 6 + .../src/platform/abstractions/i18n.service.ts | 2 +- .../src/platform/services/i18n.service.ts | 2 +- libs/key-management-ui/src/index.ts | 2 + .../session-timeout-settings.component.html | 31 ++ ...session-timeout-settings.component.spec.ts | 522 ++++++++++++++++++ .../session-timeout-settings.component.ts | 278 ++++++++++ ...sion-timeout-settings-component.service.ts | 9 + 38 files changed, 1660 insertions(+), 80 deletions(-) create mode 100644 apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts create mode 100644 apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts create mode 100644 apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts create mode 100644 apps/web/src/app/key-management/session-timeout/session-timeout.component.html create mode 100644 apps/web/src/app/key-management/session-timeout/session-timeout.component.ts create mode 100644 apps/web/src/app/settings/appearance.component.html create mode 100644 apps/web/src/app/settings/appearance.component.spec.ts create mode 100644 apps/web/src/app/settings/appearance.component.ts create mode 100644 libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts create mode 100644 libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html create mode 100644 libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts create mode 100644 libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts create mode 100644 libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a8743b0db6..4ea6940402 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -796,6 +796,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -5809,5 +5815,8 @@ }, "cardNumberLabel": { "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "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 44900acc06..2babd2a7ef 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -20,9 +20,9 @@ - {{ - "unlockWithBiometrics" | i18n - }} + + {{ "unlockWithBiometrics" | i18n }} + {{ biometricUnavailabilityReason }} @@ -38,9 +38,9 @@ type="checkbox" formControlName="enableAutoBiometricsPrompt" /> - {{ - "enableAutoBiometricsPrompt" | i18n - }} + + {{ "enableAutoBiometricsPrompt" | i18n }} + - {{ - "lockWithMasterPassOnRestart1" | i18n - }} + + {{ "lockWithMasterPassOnRestart1" | i18n }} + - -

{{ "vaultTimeoutHeader" | i18n }}

-
+ @if (consolidatedSessionTimeoutComponent$ | async) { + +

+ {{ "sessionTimeoutHeader" | i18n }} +

+
- - - + + + + } @else { + +

+ {{ "vaultTimeoutHeader" | i18n }} +

+
- - {{ "vaultTimeoutAction1" | i18n }} - - - - + + + - - {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+ + {{ "vaultTimeoutAction1" | i18n }} + + + + + + + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+
+
+ + + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} -
- - - {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - -
+ + }
diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index 28639cd1ed..d0ab479330 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -20,6 +20,7 @@ import { VaultTimeoutStringType, VaultTimeoutAction, } from "@bitwarden/common/key-management/vault-timeout"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -64,6 +65,7 @@ describe("AccountSecurityComponent", () => { const dialogService = mock(); const platformUtilsService = mock(); const lockService = mock(); + const configService = mock(); beforeEach(async () => { await TestBed.configureTestingModule({ @@ -93,6 +95,7 @@ describe("AccountSecurityComponent", () => { { provide: CollectionService, useValue: mock() }, { provide: ValidationService, useValue: validationService }, { provide: LockService, useValue: lockService }, + { provide: ConfigService, useValue: configService }, ], }) .overrideComponent(AccountSecurityComponent, { 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 4a5388ef26..c5423a5f1d 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeout, @@ -40,6 +41,7 @@ import { VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -67,6 +69,7 @@ import { BiometricStateService, BiometricsStatus, } from "@bitwarden/key-management"; +import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -100,6 +103,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; SectionComponent, SectionHeaderComponent, SelectModule, + SessionTimeoutSettingsComponent, SpotlightComponent, TypographyModule, VaultTimeoutInputComponent, @@ -133,11 +137,14 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ), ); - private refreshTimeoutSettings$ = new BehaviorSubject(undefined); + protected readonly consolidatedSessionTimeoutComponent$: Observable; + + protected refreshTimeoutSettings$ = new BehaviorSubject(undefined); private destroy$ = new Subject(); constructor( private accountService: AccountService, + private configService: ConfigService, private pinService: PinServiceAbstraction, private policyService: PolicyService, private formBuilder: FormBuilder, @@ -157,7 +164,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { private vaultNudgesService: NudgesService, private validationService: ValidationService, private logService: LogService, - ) {} + ) { + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); + } async ngOnInit() { const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); @@ -173,6 +184,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { this.hasVaultTimeoutPolicy = true; } + // Determine platform-specific timeout options const showOnLocked = !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari() && 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 new file mode 100644 index 0000000000..297718687e --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts @@ -0,0 +1,58 @@ +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 { 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); + }); + + constructor( + private readonly i18nService: I18nService, + private readonly platformUtilsService: PlatformUtilsService, + private readonly messagingService: MessagingService, + ) {} + + onTimeoutSave(timeout: VaultTimeout): void { + if (timeout === VaultTimeoutStringType.Never) { + this.messagingService.send("bgReseedStorage"); + } + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index a44ba81c40..eebf0a08a2 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -141,7 +141,10 @@ import { KdfConfigService, KeyService, } from "@bitwarden/key-management"; -import { LockComponentService } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + SessionTimeoutSettingsComponentService, +} from "@bitwarden/key-management-ui"; import { DerivedStateProvider, GlobalStateProvider, StateProvider } from "@bitwarden/state"; import { InlineDerivedStateProvider } from "@bitwarden/state-internal"; import { @@ -165,6 +168,7 @@ import AutofillService from "../../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service"; 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 { 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"; @@ -713,6 +717,11 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: BrowserSessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, PlatformUtilsService, MessagingServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index e120db339d..79e21480a7 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -31,36 +31,50 @@ - -

{{ "vaultTimeoutHeader" | i18n }}

-
+ @if (consolidatedSessionTimeoutComponent$ | async) { + +

{{ "sessionTimeoutHeader" | i18n }}

+
- - + + } @else { + +

{{ "vaultTimeoutHeader" | i18n }}

+
- - {{ "vaultTimeoutAction1" | i18n }} - - + + + + {{ + "vaultTimeoutAction1" | i18n + }} + + + + + + - - + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+ +
- - {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+ + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - - - - {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - + }
diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index cafc413862..115f743697 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -191,7 +191,7 @@ describe("SettingsComponent", () => { desktopAutotypeService.autotypeEnabledUserSetting$ = of(false); desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); - configService.getFeatureFlag$.mockReturnValue(of(true)); + configService.getFeatureFlag$.mockReturnValue(of(false)); }); afterEach(() => { diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index abebdfa5fc..3db6c08a6c 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -55,6 +55,7 @@ import { TypographyModule, } from "@bitwarden/components"; import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; +import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; import { SetPinComponent } from "../../auth/components/set-pin.component"; @@ -95,6 +96,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man SelectModule, TypographyModule, VaultTimeoutInputComponent, + SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, ], @@ -146,6 +148,8 @@ export class SettingsComponent implements OnInit, OnDestroy { pinEnabled$: Observable = of(true); isWindowsV2BiometricsEnabled: boolean = false; + consolidatedSessionTimeoutComponent$: Observable; + form = this.formBuilder.group({ // Security vaultTimeout: [null as VaultTimeout | null], @@ -184,7 +188,7 @@ export class SettingsComponent implements OnInit, OnDestroy { locale: [null as string | null], }); - private refreshTimeoutSettings$ = new BehaviorSubject(undefined); + protected refreshTimeoutSettings$ = new BehaviorSubject(undefined); private destroy$ = new Subject(); constructor( @@ -282,12 +286,17 @@ export class SettingsComponent implements OnInit, OnDestroy { value: SshAgentPromptType.RememberUntilLock, }, ]; + + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); } async ngOnInit() { + this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); + this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled(); - this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); // Autotype is for Windows initially diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index be91c30987..03d6eb5c90 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -109,7 +109,10 @@ import { BiometricStateService, BiometricsService, } from "@bitwarden/key-management"; -import { LockComponentService } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + SessionTimeoutSettingsComponentService, +} from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; @@ -125,6 +128,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 { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; @@ -480,6 +484,11 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopAutotypeDefaultSettingPolicy, deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: DesktopSessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction], + }), ]; @NgModule({ 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 new file mode 100644 index 0000000000..91c8126cdd --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts @@ -0,0 +1,48 @@ +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/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index da8d9ea0e3..981066d961 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4220,5 +4220,11 @@ }, "upgradeToPremium": { "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index ba476dc910..dbcfc7cb18 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -2,7 +2,10 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { SessionTimeoutComponent } from "../../../key-management/session-timeout/session-timeout.component"; import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component"; import { PasswordSettingsComponent } from "./password-settings/password-settings.component"; @@ -15,7 +18,20 @@ const routes: Routes = [ component: SecurityComponent, data: { titleId: "security" }, children: [ - { path: "", pathMatch: "full", redirectTo: "password" }, + { path: "", pathMatch: "full", redirectTo: "session-timeout" }, + { + path: "session-timeout", + component: SessionTimeoutComponent, + canActivate: [ + canAccessFeature( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + true, + "/settings/security/password", + false, + ), + ], + data: { titleId: "sessionTimeoutHeader" }, + }, { path: "password", component: PasswordSettingsComponent, diff --git a/apps/web/src/app/auth/settings/security/security.component.html b/apps/web/src/app/auth/settings/security/security.component.html index 355a33d442..6942713443 100644 --- a/apps/web/src/app/auth/settings/security/security.component.html +++ b/apps/web/src/app/auth/settings/security/security.component.html @@ -1,8 +1,11 @@ - + @if (consolidatedSessionTimeoutComponent$ | async) { + {{ "sessionTimeoutHeader" | i18n }} + } + @if (showChangePassword) { {{ "masterPassword" | i18n }} - + } {{ "twoStepLogin" | i18n }} {{ "devices" | i18n }} {{ "keys" | i18n }} diff --git a/apps/web/src/app/auth/settings/security/security.component.ts b/apps/web/src/app/auth/settings/security/security.component.ts index ff13515eec..629de32efc 100644 --- a/apps/web/src/app/auth/settings/security/security.component.ts +++ b/apps/web/src/app/auth/settings/security/security.component.ts @@ -1,6 +1,9 @@ import { Component, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; @@ -14,8 +17,16 @@ import { SharedModule } from "../../../shared"; export class SecurityComponent implements OnInit { showChangePassword = true; changePasswordRoute = "password"; + consolidatedSessionTimeoutComponent$: Observable; - constructor(private userVerificationService: UserVerificationService) {} + constructor( + private userVerificationService: UserVerificationService, + private configService: ConfigService, + ) { + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); + } async ngOnInit() { this.showChangePassword = await this.userVerificationService.hasMasterPassword(); diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index bf741132b0..c0716d9971 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -117,10 +117,14 @@ import { KeyService as KeyServiceAbstraction, BiometricsService, } from "@bitwarden/key-management"; -import { LockComponentService } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + SessionTimeoutSettingsComponentService, +} from "@bitwarden/key-management-ui"; 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"; @@ -465,6 +469,11 @@ const safeProviders: SafeProvider[] = [ useClass: WebSystemService, deps: [], }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: WebSessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, PlatformUtilsService], + }), ]; @NgModule({ 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 new file mode 100644 index 0000000000..61836c9825 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts @@ -0,0 +1,39 @@ +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/session-timeout.component.html b/apps/web/src/app/key-management/session-timeout/session-timeout.component.html new file mode 100644 index 0000000000..0ca6267da5 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/session-timeout.component.html @@ -0,0 +1,5 @@ +

{{ "sessionTimeoutHeader" | i18n }}

+ +
+ +
diff --git a/apps/web/src/app/key-management/session-timeout/session-timeout.component.ts b/apps/web/src/app/key-management/session-timeout/session-timeout.component.ts new file mode 100644 index 0000000000..566484ddce --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/session-timeout.component.ts @@ -0,0 +1,11 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; + +@Component({ + templateUrl: "session-timeout.component.html", + imports: [SessionTimeoutSettingsComponent, JslibModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SessionTimeoutComponent {} diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 23f22d263c..9f47406212 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -13,7 +13,11 @@ - + @if (consolidatedSessionTimeoutComponent$ | async) { + + } @else { + + } ; protected showSponsoredFamilies$: Observable; protected showSubscription$: Observable; + protected consolidatedSessionTimeoutComponent$: Observable; constructor( private syncService: SyncService, @@ -74,6 +75,10 @@ export class UserLayoutComponent implements OnInit { }), ), ); + + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); } async ngOnInit() { diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 4db6e50bc6..b40b914399 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -14,6 +14,7 @@ import { import { LoginViaWebAuthnComponent } from "@bitwarden/angular/auth/login-via-webauthn/login-via-webauthn.component"; import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { DevicesIcon, RegistrationUserAddIcon, @@ -48,6 +49,7 @@ import { NewDeviceVerificationComponent, } from "@bitwarden/auth/angular"; import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard"; @@ -82,6 +84,7 @@ import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component"; import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component"; +import { AppearanceComponent } from "./settings/appearance.component"; import { DomainRulesComponent } from "./settings/domain-rules.component"; import { PreferencesComponent } from "./settings/preferences.component"; import { CredentialGeneratorComponent } from "./tools/credential-generator/credential-generator.component"; @@ -663,9 +666,30 @@ const routes: Routes = [ component: AccountComponent, data: { titleId: "myAccount" } satisfies RouteDataProperties, }, + { + path: "appearance", + component: AppearanceComponent, + canActivate: [ + canAccessFeature( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + true, + "/settings/preferences", + false, + ), + ], + data: { titleId: "appearance" } satisfies RouteDataProperties, + }, { path: "preferences", component: PreferencesComponent, + canActivate: [ + canAccessFeature( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + false, + "/settings/appearance", + false, + ), + ], data: { titleId: "preferences" } satisfies RouteDataProperties, }, { diff --git a/apps/web/src/app/settings/appearance.component.html b/apps/web/src/app/settings/appearance.component.html new file mode 100644 index 0000000000..840895eea4 --- /dev/null +++ b/apps/web/src/app/settings/appearance.component.html @@ -0,0 +1,48 @@ + + + +
+ + {{ "theme" | i18n }} + + @for (option of themeOptions; track option.value) { + + } + + {{ "themeDesc" | i18n }} + + + + {{ "language" | i18n }} + + + + + + @for (option of localeOptions; track option.value) { + + } + + {{ "languageDesc" | i18n }} + +
+ + + + {{ "showIconsChangePasswordUrls" | i18n }} + + +
+ +
+
+
+
diff --git a/apps/web/src/app/settings/appearance.component.spec.ts b/apps/web/src/app/settings/appearance.component.spec.ts new file mode 100644 index 0000000000..53ae9f81a8 --- /dev/null +++ b/apps/web/src/app/settings/appearance.component.spec.ts @@ -0,0 +1,215 @@ +import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; + +import { AppearanceComponent } from "./appearance.component"; + +describe("AppearanceComponent", () => { + let component: AppearanceComponent; + let fixture: ComponentFixture; + let mockI18nService: MockProxy; + let mockThemeStateService: MockProxy; + let mockDomainSettingsService: MockProxy; + + const mockShowFavicons$ = new BehaviorSubject(true); + const mockSelectedTheme$ = new BehaviorSubject(ThemeTypes.Light); + const mockUserSetLocale$ = new BehaviorSubject("en"); + + const mockSupportedLocales = ["en", "es", "fr", "de"]; + const mockLocaleNames = new Map([ + ["en", "English"], + ["es", "Español"], + ["fr", "Français"], + ["de", "Deutsch"], + ]); + + beforeEach(async () => { + mockI18nService = mock(); + mockThemeStateService = mock(); + mockDomainSettingsService = mock(); + + mockI18nService.supportedTranslationLocales = mockSupportedLocales; + mockI18nService.localeNames = mockLocaleNames; + mockI18nService.collator = { + compare: jest.fn((a: string, b: string) => a.localeCompare(b)), + } as any; + mockI18nService.t.mockImplementation((key: string) => `${key}-used-i18n`); + mockI18nService.userSetLocale$ = mockUserSetLocale$; + + mockThemeStateService.selectedTheme$ = mockSelectedTheme$; + mockDomainSettingsService.showFavicons$ = mockShowFavicons$; + + mockDomainSettingsService.setShowFavicons.mockResolvedValue(undefined); + mockThemeStateService.setSelectedTheme.mockResolvedValue(undefined); + mockI18nService.setLocale.mockResolvedValue(undefined); + + await TestBed.configureTestingModule({ + imports: [AppearanceComponent, ReactiveFormsModule, NoopAnimationsModule], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: ThemeStateService, useValue: mockThemeStateService }, + { provide: DomainSettingsService, useValue: mockDomainSettingsService }, + ], + }) + .overrideComponent(AppearanceComponent, { + set: { + template: "", + imports: [], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(AppearanceComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("constructor", () => { + describe("locale options setup", () => { + it("should create locale options sorted by name from supported locales with display names", () => { + expect(component.localeOptions).toHaveLength(5); + expect(component.localeOptions[0]).toEqual({ name: "default-used-i18n", value: null }); + expect(component.localeOptions[1]).toEqual({ name: "de - Deutsch", value: "de" }); + expect(component.localeOptions[2]).toEqual({ name: "en - English", value: "en" }); + expect(component.localeOptions[3]).toEqual({ name: "es - Español", value: "es" }); + expect(component.localeOptions[4]).toEqual({ name: "fr - Français", value: "fr" }); + }); + }); + + describe("theme options setup", () => { + it("should create theme options with Light, Dark, and System", () => { + expect(component.themeOptions).toEqual([ + { name: "themeLight-used-i18n", value: ThemeTypes.Light }, + { name: "themeDark-used-i18n", value: ThemeTypes.Dark }, + { name: "themeSystem-used-i18n", value: ThemeTypes.System }, + ]); + }); + }); + }); + + describe("ngOnInit", () => { + it("should initialize form with values", fakeAsync(() => { + mockShowFavicons$.next(false); + mockSelectedTheme$.next(ThemeTypes.Dark); + mockUserSetLocale$.next("es"); + + fixture.detectChanges(); + flush(); + + expect(component.form.value).toEqual({ + enableFavicons: false, + theme: ThemeTypes.Dark, + locale: "es", + }); + })); + + it("should set locale to null when user locale not set", fakeAsync(() => { + mockUserSetLocale$.next(undefined); + + fixture.detectChanges(); + flush(); + + expect(component.form.value.locale).toBeNull(); + })); + }); + + describe("enableFavicons value changes", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + jest.clearAllMocks(); + })); + + it("should call setShowFavicons when enableFavicons changes to true", fakeAsync(() => { + component.form.controls.enableFavicons.setValue(true); + flush(); + + expect(mockDomainSettingsService.setShowFavicons).toHaveBeenCalledWith(true); + })); + + it("should call setShowFavicons when enableFavicons changes to false", fakeAsync(() => { + component.form.controls.enableFavicons.setValue(false); + flush(); + + expect(mockDomainSettingsService.setShowFavicons).toHaveBeenCalledWith(false); + })); + + it("should not call setShowFavicons when value is null", fakeAsync(() => { + component.form.controls.enableFavicons.setValue(null); + flush(); + + expect(mockDomainSettingsService.setShowFavicons).not.toHaveBeenCalled(); + })); + }); + + describe("theme value changes", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + jest.clearAllMocks(); + })); + + it.each([ThemeTypes.Light, ThemeTypes.Dark, ThemeTypes.System])( + "should call setSelectedTheme when theme changes to %s", + fakeAsync((themeType: Theme) => { + component.form.controls.theme.setValue(themeType); + flush(); + + expect(mockThemeStateService.setSelectedTheme).toHaveBeenCalledWith(themeType); + }), + ); + + it("should not call setSelectedTheme when value is null", fakeAsync(() => { + component.form.controls.theme.setValue(null); + flush(); + + expect(mockThemeStateService.setSelectedTheme).not.toHaveBeenCalled(); + })); + }); + + describe("locale value changes", () => { + let reloadMock: jest.Mock; + + beforeEach(fakeAsync(() => { + reloadMock = jest.fn(); + Object.defineProperty(window, "location", { + value: { reload: reloadMock }, + writable: true, + }); + + fixture.detectChanges(); + flush(); + jest.clearAllMocks(); + })); + + it("should call setLocale and reload window when locale changes to english", fakeAsync(() => { + component.form.controls.locale.setValue("es"); + flush(); + + expect(mockI18nService.setLocale).toHaveBeenCalledWith("es"); + expect(reloadMock).toHaveBeenCalled(); + })); + + it("should call setLocale and reload window when locale changes to default", fakeAsync(() => { + component.form.controls.locale.setValue(null); + flush(); + + expect(mockI18nService.setLocale).toHaveBeenCalledWith(null); + expect(reloadMock).toHaveBeenCalled(); + })); + }); +}); diff --git a/apps/web/src/app/settings/appearance.component.ts b/apps/web/src/app/settings/appearance.component.ts new file mode 100644 index 0000000000..d1bcf2c28f --- /dev/null +++ b/apps/web/src/app/settings/appearance.component.ts @@ -0,0 +1,107 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder } from "@angular/forms"; +import { filter, firstValueFrom, switchMap } from "rxjs"; + +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; + +import { HeaderModule } from "../layouts/header/header.module"; +import { SharedModule } from "../shared"; + +type LocaleOption = { + name: string; + value: string | null; +}; + +type ThemeOption = { + name: string; + value: Theme; +}; + +@Component({ + selector: "app-appearance", + templateUrl: "appearance.component.html", + imports: [SharedModule, HeaderModule, PermitCipherDetailsPopoverComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppearanceComponent implements OnInit { + localeOptions: LocaleOption[]; + themeOptions: ThemeOption[]; + + form = this.formBuilder.group({ + enableFavicons: true, + theme: [ThemeTypes.Light as Theme], + locale: [null as string | null], + }); + + constructor( + private formBuilder: FormBuilder, + private i18nService: I18nService, + private themeStateService: ThemeStateService, + private domainSettingsService: DomainSettingsService, + private destroyRef: DestroyRef, + ) { + const localeOptions: LocaleOption[] = []; + i18nService.supportedTranslationLocales.forEach((locale) => { + let name = locale; + if (i18nService.localeNames.has(locale)) { + name += " - " + i18nService.localeNames.get(locale); + } + localeOptions.push({ name: name, value: locale }); + }); + localeOptions.sort(Utils.getSortFunction(i18nService, "name")); + localeOptions.splice(0, 0, { name: i18nService.t("default"), value: null }); + this.localeOptions = localeOptions; + this.themeOptions = [ + { name: i18nService.t("themeLight"), value: ThemeTypes.Light }, + { name: i18nService.t("themeDark"), value: ThemeTypes.Dark }, + { name: i18nService.t("themeSystem"), value: ThemeTypes.System }, + ]; + } + + async ngOnInit() { + this.form.setValue( + { + enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$), + theme: await firstValueFrom(this.themeStateService.selectedTheme$), + locale: (await firstValueFrom(this.i18nService.userSetLocale$)) ?? null, + }, + { emitEvent: false }, + ); + + this.form.controls.enableFavicons.valueChanges + .pipe( + filter((enableFavicons) => enableFavicons != null), + switchMap(async (enableFavicons) => { + await this.domainSettingsService.setShowFavicons(enableFavicons); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + + this.form.controls.theme.valueChanges + .pipe( + filter((theme) => theme != null), + switchMap(async (theme) => { + await this.themeStateService.setSelectedTheme(theme); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + + this.form.controls.locale.valueChanges + .pipe( + switchMap(async (locale) => { + await this.i18nService.setLocale(locale); + window.location.reload(); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } +} diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index 40f2f596a1..4af7e51b80 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -48,8 +48,8 @@ - {{ "language" | i18n }} + + {{ "language" | i18n }} ; abstract locale$: Observable; - abstract setLocale(locale: string): Promise; + abstract setLocale(locale: string | null): Promise; abstract init(): Promise; } diff --git a/libs/common/src/platform/services/i18n.service.ts b/libs/common/src/platform/services/i18n.service.ts index 87c9e211ed..e9396b907f 100644 --- a/libs/common/src/platform/services/i18n.service.ts +++ b/libs/common/src/platform/services/i18n.service.ts @@ -29,7 +29,7 @@ export class I18nService extends TranslationService implements I18nServiceAbstra this.locale$ = this.userSetLocale$.pipe(map((locale) => locale ?? this.translationLocale)); } - async setLocale(locale: string): Promise { + async setLocale(locale: string | null): Promise { await this.translationLocaleState.update(() => locale); } diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index 6754722440..fee3b3250e 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -9,3 +9,5 @@ export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.co export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component"; export { RemovePasswordComponent } from "./key-connector/remove-password.component"; export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-connector-domain.component"; +export { SessionTimeoutSettingsComponent } from "./session-timeout/components/session-timeout-settings.component"; +export { SessionTimeoutSettingsComponentService } from "./session-timeout/services/session-timeout-settings-component.service"; 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 new file mode 100644 index 0000000000..467a51ee1b --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html @@ -0,0 +1,31 @@ +
+ + + + + {{ "sessionTimeoutSettingsAction" | i18n }} + + @for (action of availableTimeoutActions(); track action) { + + } + + + @if (!canLock) { + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | 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 new file mode 100644 index 0000000000..379a2c982c --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts @@ -0,0 +1,522 @@ +import { ComponentFixture, fakeAsync, flush, TestBed, waitForAsync } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs"; + +import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; +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, + VaultTimeout, + VaultTimeoutAction, + VaultTimeoutOption, + VaultTimeoutSettingsService, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service"; + +import { SessionTimeoutSettingsComponent } from "./session-timeout-settings.component"; + +describe("SessionTimeoutSettingsComponent", () => { + let component: SessionTimeoutSettingsComponent; + let fixture: ComponentFixture; + + // Mock services + let mockVaultTimeoutSettingsService: MockProxy; + let mockSessionTimeoutSettingsComponentService: MockProxy; + let mockI18nService: MockProxy; + let mockToastService: MockProxy; + let mockPolicyService: MockProxy; + let accountService: FakeAccountService; + let mockDialogService: MockProxy; + let mockLogService: MockProxy; + + const mockUserId = "user-id" as UserId; + const mockEmail = "test@example.com"; + const mockInitialTimeout = 5; + const mockInitialTimeoutAction = VaultTimeoutAction.Lock; + let refreshTimeoutActionSettings$: BehaviorSubject; + let availableTimeoutOptions$: BehaviorSubject; + + beforeEach(async () => { + refreshTimeoutActionSettings$ = new BehaviorSubject(undefined); + availableTimeoutOptions$ = new BehaviorSubject([ + { name: "oneMinute-used-i18n", value: 1 }, + { name: "fiveMinutes-used-i18n", value: 5 }, + { name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart }, + { name: "onLocked-used-i18n", value: VaultTimeoutStringType.OnLocked }, + { name: "onSleep-used-i18n", value: VaultTimeoutStringType.OnSleep }, + { name: "onIdle-used-i18n", value: VaultTimeoutStringType.OnIdle }, + { name: "never-used-i18n", value: VaultTimeoutStringType.Never }, + ]); + + mockVaultTimeoutSettingsService = mock(); + mockSessionTimeoutSettingsComponentService = mock(); + mockI18nService = mock(); + mockToastService = mock(); + mockPolicyService = mock(); + accountService = mockAccountServiceWith(mockUserId, { email: mockEmail }); + mockDialogService = mock(); + mockLogService = mock(); + + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() => + of(mockInitialTimeout), + ); + mockVaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation(() => + of(mockInitialTimeoutAction), + ); + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + mockSessionTimeoutSettingsComponentService.availableTimeoutOptions$ = + availableTimeoutOptions$.asObservable(); + mockPolicyService.policiesByType$.mockImplementation(() => of([])); + + await TestBed.configureTestingModule({ + imports: [ + SessionTimeoutSettingsComponent, + ReactiveFormsModule, + VaultTimeoutInputComponent, + NoopAnimationsModule, + ], + providers: [ + { provide: VaultTimeoutSettingsService, useValue: mockVaultTimeoutSettingsService }, + { + provide: SessionTimeoutSettingsComponentService, + useValue: mockSessionTimeoutSettingsComponentService, + }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ToastService, useValue: mockToastService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: AccountService, useValue: accountService }, + { provide: LogService, useValue: mockLogService }, + { provide: DialogService, useValue: mockDialogService }, + ], + }) + .overrideComponent(SessionTimeoutSettingsComponent, { + set: { + providers: [{ provide: DialogService, useValue: mockDialogService }], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionTimeoutSettingsComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput("refreshTimeoutActionSettings", refreshTimeoutActionSettings$); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("canLock", () => { + it("should return true when Lock action is available", fakeAsync(() => { + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.canLock).toBe(true); + })); + + it("should return false when Lock action is not available", fakeAsync(() => { + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.LogOut]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.canLock).toBe(false); + })); + }); + + describe("ngOnInit", () => { + it("should initialize available timeout options", fakeAsync(async () => { + fixture.detectChanges(); + flush(); + + const options = await firstValueFrom( + component["availableTimeoutOptions$"].pipe(filter((options) => options.length > 0)), + ); + + expect(options).toContainEqual({ name: "oneMinute-used-i18n", value: 1 }); + expect(options).toContainEqual({ name: "fiveMinutes-used-i18n", value: 5 }); + expect(options).toContainEqual({ + name: "onIdle-used-i18n", + value: VaultTimeoutStringType.OnIdle, + }); + expect(options).toContainEqual({ + name: "onSleep-used-i18n", + value: VaultTimeoutStringType.OnSleep, + }); + expect(options).toContainEqual({ + name: "onLocked-used-i18n", + value: VaultTimeoutStringType.OnLocked, + }); + expect(options).toContainEqual({ + name: "onRestart-used-i18n", + value: VaultTimeoutStringType.OnRestart, + }); + expect(options).toContainEqual({ + name: "never-used-i18n", + value: VaultTimeoutStringType.Never, + }); + })); + + it("should initialize available timeout actions", fakeAsync(() => { + const expectedActions = [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]; + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of(expectedActions), + ); + + fixture.detectChanges(); + flush(); + + expect(component["availableTimeoutActions"]()).toEqual(expectedActions); + })); + + it("should initialize timeout and action", fakeAsync(() => { + const expectedTimeout = 15; + const expectedAction = VaultTimeoutAction.Lock; + + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() => + of(expectedTimeout), + ); + mockVaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation(() => + of(expectedAction), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.value.timeout).toBe(expectedTimeout); + 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 }, + ]); + + const unavailableTimeout = VaultTimeoutStringType.Never; + + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() => + of(unavailableTimeout), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.value.timeout).toBe(VaultTimeoutStringType.OnRestart); + })); + + it("should disable timeout action control when policy enforces action", fakeAsync(() => { + const policyData: MaximumVaultTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.LogOut, + }; + mockPolicyService.policiesByType$.mockImplementation(() => + of([{ id: "1", data: policyData }] as Policy[]), + ); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + })); + + it("should disable timeout action control when only one action is available", fakeAsync(() => { + mockPolicyService.policiesByType$.mockImplementation(() => of([])); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + })); + + it("should disable timeout action control when policy enforces action and refreshed", fakeAsync(() => { + const policies$ = new BehaviorSubject([]); + mockPolicyService.policiesByType$.mockReturnValue(policies$); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.enabled).toBe(true); + + const policyData: MaximumVaultTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.LogOut, + }; + policies$.next([{ id: "1", data: policyData }] as Policy[]); + + refreshTimeoutActionSettings$.next(undefined); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + })); + + it("should disable timeout action control when only one action is available and refreshed", fakeAsync(() => { + mockPolicyService.policiesByType$.mockImplementation(() => of([])); + + const availableActions$ = new BehaviorSubject([ + VaultTimeoutAction.Lock, + VaultTimeoutAction.LogOut, + ]); + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue( + availableActions$, + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.enabled).toBe(true); + + availableActions$.next([VaultTimeoutAction.Lock]); + + refreshTimeoutActionSettings$.next(undefined); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + })); + + it("should enable timeout action control when multiple actions available and no policy and refreshed", fakeAsync(() => { + mockPolicyService.policiesByType$.mockImplementation(() => of([])); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + + refreshTimeoutActionSettings$.next(undefined); + flush(); + + expect(component.formGroup.controls.timeoutAction.enabled).toBe(true); + })); + + it("should subscribe to timeout value changes", fakeAsync(() => { + const saveSpy = jest.spyOn(component, "saveTimeout").mockResolvedValue(undefined); + + fixture.detectChanges(); + flush(); + + const newTimeout = 30; + component.formGroup.controls.timeout.setValue(newTimeout); + flush(); + + expect(saveSpy).toHaveBeenCalledWith(mockInitialTimeout, newTimeout); + })); + + it("should subscribe to timeout action value changes", fakeAsync(() => { + const saveSpy = jest.spyOn(component, "saveTimeoutAction").mockResolvedValue(undefined); + + fixture.detectChanges(); + flush(); + + component.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.LogOut); + flush(); + + expect(saveSpy).toHaveBeenCalledWith(VaultTimeoutAction.LogOut); + })); + }); + + describe("saveTimeout", () => { + it("should not save when form control timeout is invalid", fakeAsync(async () => { + fixture.detectChanges(); + flush(); + + component.formGroup.controls.timeout.setValue(null); + + await component.saveTimeout(mockInitialTimeout, 30); + flush(); + + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled(); + })); + + it("should set new value and show confirmation dialog when setting timeout to Never and dialog confirmed", waitForAsync(async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + fixture.detectChanges(); + await fixture.whenStable(); + + const previousTimeout = component.formGroup.controls.timeout.value!; + const newTimeout = VaultTimeoutStringType.Never; + + await component.saveTimeout(previousTimeout, newTimeout); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "warning" }, + content: { key: "neverLockWarning" }, + type: "warning", + }); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith( + mockUserId, + newTimeout, + mockInitialTimeoutAction, + ); + expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).toHaveBeenCalledWith( + newTimeout, + ); + })); + + it("should revert to previous value when Never confirmation is declined", waitForAsync(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + const previousTimeout = component.formGroup.controls.timeout.value!; + const newTimeout = VaultTimeoutStringType.Never; + + await component.saveTimeout(previousTimeout, newTimeout); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "warning" }, + content: { key: "neverLockWarning" }, + type: "warning", + }); + expect(component.formGroup.controls.timeout.value).toBe(previousTimeout); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled(); + expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).not.toHaveBeenCalled(); + })); + + it.each([ + 30, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnIdle, + ])( + "should set new value when setting timeout to %s", + fakeAsync(async (timeout: VaultTimeout) => { + fixture.detectChanges(); + flush(); + + const previousTimeout = component.formGroup.controls.timeout.value!; + await component.saveTimeout(previousTimeout, timeout); + flush(); + + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith( + mockUserId, + timeout, + mockInitialTimeoutAction, + ); + expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).toHaveBeenCalledWith( + timeout, + ); + }), + ); + }); + + describe("saveTimeoutAction", () => { + it("should set new value and show confirmation dialog when setting action to LogOut and dialog confirmed", waitForAsync(async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + fixture.detectChanges(); + await fixture.whenStable(); + + await component.saveTimeoutAction(VaultTimeoutAction.LogOut); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "vaultTimeoutLogOutConfirmationTitle" }, + content: { key: "vaultTimeoutLogOutConfirmation" }, + type: "warning", + }); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith( + mockUserId, + mockInitialTimeout, + VaultTimeoutAction.LogOut, + ); + })); + + it("should revert to Lock when LogOut confirmation is declined", waitForAsync(async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + fixture.detectChanges(); + await fixture.whenStable(); + + await component.saveTimeoutAction(VaultTimeoutAction.LogOut); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "vaultTimeoutLogOutConfirmationTitle" }, + content: { key: "vaultTimeoutLogOutConfirmation" }, + type: "warning", + }); + expect(component.formGroup.controls.timeoutAction.value).toBe(VaultTimeoutAction.Lock); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled(); + })); + + it("should set timeout action to Lock value when setting timeout action to Lock", fakeAsync(async () => { + fixture.detectChanges(); + flush(); + + component.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.LogOut, { + emitEvent: false, + }); + + await component.saveTimeoutAction(VaultTimeoutAction.Lock); + flush(); + + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith( + mockUserId, + mockInitialTimeout, + VaultTimeoutAction.Lock, + ); + })); + + it("should not save and show error toast when timeout has policy error", fakeAsync(async () => { + fixture.detectChanges(); + flush(); + + component.formGroup.controls.timeout.setErrors({ policyError: true }); + + await component.saveTimeoutAction(VaultTimeoutAction.Lock); + flush(); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "vaultTimeoutTooLarge-used-i18n", + }); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled(); + })); + }); +}); 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 new file mode 100644 index 0000000000..7124e3f14c --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts @@ -0,0 +1,278 @@ +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, input, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import { RouterModule } from "@angular/router"; +import { + BehaviorSubject, + combineLatest, + concatMap, + distinctUntilChanged, + filter, + firstValueFrom, + map, + Observable, + of, + pairwise, + startWith, + switchMap, +} from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; +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 { + MaximumVaultTimeoutPolicyData, + VaultTimeout, + VaultTimeoutAction, + VaultTimeoutOption, + VaultTimeoutSettingsService, + 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 { + CheckboxModule, + DialogService, + FormFieldModule, + IconButtonModule, + ItemModule, + LinkModule, + SelectModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service"; + +// 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-settings", + templateUrl: "session-timeout-settings.component.html", + imports: [ + CheckboxModule, + CommonModule, + FormFieldModule, + FormsModule, + ReactiveFormsModule, + IconButtonModule, + ItemModule, + JslibModule, + LinkModule, + RouterModule, + SelectModule, + TypographyModule, + VaultTimeoutInputComponent, + ], +}) +export class SessionTimeoutSettingsComponent implements OnInit { + // TODO remove once https://bitwarden.atlassian.net/browse/PM-27283 is completed + // This is because vaultTimeoutSettingsService.availableVaultTimeoutActions$ is not reactive, hence the change detection + // needs to be manually triggered to refresh available timeout actions + readonly refreshTimeoutActionSettings = input>( + new BehaviorSubject(undefined), + ); + + formGroup = new FormGroup({ + timeout: new FormControl(null, [Validators.required]), + timeoutAction: new FormControl(VaultTimeoutAction.Lock, [ + Validators.required, + ]), + }); + protected readonly availableTimeoutActions = signal([]); + protected readonly availableTimeoutOptions$ = + this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe( + startWith([] as VaultTimeoutOption[]), + ); + protected hasVaultTimeoutPolicy$: Observable = of(false); + + 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); + } + + 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( + 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, + timeoutAction: await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), + ), + }, + { emitEvent: false }, + ); + + this.refreshTimeoutActionSettings() + .pipe( + startWith(undefined), + switchMap(() => + combineLatest([ + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId), + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), + maximumVaultTimeoutPolicy$, + ]), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(([availableActions, action, policy]) => { + 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) { + this.formGroup.controls.timeoutAction.disable({ emitEvent: false }); + } else { + this.formGroup.controls.timeoutAction.enable({ emitEvent: false }); + } + }); + + this.formGroup.controls.timeout.valueChanges + .pipe( + startWith(timeout), // emit to init pairwise + filter((value) => value != null), + distinctUntilChanged(), + pairwise(), + concatMap(async ([previousValue, newValue]) => { + await this.saveTimeout(previousValue, newValue); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + + this.formGroup.controls.timeoutAction.valueChanges + .pipe( + filter((value) => value != null), + map(async (value) => { + await this.saveTimeoutAction(value); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + + async saveTimeout(previousValue: VaultTimeout, newValue: VaultTimeout) { + this.formGroup.controls.timeout.markAllAsTouched(); + if (this.formGroup.controls.timeout.invalid) { + return; + } + + this.logService.debug("[SessionTimeoutSettings] Saving timeout", { previousValue, newValue }); + + if (newValue === VaultTimeoutStringType.Never) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "warning" }, + content: { key: "neverLockWarning" }, + type: "warning", + }); + + if (!confirmed) { + this.formGroup.controls.timeout.setValue(previousValue, { emitEvent: false }); + return; + } + } + + const vaultTimeoutAction = await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), + ); + + await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( + this.userId, + newValue, + vaultTimeoutAction, + ); + + this.sessionTimeoutSettingsComponentService.onTimeoutSave(newValue); + } + + async saveTimeoutAction(value: VaultTimeoutAction) { + this.logService.debug("[SessionTimeoutSettings] Saving timeout action", value); + + if (value === VaultTimeoutAction.LogOut) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "vaultTimeoutLogOutConfirmationTitle" }, + content: { key: "vaultTimeoutLogOutConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + this.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.Lock, { + emitEvent: false, + }); + return; + } + } + + if (this.formGroup.controls.timeout.hasError("policyError")) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("vaultTimeoutTooLarge"), + }); + return; + } + + await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( + this.userId, + this.formGroup.controls.timeout.value!, + value, + ); + } +} 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 new file mode 100644 index 0000000000..7b9efeac9c --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts @@ -0,0 +1,9 @@ +import { Observable } from "rxjs"; + +import { VaultTimeout, VaultTimeoutOption } from "@bitwarden/common/key-management/vault-timeout"; + +export abstract class SessionTimeoutSettingsComponentService { + abstract availableTimeoutOptions$: Observable; + + abstract onTimeoutSave(timeout: VaultTimeout): void; +}