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 @@
+
+
+
+
+
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;
+}