mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
[PM-26056] Consolidated session timeout component (#16988)
* consolidated session timeout settings component * rename preferences to appearance * race condition bug on computed signal * outdated header for browser * unnecessary padding * remove required on action, fix build * rename localization key * missing user id * required * cleanup task * eslint fix signals rollback * takeUntilDestroyed, null checks * move browser specific logic outside shared component * explicit input type * input name * takeUntilDestroyed, no toast * unit tests * cleanup * cleanup, correct link to deprecation jira * tech debt todo with jira * missing web localization key when policy is on * relative import * extracting timeout options to component service * duplicate localization key * fix failing test * subsequent timeout action selecting opening without dialog on first dialog cancellation * default locale can be null * unit tests failing * rename, simplifications * one if else feature flag * timeout input component rendering before async pipe completion
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<div [formGroup]="formGroup">
|
||||
<auth-vault-timeout-input
|
||||
[vaultTimeoutOptions]="availableTimeoutOptions$ | async"
|
||||
[formControl]="formGroup.controls.timeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "sessionTimeoutSettingsAction" | i18n }}</bit-label>
|
||||
<bit-select
|
||||
id="timeoutAction"
|
||||
[formControl]="formGroup.controls.timeoutAction"
|
||||
[required]="false"
|
||||
>
|
||||
@for (action of availableTimeoutActions(); track action) {
|
||||
<bit-option [value]="action" [label]="action | i18n"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
|
||||
@if (!canLock) {
|
||||
<bit-hint>{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br /></bit-hint>
|
||||
}
|
||||
</bit-form-field>
|
||||
|
||||
@if (hasVaultTimeoutPolicy$ | async) {
|
||||
<bit-hint class="tw-mt-4">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,522 @@
|
||||
import { ComponentFixture, fakeAsync, flush, TestBed, waitForAsync } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
MaximumVaultTimeoutPolicyData,
|
||||
VaultTimeout,
|
||||
VaultTimeoutAction,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutSettingsService,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service";
|
||||
|
||||
import { SessionTimeoutSettingsComponent } from "./session-timeout-settings.component";
|
||||
|
||||
describe("SessionTimeoutSettingsComponent", () => {
|
||||
let component: SessionTimeoutSettingsComponent;
|
||||
let fixture: ComponentFixture<SessionTimeoutSettingsComponent>;
|
||||
|
||||
// Mock services
|
||||
let mockVaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let mockSessionTimeoutSettingsComponentService: MockProxy<SessionTimeoutSettingsComponentService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let accountService: FakeAccountService;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockInitialTimeout = 5;
|
||||
const mockInitialTimeoutAction = VaultTimeoutAction.Lock;
|
||||
let refreshTimeoutActionSettings$: BehaviorSubject<void>;
|
||||
let availableTimeoutOptions$: BehaviorSubject<VaultTimeoutOption[]>;
|
||||
|
||||
beforeEach(async () => {
|
||||
refreshTimeoutActionSettings$ = new BehaviorSubject<void>(undefined);
|
||||
availableTimeoutOptions$ = new BehaviorSubject<VaultTimeoutOption[]>([
|
||||
{ name: "oneMinute-used-i18n", value: 1 },
|
||||
{ name: "fiveMinutes-used-i18n", value: 5 },
|
||||
{ name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart },
|
||||
{ name: "onLocked-used-i18n", value: VaultTimeoutStringType.OnLocked },
|
||||
{ name: "onSleep-used-i18n", value: VaultTimeoutStringType.OnSleep },
|
||||
{ name: "onIdle-used-i18n", value: VaultTimeoutStringType.OnIdle },
|
||||
{ name: "never-used-i18n", value: VaultTimeoutStringType.Never },
|
||||
]);
|
||||
|
||||
mockVaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
mockSessionTimeoutSettingsComponentService = mock<SessionTimeoutSettingsComponentService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
accountService = mockAccountServiceWith(mockUserId, { email: mockEmail });
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockLogService = mock<LogService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
|
||||
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() =>
|
||||
of(mockInitialTimeout),
|
||||
);
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation(() =>
|
||||
of(mockInitialTimeoutAction),
|
||||
);
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
mockSessionTimeoutSettingsComponentService.availableTimeoutOptions$ =
|
||||
availableTimeoutOptions$.asObservable();
|
||||
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SessionTimeoutSettingsComponent,
|
||||
ReactiveFormsModule,
|
||||
VaultTimeoutInputComponent,
|
||||
NoopAnimationsModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: VaultTimeoutSettingsService, useValue: mockVaultTimeoutSettingsService },
|
||||
{
|
||||
provide: SessionTimeoutSettingsComponentService,
|
||||
useValue: mockSessionTimeoutSettingsComponentService,
|
||||
},
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(SessionTimeoutSettingsComponent, {
|
||||
set: {
|
||||
providers: [{ provide: DialogService, useValue: mockDialogService }],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SessionTimeoutSettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
fixture.componentRef.setInput("refreshTimeoutActionSettings", refreshTimeoutActionSettings$);
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("canLock", () => {
|
||||
it("should return true when Lock action is available", fakeAsync(() => {
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.canLock).toBe(true);
|
||||
}));
|
||||
|
||||
it("should return false when Lock action is not available", fakeAsync(() => {
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.canLock).toBe(false);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should initialize available timeout options", fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
const options = await firstValueFrom(
|
||||
component["availableTimeoutOptions$"].pipe(filter((options) => options.length > 0)),
|
||||
);
|
||||
|
||||
expect(options).toContainEqual({ name: "oneMinute-used-i18n", value: 1 });
|
||||
expect(options).toContainEqual({ name: "fiveMinutes-used-i18n", value: 5 });
|
||||
expect(options).toContainEqual({
|
||||
name: "onIdle-used-i18n",
|
||||
value: VaultTimeoutStringType.OnIdle,
|
||||
});
|
||||
expect(options).toContainEqual({
|
||||
name: "onSleep-used-i18n",
|
||||
value: VaultTimeoutStringType.OnSleep,
|
||||
});
|
||||
expect(options).toContainEqual({
|
||||
name: "onLocked-used-i18n",
|
||||
value: VaultTimeoutStringType.OnLocked,
|
||||
});
|
||||
expect(options).toContainEqual({
|
||||
name: "onRestart-used-i18n",
|
||||
value: VaultTimeoutStringType.OnRestart,
|
||||
});
|
||||
expect(options).toContainEqual({
|
||||
name: "never-used-i18n",
|
||||
value: VaultTimeoutStringType.Never,
|
||||
});
|
||||
}));
|
||||
|
||||
it("should initialize available timeout actions", fakeAsync(() => {
|
||||
const expectedActions = [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut];
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of(expectedActions),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component["availableTimeoutActions"]()).toEqual(expectedActions);
|
||||
}));
|
||||
|
||||
it("should initialize timeout and action", fakeAsync(() => {
|
||||
const expectedTimeout = 15;
|
||||
const expectedAction = VaultTimeoutAction.Lock;
|
||||
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() =>
|
||||
of(expectedTimeout),
|
||||
);
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation(() =>
|
||||
of(expectedAction),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.value.timeout).toBe(expectedTimeout);
|
||||
expect(component.formGroup.value.timeoutAction).toBe(expectedAction);
|
||||
}));
|
||||
|
||||
it("should fall back to OnRestart when current option is not available", fakeAsync(() => {
|
||||
availableTimeoutOptions$.next([
|
||||
{ name: "oneMinute-used-i18n", value: 1 },
|
||||
{ name: "fiveMinutes-used-i18n", value: 5 },
|
||||
{ name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart },
|
||||
]);
|
||||
|
||||
const unavailableTimeout = VaultTimeoutStringType.Never;
|
||||
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() =>
|
||||
of(unavailableTimeout),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.value.timeout).toBe(VaultTimeoutStringType.OnRestart);
|
||||
}));
|
||||
|
||||
it("should disable timeout action control when policy enforces action", fakeAsync(() => {
|
||||
const policyData: MaximumVaultTimeoutPolicyData = {
|
||||
minutes: 15,
|
||||
action: VaultTimeoutAction.LogOut,
|
||||
};
|
||||
mockPolicyService.policiesByType$.mockImplementation(() =>
|
||||
of([{ id: "1", data: policyData }] as Policy[]),
|
||||
);
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should disable timeout action control when only one action is available", fakeAsync(() => {
|
||||
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should disable timeout action control when policy enforces action and refreshed", fakeAsync(() => {
|
||||
const policies$ = new BehaviorSubject<Policy[]>([]);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(policies$);
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.enabled).toBe(true);
|
||||
|
||||
const policyData: MaximumVaultTimeoutPolicyData = {
|
||||
minutes: 15,
|
||||
action: VaultTimeoutAction.LogOut,
|
||||
};
|
||||
policies$.next([{ id: "1", data: policyData }] as Policy[]);
|
||||
|
||||
refreshTimeoutActionSettings$.next(undefined);
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should disable timeout action control when only one action is available and refreshed", fakeAsync(() => {
|
||||
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
|
||||
|
||||
const availableActions$ = new BehaviorSubject<VaultTimeoutAction[]>([
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutAction.LogOut,
|
||||
]);
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue(
|
||||
availableActions$,
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.enabled).toBe(true);
|
||||
|
||||
availableActions$.next([VaultTimeoutAction.Lock]);
|
||||
|
||||
refreshTimeoutActionSettings$.next(undefined);
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should enable timeout action control when multiple actions available and no policy and refreshed", fakeAsync(() => {
|
||||
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
refreshTimeoutActionSettings$.next(undefined);
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.enabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should subscribe to timeout value changes", fakeAsync(() => {
|
||||
const saveSpy = jest.spyOn(component, "saveTimeout").mockResolvedValue(undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
const newTimeout = 30;
|
||||
component.formGroup.controls.timeout.setValue(newTimeout);
|
||||
flush();
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(mockInitialTimeout, newTimeout);
|
||||
}));
|
||||
|
||||
it("should subscribe to timeout action value changes", fakeAsync(() => {
|
||||
const saveSpy = jest.spyOn(component, "saveTimeoutAction").mockResolvedValue(undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
component.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.LogOut);
|
||||
flush();
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(VaultTimeoutAction.LogOut);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("saveTimeout", () => {
|
||||
it("should not save when form control timeout is invalid", fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
component.formGroup.controls.timeout.setValue(null);
|
||||
|
||||
await component.saveTimeout(mockInitialTimeout, 30);
|
||||
flush();
|
||||
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("should set new value and show confirmation dialog when setting timeout to Never and dialog confirmed", waitForAsync(async () => {
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const previousTimeout = component.formGroup.controls.timeout.value!;
|
||||
const newTimeout = VaultTimeoutStringType.Never;
|
||||
|
||||
await component.saveTimeout(previousTimeout, newTimeout);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "warning" },
|
||||
content: { key: "neverLockWarning" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
newTimeout,
|
||||
mockInitialTimeoutAction,
|
||||
);
|
||||
expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).toHaveBeenCalledWith(
|
||||
newTimeout,
|
||||
);
|
||||
}));
|
||||
|
||||
it("should revert to previous value when Never confirmation is declined", waitForAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
const previousTimeout = component.formGroup.controls.timeout.value!;
|
||||
const newTimeout = VaultTimeoutStringType.Never;
|
||||
|
||||
await component.saveTimeout(previousTimeout, newTimeout);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "warning" },
|
||||
content: { key: "neverLockWarning" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(component.formGroup.controls.timeout.value).toBe(previousTimeout);
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled();
|
||||
expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it.each([
|
||||
30,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
])(
|
||||
"should set new value when setting timeout to %s",
|
||||
fakeAsync(async (timeout: VaultTimeout) => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
const previousTimeout = component.formGroup.controls.timeout.value!;
|
||||
await component.saveTimeout(previousTimeout, timeout);
|
||||
flush();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
timeout,
|
||||
mockInitialTimeoutAction,
|
||||
);
|
||||
expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).toHaveBeenCalledWith(
|
||||
timeout,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("saveTimeoutAction", () => {
|
||||
it("should set new value and show confirmation dialog when setting action to LogOut and dialog confirmed", waitForAsync(async () => {
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.saveTimeoutAction(VaultTimeoutAction.LogOut);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "vaultTimeoutLogOutConfirmationTitle" },
|
||||
content: { key: "vaultTimeoutLogOutConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockInitialTimeout,
|
||||
VaultTimeoutAction.LogOut,
|
||||
);
|
||||
}));
|
||||
|
||||
it("should revert to Lock when LogOut confirmation is declined", waitForAsync(async () => {
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.saveTimeoutAction(VaultTimeoutAction.LogOut);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "vaultTimeoutLogOutConfirmationTitle" },
|
||||
content: { key: "vaultTimeoutLogOutConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(component.formGroup.controls.timeoutAction.value).toBe(VaultTimeoutAction.Lock);
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("should set timeout action to Lock value when setting timeout action to Lock", fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
component.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.LogOut, {
|
||||
emitEvent: false,
|
||||
});
|
||||
|
||||
await component.saveTimeoutAction(VaultTimeoutAction.Lock);
|
||||
flush();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockInitialTimeout,
|
||||
VaultTimeoutAction.Lock,
|
||||
);
|
||||
}));
|
||||
|
||||
it("should not save and show error toast when timeout has policy error", fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
component.formGroup.controls.timeout.setErrors({ policyError: true });
|
||||
|
||||
await component.saveTimeoutAction(VaultTimeoutAction.Lock);
|
||||
flush();
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "vaultTimeoutTooLarge-used-i18n",
|
||||
});
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, input, OnInit, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import {
|
||||
MaximumVaultTimeoutPolicyData,
|
||||
VaultTimeout,
|
||||
VaultTimeoutAction,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutSettingsService,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
CheckboxModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ItemModule,
|
||||
LinkModule,
|
||||
SelectModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-session-timeout-settings",
|
||||
templateUrl: "session-timeout-settings.component.html",
|
||||
imports: [
|
||||
CheckboxModule,
|
||||
CommonModule,
|
||||
FormFieldModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
IconButtonModule,
|
||||
ItemModule,
|
||||
JslibModule,
|
||||
LinkModule,
|
||||
RouterModule,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
VaultTimeoutInputComponent,
|
||||
],
|
||||
})
|
||||
export class SessionTimeoutSettingsComponent implements OnInit {
|
||||
// TODO remove once https://bitwarden.atlassian.net/browse/PM-27283 is completed
|
||||
// This is because vaultTimeoutSettingsService.availableVaultTimeoutActions$ is not reactive, hence the change detection
|
||||
// needs to be manually triggered to refresh available timeout actions
|
||||
readonly refreshTimeoutActionSettings = input<Observable<void>>(
|
||||
new BehaviorSubject<void>(undefined),
|
||||
);
|
||||
|
||||
formGroup = new FormGroup({
|
||||
timeout: new FormControl<VaultTimeout | null>(null, [Validators.required]),
|
||||
timeoutAction: new FormControl<VaultTimeoutAction>(VaultTimeoutAction.Lock, [
|
||||
Validators.required,
|
||||
]),
|
||||
});
|
||||
protected readonly availableTimeoutActions = signal<VaultTimeoutAction[]>([]);
|
||||
protected readonly availableTimeoutOptions$ =
|
||||
this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe(
|
||||
startWith([] as VaultTimeoutOption[]),
|
||||
);
|
||||
protected hasVaultTimeoutPolicy$: Observable<boolean> = of(false);
|
||||
|
||||
private userId!: UserId;
|
||||
|
||||
constructor(
|
||||
private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private readonly sessionTimeoutSettingsComponentService: SessionTimeoutSettingsComponentService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly toastService: ToastService,
|
||||
private readonly policyService: PolicyService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dialogService: DialogService,
|
||||
private readonly logService: LogService,
|
||||
private readonly destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
get canLock() {
|
||||
return this.availableTimeoutActions().includes(VaultTimeoutAction.Lock);
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<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(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(this.userId),
|
||||
);
|
||||
|
||||
// Fallback if current timeout option is not available on this platform
|
||||
// Only applies to string-based timeout types, not numeric values
|
||||
const hasCurrentOption = availableTimeoutOptions.some((opt) => opt.value === timeout);
|
||||
if (!hasCurrentOption && typeof timeout !== "number") {
|
||||
this.logService.debug(
|
||||
"[SessionTimeoutSettings] Current timeout option not available, falling back from",
|
||||
{ timeout },
|
||||
);
|
||||
timeout = VaultTimeoutStringType.OnRestart;
|
||||
}
|
||||
|
||||
this.formGroup.patchValue(
|
||||
{
|
||||
timeout: timeout,
|
||||
timeoutAction: await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
|
||||
),
|
||||
},
|
||||
{ emitEvent: false },
|
||||
);
|
||||
|
||||
this.refreshTimeoutActionSettings()
|
||||
.pipe(
|
||||
startWith(undefined),
|
||||
switchMap(() =>
|
||||
combineLatest([
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId),
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
|
||||
maximumVaultTimeoutPolicy$,
|
||||
]),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe(([availableActions, action, policy]) => {
|
||||
this.availableTimeoutActions.set(availableActions);
|
||||
this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false });
|
||||
|
||||
const policyData = policy?.data as MaximumVaultTimeoutPolicyData | undefined;
|
||||
|
||||
// Enable/disable the action control based on policy or available actions
|
||||
if (policyData?.action != null || availableActions.length <= 1) {
|
||||
this.formGroup.controls.timeoutAction.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.formGroup.controls.timeoutAction.enable({ emitEvent: false });
|
||||
}
|
||||
});
|
||||
|
||||
this.formGroup.controls.timeout.valueChanges
|
||||
.pipe(
|
||||
startWith(timeout), // emit to init pairwise
|
||||
filter((value) => value != null),
|
||||
distinctUntilChanged(),
|
||||
pairwise(),
|
||||
concatMap(async ([previousValue, newValue]) => {
|
||||
await this.saveTimeout(previousValue, newValue);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.formGroup.controls.timeoutAction.valueChanges
|
||||
.pipe(
|
||||
filter((value) => value != null),
|
||||
map(async (value) => {
|
||||
await this.saveTimeoutAction(value);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async saveTimeout(previousValue: VaultTimeout, newValue: VaultTimeout) {
|
||||
this.formGroup.controls.timeout.markAllAsTouched();
|
||||
if (this.formGroup.controls.timeout.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.debug("[SessionTimeoutSettings] Saving timeout", { previousValue, newValue });
|
||||
|
||||
if (newValue === VaultTimeoutStringType.Never) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "warning" },
|
||||
content: { key: "neverLockWarning" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
this.formGroup.controls.timeout.setValue(previousValue, { emitEvent: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const vaultTimeoutAction = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
|
||||
);
|
||||
|
||||
await this.vaultTimeoutSettingsService.setVaultTimeoutOptions(
|
||||
this.userId,
|
||||
newValue,
|
||||
vaultTimeoutAction,
|
||||
);
|
||||
|
||||
this.sessionTimeoutSettingsComponentService.onTimeoutSave(newValue);
|
||||
}
|
||||
|
||||
async saveTimeoutAction(value: VaultTimeoutAction) {
|
||||
this.logService.debug("[SessionTimeoutSettings] Saving timeout action", value);
|
||||
|
||||
if (value === VaultTimeoutAction.LogOut) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "vaultTimeoutLogOutConfirmationTitle" },
|
||||
content: { key: "vaultTimeoutLogOutConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
this.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.Lock, {
|
||||
emitEvent: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.formGroup.controls.timeout.hasError("policyError")) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("vaultTimeoutTooLarge"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.vaultTimeoutSettingsService.setVaultTimeoutOptions(
|
||||
this.userId,
|
||||
this.formGroup.controls.timeout.value!,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { VaultTimeout, VaultTimeoutOption } from "@bitwarden/common/key-management/vault-timeout";
|
||||
|
||||
export abstract class SessionTimeoutSettingsComponentService {
|
||||
abstract availableTimeoutOptions$: Observable<VaultTimeoutOption[]>;
|
||||
|
||||
abstract onTimeoutSave(timeout: VaultTimeout): void;
|
||||
}
|
||||
Reference in New Issue
Block a user