1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-12-05 09:14:28 +01:00
This commit is contained in:
Maciej Zieniuk 2025-12-04 18:39:17 -06:00 committed by GitHub
commit 921000801a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 3344 additions and 569 deletions

View File

@ -5881,5 +5881,49 @@
},
"sessionTimeoutSettingsAction": {
"message": "Timeout action"
},
"sessionTimeoutSettingsManagedByOrganization": {
"message": "This setting is managed by your organization."
},
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": {
"message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
"placeholders": {
"hours": {
"content": "$1",
"example": "8"
},
"minutes": {
"content": "$2",
"example": "2"
}
}
},
"sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately": {
"message": "Your organization has set the default session timeout to Immediately."
},
"sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked": {
"message": "Your organization has set the default session timeout to On system lock."
},
"sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart": {
"message": "Your organization has set the default session timeout to On browser restart."
},
"sessionTimeoutSettingsPolicyMaximumError": {
"message": "Maximum timeout cannot exceed $HOURS$ hour(s) and $MINUTES$ minute(s)",
"placeholders": {
"hours": {
"content": "$1",
"example": "5"
},
"minutes": {
"content": "$2",
"example": "5"
}
}
},
"sessionTimeoutOnRestart": {
"message": "On browser restart"
},
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action"
}
}

View File

@ -86,12 +86,12 @@
</bit-section-header>
<bit-card>
<bit-session-timeout-input
<bit-session-timeout-input-legacy
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</bit-session-timeout-input>
</bit-session-timeout-input-legacy>
<bit-form-field disableMargin>
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>

View File

@ -70,7 +70,7 @@ import {
BiometricsStatus,
} from "@bitwarden/key-management";
import {
SessionTimeoutInputComponent,
SessionTimeoutInputLegacyComponent,
SessionTimeoutSettingsComponent,
} from "@bitwarden/key-management-ui";
@ -109,7 +109,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
SessionTimeoutSettingsComponent,
SpotlightComponent,
TypographyModule,
SessionTimeoutInputComponent,
SessionTimeoutInputLegacyComponent,
],
})
export class AccountSecurityComponent implements OnInit, OnDestroy {

View File

@ -297,6 +297,7 @@ import { SafariApp } from "../browser/safariApp";
import { PhishingDataService } from "../dirt/phishing-detection/services/phishing-data.service";
import { PhishingDetectionService } from "../dirt/phishing-detection/services/phishing-detection.service";
import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service";
import { BrowserSessionTimeoutTypeService } from "../key-management/session-timeout/services/browser-session-timeout-type.service";
import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service";
import { BrowserActionsService } from "../platform/actions/browser-actions.service";
import { DefaultBadgeBrowserApi } from "../platform/badge/badge-browser-api";
@ -738,6 +739,10 @@ export default class MainBackground {
this.accountService,
);
const sessionTimeoutTypeService = new BrowserSessionTimeoutTypeService(
this.platformUtilsService,
);
this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService(
this.accountService,
pinStateService,
@ -749,6 +754,7 @@ export default class MainBackground {
this.stateProvider,
this.logService,
VaultTimeoutStringType.OnRestart, // default vault timeout
sessionTimeoutTypeService,
);
this.apiService = new ApiService(

View File

@ -0,0 +1,57 @@
import { mock } from "jest-mock-extended";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { BrowserSessionTimeoutSettingsComponentService } from "./browser-session-timeout-settings-component.service";
describe("BrowserSessionTimeoutSettingsComponentService", () => {
let service: BrowserSessionTimeoutSettingsComponentService;
let mockI18nService: jest.Mocked<I18nService>;
let mockSessionTimeoutTypeService: jest.Mocked<SessionTimeoutTypeService>;
let mockPolicyService: jest.Mocked<PolicyService>;
let mockMessagingService: jest.Mocked<MessagingService>;
beforeEach(() => {
mockI18nService = mock<I18nService>();
mockSessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
mockPolicyService = mock<PolicyService>();
mockMessagingService = mock<MessagingService>();
service = new BrowserSessionTimeoutSettingsComponentService(
mockI18nService,
mockSessionTimeoutTypeService,
mockPolicyService,
mockMessagingService,
);
});
describe("onTimeoutSave", () => {
it("should call messagingService.send with 'bgReseedStorage' when timeout is Never", () => {
service.onTimeoutSave(VaultTimeoutStringType.Never);
expect(mockMessagingService.send).toHaveBeenCalledWith("bgReseedStorage");
});
it.each([
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.Custom,
])("should not call messagingService.send when timeout is %s", (timeoutValue) => {
service.onTimeoutSave(timeoutValue);
expect(mockMessagingService.send).not.toHaveBeenCalled();
});
});
});

View File

@ -1,56 +1,24 @@
import { defer, Observable, of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui";
export class BrowserSessionTimeoutSettingsComponentService
implements SessionTimeoutSettingsComponentService
{
availableTimeoutOptions$: Observable<VaultTimeoutOption[]> = defer(() => {
const options: VaultTimeoutOption[] = [
{ name: this.i18nService.t("immediately"), value: 0 },
{ name: this.i18nService.t("oneMinute"), value: 1 },
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
{ name: this.i18nService.t("oneHour"), value: 60 },
{ name: this.i18nService.t("fourHours"), value: 240 },
];
const showOnLocked =
!this.platformUtilsService.isFirefox() &&
!this.platformUtilsService.isSafari() &&
!(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel");
if (showOnLocked) {
options.push({
name: this.i18nService.t("onLocked"),
value: VaultTimeoutStringType.OnLocked,
});
}
options.push(
{ name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart },
{ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never },
);
return of(options);
});
export class BrowserSessionTimeoutSettingsComponentService extends SessionTimeoutSettingsComponentService {
constructor(
private readonly i18nService: I18nService,
private readonly platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
sessionTimeoutTypeService: SessionTimeoutTypeService,
policyService: PolicyService,
private readonly messagingService: MessagingService,
) {}
) {
super(i18nService, sessionTimeoutTypeService, policyService);
}
onTimeoutSave(timeout: VaultTimeout): void {
override onTimeoutSave(timeout: VaultTimeout): void {
if (timeout === VaultTimeoutStringType.Never) {
this.messagingService.send("bgReseedStorage");
}

View File

@ -0,0 +1,139 @@
import { mock } from "jest-mock-extended";
import {
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BrowserSessionTimeoutTypeService } from "./browser-session-timeout-type.service";
describe("BrowserSessionTimeoutTypeService", () => {
let service: BrowserSessionTimeoutTypeService;
let mockPlatformUtilsService: jest.Mocked<PlatformUtilsService>;
beforeEach(() => {
mockPlatformUtilsService = mock<PlatformUtilsService>();
service = new BrowserSessionTimeoutTypeService(mockPlatformUtilsService);
});
describe("isAvailable", () => {
it.each([
VaultTimeoutNumberType.Immediately,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.Custom,
])("should return true for always available type: %s", async (timeoutType) => {
const result = await service.isAvailable(timeoutType);
expect(result).toBe(true);
});
it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])(
"should return true for numeric timeout type: %s",
async (timeoutType) => {
const result = await service.isAvailable(timeoutType);
expect(result).toBe(true);
},
);
describe("OnLocked availability", () => {
const mockNavigatorPlatform = (platform: string) => {
Object.defineProperty(navigator, "platform", {
value: platform,
writable: true,
configurable: true,
});
};
beforeEach(() => {
mockNavigatorPlatform("Linux x86_64");
mockPlatformUtilsService.isFirefox.mockReturnValue(false);
mockPlatformUtilsService.isSafari.mockReturnValue(false);
mockPlatformUtilsService.isOpera.mockReturnValue(false);
});
it("should return true when not Firefox, Safari, or Opera on Mac", async () => {
const result = await service.isAvailable(VaultTimeoutStringType.OnLocked);
expect(result).toBe(true);
});
it("should return true when Opera on non-Mac platform", async () => {
mockNavigatorPlatform("Win32");
mockPlatformUtilsService.isOpera.mockReturnValue(true);
const result = await service.isAvailable(VaultTimeoutStringType.OnLocked);
expect(result).toBe(true);
});
it("should return false when Opera on Mac", async () => {
mockNavigatorPlatform("MacIntel");
mockPlatformUtilsService.isOpera.mockReturnValue(true);
const result = await service.isAvailable(VaultTimeoutStringType.OnLocked);
expect(result).toBe(false);
});
it("should return false when Firefox", async () => {
mockPlatformUtilsService.isFirefox.mockReturnValue(true);
const result = await service.isAvailable(VaultTimeoutStringType.OnLocked);
expect(result).toBe(false);
});
it("should return false when Safari", async () => {
mockPlatformUtilsService.isSafari.mockReturnValue(true);
const result = await service.isAvailable(VaultTimeoutStringType.OnLocked);
expect(result).toBe(false);
});
});
it.each([VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnSleep])(
"should return false for unavailable timeout type: %s",
async (timeoutType) => {
const result = await service.isAvailable(timeoutType);
expect(result).toBe(false);
},
);
});
describe("getOrPromoteToAvailable", () => {
it.each([
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Custom,
])("should return the original type when it is available: %s", async (timeoutType) => {
jest.spyOn(service, "isAvailable").mockResolvedValue(true);
const result = await service.getOrPromoteToAvailable(timeoutType);
expect(result).toBe(timeoutType);
expect(service.isAvailable).toHaveBeenCalledWith(timeoutType);
});
it.each([
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnLocked,
5,
])("should return OnRestart when type is not available: %s", async (timeoutType) => {
jest.spyOn(service, "isAvailable").mockResolvedValue(false);
const result = await service.getOrPromoteToAvailable(timeoutType);
expect(result).toBe(VaultTimeoutStringType.OnRestart);
expect(service.isAvailable).toHaveBeenCalledWith(timeoutType);
});
});
});

View File

@ -0,0 +1,43 @@
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
export class BrowserSessionTimeoutTypeService implements SessionTimeoutTypeService {
constructor(private readonly platformUtilsService: PlatformUtilsService) {}
async isAvailable(type: VaultTimeout): Promise<boolean> {
switch (type) {
case VaultTimeoutNumberType.Immediately:
case VaultTimeoutStringType.OnRestart:
case VaultTimeoutStringType.Never:
case VaultTimeoutStringType.Custom:
return true;
case VaultTimeoutStringType.OnLocked:
return (
!this.platformUtilsService.isFirefox() &&
!this.platformUtilsService.isSafari() &&
!(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel")
);
default:
if (isVaultTimeoutTypeNumeric(type)) {
return true;
}
break;
}
return false;
}
async getOrPromoteToAvailable(type: VaultTimeout): Promise<VaultTimeout> {
const available = await this.isAvailable(type);
if (!available) {
return VaultTimeoutStringType.OnRestart;
}
return type;
}
}

View File

@ -76,6 +76,7 @@ import {
InternalMasterPasswordServiceAbstraction,
MasterPasswordServiceAbstraction,
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeoutService,
VaultTimeoutStringType,
@ -170,6 +171,7 @@ import { InlineMenuFieldQualificationService } from "../../autofill/services/inl
import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics";
import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service";
import { BrowserSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/browser-session-timeout-settings-component.service";
import { BrowserSessionTimeoutTypeService } from "../../key-management/session-timeout/services/browser-session-timeout-type.service";
import { ForegroundVaultTimeoutService } from "../../key-management/vault-timeout/foreground-vault-timeout.service";
import { BrowserActionsService } from "../../platform/actions/browser-actions.service";
import { BrowserApi } from "../../platform/browser/browser-api";
@ -723,10 +725,20 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionNewDeviceVerificationComponentService,
deps: [],
}),
safeProvider({
provide: SessionTimeoutTypeService,
useClass: BrowserSessionTimeoutTypeService,
deps: [PlatformUtilsService],
}),
safeProvider({
provide: SessionTimeoutSettingsComponentService,
useClass: BrowserSessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, PlatformUtilsService, MessagingServiceAbstraction],
deps: [
I18nServiceAbstraction,
SessionTimeoutTypeService,
PolicyService,
MessagingServiceAbstraction,
],
}),
];

View File

@ -0,0 +1,15 @@
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
export class CliSessionTimeoutTypeService implements SessionTimeoutTypeService {
async isAvailable(timeout: VaultTimeout): Promise<boolean> {
return timeout === VaultTimeoutStringType.Never;
}
async getOrPromoteToAvailable(_: VaultTimeout): Promise<VaultTimeout> {
return VaultTimeoutStringType.Never;
}
}

View File

@ -211,6 +211,7 @@ import {
import { CliBiometricsService } from "../key-management/cli-biometrics-service";
import { CliProcessReloadService } from "../key-management/cli-process-reload.service";
import { CliSessionTimeoutTypeService } from "../key-management/session-timeout/services/cli-session-timeout-type.service";
import { flagEnabled } from "../platform/flags";
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
import { CliSdkLoadService } from "../platform/services/cli-sdk-load.service";
@ -529,6 +530,8 @@ export class ServiceContainer {
this.accountService,
);
const sessionTimeoutTypeService = new CliSessionTimeoutTypeService();
this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService(
this.accountService,
pinStateService,
@ -540,6 +543,7 @@ export class ServiceContainer {
this.stateProvider,
this.logService,
VaultTimeoutStringType.Never, // default vault timeout
sessionTimeoutTypeService,
);
const refreshAccessTokenErrorCallback = () => {

View File

@ -44,12 +44,12 @@
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
</bit-section-header>
<bit-session-timeout-input
<bit-session-timeout-input-legacy
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</bit-session-timeout-input>
</bit-session-timeout-input-legacy>
<bit-form-field disableMargin>
<bit-label for="vaultTimeoutAction">{{

View File

@ -55,7 +55,7 @@ import {
} from "@bitwarden/components";
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
import {
SessionTimeoutInputComponent,
SessionTimeoutInputLegacyComponent,
SessionTimeoutSettingsComponent,
} from "@bitwarden/key-management-ui";
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
@ -97,7 +97,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man
SectionHeaderComponent,
SelectModule,
TypographyModule,
SessionTimeoutInputComponent,
SessionTimeoutInputLegacyComponent,
SessionTimeoutSettingsComponent,
PermitCipherDetailsPopoverComponent,
PremiumBadgeComponent,

View File

@ -62,6 +62,7 @@ import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypt
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeoutSettingsService,
VaultTimeoutStringType,
@ -128,7 +129,7 @@ import { DesktopBiometricsService } from "../../key-management/biometrics/deskto
import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service";
import { ElectronKeyService } from "../../key-management/electron-key.service";
import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service";
import { DesktopSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/desktop-session-timeout-settings-component.service";
import { DesktopSessionTimeoutTypeService } from "../../key-management/session-timeout/services/desktop-session-timeout-type.service";
import { flagEnabled } from "../../platform/flags";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service";
@ -484,10 +485,15 @@ const safeProviders: SafeProvider[] = [
useClass: DesktopAutotypeDefaultSettingPolicy,
deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService],
}),
safeProvider({
provide: SessionTimeoutTypeService,
useClass: DesktopSessionTimeoutTypeService,
deps: [],
}),
safeProvider({
provide: SessionTimeoutSettingsComponentService,
useClass: DesktopSessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction],
useClass: SessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction],
}),
];

View File

@ -1,48 +0,0 @@
import { defer, from, map, Observable } from "rxjs";
import {
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui";
export class DesktopSessionTimeoutSettingsComponentService
implements SessionTimeoutSettingsComponentService
{
availableTimeoutOptions$: Observable<VaultTimeoutOption[]> = 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 {}
}

View File

@ -0,0 +1,125 @@
import {
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { DesktopSessionTimeoutTypeService } from "./desktop-session-timeout-type.service";
describe("DesktopSessionTimeoutTypeService", () => {
let service: DesktopSessionTimeoutTypeService;
let mockIsLockMonitorAvailable: jest.Mock;
beforeEach(() => {
mockIsLockMonitorAvailable = jest.fn();
(global as any).ipc = {
platform: {
powermonitor: {
isLockMonitorAvailable: mockIsLockMonitorAvailable,
},
},
};
service = new DesktopSessionTimeoutTypeService();
});
describe("isAvailable", () => {
it("should return false for Immediately", async () => {
const result = await service.isAvailable(VaultTimeoutNumberType.Immediately);
expect(result).toBe(false);
});
it.each([
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.Custom,
])("should return true for always available type: %s", async (timeoutType) => {
const result = await service.isAvailable(timeoutType);
expect(result).toBe(true);
});
it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])(
"should return true for numeric timeout type: %s",
async (timeoutType) => {
const result = await service.isAvailable(timeoutType);
expect(result).toBe(true);
},
);
describe("OnLocked availability", () => {
it("should return true when lock monitor is available", async () => {
mockIsLockMonitorAvailable.mockResolvedValue(true);
const result = await service.isAvailable(VaultTimeoutStringType.OnLocked);
expect(result).toBe(true);
expect(mockIsLockMonitorAvailable).toHaveBeenCalled();
});
it("should return false when lock monitor is not available", async () => {
mockIsLockMonitorAvailable.mockResolvedValue(false);
const result = await service.isAvailable(VaultTimeoutStringType.OnLocked);
expect(result).toBe(false);
expect(mockIsLockMonitorAvailable).toHaveBeenCalled();
});
});
});
describe("getOrPromoteToAvailable", () => {
it.each([
VaultTimeoutNumberType.OnMinute,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.Custom,
])("should return the original type when it is available: %s", async (timeoutType) => {
jest.spyOn(service, "isAvailable").mockResolvedValue(true);
const result = await service.getOrPromoteToAvailable(timeoutType);
expect(result).toBe(timeoutType);
expect(service.isAvailable).toHaveBeenCalledWith(timeoutType);
});
it("should return OnMinute when Immediately is not available", async () => {
jest.spyOn(service, "isAvailable").mockResolvedValue(false);
const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately);
expect(result).toBe(VaultTimeoutNumberType.OnMinute);
expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately);
});
it("should return OnSleep when OnLocked is not available", async () => {
jest.spyOn(service, "isAvailable").mockResolvedValue(false);
const result = await service.getOrPromoteToAvailable(VaultTimeoutStringType.OnLocked);
expect(result).toBe(VaultTimeoutStringType.OnSleep);
expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutStringType.OnLocked);
});
it.each([
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutNumberType.OnMinute,
5,
])("should return OnRestart when type is not available: %s", async (timeoutType) => {
jest.spyOn(service, "isAvailable").mockResolvedValue(false);
const result = await service.getOrPromoteToAvailable(timeoutType);
expect(result).toBe(VaultTimeoutStringType.OnRestart);
expect(service.isAvailable).toHaveBeenCalledWith(timeoutType);
});
});
});

View File

@ -0,0 +1,46 @@
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
export class DesktopSessionTimeoutTypeService implements SessionTimeoutTypeService {
async isAvailable(type: VaultTimeout): Promise<boolean> {
switch (type) {
case VaultTimeoutNumberType.Immediately:
return false;
case VaultTimeoutStringType.OnIdle:
case VaultTimeoutStringType.OnSleep:
case VaultTimeoutStringType.OnRestart:
case VaultTimeoutStringType.Never:
case VaultTimeoutStringType.Custom:
return true;
case VaultTimeoutStringType.OnLocked:
return await ipc.platform.powermonitor.isLockMonitorAvailable();
default:
if (isVaultTimeoutTypeNumeric(type)) {
return true;
}
break;
}
return false;
}
async getOrPromoteToAvailable(type: VaultTimeout): Promise<VaultTimeout> {
const available = await this.isAvailable(type);
if (!available) {
switch (type) {
case VaultTimeoutNumberType.Immediately:
return VaultTimeoutNumberType.OnMinute;
case VaultTimeoutStringType.OnLocked:
return VaultTimeoutStringType.OnSleep;
default:
return VaultTimeoutStringType.OnRestart;
}
}
return type;
}
}

View File

@ -4255,5 +4255,46 @@
},
"sessionTimeoutHeader": {
"message": "Session timeout"
},
"sessionTimeoutSettingsManagedByOrganization": {
"message": "This setting is managed by your organization."
},
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": {
"message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
"placeholders": {
"hours": {
"content": "$1",
"example": "8"
},
"minutes": {
"content": "$2",
"example": "2"
}
}
},
"sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked": {
"message": "Your organization has set the default session timeout to On system lock."
},
"sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart": {
"message": "Your organization has set the default session timeout to On restart."
},
"sessionTimeoutSettingsPolicyMaximumError": {
"message": "Maximum timeout cannot exceed $HOURS$ hour(s) and $MINUTES$ minute(s)",
"placeholders": {
"hours": {
"content": "$1",
"example": "5"
},
"minutes": {
"content": "$2",
"example": "5"
}
}
},
"sessionTimeoutOnRestart": {
"message": "On restart"
},
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action"
}
}

View File

@ -69,6 +69,7 @@ import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-managemen
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutStringType,
@ -124,7 +125,6 @@ import {
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service";
import { WebSessionTimeoutSettingsComponentService } from "@bitwarden/web-vault/app/key-management/session-timeout/services/web-session-timeout-settings-component.service";
import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service";
import { flagEnabled } from "../../utils/flags";
@ -149,6 +149,7 @@ import { WebFileDownloadService } from "../core/web-file-download.service";
import { UserKeyRotationService } from "../key-management/key-rotation/user-key-rotation.service";
import { WebLockComponentService } from "../key-management/lock/services/web-lock-component.service";
import { WebProcessReloadService } from "../key-management/services/web-process-reload.service";
import { WebSessionTimeoutTypeService } from "../key-management/session-timeout/services/web-session-timeout-type.service";
import { WebBiometricsService } from "../key-management/web-biometric.service";
import { WebIpcService } from "../platform/ipc/web-ipc.service";
import { WebEnvironmentService } from "../platform/web-environment.service";
@ -469,10 +470,15 @@ const safeProviders: SafeProvider[] = [
useClass: WebSystemService,
deps: [],
}),
safeProvider({
provide: SessionTimeoutTypeService,
useClass: WebSessionTimeoutTypeService,
deps: [PlatformUtilsService],
}),
safeProvider({
provide: SessionTimeoutSettingsComponentService,
useClass: WebSessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, PlatformUtilsService],
useClass: SessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyService],
}),
];

View File

@ -1,39 +0,0 @@
import { defer, Observable, of } from "rxjs";
import {
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui";
export class WebSessionTimeoutSettingsComponentService
implements SessionTimeoutSettingsComponentService
{
availableTimeoutOptions$: Observable<VaultTimeoutOption[]> = 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 {}
}

View File

@ -0,0 +1,115 @@
import { mock } from "jest-mock-extended";
import {
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { WebSessionTimeoutTypeService } from "./web-session-timeout-type.service";
describe("WebSessionTimeoutTypeService", () => {
let service: WebSessionTimeoutTypeService;
let mockPlatformUtilsService: jest.Mocked<PlatformUtilsService>;
beforeEach(() => {
mockPlatformUtilsService = mock<PlatformUtilsService>();
service = new WebSessionTimeoutTypeService(mockPlatformUtilsService);
});
describe("isAvailable", () => {
it("should return false for Immediately", async () => {
const result = await service.isAvailable(VaultTimeoutNumberType.Immediately);
expect(result).toBe(false);
});
it.each([VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.Custom])(
"should return true for always available type: %s",
async (timeoutType) => {
const result = await service.isAvailable(timeoutType);
expect(result).toBe(true);
},
);
it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])(
"should return true for numeric timeout type: %s",
async (timeoutType) => {
const result = await service.isAvailable(timeoutType);
expect(result).toBe(true);
},
);
it.each([
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnLocked,
])("should return false for unavailable timeout type: %s", async (timeoutType) => {
const result = await service.isAvailable(timeoutType);
expect(result).toBe(false);
});
describe("Never availability", () => {
it("should return true when in dev mode", async () => {
mockPlatformUtilsService.isDev.mockReturnValue(true);
const result = await service.isAvailable(VaultTimeoutStringType.Never);
expect(result).toBe(true);
expect(mockPlatformUtilsService.isDev).toHaveBeenCalled();
});
it("should return false when not in dev mode", async () => {
mockPlatformUtilsService.isDev.mockReturnValue(false);
const result = await service.isAvailable(VaultTimeoutStringType.Never);
expect(result).toBe(false);
expect(mockPlatformUtilsService.isDev).toHaveBeenCalled();
});
});
});
describe("getOrPromoteToAvailable", () => {
it.each([
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.Custom,
])("should return the original type when it is available: %s", async (timeoutType) => {
jest.spyOn(service, "isAvailable").mockResolvedValue(true);
const result = await service.getOrPromoteToAvailable(timeoutType);
expect(result).toBe(timeoutType);
expect(service.isAvailable).toHaveBeenCalledWith(timeoutType);
});
it("should return OnMinute when Immediately is not available", async () => {
jest.spyOn(service, "isAvailable").mockResolvedValue(false);
const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately);
expect(result).toBe(VaultTimeoutNumberType.OnMinute);
expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately);
});
it.each([
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Never,
])("should return OnRestart when type is not available: %s", async (timeoutType) => {
jest.spyOn(service, "isAvailable").mockResolvedValue(false);
const result = await service.getOrPromoteToAvailable(timeoutType);
expect(result).toBe(VaultTimeoutStringType.OnRestart);
expect(service.isAvailable).toHaveBeenCalledWith(timeoutType);
});
});
});

View File

@ -0,0 +1,44 @@
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
export class WebSessionTimeoutTypeService implements SessionTimeoutTypeService {
constructor(private readonly platformUtilsService: PlatformUtilsService) {}
async isAvailable(type: VaultTimeout): Promise<boolean> {
switch (type) {
case VaultTimeoutNumberType.Immediately:
return false;
case VaultTimeoutStringType.OnRestart:
case VaultTimeoutStringType.Custom:
return true;
case VaultTimeoutStringType.Never:
return this.platformUtilsService.isDev();
default:
if (isVaultTimeoutTypeNumeric(type)) {
return true;
}
break;
}
return false;
}
async getOrPromoteToAvailable(type: VaultTimeout): Promise<VaultTimeout> {
const available = await this.isAvailable(type);
if (!available) {
switch (type) {
case VaultTimeoutNumberType.Immediately:
return VaultTimeoutNumberType.OnMinute;
default:
return VaultTimeoutStringType.OnRestart;
}
}
return type;
}
}

View File

@ -17,12 +17,12 @@
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
</span>
</bit-callout>
<bit-session-timeout-input
<bit-session-timeout-input-legacy
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</bit-session-timeout-input>
</bit-session-timeout-input-legacy>
<ng-container *ngIf="availableVaultTimeoutActions$ | async as availableVaultTimeoutActions">
<bit-radio-group
formControlName="vaultTimeoutAction"

View File

@ -33,7 +33,7 @@ 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 { DialogService } from "@bitwarden/components";
import { SessionTimeoutInputComponent } from "@bitwarden/key-management-ui";
import { SessionTimeoutInputLegacyComponent } from "@bitwarden/key-management-ui";
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
import { HeaderModule } from "../layouts/header/header.module";
@ -52,8 +52,8 @@ import { SharedModule } from "../shared";
imports: [
SharedModule,
HeaderModule,
SessionTimeoutInputComponent,
PermitCipherDetailsPopoverComponent,
SessionTimeoutInputLegacyComponent,
],
})
export class PreferencesComponent implements OnInit, OnDestroy {

View File

@ -12214,5 +12214,43 @@
},
"userVerificationFailed": {
"message": "User verification failed."
},
"sessionTimeoutSettingsManagedByOrganization": {
"message": "This setting is managed by your organization."
},
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": {
"message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
"placeholders": {
"hours": {
"content": "$1",
"example": "8"
},
"minutes": {
"content": "$2",
"example": "2"
}
}
},
"sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart": {
"message": "Your organization has set the default session timeout to On browser refresh."
},
"sessionTimeoutSettingsPolicyMaximumError": {
"message": "Maximum timeout cannot exceed $HOURS$ hour(s) and $MINUTES$ minute(s)",
"placeholders": {
"hours": {
"content": "$1",
"example": "5"
},
"minutes": {
"content": "$2",
"example": "5"
}
}
},
"sessionTimeoutOnRestart": {
"message": "On browser refresh"
},
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action"
}
}

View File

@ -7,16 +7,16 @@ import { mock } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import {
SessionTimeoutAction,
SessionTimeoutType,
} from "@bitwarden/common/key-management/session-timeout";
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
import {
SessionTimeoutAction,
SessionTimeoutPolicyComponent,
SessionTimeoutType,
} from "./session-timeout.component";
import { SessionTimeoutPolicyComponent } from "./session-timeout.component";
// Mock DialogRef, so we can mock "readonly closed" property.
class MockDialogRef extends DialogRef {

View File

@ -10,26 +10,22 @@ import {
} from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
MaximumSessionTimeoutPolicyData,
SessionTimeoutAction,
SessionTimeoutType,
} from "@bitwarden/common/key-management/session-timeout";
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
import {
BasePolicyEditDefinition,
BasePolicyEditComponent,
BasePolicyEditDefinition,
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
export type SessionTimeoutAction = null | "lock" | "logOut";
export type SessionTimeoutType =
| null
| "never"
| "onAppRestart"
| "onSystemLock"
| "immediately"
| "custom";
export class SessionTimeoutPolicy extends BasePolicyEditDefinition {
name = "sessionTimeoutPolicyTitle";
description = "sessionTimeoutPolicyDescription";
@ -50,9 +46,6 @@ export class SessionTimeoutPolicyComponent
extends BasePolicyEditComponent
implements OnInit, OnDestroy
{
private destroy$ = new Subject<void>();
private lastConfirmedType$ = new BehaviorSubject<SessionTimeoutType>(null);
actionOptions: { name: string; value: SessionTimeoutAction }[];
typeOptions: { name: string; value: SessionTimeoutType }[];
data = this.formBuilder.group({
@ -74,6 +67,9 @@ export class SessionTimeoutPolicyComponent
action: new FormControl<SessionTimeoutAction>(null),
});
private destroy$ = new Subject<void>();
private lastConfirmedType$ = new BehaviorSubject<SessionTimeoutType>(null);
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,
@ -123,12 +119,10 @@ export class SessionTimeoutPolicyComponent
}
protected override loadData() {
const minutes: number | null = this.policyResponse?.data?.minutes ?? null;
const action: SessionTimeoutAction =
this.policyResponse?.data?.action ?? (null satisfies SessionTimeoutAction);
const minutes: number | null = this.policyData?.minutes ?? null;
const action: SessionTimeoutAction = this.policyData?.action ?? null;
// For backward compatibility, the "type" field might not exist, hence we initialize it based on the presence of "minutes"
const type: SessionTimeoutType =
this.policyResponse?.data?.type ?? ((minutes ? "custom" : null) satisfies SessionTimeoutType);
const type: SessionTimeoutType = this.policyData?.type ?? (minutes ? "custom" : null);
this.updateFormControls(type);
this.data.patchValue({
@ -165,7 +159,11 @@ export class SessionTimeoutPolicyComponent
type,
minutes,
action: this.data.value.action,
};
} satisfies MaximumSessionTimeoutPolicyData;
}
private get policyData(): MaximumSessionTimeoutPolicyData | null {
return this.policyResponse?.data ?? null;
}
private async confirmTypeChange(newType: SessionTimeoutType): Promise<boolean> {

View File

@ -207,6 +207,7 @@ import {
SendPasswordService,
DefaultSendPasswordService,
} from "@bitwarden/common/key-management/sends";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
DefaultVaultTimeoutService,
DefaultVaultTimeoutSettingsService,
@ -912,6 +913,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
LogService,
DEFAULT_VAULT_TIMEOUT,
SessionTimeoutTypeService,
],
}),
safeProvider({

View File

@ -0,0 +1,15 @@
import { VaultTimeout } from "../../vault-timeout";
export abstract class SessionTimeoutTypeService {
/**
* Is provided timeout type available on this client type, OS ?
* @param timeout the timeout type
*/
abstract isAvailable(timeout: VaultTimeout): Promise<boolean>;
/**
* Returns the highest available and permissive timeout type, that is higher than or equals the provided timeout type.
* @param timeout the provided timeout type
*/
abstract getOrPromoteToAvailable(timeout: VaultTimeout): Promise<VaultTimeout>;
}

View File

@ -0,0 +1,3 @@
export { SessionTimeoutTypeService } from "./abstractions/session-timeout-type.service";
export { MaximumSessionTimeoutPolicyData } from "./types/maximum-session-timeout-policy.type";
export { SessionTimeoutAction, SessionTimeoutType } from "./types/session-timeout.type";

View File

@ -0,0 +1,7 @@
import { SessionTimeoutAction, SessionTimeoutType } from "./session-timeout.type";
export interface MaximumSessionTimeoutPolicyData {
type?: SessionTimeoutType;
minutes: number;
action?: SessionTimeoutAction;
}

View File

@ -0,0 +1,8 @@
export type SessionTimeoutAction = null | "lock" | "logOut";
export type SessionTimeoutType =
| null
| "never"
| "onAppRestart"
| "onSystemLock"
| "immediately"
| "custom";

View File

@ -4,8 +4,9 @@ export { VaultTimeoutService } from "./abstractions/vault-timeout.service";
export { VaultTimeoutService as DefaultVaultTimeoutService } from "./services/vault-timeout.service";
export { VaultTimeoutAction } from "./enums/vault-timeout-action.enum";
export {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "./types/vault-timeout.type";
export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type";

View File

@ -21,9 +21,14 @@ import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction";
import { SessionTimeoutTypeService } from "../../session-timeout";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
import {
VaultTimeout,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "../types/vault-timeout.type";
import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service";
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
@ -40,9 +45,11 @@ describe("VaultTimeoutSettingsService", () => {
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout
const mockUserId = Utils.newGuid() as UserId;
let stateProvider: FakeStateProvider;
let logService: MockProxy<LogService>;
let sessionTimeoutTypeService: MockProxy<SessionTimeoutTypeService>;
beforeEach(() => {
accountService = mockAccountServiceWith(mockUserId);
@ -67,8 +74,8 @@ describe("VaultTimeoutSettingsService", () => {
stateProvider = new FakeStateProvider(accountService);
logService = mock<LogService>();
sessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout
vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout);
biometricStateService.biometricUnlockEnabled$ = of(false);
@ -259,40 +266,276 @@ describe("VaultTimeoutSettingsService", () => {
);
});
it.each([
// policy, vaultTimeout, expected
[null, null, 15], // no policy, no vault timeout, falls back to default
[30, 90, 30], // policy overrides vault timeout
[30, 15, 15], // policy doesn't override vault timeout when it's within acceptable range
[90, VaultTimeoutStringType.Never, 90], // policy overrides vault timeout when it's "never"
[null, VaultTimeoutStringType.Never, VaultTimeoutStringType.Never], // no policy, persist "never" vault timeout
[90, 0, 0], // policy doesn't override vault timeout when it's 0 (immediate)
[null, 0, 0], // no policy, persist 0 (immediate) vault timeout
[90, VaultTimeoutStringType.OnRestart, 90], // policy overrides vault timeout when it's "onRestart"
[null, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnRestart], // no policy, persist "onRestart" vault timeout
[90, VaultTimeoutStringType.OnLocked, 90], // policy overrides vault timeout when it's "onLocked"
[null, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnLocked], // no policy, persist "onLocked" vault timeout
[90, VaultTimeoutStringType.OnSleep, 90], // policy overrides vault timeout when it's "onSleep"
[null, VaultTimeoutStringType.OnSleep, VaultTimeoutStringType.OnSleep], // no policy, persist "onSleep" vault timeout
[90, VaultTimeoutStringType.OnIdle, 90], // policy overrides vault timeout when it's "onIdle"
[null, VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnIdle], // no policy, persist "onIdle" vault timeout
])(
"when policy is %s, and vault timeout is %s, returns %s",
async (policy, vaultTimeout, expected) => {
describe("no policy", () => {
it("when vault timeout is null, returns default", async () => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])),
policyService.policiesByType$.mockReturnValue(of([]));
await stateProvider.setUserState(VAULT_TIMEOUT, null, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(defaultVaultTimeout);
});
it.each([
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnIdle,
])("when vault timeout is %s, returns unchanged", async (vaultTimeout) => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(of([]));
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(expected);
},
);
expect(result).toBe(vaultTimeout);
});
});
describe("policy type: custom", () => {
const policyMinutes = 30;
it.each([
VaultTimeoutNumberType.EightHours,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnIdle,
])(
"when vault timeout is %s and exceeds policy max, returns policy minutes",
async (vaultTimeout) => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(policyMinutes);
},
);
it.each([VaultTimeoutNumberType.OnMinute, policyMinutes])(
"when vault timeout is %s and within policy max, returns unchanged",
async (vaultTimeout) => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(vaultTimeout);
},
);
it("when vault timeout is Immediately, returns Immediately", async () => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutNumberType.Immediately,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(VaultTimeoutNumberType.Immediately);
});
});
describe("policy type: immediately", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
])(
"when current timeout is %s, returns immediately or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutNumberType.Immediately;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(expectedTimeout);
},
);
});
describe("policy type: onSystemLock", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
])(
"when current timeout is %s, returns onLocked or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutStringType.OnLocked;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnLocked,
);
expect(result).toBe(expectedTimeout);
},
);
it.each([
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
])("when current timeout is numeric %s, returns unchanged", async (currentTimeout) => {
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(result).toBe(currentTimeout);
});
});
describe("policy type: onAppRestart", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
])("when current timeout is %s, returns onRestart", async (currentTimeout) => {
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(result).toBe(VaultTimeoutStringType.OnRestart);
});
it.each([
VaultTimeoutStringType.OnRestart,
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
])("when current timeout is %s, returns unchanged", async (currentTimeout) => {
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(result).toBe(currentTimeout);
});
});
describe("policy type: never", () => {
it("when current timeout is never, returns never or promoted value", async () => {
const expectedTimeout = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.Never,
);
expect(result).toBe(expectedTimeout);
});
it.each([
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
])("when current timeout is %s, returns unchanged", async (currentTimeout) => {
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(result).toBe(currentTimeout);
});
});
});
describe("setVaultTimeoutOptions", () => {
@ -405,6 +648,7 @@ describe("VaultTimeoutSettingsService", () => {
stateProvider,
logService,
defaultVaultTimeout,
sessionTimeoutTypeService,
);
}
});

View File

@ -1,14 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
EMPTY,
Observable,
catchError,
combineLatest,
defer,
distinctUntilChanged,
EMPTY,
firstValueFrom,
from,
map,
Observable,
shareReplay,
switchMap,
tap,
@ -23,7 +24,6 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management";
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { TokenService } from "../../../auth/abstractions/token.service";
@ -31,9 +31,15 @@ import { LogService } from "../../../platform/abstractions/log.service";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction";
import { MaximumSessionTimeoutPolicyData, SessionTimeoutTypeService } from "../../session-timeout";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "../types/vault-timeout.type";
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
@ -49,6 +55,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private stateProvider: StateProvider,
private logService: LogService,
private defaultVaultTimeout: VaultTimeout,
private sessionTimeoutTypeService: SessionTimeoutTypeService,
) {}
async setVaultTimeoutOptions(
@ -131,11 +138,25 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return combineLatest([
this.stateProvider.getUserState$(VAULT_TIMEOUT, userId),
this.getMaxVaultTimeoutPolicyByUserId$(userId),
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
]).pipe(
switchMap(([currentVaultTimeout, maxVaultTimeoutPolicy]) => {
return from(this.determineVaultTimeout(currentVaultTimeout, maxVaultTimeoutPolicy)).pipe(
switchMap(([currentVaultTimeout, maxSessionTimeoutPolicyData]) => {
this.logService.debug(
"[VaultTimeoutSettingsService] Current vault timeout is %o for user id %s, max session policy %o",
currentVaultTimeout,
userId,
maxSessionTimeoutPolicyData,
);
return from(
this.determineVaultTimeout(currentVaultTimeout, maxSessionTimeoutPolicyData),
).pipe(
tap((vaultTimeout: VaultTimeout) => {
this.logService.debug(
"[VaultTimeoutSettingsService] Determined vault timeout is %o for user id %s",
vaultTimeout,
userId,
);
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
if (vaultTimeout !== currentVaultTimeout) {
return this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId);
@ -155,28 +176,63 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private async determineVaultTimeout(
currentVaultTimeout: VaultTimeout | null,
maxVaultTimeoutPolicy: Policy | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout | null> {
// if current vault timeout is null, apply the client specific default
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
// If no policy applies, return the current vault timeout
if (!maxVaultTimeoutPolicy) {
if (maxSessionTimeoutPolicyData == null) {
return currentVaultTimeout;
}
// User is subject to a max vault timeout policy
const maxVaultTimeoutPolicyData = maxVaultTimeoutPolicy.data;
// If the current vault timeout is not numeric, change it to the policy compliant value
if (typeof currentVaultTimeout === "string") {
return maxVaultTimeoutPolicyData.minutes;
switch (maxSessionTimeoutPolicyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
case "custom":
case null:
case undefined:
if (currentVaultTimeout === VaultTimeoutNumberType.Immediately) {
return currentVaultTimeout;
}
if (isVaultTimeoutTypeNumeric(currentVaultTimeout)) {
return Math.min(currentVaultTimeout as number, maxSessionTimeoutPolicyData.minutes);
}
return maxSessionTimeoutPolicyData.minutes;
case "onSystemLock":
if (
currentVaultTimeout === VaultTimeoutStringType.Never ||
currentVaultTimeout === VaultTimeoutStringType.OnRestart ||
currentVaultTimeout === VaultTimeoutStringType.OnLocked ||
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
}
break;
case "onAppRestart":
if (
currentVaultTimeout === VaultTimeoutStringType.Never ||
currentVaultTimeout === VaultTimeoutStringType.OnLocked ||
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
) {
return VaultTimeoutStringType.OnRestart;
}
break;
case "never":
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.Never,
);
}
break;
}
// For numeric vault timeouts, ensure they are smaller than maximum allowed value according to policy
const policyCompliantTimeout = Math.min(currentVaultTimeout, maxVaultTimeoutPolicyData.minutes);
return policyCompliantTimeout;
return currentVaultTimeout;
}
private async setVaultTimeoutAction(userId: UserId, action: VaultTimeoutAction): Promise<void> {
@ -198,14 +254,14 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return combineLatest([
this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId),
this.getMaxVaultTimeoutPolicyByUserId$(userId),
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
]).pipe(
switchMap(([currentVaultTimeoutAction, maxVaultTimeoutPolicy]) => {
switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => {
return from(
this.determineVaultTimeoutAction(
userId,
currentVaultTimeoutAction,
maxVaultTimeoutPolicy,
maxSessionTimeoutPolicyData,
),
).pipe(
tap((vaultTimeoutAction: VaultTimeoutAction) => {
@ -235,7 +291,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private async determineVaultTimeoutAction(
userId: string,
currentVaultTimeoutAction: VaultTimeoutAction | null,
maxVaultTimeoutPolicy: Policy | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeoutAction> {
const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId);
if (availableVaultTimeoutActions.length === 1) {
@ -243,11 +299,13 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
if (
maxVaultTimeoutPolicy?.data?.action &&
availableVaultTimeoutActions.includes(maxVaultTimeoutPolicy.data.action)
maxSessionTimeoutPolicyData?.action &&
availableVaultTimeoutActions.includes(
maxSessionTimeoutPolicyData.action as VaultTimeoutAction,
)
) {
// return policy defined vault timeout action
return maxVaultTimeoutPolicy.data.action;
// return policy defined session timeout action
return maxSessionTimeoutPolicyData.action as VaultTimeoutAction;
}
// No policy applies from here on
@ -262,14 +320,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return currentVaultTimeoutAction;
}
private getMaxVaultTimeoutPolicyByUserId$(userId: UserId): Observable<Policy | null> {
private getMaxSessionTimeoutPolicyDataByUserId$(
userId: UserId,
): Observable<MaximumSessionTimeoutPolicyData | null> {
if (!userId) {
throw new Error("User id required. Cannot get max vault timeout policy.");
throw new Error("User id required. Cannot get max session timeout policy.");
}
return this.policyService
.policiesByType$(PolicyType.MaximumVaultTimeout, userId)
.pipe(getFirstPolicy);
return this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId).pipe(
getFirstPolicy,
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
);
}
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {

View File

@ -1,6 +0,0 @@
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
export interface MaximumVaultTimeoutPolicyData {
minutes: number;
action?: VaultTimeoutAction;
}

View File

@ -5,13 +5,25 @@ export const VaultTimeoutStringType = {
OnLocked: "onLocked", // -2
OnSleep: "onSleep", // -3
OnIdle: "onIdle", // -4
Custom: "custom", // -100
} as const;
export const VaultTimeoutNumberType = {
Immediately: 0,
OnMinute: 1,
EightHours: 480,
} as const;
export type VaultTimeout =
| number // 0 or positive numbers only
| (typeof VaultTimeoutNumberType)[keyof typeof VaultTimeoutNumberType]
| number // 0 or positive numbers (in minutes). See VaultTimeoutNumberType for common numeric presets
| (typeof VaultTimeoutStringType)[keyof typeof VaultTimeoutStringType];
export interface VaultTimeoutOption {
name: string;
value: VaultTimeout;
}
export function isVaultTimeoutTypeNumeric(timeout: VaultTimeout): boolean {
return typeof timeout === "number";
}

View File

@ -12,3 +12,4 @@ export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-
export { SessionTimeoutSettingsComponent } from "./session-timeout/components/session-timeout-settings.component";
export { SessionTimeoutSettingsComponentService } from "./session-timeout/services/session-timeout-settings-component.service";
export { SessionTimeoutInputComponent } from "./session-timeout/components/session-timeout-input.component";
export { SessionTimeoutInputLegacyComponent } from "./session-timeout/components/session-timeout-input-legacy.component";

View File

@ -0,0 +1,47 @@
<div [formGroup]="form" class="tw-mb-4">
<bit-form-field [disableMargin]="!showCustom">
<bit-label>{{ "vaultTimeout1" | i18n }}</bit-label>
<bit-select formControlName="vaultTimeout">
<bit-option
*ngFor="let o of filteredVaultTimeoutOptions"
[value]="o.value"
[label]="o.name"
></bit-option>
</bit-select>
</bit-form-field>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" *ngIf="showCustom" formGroupName="custom">
<bit-form-field class="tw-col-span-6" disableMargin>
<input
bitInput
type="number"
min="0"
formControlName="hours"
aria-labelledby="maximum-error"
/>
<bit-label>{{ "hours" | i18n }}</bit-label>
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-end" disableMargin>
<input
bitInput
type="number"
min="0"
name="minutes"
formControlName="minutes"
aria-labelledby="maximum-error"
/>
<bit-label>{{ "minutes" | i18n }}</bit-label>
</bit-form-field>
</div>
<bit-hint *ngIf="vaultTimeoutPolicy != null && !exceedsMaximumTimeout">
{{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }}
</bit-hint>
<small *ngIf="!exceedsMinimumTimeout" class="tw-text-danger">
<i class="bwi bwi-error" aria-hidden="true"></i> {{ "vaultCustomTimeoutMinimum" | i18n }}
</small>
<small class="tw-text-danger" *ngIf="exceedsMaximumTimeout" id="maximum-error">
<i class="bwi bwi-error" aria-hidden="true"></i>
{{
"vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes
}}
</small>
</div>

View File

@ -0,0 +1,296 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, OnDestroy, OnInit } from "@angular/core";
import {
AbstractControl,
ControlValueAccessor,
FormBuilder,
FormControl,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
ValidationErrors,
Validator,
} from "@angular/forms";
import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutSettingsService,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FormFieldModule, SelectModule } from "@bitwarden/components";
type VaultTimeoutForm = FormGroup<{
vaultTimeout: FormControl<VaultTimeout | null>;
custom: FormGroup<{
hours: FormControl<number | null>;
minutes: FormControl<number | null>;
}>;
}>;
type VaultTimeoutFormValue = VaultTimeoutForm["value"];
/**
* @deprecated Use {@link SessionTimeoutInputComponent} instead.
*
* TODO Cleanup once feature flag enabled: https://bitwarden.atlassian.net/browse/PM-27297
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-session-timeout-input-legacy",
templateUrl: "session-timeout-input-legacy.component.html",
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: SessionTimeoutInputLegacyComponent,
},
{
provide: NG_VALIDATORS,
multi: true,
useExisting: SessionTimeoutInputLegacyComponent,
},
],
})
export class SessionTimeoutInputLegacyComponent
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
{
static CUSTOM_VALUE = -100;
static MIN_CUSTOM_MINUTES = 0;
form: VaultTimeoutForm = this.formBuilder.group({
vaultTimeout: [null],
custom: this.formBuilder.group({
hours: [null],
minutes: [null],
}),
});
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() vaultTimeoutOptions: VaultTimeoutOption[];
vaultTimeoutPolicy: Policy;
vaultTimeoutPolicyHours: number;
vaultTimeoutPolicyMinutes: number;
protected readonly VaultTimeoutAction = VaultTimeoutAction;
protected canLockVault$: Observable<boolean>;
private onChange: (vaultTimeout: VaultTimeout) => void;
private validatorChange: () => void;
private destroy$ = new Subject<void>();
constructor(
private formBuilder: FormBuilder,
private policyService: PolicyService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private i18nService: I18nService,
private accountService: AccountService,
) {}
get showCustom() {
return this.form.get("vaultTimeout").value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE;
}
get exceedsMinimumTimeout(): boolean {
return (
!this.showCustom ||
this.customTimeInMinutes() > SessionTimeoutInputLegacyComponent.MIN_CUSTOM_MINUTES
);
}
get exceedsMaximumTimeout(): boolean {
return (
this.showCustom &&
this.customTimeInMinutes() >
this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours
);
}
get filteredVaultTimeoutOptions(): VaultTimeoutOption[] {
// by policy max value
if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) {
return this.vaultTimeoutOptions;
}
return this.vaultTimeoutOptions.filter((option) => {
if (typeof option.value === "number") {
return option.value <= this.vaultTimeoutPolicy.data.minutes;
}
return false;
});
}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
filter((policy) => policy != null),
takeUntil(this.destroy$),
)
.subscribe((policy) => {
this.vaultTimeoutPolicy = policy;
this.applyVaultTimeoutPolicy();
});
this.form.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value: VaultTimeoutFormValue) => {
if (this.onChange) {
this.onChange(this.getVaultTimeout(value));
}
});
// Assign the current value to the custom fields
// so that if the user goes from a numeric value to custom
// we can initialize the custom fields with the current value
// ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields
this.form.controls.vaultTimeout.valueChanges
.pipe(
filter((value) => value !== SessionTimeoutInputLegacyComponent.CUSTOM_VALUE),
takeUntil(this.destroy$),
)
.subscribe((value) => {
const current = typeof value === "string" ? 0 : Math.max(value, 0);
// This cannot emit an event b/c it would cause form.valueChanges to fire again
// and we are already handling that above so just silently update
// custom fields when vaultTimeout changes to a non-custom value
this.form.patchValue(
{
custom: {
hours: Math.floor(current / 60),
minutes: current % 60,
},
},
{ emitEvent: false },
);
});
this.canLockVault$ = this.vaultTimeoutSettingsService
.availableVaultTimeoutActions$()
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
ngOnChanges() {
if (
!this.vaultTimeoutOptions.find(
(p) => p.value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE,
)
) {
this.vaultTimeoutOptions.push({
name: this.i18nService.t("custom"),
value: SessionTimeoutInputLegacyComponent.CUSTOM_VALUE,
});
}
}
getVaultTimeout(value: VaultTimeoutFormValue) {
if (value.vaultTimeout !== SessionTimeoutInputLegacyComponent.CUSTOM_VALUE) {
return value.vaultTimeout;
}
return value.custom.hours * 60 + value.custom.minutes;
}
writeValue(value: number): void {
if (value == null) {
return;
}
if (this.vaultTimeoutOptions.every((p) => p.value !== value)) {
this.form.setValue({
vaultTimeout: SessionTimeoutInputLegacyComponent.CUSTOM_VALUE,
custom: {
hours: Math.floor(value / 60),
minutes: value % 60,
},
});
return;
}
this.form.patchValue({
vaultTimeout: value,
});
}
registerOnChange(onChange: any): void {
this.onChange = onChange;
}
registerOnTouched(onTouched: any): void {
// Empty
}
setDisabledState?(isDisabled: boolean): void {
// Empty
}
validate(control: AbstractControl): ValidationErrors {
if (this.vaultTimeoutPolicy && this.vaultTimeoutPolicy?.data?.minutes < control.value) {
return { policyError: true };
}
if (!this.exceedsMinimumTimeout) {
return { minTimeoutError: true };
}
return null;
}
registerOnValidatorChange(fn: () => void): void {
this.validatorChange = fn;
}
private customTimeInMinutes() {
return this.form.value.custom.hours * 60 + this.form.value.custom.minutes;
}
private applyVaultTimeoutPolicy() {
this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60);
this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60;
this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => {
// Always include the custom option
if (vaultTimeoutOption.value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE) {
return true;
}
if (typeof vaultTimeoutOption.value === "number") {
// Include numeric values that are less than or equal to the policy minutes
return vaultTimeoutOption.value <= this.vaultTimeoutPolicy.data.minutes;
}
// Exclude all string cases when there's a numeric policy defined
return false;
});
// Only call validator change if it's been set
if (this.validatorChange) {
this.validatorChange();
}
}
}

View File

@ -1,47 +1,51 @@
<div [formGroup]="form" class="tw-mb-4">
<bit-form-field [disableMargin]="!showCustom">
<bit-form-field [disableMargin]="!isCustomTimeoutType">
<bit-label>{{ "vaultTimeout1" | i18n }}</bit-label>
<bit-select formControlName="vaultTimeout">
<bit-option
*ngFor="let o of filteredVaultTimeoutOptions"
[value]="o.value"
[label]="o.name"
></bit-option>
@for (option of availableTimeoutOptions(); track option.value) {
<bit-option [value]="option.value" [label]="option.name | i18n"></bit-option>
}
</bit-select>
</bit-form-field>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" *ngIf="showCustom" formGroupName="custom">
<bit-form-field class="tw-col-span-6" disableMargin>
<input
bitInput
type="number"
min="0"
formControlName="hours"
aria-labelledby="maximum-error"
/>
<bit-label>{{ "hours" | i18n }}</bit-label>
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-end" disableMargin>
<input
bitInput
type="number"
min="0"
name="minutes"
formControlName="minutes"
aria-labelledby="maximum-error"
/>
<bit-label>{{ "minutes" | i18n }}</bit-label>
</bit-form-field>
</div>
<bit-hint *ngIf="vaultTimeoutPolicy != null && !exceedsMaximumTimeout">
{{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }}
</bit-hint>
<small *ngIf="!exceedsMinimumTimeout" class="tw-text-danger">
<i class="bwi bwi-error" aria-hidden="true"></i> {{ "vaultCustomTimeoutMinimum" | i18n }}
</small>
<small class="tw-text-danger" *ngIf="exceedsMaximumTimeout" id="maximum-error">
<i class="bwi bwi-error" aria-hidden="true"></i>
{{
"vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes
}}
</small>
@if (isCustomTimeoutType) {
<div class="tw-grid tw-grid-cols-12 tw-gap-4" formGroupName="custom">
<bit-form-field class="tw-col-span-6" disableMargin>
<input
bitInput
type="number"
min="0"
formControlName="hours"
aria-describedby="session-timeout-maximum-error"
/>
<bit-label>{{ "hours" | i18n }}</bit-label>
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-start" disableMargin>
<input
bitInput
type="number"
[min]="customMinutesMin"
max="59"
formControlName="minutes"
aria-describedby="session-timeout-maximum-error"
/>
<bit-label>{{ "minutes" | i18n }}</bit-label>
</bit-form-field>
</div>
}
@if (form.hasError("maxTimeoutError")) {
<div class="tw-mt-1 tw-text-danger tw-text-xs" id="session-timeout-maximum-error">
<i class="bwi bwi-error" aria-hidden="true"></i>
{{
"sessionTimeoutSettingsPolicyMaximumError"
| i18n: maxSessionTimeoutPolicyHours : maxSessionTimeoutPolicyMinutes
}}
</div>
} @else if (maxSessionTimeoutPolicyData != null) {
@let policyTimeoutMessage = policyTimeoutMessage$ | async;
@if (policyTimeoutMessage != null) {
<bit-hint class="tw-mb-1" id="session-timeout-maximum-error">
{{ policyTimeoutMessage }}
</bit-hint>
}
}
</div>

View File

@ -1,90 +1,819 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { BehaviorSubject } from "rxjs";
import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
VaultTimeoutSettingsService,
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service";
import { SessionTimeoutInputComponent } from "./session-timeout-input.component";
describe("SessionTimeoutInputComponent", () => {
let component: SessionTimeoutInputComponent;
let fixture: ComponentFixture<SessionTimeoutInputComponent>;
const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({}));
const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([]));
const mockUserId = Utils.newGuid() as UserId;
const accountService = mockAccountServiceWith(mockUserId);
// Test constants
const MOCK_USER_ID = "user-id" as UserId;
const ONE_MINUTE = 1;
const FIVE_MINUTES = 5;
const FIFTEEN_MINUTES = 15;
const THIRTY_MINUTES = 30;
const ONE_HOUR = 60;
const FOUR_HOURS = 240;
const NINETY_MINUTES = 90;
// Mock services
let mockPolicyService: MockProxy<PolicyService>;
let mockSessionTimeoutSettingsComponentService: MockProxy<SessionTimeoutSettingsComponentService>;
let mockSessionTimeoutTypeService: MockProxy<SessionTimeoutTypeService>;
let mockI18nService: MockProxy<I18nService>;
let mockLogService: MockProxy<LogService>;
let accountService: AccountService;
// BehaviorSubjects for reactive testing
let policies$: BehaviorSubject<Policy[]>;
let availableTimeoutOptions: VaultTimeoutOption[];
beforeEach(async () => {
// Initialize BehaviorSubjects
policies$ = new BehaviorSubject<Policy[]>([]);
// Initialize available timeout options
availableTimeoutOptions = [
{ name: "oneMinute-used-i18n", value: ONE_MINUTE },
{ name: "fiveMinutes-used-i18n", value: FIVE_MINUTES },
{ name: "fifteenMinutes-used-i18n", value: FIFTEEN_MINUTES },
{ name: "thirtyMinutes-used-i18n", value: THIRTY_MINUTES },
{ name: "oneHour-used-i18n", value: ONE_HOUR },
{ name: "fourHours-used-i18n", value: FOUR_HOURS },
{ name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart },
{ name: "onLocked-used-i18n", value: VaultTimeoutStringType.OnLocked },
{ name: "never-used-i18n", value: VaultTimeoutStringType.Never },
];
// Initialize mocks
mockPolicyService = mock<PolicyService>();
mockPolicyService.policiesByType$.mockReturnValue(policies$.asObservable());
accountService = mockAccountServiceWith(MOCK_USER_ID);
mockI18nService = mock<I18nService>();
mockI18nService.t.mockImplementation((key, ...args) => {
if (args.length > 0) {
return `${key}-used-i18n-${args.join("-")}`;
}
return `${key}-used-i18n`;
});
mockLogService = mock<LogService>();
mockSessionTimeoutSettingsComponentService = mock<SessionTimeoutSettingsComponentService>();
mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockReturnValue(
of(availableTimeoutOptions),
);
mockSessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true);
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation(
async (timeout: VaultTimeout) => timeout,
);
await TestBed.configureTestingModule({
imports: [SessionTimeoutInputComponent],
providers: [
{ provide: PolicyService, useValue: { policiesByType$ } },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: AccountService, useValue: accountService },
{ provide: VaultTimeoutSettingsService, useValue: { availableVaultTimeoutActions$ } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: I18nService, useValue: mockI18nService },
{ provide: LogService, useValue: mockLogService },
{
provide: SessionTimeoutSettingsComponentService,
useValue: mockSessionTimeoutSettingsComponentService,
},
{ provide: SessionTimeoutTypeService, useValue: mockSessionTimeoutTypeService },
],
}).compileComponents();
fixture = TestBed.createComponent(SessionTimeoutInputComponent);
component = fixture.componentInstance;
component.vaultTimeoutOptions = [
{ name: "oneMinute", value: 1 },
{ name: "fiveMinutes", value: 5 },
{ name: "fifteenMinutes", value: 15 },
{ name: "thirtyMinutes", value: 30 },
{ name: "oneHour", value: 60 },
{ name: "fourHours", value: 240 },
{ name: "onRefresh", value: VaultTimeoutStringType.OnRestart },
];
fixture.detectChanges();
fixture.componentRef.setInput("availableTimeoutOptions", availableTimeoutOptions);
});
describe("form", () => {
beforeEach(async () => {
await component.ngOnInit();
it("should create", () => {
expect(component).toBeTruthy();
});
describe("ngOnInit", () => {
describe("policy data subscription and initialization", () => {
it("should initialize maxSessionTimeoutPolicyData to null when no policy exists", fakeAsync(() => {
fixture.detectChanges();
flush();
expect(component["maxSessionTimeoutPolicyData"]).toBeNull();
}));
it("should set maxSessionTimeoutPolicyData when policy exists", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: NINETY_MINUTES,
};
fixture.detectChanges();
flush();
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component["maxSessionTimeoutPolicyData"]).toEqual(policyData);
}));
it("should trigger validatorChange callback when policy data changes", fakeAsync(() => {
const validatorChangeFn = jest.fn();
component.registerOnValidatorChange(validatorChangeFn);
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: NINETY_MINUTES,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(validatorChangeFn).toHaveBeenCalled();
}));
it("should update form validation when policy data changes", fakeAsync(() => {
const updateSpy = jest.spyOn(component.form.controls.custom, "updateValueAndValidity");
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: FIFTEEN_MINUTES,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(updateSpy).toHaveBeenCalled();
}));
});
it("invokes the onChange associated with `ControlValueAccessor`", () => {
const onChange = jest.fn();
component.registerOnChange(onChange);
describe("policyTimeoutMessage$ observable", () => {
it("should emit custom timeout message when policy has custom type", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: NINETY_MINUTES,
};
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnRestart);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
expect(onChange).toHaveBeenCalledWith(VaultTimeoutStringType.OnRestart);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe(
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes-used-i18n-1-30",
);
}));
it("should emit immediately message when policy has immediately type", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "immediately",
minutes: 0,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe(
"sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately-used-i18n",
);
}));
it("should emit onLocked message when policy has onSystemLock type", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onSystemLock",
minutes: 0,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked-used-i18n");
}));
it("should emit onRestart message when policy has onAppRestart type", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onAppRestart",
minutes: 0,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n");
}));
it("should emit null when policy has never type and promoted value is Never", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "never",
minutes: 0,
};
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
VaultTimeoutStringType.Never,
);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = "initial";
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBeNull();
}));
it("should emit numeric timeout message when immediately is promoted to 1 minute", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "immediately",
minutes: 0,
};
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(ONE_MINUTE);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe(
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes-used-i18n-0-1",
);
}));
it("should emit onRestart message when onSystemLock is promoted to OnRestart", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onSystemLock",
minutes: 0,
};
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
VaultTimeoutStringType.OnRestart,
);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n");
}));
it("should emit onRestart message when never is promoted to OnRestart", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "never",
minutes: 0,
};
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
VaultTimeoutStringType.OnRestart,
);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
fixture.detectChanges();
flush();
let message: string | null = null;
component["policyTimeoutMessage$"].subscribe((msg) => (message = msg));
flush();
expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n");
}));
});
it("updates custom value to match preset option", () => {
// 1 hour
component.form.controls.vaultTimeout.setValue(60);
describe("form value changes subscription", () => {
it("should call onChange with vault timeout when form is valid and in custom mode", fakeAsync(() => {
const onChange = jest.fn();
component.registerOnChange(onChange);
expect(component.form.value.custom).toEqual({ hours: 1, minutes: 0 });
fixture.detectChanges();
flush();
// 17 minutes
component.form.controls.vaultTimeout.setValue(17);
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 1, minutes: 30 });
flush();
expect(component.form.value.custom).toEqual({ hours: 0, minutes: 17 });
expect(onChange).toHaveBeenCalledWith(NINETY_MINUTES);
}));
// 2.25 hours
component.form.controls.vaultTimeout.setValue(135);
it("should call onChange when form changes to non-custom mode", fakeAsync(() => {
const onChange = jest.fn();
component.registerOnChange(onChange);
expect(component.form.value.custom).toEqual({ hours: 2, minutes: 15 });
fixture.detectChanges();
flush();
onChange.mockClear();
component.form.controls.vaultTimeout.setValue(FIFTEEN_MINUTES);
flush();
expect(onChange).toHaveBeenCalledWith(FIFTEEN_MINUTES);
}));
it("should not call onChange when custom controls are invalid", fakeAsync(() => {
const onChange = jest.fn();
component.registerOnChange(onChange);
fixture.detectChanges();
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
flush();
onChange.mockClear();
component.form.controls.custom.controls.hours.setValue(null);
flush();
expect(onChange).not.toHaveBeenCalled();
}));
it("should not call onChange when vaultTimeout is null", fakeAsync(() => {
const onChange = jest.fn();
component.registerOnChange(onChange);
fixture.detectChanges();
flush();
onChange.mockClear();
component.form.controls.vaultTimeout.setValue(null);
flush();
expect(onChange).not.toHaveBeenCalled();
}));
});
it("sets custom timeout to 0 when a preset string option is selected", () => {
// Set custom value to random values
component.form.controls.custom.setValue({ hours: 1, minutes: 1 });
describe("custom fields initialization from vaultTimeout changes", () => {
it("should update custom fields when vaultTimeout changes to numeric value", fakeAsync(() => {
fixture.detectChanges();
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnLocked);
component.form.controls.vaultTimeout.setValue(NINETY_MINUTES);
flush();
expect(component.form.value.custom).toEqual({ hours: 0, minutes: 0 });
expect(component.form.value.custom).toEqual({ hours: 1, minutes: 30 });
}));
it.each([
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnIdle,
])(
"should set custom fields to 8 hours when vaultTimeout changes to %s",
fakeAsync((timeoutType: VaultTimeout) => {
fixture.detectChanges();
flush();
component.form.controls.custom.setValue({ hours: 1, minutes: 30 });
component.form.controls.vaultTimeout.setValue(timeoutType);
flush();
expect(component.form.value.custom).toEqual({ hours: 8, minutes: 0 });
}),
);
it("should mark custom fields as touched after update", fakeAsync(() => {
fixture.detectChanges();
flush();
component.form.controls.vaultTimeout.setValue(ONE_HOUR);
flush();
expect(component.form.controls.custom.controls.hours.touched).toBe(true);
expect(component.form.controls.custom.controls.minutes.touched).toBe(true);
}));
it("should not update custom fields when vaultTimeout changes to Custom", fakeAsync(() => {
fixture.detectChanges();
flush();
component.form.controls.custom.setValue({ hours: 5, minutes: 15 });
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
flush();
expect(component.form.value.custom).toEqual({ hours: 5, minutes: 15 });
}));
});
});
describe("isCustomTimeoutType", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return true when vaultTimeout is Custom", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
flush();
expect(component.isCustomTimeoutType).toBe(true);
}));
it.each([
ONE_MINUTE,
FIFTEEN_MINUTES,
ONE_HOUR,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Never,
])(
"should return false when vaultTimeout is %s",
fakeAsync((timeout: VaultTimeout) => {
component.form.controls.vaultTimeout.setValue(timeout);
flush();
expect(component.isCustomTimeoutType).toBe(false);
}),
);
});
describe("customMinutesMin", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return 1 when hours is 0", fakeAsync(() => {
component.form.controls.custom.controls.hours.setValue(0);
flush();
expect(component.customMinutesMin).toBe(1);
}));
it.each([1, 2, 5, 10])(
"should return 0 when hours is %s",
fakeAsync((hours: number) => {
component.form.controls.custom.controls.hours.setValue(hours);
flush();
expect(component.customMinutesMin).toBe(0);
}),
);
});
describe("maxSessionTimeoutPolicyHours", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return 0 when no policy exists", fakeAsync(() => {
expect(component.maxSessionTimeoutPolicyHours).toBe(0);
}));
it.each([
{ minutes: ONE_HOUR, expectedHours: 1 },
{ minutes: NINETY_MINUTES, expectedHours: 1 },
{ minutes: FOUR_HOURS, expectedHours: 4 },
{ minutes: 300, expectedHours: 5 },
])(
"should return $expectedHours when policy minutes is $minutes",
fakeAsync(({ minutes, expectedHours }: { minutes: number; expectedHours: number }) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.maxSessionTimeoutPolicyHours).toBe(expectedHours);
}),
);
});
describe("maxSessionTimeoutPolicyMinutes", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return 0 when no policy exists", fakeAsync(() => {
expect(component.maxSessionTimeoutPolicyMinutes).toBe(0);
}));
it.each([
{ minutes: ONE_HOUR, expectedMinutes: 0 },
{ minutes: NINETY_MINUTES, expectedMinutes: 30 },
{ minutes: 65, expectedMinutes: 5 },
{ minutes: 137, expectedMinutes: 17 },
])(
"should return $expectedMinutes when policy minutes is $minutes",
fakeAsync(({ minutes, expectedMinutes }: { minutes: number; expectedMinutes: number }) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.maxSessionTimeoutPolicyMinutes).toBe(expectedMinutes);
}),
);
});
describe("exceedsPolicyMaximumTimeout", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return true when custom timeout exceeds policy maximum", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: ONE_HOUR,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 2, minutes: 0 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(true);
}));
it("should return false when no policy exists", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 100, minutes: 0 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
it("should return false when policy type is not custom", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "immediately",
minutes: 0,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 10, minutes: 0 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
it("should return false when policy type is custom and form timeout is not custom", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: FIFTEEN_MINUTES,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(ONE_HOUR);
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
it("should return false when custom timeout equals policy maximum", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: ONE_HOUR,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 1, minutes: 0 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
it("should return false when custom timeout is below policy maximum", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: FOUR_HOURS,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 2, minutes: 30 });
flush();
expect(component.exceedsPolicyMaximumTimeout).toBe(false);
}));
});
describe("writeValue", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should do nothing when value is null", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(ONE_HOUR);
flush();
component.writeValue(null);
flush();
expect(component.form.controls.vaultTimeout.value).toBe(ONE_HOUR);
}));
it("should set form to custom mode when value doesn't match any available option", fakeAsync(() => {
component.writeValue(NINETY_MINUTES);
flush();
expect(component.form.controls.vaultTimeout.value).toBe(VaultTimeoutStringType.Custom);
expect(component.form.controls.custom.value).toEqual({ hours: 1, minutes: 30 });
}));
it.each([ONE_MINUTE, FIVE_MINUTES, FIFTEEN_MINUTES, THIRTY_MINUTES, ONE_HOUR, FOUR_HOURS])(
"should set vaultTimeout directly when numeric value %s matches preset option",
fakeAsync((timeout: number) => {
component.writeValue(timeout);
flush();
expect(component.form.controls.vaultTimeout.value).toBe(timeout);
}),
);
it.each([
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.Never,
])(
"should set vaultTimeout directly when string value %s matches preset option",
fakeAsync((timeout: VaultTimeout) => {
component.writeValue(timeout);
flush();
expect(component.form.controls.vaultTimeout.value).toBe(timeout);
}),
);
});
describe("validate", () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
flush();
}));
it("should return null when vaultTimeout is not custom", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(ONE_HOUR);
flush();
const result = component.validate(component.form);
expect(result).toBeNull();
}));
it("should return required error when vaultTimeout is custom and hours is null", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.controls.hours.setValue(null);
component.form.controls.custom.controls.minutes.setValue(30);
flush();
const result = component.validate(component.form);
expect(result).toEqual({ required: true });
}));
it("should return required error when vaultTimeout is custom and minutes is null", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.controls.hours.setValue(1);
component.form.controls.custom.controls.minutes.setValue(null);
flush();
const result = component.validate(component.form);
expect(result).toEqual({ required: true });
}));
it("should return required error when vaultTimeout is custom and both hours and minutes are null", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.controls.hours.setValue(null);
component.form.controls.custom.controls.minutes.setValue(null);
flush();
const result = component.validate(component.form);
expect(result).toEqual({ required: true });
}));
it("should return minTimeoutError when total minutes is 0", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 0, minutes: 0 });
flush();
const result = component.validate(component.form);
expect(result).toEqual({ minTimeoutError: true });
}));
it("should return maxTimeoutError when exceeds policy maximum", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: ONE_HOUR,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 2, minutes: 0 });
flush();
const result = component.validate(component.form);
expect(result).toEqual({ maxTimeoutError: true });
}));
it("should return null when custom values are valid and within policy limit", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "custom",
minutes: FOUR_HOURS,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 2, minutes: 30 });
flush();
const result = component.validate(component.form);
expect(result).toBeNull();
}));
it("should return null when custom values are valid and no policy exists", fakeAsync(() => {
component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom);
component.form.controls.custom.setValue({ hours: 5, minutes: 15 });
flush();
const result = component.validate(component.form);
expect(result).toBeNull();
}));
});
});

View File

@ -1,9 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, OnDestroy, OnInit } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
input,
OnInit,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
AbstractControl,
AbstractControlOptions,
ControlValueAccessor,
FormBuilder,
FormControl,
@ -13,26 +20,32 @@ import {
ReactiveFormsModule,
ValidationErrors,
Validator,
Validators,
} from "@angular/forms";
import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
import { filter, map, Observable, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutSettingsService,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FormFieldModule, SelectModule } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
type VaultTimeoutForm = FormGroup<{
type SessionTimeoutForm = FormGroup<{
vaultTimeout: FormControl<VaultTimeout | null>;
custom: FormGroup<{
hours: FormControl<number | null>;
@ -40,10 +53,8 @@ type VaultTimeoutForm = FormGroup<{
}>;
}>;
type VaultTimeoutFormValue = VaultTimeoutForm["value"];
type SessionTimeoutFormValue = SessionTimeoutForm["value"];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-session-timeout-input",
templateUrl: "session-timeout-input.component.html",
@ -60,111 +71,110 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"];
useExisting: SessionTimeoutInputComponent,
},
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SessionTimeoutInputComponent
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
{
static CUSTOM_VALUE = -100;
static MIN_CUSTOM_MINUTES = 0;
form: VaultTimeoutForm = this.formBuilder.group({
vaultTimeout: [null],
custom: this.formBuilder.group({
hours: [null],
minutes: [null],
}),
});
export class SessionTimeoutInputComponent implements ControlValueAccessor, Validator, OnInit {
static readonly MIN_CUSTOM_MINUTES = 0;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() vaultTimeoutOptions: VaultTimeoutOption[];
private readonly formBuilder = inject(FormBuilder);
private readonly policyService = inject(PolicyService);
private readonly i18nService = inject(I18nService);
private readonly accountService = inject(AccountService);
private readonly destroyRef = inject(DestroyRef);
private readonly sessionTimeoutTypeService = inject(SessionTimeoutTypeService);
private readonly logService = inject(LogService);
vaultTimeoutPolicy: Policy;
vaultTimeoutPolicyHours: number;
vaultTimeoutPolicyMinutes: number;
readonly availableTimeoutOptions = input.required<VaultTimeoutOption[]>();
protected readonly VaultTimeoutAction = VaultTimeoutAction;
protected maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null = null;
protected policyTimeoutMessage$!: Observable<string | null>;
protected canLockVault$: Observable<boolean>;
private onChange: (vaultTimeout: VaultTimeout) => void;
private validatorChange: () => void;
private destroy$ = new Subject<void>();
readonly form: SessionTimeoutForm = this.formBuilder.group(
{
vaultTimeout: [null as VaultTimeout | null],
custom: this.formBuilder.group({
hours: [0, [Validators.required, Validators.min(0)]],
minutes: [0, [Validators.required, Validators.min(0), Validators.max(59)]],
}),
},
{ validators: [this.formValidator.bind(this)] } as AbstractControlOptions,
);
constructor(
private formBuilder: FormBuilder,
private policyService: PolicyService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private i18nService: I18nService,
private accountService: AccountService,
) {}
private onChange: ((vaultTimeout: VaultTimeout) => void) | null = null;
private validatorChange: (() => void) | null = null;
get showCustom() {
return this.form.get("vaultTimeout").value === SessionTimeoutInputComponent.CUSTOM_VALUE;
get isCustomTimeoutType(): boolean {
return this.form.controls.vaultTimeout.value === VaultTimeoutStringType.Custom;
}
get exceedsMinimumTimeout(): boolean {
get customMinutesMin(): number {
return this.form.controls.custom.controls.hours.value === 0 ? 1 : 0;
}
get exceedsPolicyMaximumTimeout(): boolean {
return (
!this.showCustom ||
this.customTimeInMinutes() > SessionTimeoutInputComponent.MIN_CUSTOM_MINUTES
this.maxSessionTimeoutPolicyData?.type === VaultTimeoutStringType.Custom &&
this.isCustomTimeoutType &&
this.getTotalMinutesFromCustomValue(this.form.value.custom) >
this.maxSessionTimeoutPolicyMinutes + 60 * this.maxSessionTimeoutPolicyHours
);
}
get exceedsMaximumTimeout(): boolean {
return (
this.showCustom &&
this.customTimeInMinutes() >
this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours
ngOnInit(): void {
const maximumSessionTimeoutPolicyData$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
filter((policy) => policy != null),
map((policy) => policy.data as MaximumSessionTimeoutPolicyData),
);
}
get filteredVaultTimeoutOptions(): VaultTimeoutOption[] {
// by policy max value
if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) {
return this.vaultTimeoutOptions;
}
this.policyTimeoutMessage$ = maximumSessionTimeoutPolicyData$.pipe(
switchMap((policyData) => this.getPolicyTimeoutMessage(policyData)),
);
return this.vaultTimeoutOptions.filter((option) => {
if (typeof option.value === "number") {
return option.value <= this.vaultTimeoutPolicy.data.minutes;
}
return false;
});
}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
filter((policy) => policy != null),
takeUntil(this.destroy$),
)
.subscribe((policy) => {
this.vaultTimeoutPolicy = policy;
this.applyVaultTimeoutPolicy();
});
this.form.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value: VaultTimeoutFormValue) => {
if (this.onChange) {
this.onChange(this.getVaultTimeout(value));
maximumSessionTimeoutPolicyData$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((policyData) => {
this.maxSessionTimeoutPolicyData = policyData;
// Re-validate custom form group with new policy data
this.form.controls.custom.updateValueAndValidity();
// Trigger validator change when policy data changes
if (this.validatorChange) {
this.validatorChange();
}
});
// Subscribe to form value changes
this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value) => {
if (this.onChange) {
const vaultTimeout = this.getVaultTimeout(value);
if (vaultTimeout != null) {
// Only call onChange if the form is valid
// For non-numeric values, we don't need to validate custom fields
const isValid = !this.isCustomTimeoutType || this.form.controls.custom.valid;
if (isValid) {
this.onChange(vaultTimeout);
}
}
}
});
// Assign the current value to the custom fields
// so that if the user goes from a numeric value to custom
// we can initialize the custom fields with the current value
// ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields
this.form.controls.vaultTimeout.valueChanges
.pipe(
filter((value) => value !== SessionTimeoutInputComponent.CUSTOM_VALUE),
takeUntil(this.destroy$),
filter((value) => value != null && value !== VaultTimeoutStringType.Custom),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((value) => {
const current = typeof value === "string" ? 0 : Math.max(value, 0);
const current = isVaultTimeoutTypeNumeric(value)
? (value as number)
: VaultTimeoutNumberType.EightHours;
// This cannot emit an event b/c it would cause form.valueChanges to fire again
// and we are already handling that above so just silently update
@ -178,112 +188,169 @@ export class SessionTimeoutInputComponent
},
{ emitEvent: false },
);
this.form.controls.custom.markAllAsTouched();
});
this.canLockVault$ = this.vaultTimeoutSettingsService
.availableVaultTimeoutActions$()
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
get maxSessionTimeoutPolicyHours(): number {
return Math.floor((this.maxSessionTimeoutPolicyData?.minutes ?? 0) / 60);
}
ngOnChanges() {
if (
!this.vaultTimeoutOptions.find((p) => p.value === SessionTimeoutInputComponent.CUSTOM_VALUE)
) {
this.vaultTimeoutOptions.push({
name: this.i18nService.t("custom"),
value: SessionTimeoutInputComponent.CUSTOM_VALUE,
});
}
get maxSessionTimeoutPolicyMinutes(): number {
return (this.maxSessionTimeoutPolicyData?.minutes ?? 0) % 60;
}
getVaultTimeout(value: VaultTimeoutFormValue) {
if (value.vaultTimeout !== SessionTimeoutInputComponent.CUSTOM_VALUE) {
return value.vaultTimeout;
}
return value.custom.hours * 60 + value.custom.minutes;
}
writeValue(value: number): void {
writeValue(value: VaultTimeout | null): void {
if (value == null) {
return;
}
if (this.vaultTimeoutOptions.every((p) => p.value !== value)) {
// Normalize the custom numeric value to preset (i.e. 1 minute), otherwise set as custom
const options = this.availableTimeoutOptions();
const matchingOption = options.some((opt) => opt.value === value);
if (!matchingOption) {
this.logService.debug(
`[SessionTimeoutInputComponent] form control write value as custom ${value}`,
);
this.form.setValue({
vaultTimeout: SessionTimeoutInputComponent.CUSTOM_VALUE,
vaultTimeout: VaultTimeoutStringType.Custom,
custom: {
hours: Math.floor(value / 60),
minutes: value % 60,
hours: Math.floor((value as number) / 60),
minutes: (value as number) % 60,
},
});
return;
}
this.logService.debug(
`[SessionTimeoutInputComponent] form control write value as preset ${value}`,
);
// For string values (e.g., "onLocked", "never"), set directly
this.form.patchValue({
vaultTimeout: value,
});
}
registerOnChange(onChange: any): void {
registerOnChange(onChange: (vaultTimeout: VaultTimeout) => void): void {
this.onChange = onChange;
}
registerOnTouched(onTouched: any): void {
registerOnTouched(_onTouched: () => void): void {
// Empty
}
setDisabledState?(isDisabled: boolean): void {
setDisabledState?(_isDisabled: boolean): void {
// Empty
}
validate(control: AbstractControl): ValidationErrors {
if (this.vaultTimeoutPolicy && this.vaultTimeoutPolicy?.data?.minutes < control.value) {
return { policyError: true };
}
if (!this.exceedsMinimumTimeout) {
return { minTimeoutError: true };
}
return null;
validate(_: AbstractControl): ValidationErrors | null {
return this.form.errors;
}
registerOnValidatorChange(fn: () => void): void {
this.validatorChange = fn;
}
private customTimeInMinutes() {
return this.form.value.custom.hours * 60 + this.form.value.custom.minutes;
private getTotalMinutesFromCustomValue(customValue: SessionTimeoutFormValue["custom"]): number {
const hours = customValue?.hours ?? 0;
const minutes = customValue?.minutes ?? 0;
return hours * 60 + minutes;
}
private applyVaultTimeoutPolicy() {
this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60);
this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60;
private formValidator(control: AbstractControl): ValidationErrors | null {
const formValue = control.value as SessionTimeoutFormValue;
const isCustomMode = formValue.vaultTimeout === VaultTimeoutStringType.Custom;
this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => {
// Always include the custom option
if (vaultTimeoutOption.value === SessionTimeoutInputComponent.CUSTOM_VALUE) {
return true;
// Only validate when in custom mode
if (!isCustomMode) {
return null;
}
const hours = formValue.custom?.hours;
const minutes = formValue.custom?.minutes;
if (hours == null || minutes == null) {
return { required: true };
}
const totalMinutes = this.getTotalMinutesFromCustomValue(formValue.custom);
if (totalMinutes === 0) {
return { minTimeoutError: true };
}
if (this.exceedsPolicyMaximumTimeout) {
return { maxTimeoutError: true };
}
return null;
}
private getVaultTimeout(value: SessionTimeoutFormValue): VaultTimeout | null {
if (value.vaultTimeout !== VaultTimeoutStringType.Custom) {
return value.vaultTimeout ?? null;
}
return this.getTotalMinutesFromCustomValue(value.custom);
}
private async getPolicyTimeoutMessage(
policyData: MaximumSessionTimeoutPolicyData,
): Promise<string | null> {
const timeout = await this.getPolicyAppliedTimeout(policyData);
switch (timeout) {
case null:
// Don't display the policy message
return null;
case VaultTimeoutNumberType.Immediately:
return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately");
case VaultTimeoutStringType.OnLocked:
return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked");
case VaultTimeoutStringType.OnRestart:
return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart");
default:
if (isVaultTimeoutTypeNumeric(timeout)) {
const hours = Math.floor((timeout as number) / 60);
const minutes = (timeout as number) % 60;
return this.i18nService.t(
"sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes",
hours,
minutes,
);
}
throw new Error("Invalid timeout parameter");
}
}
private async getPolicyAppliedTimeout(
policyData: MaximumSessionTimeoutPolicyData,
): Promise<VaultTimeout | null> {
switch (policyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
case "onSystemLock":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
case "onAppRestart":
return VaultTimeoutStringType.OnRestart;
case "never": {
const timeout = await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.Never,
);
if (timeout == VaultTimeoutStringType.Never) {
// Don't display policy message, when the policy doesn't change the available timeout options
return null;
}
return timeout;
}
if (typeof vaultTimeoutOption.value === "number") {
// Include numeric values that are less than or equal to the policy minutes
return vaultTimeoutOption.value <= this.vaultTimeoutPolicy.data.minutes;
}
// Exclude all string cases when there's a numeric policy defined
return false;
});
// Only call validator change if it's been set
if (this.validatorChange) {
this.validatorChange();
case "custom":
default:
return policyData.minutes;
}
}
}

View File

@ -1,10 +1,11 @@
<div [formGroup]="formGroup">
<bit-session-timeout-input
[vaultTimeoutOptions]="availableTimeoutOptions$ | async"
[formControl]="formGroup.controls.timeout"
ngDefaultControl
>
</bit-session-timeout-input>
@if (availableTimeoutOptions$ | async; as options) {
<bit-session-timeout-input
[availableTimeoutOptions]="options"
[formControl]="formGroup.controls.timeout"
>
</bit-session-timeout-input>
}
<bit-form-field [disableMargin]="true">
<bit-label>{{ "sessionTimeoutSettingsAction" | i18n }}</bit-label>
@ -18,14 +19,10 @@
}
</bit-select>
@if (!canLock) {
<bit-hint>{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br /></bit-hint>
@if (!canLock && supportsLock) {
<bit-hint>{{ "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction" | i18n }}</bit-hint>
} @else if ((sessionTimeoutActionFromPolicy$ | async) != null) {
<bit-hint>{{ "sessionTimeoutSettingsManagedByOrganization" | i18n }}</bit-hint>
}
</bit-form-field>
@if (hasVaultTimeoutPolicy$ | async) {
<bit-hint class="tw-mt-4">
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
</bit-hint>
}
</div>

View File

@ -4,11 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
MaximumVaultTimeoutPolicyData,
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
@ -16,6 +20,7 @@ import {
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
@ -39,6 +44,8 @@ describe("SessionTimeoutSettingsComponent", () => {
let accountService: FakeAccountService;
let mockDialogService: MockProxy<DialogService>;
let mockLogService: MockProxy<LogService>;
const mockPlatformUtilsService = mock<PlatformUtilsService>();
const mockSessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
const mockUserId = "user-id" as UserId;
const mockEmail = "test@example.com";
@ -46,6 +53,7 @@ describe("SessionTimeoutSettingsComponent", () => {
const mockInitialTimeoutAction = VaultTimeoutAction.Lock;
let refreshTimeoutActionSettings$: BehaviorSubject<void>;
let availableTimeoutOptions$: BehaviorSubject<VaultTimeoutOption[]>;
let policies$: BehaviorSubject<Policy[]>;
beforeEach(async () => {
refreshTimeoutActionSettings$ = new BehaviorSubject<void>(undefined);
@ -58,6 +66,7 @@ describe("SessionTimeoutSettingsComponent", () => {
{ name: "onIdle-used-i18n", value: VaultTimeoutStringType.OnIdle },
{ name: "never-used-i18n", value: VaultTimeoutStringType.Never },
]);
policies$ = new BehaviorSubject<Policy[]>([]);
mockVaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
mockSessionTimeoutSettingsComponentService = mock<SessionTimeoutSettingsComponentService>();
@ -79,9 +88,10 @@ describe("SessionTimeoutSettingsComponent", () => {
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
);
mockSessionTimeoutSettingsComponentService.availableTimeoutOptions$ =
availableTimeoutOptions$.asObservable();
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockImplementation(
(userId) => availableTimeoutOptions$.asObservable(),
);
mockPolicyService.policiesByType$.mockReturnValue(policies$.asObservable());
await TestBed.configureTestingModule({
imports: [
@ -102,6 +112,8 @@ describe("SessionTimeoutSettingsComponent", () => {
{ provide: AccountService, useValue: accountService },
{ provide: LogService, useValue: mockLogService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: SessionTimeoutTypeService, useValue: mockSessionTimeoutTypeService },
],
})
.overrideComponent(SessionTimeoutSettingsComponent, {
@ -145,6 +157,83 @@ describe("SessionTimeoutSettingsComponent", () => {
}));
});
describe("supportsLock", () => {
it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli])(
"should return true when client is %s and policy action is null",
fakeAsync((clientType: ClientType) => {
mockPlatformUtilsService.getClientType.mockReturnValue(clientType);
fixture.detectChanges();
flush();
expect(component.supportsLock).toBe(true);
}),
);
it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli])(
"should return true when client is %s and policy action is lock",
fakeAsync((clientType: ClientType) => {
mockPlatformUtilsService.getClientType.mockReturnValue(clientType);
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.Lock,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.supportsLock).toBe(true);
}),
);
it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli, ClientType.Web])(
"should return false when client is %s and policy action is logOut",
fakeAsync((clientType: ClientType) => {
mockPlatformUtilsService.getClientType.mockReturnValue(clientType);
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.LogOut,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.supportsLock).toBe(false);
}),
);
it("should return false when client is Web and policy action is null", fakeAsync(() => {
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
fixture.detectChanges();
flush();
expect(component.supportsLock).toBe(false);
}));
it("should return false when client is Web and policy action is lock", fakeAsync(() => {
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
fixture.detectChanges();
flush();
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.Lock,
};
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component.supportsLock).toBe(false);
}));
});
describe("ngOnInit", () => {
it("should initialize available timeout options", fakeAsync(async () => {
fixture.detectChanges();
@ -178,7 +267,7 @@ describe("SessionTimeoutSettingsComponent", () => {
});
}));
it("should initialize available timeout actions", fakeAsync(() => {
it("should initialize available timeout actions signal", fakeAsync(() => {
const expectedActions = [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut];
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
@ -209,27 +298,55 @@ describe("SessionTimeoutSettingsComponent", () => {
expect(component.formGroup.value.timeoutAction).toBe(expectedAction);
}));
it("should fall back to OnRestart when current option is not available", fakeAsync(() => {
availableTimeoutOptions$.next([
{ name: "oneMinute-used-i18n", value: 1 },
{ name: "fiveMinutes-used-i18n", value: 5 },
{ name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart },
]);
it("should initialize userId from active account", fakeAsync(() => {
fixture.detectChanges();
flush();
const unavailableTimeout = VaultTimeoutStringType.Never;
expect(component["userId"]).toBe(mockUserId);
}));
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() =>
of(unavailableTimeout),
);
it("should initialize sessionTimeoutActionFromPolicy signal with null when no policy exists", fakeAsync(() => {
fixture.detectChanges();
flush();
expect(component["sessionTimeoutActionFromPolicy"]()).toBeNull();
}));
it.each([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut])(
"should initialize sessionTimeoutActionFromPolicy signal with policy action %s when policy exists",
fakeAsync((timeoutAction: VaultTimeoutAction) => {
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: timeoutAction,
};
fixture.detectChanges();
flush();
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component["sessionTimeoutActionFromPolicy"]()).toBe(timeoutAction);
}),
);
it("should initialize sessionTimeoutActionFromPolicy signal with null when policy exists and action is user preference", fakeAsync(() => {
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: null,
};
fixture.detectChanges();
flush();
expect(component.formGroup.value.timeout).toBe(VaultTimeoutStringType.OnRestart);
policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]);
flush();
expect(component["sessionTimeoutActionFromPolicy"]()).toBeNull();
}));
it("should disable timeout action control when policy enforces action", fakeAsync(() => {
const policyData: MaximumVaultTimeoutPolicyData = {
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.LogOut,
};
@ -273,7 +390,7 @@ describe("SessionTimeoutSettingsComponent", () => {
expect(component.formGroup.controls.timeoutAction.enabled).toBe(true);
const policyData: MaximumVaultTimeoutPolicyData = {
const policyData: MaximumSessionTimeoutPolicyData = {
minutes: 15,
action: VaultTimeoutAction.LogOut,
};
@ -355,6 +472,56 @@ describe("SessionTimeoutSettingsComponent", () => {
expect(saveSpy).toHaveBeenCalledWith(VaultTimeoutAction.LogOut);
}));
it("should sync form timeout when service emits new timeout value", fakeAsync(() => {
const timeout$ = new BehaviorSubject<VaultTimeout>(mockInitialTimeout);
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(timeout$);
fixture.detectChanges();
flush();
expect(component.formGroup.controls.timeout.value).toBe(mockInitialTimeout);
const newTimeout = 30;
timeout$.next(newTimeout);
flush();
expect(component.formGroup.controls.timeout.value).toBe(newTimeout);
}));
it("should not sync form timeout when service emits same timeout value", fakeAsync(() => {
const timeout$ = new BehaviorSubject<VaultTimeout>(mockInitialTimeout);
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(timeout$);
fixture.detectChanges();
flush();
const setValueSpy = jest.spyOn(component.formGroup.controls.timeout, "setValue");
timeout$.next(mockInitialTimeout);
flush();
expect(setValueSpy).not.toHaveBeenCalled();
}));
it("should update availableTimeoutActions signal when service emits new actions", fakeAsync(() => {
const actions$ = new BehaviorSubject<VaultTimeoutAction[]>([VaultTimeoutAction.Lock]);
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue(actions$);
fixture.detectChanges();
flush();
expect(component["availableTimeoutActions"]()).toEqual([VaultTimeoutAction.Lock]);
actions$.next([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]);
refreshTimeoutActionSettings$.next(undefined);
flush();
expect(component["availableTimeoutActions"]()).toEqual([
VaultTimeoutAction.Lock,
VaultTimeoutAction.LogOut,
]);
}));
});
describe("saveTimeout", () => {

View File

@ -1,6 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, input, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Component, DestroyRef, inject, input, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import {
FormControl,
FormGroup,
@ -18,27 +18,28 @@ import {
firstValueFrom,
map,
Observable,
of,
pairwise,
startWith,
switchMap,
tap,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ClientType } from "@bitwarden/client-type";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { MaximumSessionTimeoutPolicyData } from "@bitwarden/common/key-management/session-timeout";
import {
MaximumVaultTimeoutPolicyData,
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
CheckboxModule,
@ -86,6 +87,19 @@ export class SessionTimeoutSettingsComponent implements OnInit {
new BehaviorSubject<void>(undefined),
);
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private readonly sessionTimeoutSettingsComponentService = inject(
SessionTimeoutSettingsComponentService,
);
private readonly i18nService = inject(I18nService);
private readonly toastService = inject(ToastService);
private readonly policyService = inject(PolicyService);
private readonly accountService = inject(AccountService);
private readonly dialogService = inject(DialogService);
private readonly logService = inject(LogService);
private readonly destroyRef = inject(DestroyRef);
private readonly platformUtilsService = inject(PlatformUtilsService);
formGroup = new FormGroup({
timeout: new FormControl<VaultTimeout | null>(null, [Validators.required]),
timeoutAction: new FormControl<VaultTimeoutAction>(VaultTimeoutAction.Lock, [
@ -93,63 +107,48 @@ export class SessionTimeoutSettingsComponent implements OnInit {
]),
});
protected readonly availableTimeoutActions = signal<VaultTimeoutAction[]>([]);
protected readonly availableTimeoutOptions$ =
this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe(
startWith([] as VaultTimeoutOption[]),
);
protected hasVaultTimeoutPolicy$: Observable<boolean> = of(false);
protected readonly availableTimeoutOptions$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.sessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$(userId),
),
tap((options) => {
this.logService.debug("[SessionTimeoutSettings] Available timeout options", options);
}),
);
protected readonly sessionTimeoutActionFromPolicy$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
map((policy) => policy?.data as MaximumSessionTimeoutPolicyData | undefined),
map((data) => data?.action ?? null),
);
protected readonly sessionTimeoutActionFromPolicy = toSignal(
this.sessionTimeoutActionFromPolicy$,
);
private userId!: UserId;
constructor(
private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private readonly sessionTimeoutSettingsComponentService: SessionTimeoutSettingsComponentService,
private readonly i18nService: I18nService,
private readonly toastService: ToastService,
private readonly policyService: PolicyService,
private readonly accountService: AccountService,
private readonly dialogService: DialogService,
private readonly logService: LogService,
private readonly destroyRef: DestroyRef,
) {}
get canLock() {
return this.availableTimeoutActions().includes(VaultTimeoutAction.Lock);
}
get supportsLock() {
return (
this.platformUtilsService.getClientType() !== ClientType.Web &&
this.sessionTimeoutActionFromPolicy() !== "logOut"
);
}
async ngOnInit(): Promise<void> {
const availableTimeoutOptions = await firstValueFrom(
this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$,
);
this.logService.debug(
"[SessionTimeoutSettings] Available timeout options",
availableTimeoutOptions,
);
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const maximumVaultTimeoutPolicy$ = this.policyService
.policiesByType$(PolicyType.MaximumVaultTimeout, this.userId)
.pipe(getFirstPolicy);
this.hasVaultTimeoutPolicy$ = maximumVaultTimeoutPolicy$.pipe(map((policy) => policy != null));
let timeout = await firstValueFrom(
const timeout = await firstValueFrom(
this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(this.userId),
);
// Fallback if current timeout option is not available on this platform
// Only applies to string-based timeout types, not numeric values
const hasCurrentOption = availableTimeoutOptions.some((opt) => opt.value === timeout);
if (!hasCurrentOption && typeof timeout !== "number") {
this.logService.debug(
"[SessionTimeoutSettings] Current timeout option not available, falling back from",
{ timeout },
);
timeout = VaultTimeoutStringType.OnRestart;
}
this.formGroup.patchValue(
{
timeout: timeout,
@ -160,6 +159,23 @@ export class SessionTimeoutSettingsComponent implements OnInit {
{ emitEvent: false },
);
// Sync form with reactive timeout updates to handle race condition where policies
// load asynchronously and may override the initially set timeout value
this.vaultTimeoutSettingsService
.getVaultTimeoutByUserId$(this.userId)
.pipe(
filter((timeout) => this.formGroup.controls.timeout.value !== timeout),
tap((timeout) =>
this.logService.debug(
`[SessionTimeoutSettings] Updating initial form timeout from ${this.formGroup.controls.timeout.value} to ${timeout}`,
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((timeout) => {
this.formGroup.controls.timeout.setValue(timeout, { emitEvent: false });
});
this.refreshTimeoutActionSettings()
.pipe(
startWith(undefined),
@ -167,19 +183,17 @@ export class SessionTimeoutSettingsComponent implements OnInit {
combineLatest([
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId),
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
maximumVaultTimeoutPolicy$,
this.sessionTimeoutActionFromPolicy$,
]),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([availableActions, action, policy]) => {
.subscribe(([availableActions, action, sessionTimeoutActionFromPolicy]) => {
this.availableTimeoutActions.set(availableActions);
this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false });
const policyData = policy?.data as MaximumVaultTimeoutPolicyData | undefined;
// Enable/disable the action control based on policy or available actions
if (policyData?.action != null || availableActions.length <= 1) {
if (sessionTimeoutActionFromPolicy != null || availableActions.length <= 1) {
this.formGroup.controls.timeoutAction.disable({ emitEvent: false });
} else {
this.formGroup.controls.timeoutAction.enable({ emitEvent: false });

View File

@ -0,0 +1,337 @@
import { fakeAsync, flush } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import {
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { SessionTimeoutSettingsComponentService } from "./session-timeout-settings-component.service";
describe("SessionTimeoutSettingsComponentService", () => {
let service: SessionTimeoutSettingsComponentService;
let mockI18nService: MockProxy<I18nService>;
let mockSessionTimeoutTypeService: MockProxy<SessionTimeoutTypeService>;
let mockPolicyService: MockProxy<PolicyService>;
const mockUserId = "test-user-id" as UserId;
beforeEach(() => {
mockI18nService = mock<I18nService>();
mockSessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
mockPolicyService = mock<PolicyService>();
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true);
mockPolicyService.policiesByType$.mockReturnValue(of([]));
service = new SessionTimeoutSettingsComponentService(
mockI18nService,
mockSessionTimeoutTypeService,
mockPolicyService,
);
});
it("should create", () => {
expect(service).toBeTruthy();
});
describe("availableTimeoutOptions$", () => {
it("should return all options when isAvailable returns true for all", fakeAsync(async () => {
mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true);
flush();
const options = await firstValueFrom(service["availableTimeoutOptions$"]);
assertAllTimeoutTypes(options);
}));
it("should filter options based on isAvailable() results", fakeAsync(async () => {
mockSessionTimeoutTypeService.isAvailable.mockImplementation(async (value: VaultTimeout) => {
return (
value === VaultTimeoutNumberType.OnMinute ||
value === 5 ||
value === VaultTimeoutStringType.OnLocked
);
});
flush();
const options = await firstValueFrom(service["availableTimeoutOptions$"]);
expect(options).toHaveLength(3);
expect(options).toContainEqual({ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute });
expect(options).toContainEqual({ name: "fiveMinutes", value: 5 });
expect(options).toContainEqual({ name: "onLocked", value: VaultTimeoutStringType.OnLocked });
expect(options).not.toContainEqual({
name: "immediately",
value: VaultTimeoutNumberType.Immediately,
});
}));
});
describe("policyFilteredTimeoutOptions$", () => {
it("should return all available options when no policy for user", fakeAsync(async () => {
mockPolicyService.policiesByType$.mockReturnValue(of([]));
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
assertAllTimeoutTypes(options);
}));
describe('policy type "immediately"', () => {
it.each([VaultTimeoutNumberType.Immediately, VaultTimeoutNumberType.OnMinute])(
"should only return immediately option or fallback",
fakeAsync(async (availableTimeoutOrPromoted: VaultTimeout) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "immediately",
minutes: 0,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
availableTimeoutOrPromoted,
);
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
expect(options).toHaveLength(1);
if (availableTimeoutOrPromoted === VaultTimeoutNumberType.Immediately) {
expect(options[0]).toEqual({
name: "immediately",
value: VaultTimeoutNumberType.Immediately,
});
} else {
expect(options[0]).toEqual({
name: "oneMinute",
value: VaultTimeoutNumberType.OnMinute,
});
}
}),
);
});
describe('policy type "onSystemLock"', () => {
it.each([VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnRestart])(
"should allow immediately, numeric, custom, onLocked, onIdle, onSleep or fallback",
fakeAsync(async (availableTimeoutOrPromoted: VaultTimeout) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onSystemLock",
minutes: 0,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(
availableTimeoutOrPromoted,
);
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
assertNumericTimeoutTypes(options);
expect(options).toContainEqual({
name: "onLocked",
value: VaultTimeoutStringType.OnLocked,
});
expect(options).toContainEqual({ name: "onIdle", value: VaultTimeoutStringType.OnIdle });
expect(options).toContainEqual({
name: "onSleep",
value: VaultTimeoutStringType.OnSleep,
});
expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom });
expect(options).not.toContainEqual({
name: "never",
value: VaultTimeoutStringType.Never,
});
if (availableTimeoutOrPromoted === VaultTimeoutStringType.OnLocked) {
expect(options).not.toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
} else {
expect(options).toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
}
}),
);
});
describe('policy type "onAppRestart"', () => {
it("should allow immediately, numeric, custom, and onRestart", fakeAsync(async () => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "onAppRestart",
minutes: 0,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
assertNumericTimeoutTypes(options);
expect(options).toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom });
expect(options).not.toContainEqual({
name: "onLocked",
value: VaultTimeoutStringType.OnLocked,
});
expect(options).not.toContainEqual({
name: "onIdle",
value: VaultTimeoutStringType.OnIdle,
});
expect(options).not.toContainEqual({
name: "onSleep",
value: VaultTimeoutStringType.OnSleep,
});
expect(options).not.toContainEqual({ name: "never", value: VaultTimeoutStringType.Never });
}));
});
describe('policy type "custom", null, or undefined', () => {
it.each(["custom", null, undefined])(
"should allow immediately, custom, and numeric values within policy limit when type is %s",
fakeAsync(async (policyType: "custom" | null | undefined) => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: policyType as "custom" | null | undefined,
minutes: 15,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
expect(options).toContainEqual({
name: "immediately",
value: VaultTimeoutNumberType.Immediately,
});
expect(options).toContainEqual({
name: "oneMinute",
value: VaultTimeoutNumberType.OnMinute,
});
expect(options).toContainEqual({ name: "fiveMinutes", value: 5 });
expect(options).toContainEqual({ name: "fifteenMinutes", value: 15 });
expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom });
expect(options).not.toContainEqual({ name: "thirtyMinutes", value: 30 });
expect(options).not.toContainEqual({ name: "oneHour", value: 60 });
expect(options).not.toContainEqual({ name: "fourHours", value: 240 });
expect(options).not.toContainEqual({
name: "onLocked",
value: VaultTimeoutStringType.OnLocked,
});
expect(options).not.toContainEqual({
name: "onIdle",
value: VaultTimeoutStringType.OnIdle,
});
expect(options).not.toContainEqual({
name: "onSleep",
value: VaultTimeoutStringType.OnSleep,
});
expect(options).not.toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
expect(options).not.toContainEqual({
name: "never",
value: VaultTimeoutStringType.Never,
});
}),
);
});
describe('policy type "never"', () => {
it("should return all available options", fakeAsync(async () => {
const policyData: MaximumSessionTimeoutPolicyData = {
type: "never",
minutes: 0,
};
const policy = {
id: "policy-id",
organizationId: "org-id",
type: PolicyType.MaximumVaultTimeout,
data: policyData,
enabled: true,
} as Policy;
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
flush();
const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId));
assertAllTimeoutTypes(options);
}));
});
});
function assertAllTimeoutTypes(options: VaultTimeoutOption[]) {
assertNumericTimeoutTypes(options);
expect(options).toContainEqual({ name: "onIdle", value: VaultTimeoutStringType.OnIdle });
expect(options).toContainEqual({ name: "onSleep", value: VaultTimeoutStringType.OnSleep });
expect(options).toContainEqual({ name: "onLocked", value: VaultTimeoutStringType.OnLocked });
expect(options).toContainEqual({
name: "sessionTimeoutOnRestart",
value: VaultTimeoutStringType.OnRestart,
});
expect(options).toContainEqual({ name: "never", value: VaultTimeoutStringType.Never });
expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom });
}
function assertNumericTimeoutTypes(options: VaultTimeoutOption[]) {
expect(options).toContainEqual({
name: "immediately",
value: VaultTimeoutNumberType.Immediately,
});
expect(options).toContainEqual({ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute });
expect(options).toContainEqual({ name: "fiveMinutes", value: 5 });
expect(options).toContainEqual({ name: "fifteenMinutes", value: 15 });
expect(options).toContainEqual({ name: "thirtyMinutes", value: 30 });
expect(options).toContainEqual({ name: "oneHour", value: 60 });
expect(options).toContainEqual({ name: "fourHours", value: 240 });
}
});

View File

@ -1,9 +1,158 @@
import { Observable } from "rxjs";
import { combineLatest, concatMap, defer, map, Observable } from "rxjs";
import { VaultTimeout, VaultTimeoutOption } from "@bitwarden/common/key-management/vault-timeout";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import {
MaximumSessionTimeoutPolicyData,
SessionTimeoutTypeService,
} from "@bitwarden/common/key-management/session-timeout";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/user-core";
export abstract class SessionTimeoutSettingsComponentService {
abstract availableTimeoutOptions$: Observable<VaultTimeoutOption[]>;
export class SessionTimeoutSettingsComponentService {
private readonly availableTimeoutOptions$: Observable<VaultTimeoutOption[]>;
abstract onTimeoutSave(timeout: VaultTimeout): void;
constructor(
protected readonly i18nService: I18nService,
protected readonly sessionTimeoutTypeService: SessionTimeoutTypeService,
protected readonly policyService: PolicyService,
) {
this.availableTimeoutOptions$ = defer(async () => {
const allOptions = this.getAllTimeoutOptions();
const availabilityResults = await Promise.all(
allOptions.map(async (option) => ({
option,
available: await this.sessionTimeoutTypeService.isAvailable(option.value),
})),
);
return availabilityResults
.filter((result) => result.available)
.map((result) => result.option);
});
}
onTimeoutSave(_timeout: VaultTimeout): void {
// Default implementation does nothing, but other clients might want to override this
}
policyFilteredTimeoutOptions$(userId: UserId): Observable<VaultTimeoutOption[]> {
const policyData$ = this.policyService
.policiesByType$(PolicyType.MaximumVaultTimeout, userId)
.pipe(
getFirstPolicy,
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
);
return combineLatest([
this.availableTimeoutOptions$,
policyData$,
policyData$.pipe(
concatMap(async (policyData) => {
if (policyData == null) {
return null;
}
switch (policyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
case "onSystemLock":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
}
return null;
}),
),
]).pipe(
concatMap(
async ([availableOptions, policyData, highestAvailableEnforcedByPolicyTimeoutType]) => {
if (policyData == null) {
return availableOptions;
}
return availableOptions.filter((option) => {
switch (policyData.type) {
case "immediately": {
// Policy requires immediate lock.
return option.value === highestAvailableEnforcedByPolicyTimeoutType;
}
case "onSystemLock": {
// Allow immediately, numeric values, custom, and any system lock-related options.
if (
option.value === VaultTimeoutNumberType.Immediately ||
isVaultTimeoutTypeNumeric(option.value) ||
option.value === VaultTimeoutStringType.Custom ||
option.value === VaultTimeoutStringType.OnLocked ||
option.value === VaultTimeoutStringType.OnIdle ||
option.value === VaultTimeoutStringType.OnSleep
) {
return true;
}
// When on locked is not supported, fallback.
return option.value === highestAvailableEnforcedByPolicyTimeoutType;
}
case "onAppRestart":
// Allow immediately, numeric values, custom, and on app restart
return (
option.value === VaultTimeoutNumberType.Immediately ||
isVaultTimeoutTypeNumeric(option.value) ||
option.value === VaultTimeoutStringType.Custom ||
option.value === VaultTimeoutStringType.OnRestart
);
case "custom":
case null:
case undefined:
// Allow immediately, custom, and numeric values within policy limit
return (
option.value === VaultTimeoutNumberType.Immediately ||
option.value === VaultTimeoutStringType.Custom ||
(isVaultTimeoutTypeNumeric(option.value) &&
(option.value as number) <= policyData.minutes)
);
case "never":
// No policy restriction
return true;
default:
throw Error(`Unsupported policy type: ${policyData.type}`);
}
});
},
),
);
}
private getAllTimeoutOptions(): VaultTimeoutOption[] {
return [
{ name: "immediately", value: VaultTimeoutNumberType.Immediately },
{ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute },
{ name: "fiveMinutes", value: 5 },
{ name: "fifteenMinutes", value: 15 },
{ name: "thirtyMinutes", value: 30 },
{ name: "oneHour", value: 60 },
{ name: "fourHours", value: 240 },
{ name: "onIdle", value: VaultTimeoutStringType.OnIdle },
{ name: "onSleep", value: VaultTimeoutStringType.OnSleep },
{ name: "onLocked", value: VaultTimeoutStringType.OnLocked },
{ name: "sessionTimeoutOnRestart", value: VaultTimeoutStringType.OnRestart },
{ name: "never", value: VaultTimeoutStringType.Never },
{ name: "custom", value: VaultTimeoutStringType.Custom },
];
}
}