mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-19300] Session timeout policy (#16583)
* Session timeout policy * default "custom" is 8 hours, validation fixes * ownership update * default max allowed timeout is not selected * adjusting defaults, fixing backwards compatibility, skip type confirmation dialog when switching between the never and on system lock * unit test coverage * wording update, custom hours, minutes jumping on errors * wording update * wrong session timeout action dropdown label * show dialog as valid when opened first time, use @for loop, use controls instead of get * dialog static opener * easier to understand type value listener * unit tests * explicit maximum allowed timeout required error * eslint revert
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
export { ActivateAutofillPolicy } from "./activate-autofill.component";
|
||||
export { AutomaticAppLoginPolicy } from "./automatic-app-login.component";
|
||||
export { DisablePersonalVaultExportPolicy } from "./disable-personal-vault-export.component";
|
||||
export { MaximumVaultTimeoutPolicy } from "./maximum-vault-timeout.component";
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<bit-callout title="{{ 'prerequisite' | i18n }}">
|
||||
{{ "requireSsoPolicyReq" | i18n }}
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
|
||||
<bit-label>{{ "turnOn" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<div [formGroup]="data">
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-6 !tw-mb-0">
|
||||
<bit-label>{{ "maximumVaultTimeoutLabel" | i18n }}</bit-label>
|
||||
<input bitInput type="number" min="0" formControlName="hours" />
|
||||
<bit-hint>{{ "hours" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-6 tw-self-end !tw-mb-0">
|
||||
<input bitInput type="number" min="0" max="59" formControlName="minutes" />
|
||||
<bit-hint>{{ "minutes" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "vaultTimeoutAction" | i18n }}</bit-label>
|
||||
<bit-select formControlName="action">
|
||||
<bit-option
|
||||
*ngFor="let option of vaultTimeoutActionOptions"
|
||||
[value]="option.value"
|
||||
[label]="option.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,79 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component } from "@angular/core";
|
||||
import { FormBuilder, FormControl } from "@angular/forms";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
BasePolicyEditDefinition,
|
||||
BasePolicyEditComponent,
|
||||
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
export class MaximumVaultTimeoutPolicy extends BasePolicyEditDefinition {
|
||||
name = "maximumVaultTimeout";
|
||||
description = "maximumVaultTimeoutDesc";
|
||||
type = PolicyType.MaximumVaultTimeout;
|
||||
component = MaximumVaultTimeoutPolicyComponent;
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "maximum-vault-timeout.component.html",
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class MaximumVaultTimeoutPolicyComponent extends BasePolicyEditComponent {
|
||||
vaultTimeoutActionOptions: { name: string; value: string }[];
|
||||
data = this.formBuilder.group({
|
||||
hours: new FormControl<number>(null),
|
||||
minutes: new FormControl<number>(null),
|
||||
action: new FormControl<string>(null),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
super();
|
||||
this.vaultTimeoutActionOptions = [
|
||||
{ name: i18nService.t("userPreference"), value: null },
|
||||
{ name: i18nService.t(VaultTimeoutAction.Lock), value: VaultTimeoutAction.Lock },
|
||||
{ name: i18nService.t(VaultTimeoutAction.LogOut), value: VaultTimeoutAction.LogOut },
|
||||
];
|
||||
}
|
||||
|
||||
protected loadData() {
|
||||
const minutes = this.policyResponse.data?.minutes;
|
||||
const action = this.policyResponse.data?.action;
|
||||
|
||||
this.data.patchValue({
|
||||
hours: minutes ? Math.floor(minutes / 60) : null,
|
||||
minutes: minutes ? minutes % 60 : null,
|
||||
action: action,
|
||||
});
|
||||
}
|
||||
|
||||
protected buildRequestData() {
|
||||
if (this.data.value.hours == null && this.data.value.minutes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
minutes: this.data.value.hours * 60 + this.data.value.minutes,
|
||||
action: this.data.value.action,
|
||||
};
|
||||
}
|
||||
|
||||
async buildRequest(): Promise<PolicyRequest> {
|
||||
const request = await super.buildRequest();
|
||||
if (request.data?.minutes == null || request.data?.minutes <= 0) {
|
||||
throw new Error(this.i18nService.t("invalidMaximumVaultTimeout"));
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
|
||||
|
||||
import { FreeFamiliesSponsorshipPolicy } from "../../billing/policies/free-families-sponsorship.component";
|
||||
import { SessionTimeoutPolicy } from "../../key-management/policies/session-timeout.component";
|
||||
|
||||
import {
|
||||
ActivateAutofillPolicy,
|
||||
AutomaticAppLoginPolicy,
|
||||
DisablePersonalVaultExportPolicy,
|
||||
MaximumVaultTimeoutPolicy,
|
||||
} from "./policy-edit-definitions";
|
||||
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
* It will not appear in the web vault when running in OSS mode.
|
||||
*/
|
||||
const policyEditRegister: BasePolicyEditDefinition[] = [
|
||||
new MaximumVaultTimeoutPolicy(),
|
||||
new SessionTimeoutPolicy(),
|
||||
new DisablePersonalVaultExportPolicy(),
|
||||
new FreeFamiliesSponsorshipPolicy(),
|
||||
new ActivateAutofillPolicy(),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<bit-dialog dialogSize="small">
|
||||
<div bitDialogTitle class="tw-mt-4 tw-flex tw-flex-col tw-gap-2 tw-text-center">
|
||||
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
|
||||
<h1
|
||||
bitTypography="h3"
|
||||
class="tw-break-words tw-hyphens-auto tw-whitespace-normal tw-max-w-fit tw-inline-block"
|
||||
>
|
||||
{{ "sessionTimeoutConfirmationNeverTitle" | i18n }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<span
|
||||
bitDialogContent
|
||||
class="tw-flex tw-flex-col tw-gap-2 tw-items-center tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
|
||||
>
|
||||
<p>{{ "sessionTimeoutConfirmationNeverDescription" | i18n }}</p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
appA11yTitle="{{ 'learnMoreAboutDeviceProtection' | i18n }}"
|
||||
href="https://bitwarden.com/help/vault-timeout/"
|
||||
bitLink
|
||||
class="tw-flex tw-flex-row tw-gap-1"
|
||||
>
|
||||
{{ "learnMoreAboutDeviceProtection" | i18n }}
|
||||
<i class="bwi bwi-external-link" aria-hidden="true"></i>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<div bitDialogFooter class="tw-flex tw-flex-col tw-flex-grow tw-gap-2">
|
||||
<button bitButton buttonType="primary" type="button" (click)="dialogRef.close(true)">
|
||||
{{ "yes" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" type="button" (click)="dialogRef.close(false)">
|
||||
{{ "no" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
|
||||
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
|
||||
|
||||
describe("SessionTimeoutConfirmationNeverComponent", () => {
|
||||
let component: SessionTimeoutConfirmationNeverComponent;
|
||||
let fixture: ComponentFixture<SessionTimeoutConfirmationNeverComponent>;
|
||||
let mockDialogRef: jest.Mocked<DialogRef>;
|
||||
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockDialogService = mock<DialogService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDialogRef = mock<DialogRef>();
|
||||
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SessionTimeoutConfirmationNeverComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SessionTimeoutConfirmationNeverComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("open", () => {
|
||||
it("should call dialogService.open with correct parameters", () => {
|
||||
const mockResult = mock<DialogRef>();
|
||||
mockDialogService.open.mockReturnValue(mockResult);
|
||||
|
||||
const result = SessionTimeoutConfirmationNeverComponent.open(mockDialogService);
|
||||
|
||||
expect(mockDialogService.open).toHaveBeenCalledWith(
|
||||
SessionTimeoutConfirmationNeverComponent,
|
||||
{
|
||||
disableClose: true,
|
||||
},
|
||||
);
|
||||
expect(result).toBe(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe("button clicks", () => {
|
||||
it("should close dialog with true when Yes button is clicked", () => {
|
||||
const yesButton = fixture.nativeElement.querySelector(
|
||||
'button[buttonType="primary"]',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
yesButton.click();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith(true);
|
||||
expect(yesButton.textContent?.trim()).toBe("yes-used-i18n");
|
||||
});
|
||||
|
||||
it("should close dialog with false when No button is clicked", () => {
|
||||
const noButton = fixture.nativeElement.querySelector(
|
||||
'button[buttonType="secondary"]',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
noButton.click();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith(false);
|
||||
expect(noButton.textContent?.trim()).toBe("no-used-i18n");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
@Component({
|
||||
imports: [SharedModule],
|
||||
templateUrl: "./session-timeout-confirmation-never.component.html",
|
||||
})
|
||||
export class SessionTimeoutConfirmationNeverComponent {
|
||||
constructor(public dialogRef: DialogRef) {}
|
||||
|
||||
static open(dialogService: DialogService) {
|
||||
return dialogService.open<boolean>(SessionTimeoutConfirmationNeverComponent, {
|
||||
disableClose: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<bit-callout title="{{ 'prerequisite' | i18n }}">
|
||||
{{ "requireSsoPolicyReq" | i18n }}
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
|
||||
<bit-label>{{ "turnOn" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<div [formGroup]="data">
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<bit-form-field class="tw-col-span-12 !tw-mb-0">
|
||||
<bit-label>{{ "maximumAllowedTimeout" | i18n }}</bit-label>
|
||||
<bit-select formControlName="type">
|
||||
@for (option of typeOptions; track option.value) {
|
||||
<bit-option [value]="option.value" [label]="option.name"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
@if (data.value.type === "custom") {
|
||||
<bit-form-field class="tw-col-span-6 tw-self-start !tw-mb-0">
|
||||
<bit-label>{{ "hours" | i18n }}</bit-label>
|
||||
<input bitInput type="number" min="0" formControlName="hours" />
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-col-span-6 tw-self-start !tw-mb-0">
|
||||
<bit-label>{{ "minutes" | i18n }}</bit-label>
|
||||
<input bitInput type="number" min="0" max="59" formControlName="minutes" />
|
||||
</bit-form-field>
|
||||
}
|
||||
<bit-form-field class="tw-col-span-12">
|
||||
<bit-label>{{ "sessionTimeoutAction" | i18n }}</bit-label>
|
||||
<bit-select formControlName="action">
|
||||
@for (option of actionOptions; track option.value) {
|
||||
<bit-option [value]="option.value" [label]="option.name"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,441 @@
|
||||
import { DialogCloseOptions } from "@angular/cdk/dialog";
|
||||
import { DebugElement } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Observable, of } from "rxjs";
|
||||
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
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";
|
||||
|
||||
// Mock DialogRef, so we can mock "readonly closed" property.
|
||||
class MockDialogRef extends DialogRef {
|
||||
close(result: unknown | undefined, options: DialogCloseOptions | undefined): void {}
|
||||
|
||||
closed: Observable<unknown | undefined> = of();
|
||||
componentInstance: unknown | null;
|
||||
disableClose: boolean | undefined;
|
||||
isDrawer: boolean = false;
|
||||
}
|
||||
|
||||
describe("SessionTimeoutPolicyComponent", () => {
|
||||
let component: SessionTimeoutPolicyComponent;
|
||||
let fixture: ComponentFixture<SessionTimeoutPolicyComponent>;
|
||||
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockDialogService = mock<DialogService>();
|
||||
const mockDialogRef = mock<MockDialogRef>();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
mockDialogRef.closed = of(true);
|
||||
mockDialogService.open.mockReturnValue(mockDialogRef);
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
|
||||
|
||||
const testBed = TestBed.configureTestingModule({
|
||||
imports: [SessionTimeoutPolicyComponent, ReactiveFormsModule],
|
||||
providers: [FormBuilder, { provide: I18nService, useValue: mockI18nService }],
|
||||
});
|
||||
|
||||
// Override DialogService provided from SharedModule (which includes DialogModule)
|
||||
testBed.overrideProvider(DialogService, { useValue: mockDialogService });
|
||||
|
||||
await testBed.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SessionTimeoutPolicyComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
function assertHoursAndMinutesInputsNotVisible() {
|
||||
const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]');
|
||||
const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]');
|
||||
|
||||
expect(hoursInput).toBeFalsy();
|
||||
expect(minutesInput).toBeFalsy();
|
||||
}
|
||||
|
||||
function assertHoursAndMinutesInputs(expectedHours: string, expectedMinutes: string) {
|
||||
const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]');
|
||||
const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]');
|
||||
|
||||
expect(hoursInput).toBeTruthy();
|
||||
expect(minutesInput).toBeTruthy();
|
||||
expect(hoursInput.disabled).toBe(false);
|
||||
expect(minutesInput.disabled).toBe(false);
|
||||
expect(hoursInput.value).toBe(expectedHours);
|
||||
expect(minutesInput.value).toBe(expectedMinutes);
|
||||
}
|
||||
|
||||
function setPolicyResponseType(type: SessionTimeoutType) {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
Data: {
|
||||
type,
|
||||
minutes: 480,
|
||||
action: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("initialization and data loading", () => {
|
||||
function assertTypeAndActionSelectElementsVisible() {
|
||||
// Type and action selects should always be present
|
||||
const typeSelectDebug: DebugElement = fixture.debugElement.query(
|
||||
By.css('bit-select[formControlName="type"]'),
|
||||
);
|
||||
const actionSelectDebug: DebugElement = fixture.debugElement.query(
|
||||
By.css('bit-select[formControlName="action"]'),
|
||||
);
|
||||
|
||||
expect(typeSelectDebug).toBeTruthy();
|
||||
expect(actionSelectDebug).toBeTruthy();
|
||||
}
|
||||
|
||||
it("should initialize with default state when policy have no value", () => {
|
||||
component.policyResponse = undefined;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.data.controls.type.value).toBeNull();
|
||||
expect(component.data.controls.type.hasError("required")).toBe(true);
|
||||
expect(component.data.controls.hours.value).toBe(8);
|
||||
expect(component.data.controls.hours.disabled).toBe(true);
|
||||
expect(component.data.controls.minutes.value).toBe(0);
|
||||
expect(component.data.controls.minutes.disabled).toBe(true);
|
||||
expect(component.data.controls.action.value).toBeNull();
|
||||
|
||||
assertTypeAndActionSelectElementsVisible();
|
||||
assertHoursAndMinutesInputsNotVisible();
|
||||
});
|
||||
|
||||
// This is for backward compatibility when type field did not exist
|
||||
it("should load as custom type when type field does not exist but minutes does", () => {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
Data: {
|
||||
minutes: 500,
|
||||
action: VaultTimeoutAction.Lock,
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.data.controls.type.value).toBe("custom");
|
||||
expect(component.data.controls.hours.value).toBe(8);
|
||||
expect(component.data.controls.hours.disabled).toBe(false);
|
||||
expect(component.data.controls.minutes.value).toBe(20);
|
||||
expect(component.data.controls.minutes.disabled).toBe(false);
|
||||
expect(component.data.controls.action.value).toBe(VaultTimeoutAction.Lock);
|
||||
|
||||
assertTypeAndActionSelectElementsVisible();
|
||||
assertHoursAndMinutesInputs("8", "20");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["never", null],
|
||||
["never", VaultTimeoutAction.Lock],
|
||||
["never", VaultTimeoutAction.LogOut],
|
||||
["onAppRestart", null],
|
||||
["onAppRestart", VaultTimeoutAction.Lock],
|
||||
["onAppRestart", VaultTimeoutAction.LogOut],
|
||||
["onSystemLock", null],
|
||||
["onSystemLock", VaultTimeoutAction.Lock],
|
||||
["onSystemLock", VaultTimeoutAction.LogOut],
|
||||
["immediately", null],
|
||||
["immediately", VaultTimeoutAction.Lock],
|
||||
["immediately", VaultTimeoutAction.LogOut],
|
||||
["custom", null],
|
||||
["custom", VaultTimeoutAction.Lock],
|
||||
["custom", VaultTimeoutAction.LogOut],
|
||||
])("should load correctly when policy type is %s and action is %s", (type, action) => {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
Data: {
|
||||
type,
|
||||
minutes: 510,
|
||||
action,
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.data.controls.type.value).toBe(type);
|
||||
expect(component.data.controls.action.value).toBe(action);
|
||||
|
||||
assertTypeAndActionSelectElementsVisible();
|
||||
|
||||
if (type === "custom") {
|
||||
expect(component.data.controls.hours.value).toBe(8);
|
||||
expect(component.data.controls.minutes.value).toBe(30);
|
||||
expect(component.data.controls.hours.disabled).toBe(false);
|
||||
expect(component.data.controls.minutes.disabled).toBe(false);
|
||||
|
||||
assertHoursAndMinutesInputs("8", "30");
|
||||
} else {
|
||||
expect(component.data.controls.hours.disabled).toBe(true);
|
||||
expect(component.data.controls.minutes.disabled).toBe(true);
|
||||
|
||||
assertHoursAndMinutesInputsNotVisible();
|
||||
}
|
||||
});
|
||||
|
||||
it("should have all type options and update form control when value changes", fakeAsync(() => {
|
||||
expect(component.typeOptions.length).toBe(5);
|
||||
expect(component.typeOptions[0].value).toBe("immediately");
|
||||
expect(component.typeOptions[1].value).toBe("custom");
|
||||
expect(component.typeOptions[2].value).toBe("onSystemLock");
|
||||
expect(component.typeOptions[3].value).toBe("onAppRestart");
|
||||
expect(component.typeOptions[4].value).toBe("never");
|
||||
}));
|
||||
|
||||
it("should have all action options and update form control when value changes", () => {
|
||||
expect(component.actionOptions.length).toBe(3);
|
||||
expect(component.actionOptions[0].value).toBeNull();
|
||||
expect(component.actionOptions[1].value).toBe(VaultTimeoutAction.Lock);
|
||||
expect(component.actionOptions[2].value).toBe(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
});
|
||||
|
||||
describe("form controls change detection", () => {
|
||||
it.each(["never", "onAppRestart", "onSystemLock", "immediately"])(
|
||||
"should disable hours and minutes inputs when type changes from custom to %s",
|
||||
fakeAsync((newType: SessionTimeoutType) => {
|
||||
setPolicyResponseType("custom");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.data.controls.hours.value).toBe(8);
|
||||
expect(component.data.controls.minutes.value).toBe(0);
|
||||
expect(component.data.controls.hours.disabled).toBe(false);
|
||||
expect(component.data.controls.minutes.disabled).toBe(false);
|
||||
|
||||
component.data.patchValue({ type: newType });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.data.controls.hours.disabled).toBe(true);
|
||||
expect(component.data.controls.minutes.disabled).toBe(true);
|
||||
|
||||
assertHoursAndMinutesInputsNotVisible();
|
||||
}),
|
||||
);
|
||||
|
||||
it.each(["never", "onAppRestart", "onSystemLock", "immediately"])(
|
||||
"should enable hours and minutes inputs when type changes from %s to custom",
|
||||
fakeAsync((oldType: SessionTimeoutType) => {
|
||||
setPolicyResponseType(oldType);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.data.controls.hours.disabled).toBe(true);
|
||||
expect(component.data.controls.minutes.disabled).toBe(true);
|
||||
|
||||
component.data.patchValue({ type: "custom", hours: 8, minutes: 1 });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.data.controls.hours.value).toBe(8);
|
||||
expect(component.data.controls.minutes.value).toBe(1);
|
||||
expect(component.data.controls.hours.disabled).toBe(false);
|
||||
expect(component.data.controls.minutes.disabled).toBe(false);
|
||||
|
||||
assertHoursAndMinutesInputs("8", "1");
|
||||
}),
|
||||
);
|
||||
|
||||
it.each(["custom", "onAppRestart", "immediately"])(
|
||||
"should not show confirmation dialog when changing to %s type",
|
||||
fakeAsync((newType: SessionTimeoutType) => {
|
||||
setPolicyResponseType(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.data.patchValue({ type: newType });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
}),
|
||||
);
|
||||
|
||||
it("should show never confirmation dialog when changing to never type", fakeAsync(() => {
|
||||
setPolicyResponseType(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.data.patchValue({ type: "never" });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockDialogService.open).toHaveBeenCalledWith(
|
||||
SessionTimeoutConfirmationNeverComponent,
|
||||
{
|
||||
disableClose: true,
|
||||
},
|
||||
);
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("should show simple confirmation dialog when changing to onSystemLock type", fakeAsync(() => {
|
||||
setPolicyResponseType(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.data.patchValue({ type: "onSystemLock" });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
type: "info",
|
||||
title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" },
|
||||
content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" },
|
||||
acceptButtonText: { key: "continue" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
});
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(component.data.controls.type.value).toBe("onSystemLock");
|
||||
}));
|
||||
|
||||
it("should revert to previous type when type changed to never and dialog not confirmed", fakeAsync(() => {
|
||||
mockDialogRef.closed = of(false);
|
||||
setPolicyResponseType("immediately");
|
||||
fixture.detectChanges();
|
||||
|
||||
component.data.patchValue({ type: "never" });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockDialogService.open).toHaveBeenCalled();
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(component.data.controls.type.value).toBe("immediately");
|
||||
}));
|
||||
|
||||
it("should revert to previous type when type changed to onSystemLock and dialog not confirmed", fakeAsync(() => {
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
setPolicyResponseType("immediately");
|
||||
fixture.detectChanges();
|
||||
|
||||
component.data.patchValue({ type: "onSystemLock" });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalled();
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(component.data.controls.type.value).toBe("immediately");
|
||||
}));
|
||||
|
||||
it("should revert to last confirmed type when canceling multiple times", fakeAsync(() => {
|
||||
mockDialogRef.closed = of(false);
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
setPolicyResponseType("custom");
|
||||
fixture.detectChanges();
|
||||
|
||||
// First attempt: custom -> never (cancel)
|
||||
component.data.patchValue({ type: "never" });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.data.controls.type.value).toBe("custom");
|
||||
|
||||
// Second attempt: custom -> onSystemLock (cancel)
|
||||
component.data.patchValue({ type: "onSystemLock" });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Should revert to "custom", not "never"
|
||||
expect(component.data.controls.type.value).toBe("custom");
|
||||
}));
|
||||
});
|
||||
|
||||
describe("buildRequestData", () => {
|
||||
beforeEach(() => {
|
||||
setPolicyResponseType("custom");
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should throw max allowed timeout required error when type is invalid", () => {
|
||||
component.data.patchValue({ type: null });
|
||||
|
||||
expect(() => component["buildRequestData"]()).toThrow(
|
||||
"maximumAllowedTimeoutRequired-used-i18n",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
[null, 0],
|
||||
[0, null],
|
||||
[0, 0],
|
||||
])(
|
||||
"should throw invalid time error when type is custom, hours is %o and minutes is %o ",
|
||||
(hours, minutes) => {
|
||||
component.data.patchValue({
|
||||
type: "custom",
|
||||
hours: hours,
|
||||
minutes: minutes,
|
||||
});
|
||||
|
||||
expect(() => component["buildRequestData"]()).toThrow(
|
||||
"sessionTimeoutPolicyInvalidTime-used-i18n",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("should return correct data when type is custom with valid time", () => {
|
||||
component.data.patchValue({
|
||||
type: "custom",
|
||||
hours: 8,
|
||||
minutes: 30,
|
||||
action: VaultTimeoutAction.Lock,
|
||||
});
|
||||
|
||||
const result = component["buildRequestData"]();
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "custom",
|
||||
minutes: 510,
|
||||
action: VaultTimeoutAction.Lock,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["never", null],
|
||||
["never", VaultTimeoutAction.Lock],
|
||||
["never", VaultTimeoutAction.LogOut],
|
||||
["immediately", null],
|
||||
["immediately", VaultTimeoutAction.Lock],
|
||||
["immediately", VaultTimeoutAction.LogOut],
|
||||
["onSystemLock", null],
|
||||
["onSystemLock", VaultTimeoutAction.Lock],
|
||||
["onSystemLock", VaultTimeoutAction.LogOut],
|
||||
["onAppRestart", null],
|
||||
["onAppRestart", VaultTimeoutAction.Lock],
|
||||
["onAppRestart", VaultTimeoutAction.LogOut],
|
||||
])(
|
||||
"should return default 8 hours for backward compatibility when type is %s and action is %s",
|
||||
(type, action) => {
|
||||
component.data.patchValue({
|
||||
type: type as SessionTimeoutType,
|
||||
hours: 5,
|
||||
minutes: 25,
|
||||
action: action as SessionTimeoutAction,
|
||||
});
|
||||
|
||||
const result = component["buildRequestData"]();
|
||||
|
||||
expect(result).toEqual({
|
||||
type,
|
||||
minutes: 480,
|
||||
action,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
concatMap,
|
||||
firstValueFrom,
|
||||
Subject,
|
||||
takeUntil,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
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,
|
||||
} 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";
|
||||
type = PolicyType.MaximumVaultTimeout;
|
||||
component = SessionTimeoutPolicyComponent;
|
||||
}
|
||||
|
||||
const DEFAULT_HOURS = 8;
|
||||
const DEFAULT_MINUTES = 0;
|
||||
|
||||
@Component({
|
||||
templateUrl: "session-timeout.component.html",
|
||||
imports: [SharedModule],
|
||||
})
|
||||
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({
|
||||
type: new FormControl<SessionTimeoutType>(null, [Validators.required]),
|
||||
hours: new FormControl<number>(
|
||||
{
|
||||
value: DEFAULT_HOURS,
|
||||
disabled: true,
|
||||
},
|
||||
[Validators.required],
|
||||
),
|
||||
minutes: new FormControl<number>(
|
||||
{
|
||||
value: DEFAULT_MINUTES,
|
||||
disabled: true,
|
||||
},
|
||||
[Validators.required],
|
||||
),
|
||||
action: new FormControl<SessionTimeoutAction>(null),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
) {
|
||||
super();
|
||||
this.actionOptions = [
|
||||
{ name: i18nService.t("userPreference"), value: null },
|
||||
{ name: i18nService.t("lock"), value: VaultTimeoutAction.Lock },
|
||||
{ name: i18nService.t("logOut"), value: VaultTimeoutAction.LogOut },
|
||||
];
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("immediately"), value: "immediately" },
|
||||
{ name: i18nService.t("custom"), value: "custom" },
|
||||
{ name: i18nService.t("onSystemLock"), value: "onSystemLock" },
|
||||
{ name: i18nService.t("onAppRestart"), value: "onAppRestart" },
|
||||
{ name: i18nService.t("never"), value: "never" },
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
super.ngOnInit();
|
||||
|
||||
const typeControl = this.data.controls.type;
|
||||
this.lastConfirmedType$.next(typeControl.value ?? null);
|
||||
|
||||
typeControl.valueChanges
|
||||
.pipe(
|
||||
withLatestFrom(this.lastConfirmedType$),
|
||||
concatMap(async ([newType, lastConfirmedType]) => {
|
||||
const confirmed = await this.confirmTypeChange(newType);
|
||||
if (confirmed) {
|
||||
this.updateFormControls(newType);
|
||||
this.lastConfirmedType$.next(newType);
|
||||
} else {
|
||||
typeControl.setValue(lastConfirmedType, { emitEvent: false });
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
protected override loadData() {
|
||||
const minutes: number | null = this.policyResponse?.data?.minutes ?? null;
|
||||
const action: SessionTimeoutAction =
|
||||
this.policyResponse?.data?.action ?? (null satisfies SessionTimeoutAction);
|
||||
// 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);
|
||||
|
||||
this.updateFormControls(type);
|
||||
this.data.patchValue({
|
||||
type: type,
|
||||
hours: minutes ? Math.floor(minutes / 60) : DEFAULT_HOURS,
|
||||
minutes: minutes ? minutes % 60 : DEFAULT_MINUTES,
|
||||
action: action,
|
||||
});
|
||||
}
|
||||
|
||||
protected override buildRequestData() {
|
||||
this.data.markAllAsTouched();
|
||||
this.data.updateValueAndValidity();
|
||||
if (this.data.invalid) {
|
||||
if (this.data.controls.type.hasError("required")) {
|
||||
throw new Error(this.i18nService.t("maximumAllowedTimeoutRequired"));
|
||||
}
|
||||
throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime"));
|
||||
}
|
||||
|
||||
let minutes = this.data.value.hours! * 60 + this.data.value.minutes!;
|
||||
|
||||
const type = this.data.value.type;
|
||||
if (type === "custom") {
|
||||
if (minutes <= 0) {
|
||||
throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime"));
|
||||
}
|
||||
} else {
|
||||
// For backwards compatibility, we set minutes to 8 hours, so older client's vault timeout will not be broken
|
||||
minutes = DEFAULT_HOURS * 60 + DEFAULT_MINUTES;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
minutes,
|
||||
action: this.data.value.action,
|
||||
};
|
||||
}
|
||||
|
||||
private async confirmTypeChange(newType: SessionTimeoutType): Promise<boolean> {
|
||||
if (newType === "never") {
|
||||
const dialogRef = SessionTimeoutConfirmationNeverComponent.open(this.dialogService);
|
||||
return !!(await firstValueFrom(dialogRef.closed));
|
||||
} else if (newType === "onSystemLock") {
|
||||
return await this.dialogService.openSimpleDialog({
|
||||
type: "info",
|
||||
title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" },
|
||||
content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" },
|
||||
acceptButtonText: { key: "continue" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private updateFormControls(type: SessionTimeoutType) {
|
||||
const hoursControl = this.data.controls.hours;
|
||||
const minutesControl = this.data.controls.minutes;
|
||||
if (type === "custom") {
|
||||
hoursControl.enable();
|
||||
minutesControl.enable();
|
||||
} else {
|
||||
hoursControl.disable();
|
||||
minutesControl.disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user