mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-26057] Enforce session timeout policy (#17424)
* enforce session timeout policy * better angular validation * lint fix * missing switch break * fallback when timeout not supported with highest available timeout * failing unit tests * incorrect policy message * vault timeout type adjustments * fallback to "on browser refresh" for browser, when policy is set to "on system locked", but not available (Safari) * docs, naming improvements * fallback for current user session timeout to "on refresh", when policy is set to "on system locked", but not available. * don't display policy message when the policy does not affect available timeout options * 8 hours default when changing from non-numeric timeout to Custom. * failing unit test * missing locales, changing functions access to private, docs * removal of redundant magic number * missing await * await once for available timeout options * adjusted messaging * unit test coverage * vault timeout numeric module exports * unit test coverage
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user