mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
[PM-26056] Consolidated session timeout component (#16988)
* consolidated session timeout settings component * rename preferences to appearance * race condition bug on computed signal * outdated header for browser * unnecessary padding * remove required on action, fix build * rename localization key * missing user id * required * cleanup task * eslint fix signals rollback * takeUntilDestroyed, null checks * move browser specific logic outside shared component * explicit input type * input name * takeUntilDestroyed, no toast * unit tests * cleanup * cleanup, correct link to deprecation jira * tech debt todo with jira * missing web localization key when policy is on * relative import * extracting timeout options to component service * duplicate localization key * fix failing test * subsequent timeout action selecting opening without dialog on first dialog cancellation * default locale can be null * unit tests failing * rename, simplifications * one if else feature flag * timeout input component rendering before async pipe completion
This commit is contained in:
@@ -796,6 +796,12 @@
|
||||
"onLocked": {
|
||||
"message": "On system lock"
|
||||
},
|
||||
"onIdle": {
|
||||
"message": "On system idle"
|
||||
},
|
||||
"onSleep": {
|
||||
"message": "On system sleep"
|
||||
},
|
||||
"onRestart": {
|
||||
"message": "On browser restart"
|
||||
},
|
||||
@@ -5809,5 +5815,8 @@
|
||||
},
|
||||
"cardNumberLabel": {
|
||||
"message": "Card number"
|
||||
},
|
||||
"sessionTimeoutSettingsAction": {
|
||||
"message": "Timeout action"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<bit-card>
|
||||
<bit-form-control [disableMargin]="!((pinEnabled$ | async) || this.form.value.pin)">
|
||||
<input bitCheckbox id="biometric" type="checkbox" formControlName="biometric" />
|
||||
<bit-label for="biometric" class="tw-whitespace-normal">{{
|
||||
"unlockWithBiometrics" | i18n
|
||||
}}</bit-label>
|
||||
<bit-label for="biometric" class="tw-whitespace-normal">
|
||||
{{ "unlockWithBiometrics" | i18n }}
|
||||
</bit-label>
|
||||
<bit-hint *ngIf="biometricUnavailabilityReason">
|
||||
{{ biometricUnavailabilityReason }}
|
||||
</bit-hint>
|
||||
@@ -38,9 +38,9 @@
|
||||
type="checkbox"
|
||||
formControlName="enableAutoBiometricsPrompt"
|
||||
/>
|
||||
<bit-label for="autoBiometricsPrompt" class="tw-whitespace-normal">{{
|
||||
"enableAutoBiometricsPrompt" | i18n
|
||||
}}</bit-label>
|
||||
<bit-label for="autoBiometricsPrompt" class="tw-whitespace-normal">
|
||||
{{ "enableAutoBiometricsPrompt" | i18n }}
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control
|
||||
[disableMargin]="!(this.form.value.pin && showMasterPasswordOnClientRestartOption)"
|
||||
@@ -60,16 +60,29 @@
|
||||
type="checkbox"
|
||||
formControlName="pinLockWithMasterPassword"
|
||||
/>
|
||||
<bit-label for="pinEphemeral" class="tw-whitespace-normal">{{
|
||||
"lockWithMasterPassOnRestart1" | i18n
|
||||
}}</bit-label>
|
||||
<bit-label for="pinEphemeral" class="tw-whitespace-normal">
|
||||
{{ "lockWithMasterPassOnRestart1" | i18n }}
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
|
||||
<bit-section>
|
||||
@if (consolidatedSessionTimeoutComponent$ | async) {
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
|
||||
<h2 bitTypography="h6">
|
||||
{{ "sessionTimeoutHeader" | i18n }}
|
||||
</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<bit-card>
|
||||
<bit-session-timeout-settings [refreshTimeoutActionSettings]="refreshTimeoutSettings$" />
|
||||
</bit-card>
|
||||
} @else {
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">
|
||||
{{ "vaultTimeoutHeader" | i18n }}
|
||||
</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<bit-card>
|
||||
@@ -100,6 +113,7 @@
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
</bit-card>
|
||||
}
|
||||
</bit-section>
|
||||
|
||||
<bit-section>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
VaultTimeoutStringType,
|
||||
VaultTimeoutAction,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -64,6 +65,7 @@ describe("AccountSecurityComponent", () => {
|
||||
const dialogService = mock<DialogService>();
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
const lockService = mock<LockService>();
|
||||
const configService = mock<ConfigService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -93,6 +95,7 @@ describe("AccountSecurityComponent", () => {
|
||||
{ provide: CollectionService, useValue: mock<CollectionService>() },
|
||||
{ provide: ValidationService, useValue: validationService },
|
||||
{ provide: LockService, useValue: lockService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(AccountSecurityComponent, {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import {
|
||||
VaultTimeout,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
VaultTimeoutSettingsService,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
BiometricStateService,
|
||||
BiometricsStatus,
|
||||
} from "@bitwarden/key-management";
|
||||
import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
@@ -100,6 +103,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
SelectModule,
|
||||
SessionTimeoutSettingsComponent,
|
||||
SpotlightComponent,
|
||||
TypographyModule,
|
||||
VaultTimeoutInputComponent,
|
||||
@@ -133,11 +137,14 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
),
|
||||
);
|
||||
|
||||
private refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
|
||||
protected readonly consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
protected refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private pinService: PinServiceAbstraction,
|
||||
private policyService: PolicyService,
|
||||
private formBuilder: FormBuilder,
|
||||
@@ -157,7 +164,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
private vaultNudgesService: NudgesService,
|
||||
private validationService: ValidationService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
) {
|
||||
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const hasMasterPassword = await this.userVerificationService.hasMasterPassword();
|
||||
@@ -173,6 +184,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
this.hasVaultTimeoutPolicy = true;
|
||||
}
|
||||
|
||||
// Determine platform-specific timeout options
|
||||
const showOnLocked =
|
||||
!this.platformUtilsService.isFirefox() &&
|
||||
!this.platformUtilsService.isSafari() &&
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defer, Observable, of } from "rxjs";
|
||||
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui";
|
||||
|
||||
export class BrowserSessionTimeoutSettingsComponentService
|
||||
implements SessionTimeoutSettingsComponentService
|
||||
{
|
||||
availableTimeoutOptions$: Observable<VaultTimeoutOption[]> = defer(() => {
|
||||
const options: VaultTimeoutOption[] = [
|
||||
{ name: this.i18nService.t("immediately"), value: 0 },
|
||||
{ name: this.i18nService.t("oneMinute"), value: 1 },
|
||||
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
|
||||
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
|
||||
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
|
||||
{ name: this.i18nService.t("oneHour"), value: 60 },
|
||||
{ name: this.i18nService.t("fourHours"), value: 240 },
|
||||
];
|
||||
|
||||
const showOnLocked =
|
||||
!this.platformUtilsService.isFirefox() &&
|
||||
!this.platformUtilsService.isSafari() &&
|
||||
!(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel");
|
||||
|
||||
if (showOnLocked) {
|
||||
options.push({
|
||||
name: this.i18nService.t("onLocked"),
|
||||
value: VaultTimeoutStringType.OnLocked,
|
||||
});
|
||||
}
|
||||
|
||||
options.push(
|
||||
{ name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart },
|
||||
{ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never },
|
||||
);
|
||||
|
||||
return of(options);
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly messagingService: MessagingService,
|
||||
) {}
|
||||
|
||||
onTimeoutSave(timeout: VaultTimeout): void {
|
||||
if (timeout === VaultTimeoutStringType.Never) {
|
||||
this.messagingService.send("bgReseedStorage");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,10 @@ import {
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LockComponentService } from "@bitwarden/key-management-ui";
|
||||
import {
|
||||
LockComponentService,
|
||||
SessionTimeoutSettingsComponentService,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { DerivedStateProvider, GlobalStateProvider, StateProvider } from "@bitwarden/state";
|
||||
import { InlineDerivedStateProvider } from "@bitwarden/state-internal";
|
||||
import {
|
||||
@@ -165,6 +168,7 @@ import AutofillService from "../../autofill/services/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service";
|
||||
import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics";
|
||||
import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service";
|
||||
import { BrowserSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/browser-session-timeout-settings-component.service";
|
||||
import { ForegroundVaultTimeoutService } from "../../key-management/vault-timeout/foreground-vault-timeout.service";
|
||||
import { BrowserActionsService } from "../../platform/actions/browser-actions.service";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
@@ -713,6 +717,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: ExtensionNewDeviceVerificationComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SessionTimeoutSettingsComponentService,
|
||||
useClass: BrowserSessionTimeoutSettingsComponentService,
|
||||
deps: [I18nServiceAbstraction, PlatformUtilsService, MessagingServiceAbstraction],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
</h2>
|
||||
<ng-container *ngIf="showSecurity">
|
||||
<bit-section disableMargin>
|
||||
@if (consolidatedSessionTimeoutComponent$ | async) {
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "sessionTimeoutHeader" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<bit-session-timeout-settings
|
||||
[refreshTimeoutActionSettings]="refreshTimeoutSettings$"
|
||||
/>
|
||||
} @else {
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
@@ -43,7 +52,9 @@
|
||||
</auth-vault-timeout-input>
|
||||
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
|
||||
<bit-label for="vaultTimeoutAction">{{
|
||||
"vaultTimeoutAction1" | i18n
|
||||
}}</bit-label>
|
||||
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
|
||||
<bit-option
|
||||
*ngFor="let action of availableVaultTimeoutActions"
|
||||
@@ -53,7 +64,9 @@
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
|
||||
<bit-hint *ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)">
|
||||
<bit-hint
|
||||
*ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)"
|
||||
>
|
||||
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
@@ -61,6 +74,7 @@
|
||||
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
}
|
||||
</bit-section>
|
||||
<div class="form-group tw-mt-4" *ngIf="(pinEnabled$ | async) || this.form.value.pin">
|
||||
<div class="checkbox">
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("SettingsComponent", () => {
|
||||
desktopAutotypeService.autotypeEnabledUserSetting$ = of(false);
|
||||
desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]);
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui";
|
||||
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
|
||||
|
||||
import { SetPinComponent } from "../../auth/components/set-pin.component";
|
||||
@@ -95,6 +96,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
VaultTimeoutInputComponent,
|
||||
SessionTimeoutSettingsComponent,
|
||||
PermitCipherDetailsPopoverComponent,
|
||||
PremiumBadgeComponent,
|
||||
],
|
||||
@@ -146,6 +148,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
pinEnabled$: Observable<boolean> = of(true);
|
||||
isWindowsV2BiometricsEnabled: boolean = false;
|
||||
|
||||
consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
form = this.formBuilder.group({
|
||||
// Security
|
||||
vaultTimeout: [null as VaultTimeout | null],
|
||||
@@ -184,7 +188,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
locale: [null as string | null],
|
||||
});
|
||||
|
||||
private refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
|
||||
protected refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
@@ -282,12 +286,17 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
value: SshAgentPromptType.RememberUntilLock,
|
||||
},
|
||||
];
|
||||
|
||||
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
|
||||
|
||||
this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled();
|
||||
|
||||
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
// Autotype is for Windows initially
|
||||
|
||||
@@ -109,7 +109,10 @@ import {
|
||||
BiometricStateService,
|
||||
BiometricsService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LockComponentService } from "@bitwarden/key-management-ui";
|
||||
import {
|
||||
LockComponentService,
|
||||
SessionTimeoutSettingsComponentService,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
|
||||
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
|
||||
@@ -125,6 +128,7 @@ import { DesktopBiometricsService } from "../../key-management/biometrics/deskto
|
||||
import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service";
|
||||
import { ElectronKeyService } from "../../key-management/electron-key.service";
|
||||
import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service";
|
||||
import { DesktopSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/desktop-session-timeout-settings-component.service";
|
||||
import { flagEnabled } from "../../platform/flags";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service";
|
||||
@@ -480,6 +484,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DesktopAutotypeDefaultSettingPolicy,
|
||||
deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SessionTimeoutSettingsComponentService,
|
||||
useClass: DesktopSessionTimeoutSettingsComponentService,
|
||||
deps: [I18nServiceAbstraction],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defer, from, map, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui";
|
||||
|
||||
export class DesktopSessionTimeoutSettingsComponentService
|
||||
implements SessionTimeoutSettingsComponentService
|
||||
{
|
||||
availableTimeoutOptions$: Observable<VaultTimeoutOption[]> = defer(() =>
|
||||
from(ipc.platform.powermonitor.isLockMonitorAvailable()).pipe(
|
||||
map((isLockMonitorAvailable) => {
|
||||
const options: VaultTimeoutOption[] = [
|
||||
{ name: this.i18nService.t("oneMinute"), value: 1 },
|
||||
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
|
||||
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
|
||||
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
|
||||
{ name: this.i18nService.t("oneHour"), value: 60 },
|
||||
{ name: this.i18nService.t("fourHours"), value: 240 },
|
||||
{ name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle },
|
||||
{ name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep },
|
||||
];
|
||||
|
||||
if (isLockMonitorAvailable) {
|
||||
options.push({
|
||||
name: this.i18nService.t("onLocked"),
|
||||
value: VaultTimeoutStringType.OnLocked,
|
||||
});
|
||||
}
|
||||
|
||||
options.push(
|
||||
{ name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart },
|
||||
{ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never },
|
||||
);
|
||||
|
||||
return options;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(private readonly i18nService: I18nService) {}
|
||||
|
||||
onTimeoutSave(_: VaultTimeout): void {}
|
||||
}
|
||||
@@ -4220,5 +4220,11 @@
|
||||
},
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade to Premium"
|
||||
},
|
||||
"sessionTimeoutSettingsAction": {
|
||||
"message": "Timeout action"
|
||||
},
|
||||
"sessionTimeoutHeader": {
|
||||
"message": "Session timeout"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { SessionTimeoutComponent } from "../../../key-management/session-timeout/session-timeout.component";
|
||||
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component";
|
||||
|
||||
import { PasswordSettingsComponent } from "./password-settings/password-settings.component";
|
||||
@@ -15,7 +18,20 @@ const routes: Routes = [
|
||||
component: SecurityComponent,
|
||||
data: { titleId: "security" },
|
||||
children: [
|
||||
{ path: "", pathMatch: "full", redirectTo: "password" },
|
||||
{ path: "", pathMatch: "full", redirectTo: "session-timeout" },
|
||||
{
|
||||
path: "session-timeout",
|
||||
component: SessionTimeoutComponent,
|
||||
canActivate: [
|
||||
canAccessFeature(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
true,
|
||||
"/settings/security/password",
|
||||
false,
|
||||
),
|
||||
],
|
||||
data: { titleId: "sessionTimeoutHeader" },
|
||||
},
|
||||
{
|
||||
path: "password",
|
||||
component: PasswordSettingsComponent,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<app-header>
|
||||
<bit-tab-nav-bar slot="tabs">
|
||||
<ng-container *ngIf="showChangePassword">
|
||||
@if (consolidatedSessionTimeoutComponent$ | async) {
|
||||
<bit-tab-link route="session-timeout">{{ "sessionTimeoutHeader" | i18n }}</bit-tab-link>
|
||||
}
|
||||
@if (showChangePassword) {
|
||||
<bit-tab-link [route]="changePasswordRoute">{{ "masterPassword" | i18n }}</bit-tab-link>
|
||||
</ng-container>
|
||||
}
|
||||
<bit-tab-link route="two-factor">{{ "twoStepLogin" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="device-management">{{ "devices" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="security-keys">{{ "keys" | i18n }}</bit-tab-link>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../shared";
|
||||
@@ -14,8 +17,16 @@ import { SharedModule } from "../../../shared";
|
||||
export class SecurityComponent implements OnInit {
|
||||
showChangePassword = true;
|
||||
changePasswordRoute = "password";
|
||||
consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
constructor(private userVerificationService: UserVerificationService) {}
|
||||
constructor(
|
||||
private userVerificationService: UserVerificationService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.showChangePassword = await this.userVerificationService.hasMasterPassword();
|
||||
|
||||
@@ -117,10 +117,14 @@ import {
|
||||
KeyService as KeyServiceAbstraction,
|
||||
BiometricsService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LockComponentService } from "@bitwarden/key-management-ui";
|
||||
import {
|
||||
LockComponentService,
|
||||
SessionTimeoutSettingsComponentService,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
|
||||
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service";
|
||||
import { WebSessionTimeoutSettingsComponentService } from "@bitwarden/web-vault/app/key-management/session-timeout/services/web-session-timeout-settings-component.service";
|
||||
import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service";
|
||||
|
||||
import { flagEnabled } from "../../utils/flags";
|
||||
@@ -465,6 +469,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: WebSystemService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SessionTimeoutSettingsComponentService,
|
||||
useClass: WebSessionTimeoutSettingsComponentService,
|
||||
deps: [I18nServiceAbstraction, PlatformUtilsService],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defer, Observable, of } from "rxjs";
|
||||
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui";
|
||||
|
||||
export class WebSessionTimeoutSettingsComponentService
|
||||
implements SessionTimeoutSettingsComponentService
|
||||
{
|
||||
availableTimeoutOptions$: Observable<VaultTimeoutOption[]> = defer(() => {
|
||||
const options: VaultTimeoutOption[] = [
|
||||
{ name: this.i18nService.t("oneMinute"), value: 1 },
|
||||
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
|
||||
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
|
||||
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
|
||||
{ name: this.i18nService.t("oneHour"), value: 60 },
|
||||
{ name: this.i18nService.t("fourHours"), value: 240 },
|
||||
{ name: this.i18nService.t("onRefresh"), value: VaultTimeoutStringType.OnRestart },
|
||||
];
|
||||
|
||||
if (this.platformUtilsService.isDev()) {
|
||||
options.push({ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never });
|
||||
}
|
||||
|
||||
return of(options);
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
onTimeoutSave(_: VaultTimeout): void {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<h2 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "sessionTimeoutHeader" | i18n }}</h2>
|
||||
|
||||
<div class="tw-max-w-lg">
|
||||
<bit-session-timeout-settings />
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
@Component({
|
||||
templateUrl: "session-timeout.component.html",
|
||||
imports: [SessionTimeoutSettingsComponent, JslibModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SessionTimeoutComponent {}
|
||||
@@ -13,7 +13,11 @@
|
||||
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" route="settings">
|
||||
<bit-nav-item [text]="'myAccount' | i18n" route="settings/account"></bit-nav-item>
|
||||
<bit-nav-item [text]="'security' | i18n" route="settings/security"></bit-nav-item>
|
||||
@if (consolidatedSessionTimeoutComponent$ | async) {
|
||||
<bit-nav-item [text]="'appearance' | i18n" route="settings/appearance"></bit-nav-item>
|
||||
} @else {
|
||||
<bit-nav-item [text]="'preferences' | i18n" route="settings/preferences"></bit-nav-item>
|
||||
}
|
||||
<bit-nav-item
|
||||
[text]="'subscription' | i18n"
|
||||
route="settings/subscription"
|
||||
|
||||
@@ -42,6 +42,7 @@ export class UserLayoutComponent implements OnInit {
|
||||
protected hasFamilySponsorshipAvailable$: Observable<boolean>;
|
||||
protected showSponsoredFamilies$: Observable<boolean>;
|
||||
protected showSubscription$: Observable<boolean>;
|
||||
protected consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
@@ -74,6 +75,10 @@ export class UserLayoutComponent implements OnInit {
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { LoginViaWebAuthnComponent } from "@bitwarden/angular/auth/login-via-webauthn/login-via-webauthn.component";
|
||||
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
|
||||
import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import {
|
||||
DevicesIcon,
|
||||
RegistrationUserAddIcon,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
NewDeviceVerificationComponent,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { LockComponent } from "@bitwarden/key-management-ui";
|
||||
import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard";
|
||||
@@ -82,6 +84,7 @@ import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
|
||||
import { UserLayoutComponent } from "./layouts/user-layout.component";
|
||||
import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component";
|
||||
import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component";
|
||||
import { AppearanceComponent } from "./settings/appearance.component";
|
||||
import { DomainRulesComponent } from "./settings/domain-rules.component";
|
||||
import { PreferencesComponent } from "./settings/preferences.component";
|
||||
import { CredentialGeneratorComponent } from "./tools/credential-generator/credential-generator.component";
|
||||
@@ -663,9 +666,30 @@ const routes: Routes = [
|
||||
component: AccountComponent,
|
||||
data: { titleId: "myAccount" } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
path: "appearance",
|
||||
component: AppearanceComponent,
|
||||
canActivate: [
|
||||
canAccessFeature(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
true,
|
||||
"/settings/preferences",
|
||||
false,
|
||||
),
|
||||
],
|
||||
data: { titleId: "appearance" } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
path: "preferences",
|
||||
component: PreferencesComponent,
|
||||
canActivate: [
|
||||
canAccessFeature(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
false,
|
||||
"/settings/appearance",
|
||||
false,
|
||||
),
|
||||
],
|
||||
data: { titleId: "preferences" } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
|
||||
48
apps/web/src/app/settings/appearance.component.html
Normal file
48
apps/web/src/app/settings/appearance.component.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<app-header></app-header>
|
||||
|
||||
<bit-container>
|
||||
<form [formGroup]="form" class="tw-w-full tw-max-w-md">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "theme" | i18n }}</bit-label>
|
||||
<bit-select formControlName="theme" id="theme">
|
||||
@for (option of themeOptions; track option.value) {
|
||||
<bit-option [value]="option.value" [label]="option.name"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
<bit-hint>{{ "themeDesc" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
{{ "language" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
class="tw-float-right"
|
||||
href="https://bitwarden.com/help/localization/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
appA11yTitle="{{ 'learnMoreAboutLocalization' | i18n }}"
|
||||
slot="end"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-label>
|
||||
<bit-select formControlName="locale" id="locale">
|
||||
@for (option of localeOptions; track option.value) {
|
||||
<bit-option [value]="option.value" [label]="option.name"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
<bit-hint>{{ "languageDesc" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex tw-items-start tw-gap-1.5">
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="enableFavicons" />
|
||||
<bit-label>
|
||||
{{ "showIconsChangePasswordUrls" | i18n }}
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
<div class="-tw-mt-0.5">
|
||||
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</bit-container>
|
||||
215
apps/web/src/app/settings/appearance.component.spec.ts
Normal file
215
apps/web/src/app/settings/appearance.component.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
|
||||
import { AppearanceComponent } from "./appearance.component";
|
||||
|
||||
describe("AppearanceComponent", () => {
|
||||
let component: AppearanceComponent;
|
||||
let fixture: ComponentFixture<AppearanceComponent>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockThemeStateService: MockProxy<ThemeStateService>;
|
||||
let mockDomainSettingsService: MockProxy<DomainSettingsService>;
|
||||
|
||||
const mockShowFavicons$ = new BehaviorSubject<boolean>(true);
|
||||
const mockSelectedTheme$ = new BehaviorSubject<Theme>(ThemeTypes.Light);
|
||||
const mockUserSetLocale$ = new BehaviorSubject<string | undefined>("en");
|
||||
|
||||
const mockSupportedLocales = ["en", "es", "fr", "de"];
|
||||
const mockLocaleNames = new Map([
|
||||
["en", "English"],
|
||||
["es", "Español"],
|
||||
["fr", "Français"],
|
||||
["de", "Deutsch"],
|
||||
]);
|
||||
|
||||
beforeEach(async () => {
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockThemeStateService = mock<ThemeStateService>();
|
||||
mockDomainSettingsService = mock<DomainSettingsService>();
|
||||
|
||||
mockI18nService.supportedTranslationLocales = mockSupportedLocales;
|
||||
mockI18nService.localeNames = mockLocaleNames;
|
||||
mockI18nService.collator = {
|
||||
compare: jest.fn((a: string, b: string) => a.localeCompare(b)),
|
||||
} as any;
|
||||
mockI18nService.t.mockImplementation((key: string) => `${key}-used-i18n`);
|
||||
mockI18nService.userSetLocale$ = mockUserSetLocale$;
|
||||
|
||||
mockThemeStateService.selectedTheme$ = mockSelectedTheme$;
|
||||
mockDomainSettingsService.showFavicons$ = mockShowFavicons$;
|
||||
|
||||
mockDomainSettingsService.setShowFavicons.mockResolvedValue(undefined);
|
||||
mockThemeStateService.setSelectedTheme.mockResolvedValue(undefined);
|
||||
mockI18nService.setLocale.mockResolvedValue(undefined);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppearanceComponent, ReactiveFormsModule, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ThemeStateService, useValue: mockThemeStateService },
|
||||
{ provide: DomainSettingsService, useValue: mockDomainSettingsService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(AppearanceComponent, {
|
||||
set: {
|
||||
template: "",
|
||||
imports: [],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppearanceComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
describe("locale options setup", () => {
|
||||
it("should create locale options sorted by name from supported locales with display names", () => {
|
||||
expect(component.localeOptions).toHaveLength(5);
|
||||
expect(component.localeOptions[0]).toEqual({ name: "default-used-i18n", value: null });
|
||||
expect(component.localeOptions[1]).toEqual({ name: "de - Deutsch", value: "de" });
|
||||
expect(component.localeOptions[2]).toEqual({ name: "en - English", value: "en" });
|
||||
expect(component.localeOptions[3]).toEqual({ name: "es - Español", value: "es" });
|
||||
expect(component.localeOptions[4]).toEqual({ name: "fr - Français", value: "fr" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme options setup", () => {
|
||||
it("should create theme options with Light, Dark, and System", () => {
|
||||
expect(component.themeOptions).toEqual([
|
||||
{ name: "themeLight-used-i18n", value: ThemeTypes.Light },
|
||||
{ name: "themeDark-used-i18n", value: ThemeTypes.Dark },
|
||||
{ name: "themeSystem-used-i18n", value: ThemeTypes.System },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should initialize form with values", fakeAsync(() => {
|
||||
mockShowFavicons$.next(false);
|
||||
mockSelectedTheme$.next(ThemeTypes.Dark);
|
||||
mockUserSetLocale$.next("es");
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.form.value).toEqual({
|
||||
enableFavicons: false,
|
||||
theme: ThemeTypes.Dark,
|
||||
locale: "es",
|
||||
});
|
||||
}));
|
||||
|
||||
it("should set locale to null when user locale not set", fakeAsync(() => {
|
||||
mockUserSetLocale$.next(undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.form.value.locale).toBeNull();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("enableFavicons value changes", () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
jest.clearAllMocks();
|
||||
}));
|
||||
|
||||
it("should call setShowFavicons when enableFavicons changes to true", fakeAsync(() => {
|
||||
component.form.controls.enableFavicons.setValue(true);
|
||||
flush();
|
||||
|
||||
expect(mockDomainSettingsService.setShowFavicons).toHaveBeenCalledWith(true);
|
||||
}));
|
||||
|
||||
it("should call setShowFavicons when enableFavicons changes to false", fakeAsync(() => {
|
||||
component.form.controls.enableFavicons.setValue(false);
|
||||
flush();
|
||||
|
||||
expect(mockDomainSettingsService.setShowFavicons).toHaveBeenCalledWith(false);
|
||||
}));
|
||||
|
||||
it("should not call setShowFavicons when value is null", fakeAsync(() => {
|
||||
component.form.controls.enableFavicons.setValue(null);
|
||||
flush();
|
||||
|
||||
expect(mockDomainSettingsService.setShowFavicons).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("theme value changes", () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
jest.clearAllMocks();
|
||||
}));
|
||||
|
||||
it.each([ThemeTypes.Light, ThemeTypes.Dark, ThemeTypes.System])(
|
||||
"should call setSelectedTheme when theme changes to %s",
|
||||
fakeAsync((themeType: Theme) => {
|
||||
component.form.controls.theme.setValue(themeType);
|
||||
flush();
|
||||
|
||||
expect(mockThemeStateService.setSelectedTheme).toHaveBeenCalledWith(themeType);
|
||||
}),
|
||||
);
|
||||
|
||||
it("should not call setSelectedTheme when value is null", fakeAsync(() => {
|
||||
component.form.controls.theme.setValue(null);
|
||||
flush();
|
||||
|
||||
expect(mockThemeStateService.setSelectedTheme).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("locale value changes", () => {
|
||||
let reloadMock: jest.Mock;
|
||||
|
||||
beforeEach(fakeAsync(() => {
|
||||
reloadMock = jest.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { reload: reloadMock },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
jest.clearAllMocks();
|
||||
}));
|
||||
|
||||
it("should call setLocale and reload window when locale changes to english", fakeAsync(() => {
|
||||
component.form.controls.locale.setValue("es");
|
||||
flush();
|
||||
|
||||
expect(mockI18nService.setLocale).toHaveBeenCalledWith("es");
|
||||
expect(reloadMock).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("should call setLocale and reload window when locale changes to default", fakeAsync(() => {
|
||||
component.form.controls.locale.setValue(null);
|
||||
flush();
|
||||
|
||||
expect(mockI18nService.setLocale).toHaveBeenCalledWith(null);
|
||||
expect(reloadMock).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
});
|
||||
107
apps/web/src/app/settings/appearance.component.ts
Normal file
107
apps/web/src/app/settings/appearance.component.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { filter, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
|
||||
|
||||
import { HeaderModule } from "../layouts/header/header.module";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
type LocaleOption = {
|
||||
name: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
type ThemeOption = {
|
||||
name: string;
|
||||
value: Theme;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-appearance",
|
||||
templateUrl: "appearance.component.html",
|
||||
imports: [SharedModule, HeaderModule, PermitCipherDetailsPopoverComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppearanceComponent implements OnInit {
|
||||
localeOptions: LocaleOption[];
|
||||
themeOptions: ThemeOption[];
|
||||
|
||||
form = this.formBuilder.group({
|
||||
enableFavicons: true,
|
||||
theme: [ThemeTypes.Light as Theme],
|
||||
locale: [null as string | null],
|
||||
});
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private themeStateService: ThemeStateService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private destroyRef: DestroyRef,
|
||||
) {
|
||||
const localeOptions: LocaleOption[] = [];
|
||||
i18nService.supportedTranslationLocales.forEach((locale) => {
|
||||
let name = locale;
|
||||
if (i18nService.localeNames.has(locale)) {
|
||||
name += " - " + i18nService.localeNames.get(locale);
|
||||
}
|
||||
localeOptions.push({ name: name, value: locale });
|
||||
});
|
||||
localeOptions.sort(Utils.getSortFunction(i18nService, "name"));
|
||||
localeOptions.splice(0, 0, { name: i18nService.t("default"), value: null });
|
||||
this.localeOptions = localeOptions;
|
||||
this.themeOptions = [
|
||||
{ name: i18nService.t("themeLight"), value: ThemeTypes.Light },
|
||||
{ name: i18nService.t("themeDark"), value: ThemeTypes.Dark },
|
||||
{ name: i18nService.t("themeSystem"), value: ThemeTypes.System },
|
||||
];
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.form.setValue(
|
||||
{
|
||||
enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$),
|
||||
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
|
||||
locale: (await firstValueFrom(this.i18nService.userSetLocale$)) ?? null,
|
||||
},
|
||||
{ emitEvent: false },
|
||||
);
|
||||
|
||||
this.form.controls.enableFavicons.valueChanges
|
||||
.pipe(
|
||||
filter((enableFavicons) => enableFavicons != null),
|
||||
switchMap(async (enableFavicons) => {
|
||||
await this.domainSettingsService.setShowFavicons(enableFavicons);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.theme.valueChanges
|
||||
.pipe(
|
||||
filter((theme) => theme != null),
|
||||
switchMap(async (theme) => {
|
||||
await this.themeStateService.setSelectedTheme(theme);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.locale.valueChanges
|
||||
.pipe(
|
||||
switchMap(async (locale) => {
|
||||
await this.i18nService.setLocale(locale);
|
||||
window.location.reload();
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,8 @@
|
||||
</bit-radio-group>
|
||||
</ng-container>
|
||||
<bit-form-field>
|
||||
<bit-label
|
||||
>{{ "language" | i18n }}
|
||||
<bit-label>
|
||||
{{ "language" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
class="tw-float-right"
|
||||
|
||||
@@ -39,6 +39,11 @@ import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
|
||||
import { HeaderModule } from "../layouts/header/header.module";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link AppearanceComponent} and {@link SessionTimeoutComponent} 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({
|
||||
@@ -211,6 +216,8 @@ export class PreferencesComponent implements OnInit, OnDestroy {
|
||||
values.vaultTimeout,
|
||||
values.vaultTimeoutAction,
|
||||
);
|
||||
|
||||
// Save other preferences (theme, locale, favicons)
|
||||
await this.domainSettingsService.setShowFavicons(values.enableFavicons);
|
||||
await this.themeStateService.setSelectedTheme(values.theme);
|
||||
await this.i18nService.setLocale(values.locale);
|
||||
|
||||
@@ -12103,5 +12103,39 @@
|
||||
},
|
||||
"startFreeFamiliesTrial": {
|
||||
"message": "Start free Families trial"
|
||||
},
|
||||
"unlockMethodNeededToChangeTimeoutActionDesc": {
|
||||
"message": "Set up an unlock method to change your vault timeout action."
|
||||
},
|
||||
"vaultTimeoutPolicyAffectingOptions": {
|
||||
"message": "Enterprise policy requirements have been applied to your timeout options"
|
||||
},
|
||||
"vaultTimeoutTooLarge": {
|
||||
"message": "Your vault timeout exceeds the restrictions set by your organization."
|
||||
},
|
||||
"neverLockWarning": {
|
||||
"message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected."
|
||||
},
|
||||
"sessionTimeoutSettingsAction": {
|
||||
"message": "Timeout action"
|
||||
},
|
||||
"sessionTimeoutHeader": {
|
||||
"message": "Session timeout"
|
||||
},
|
||||
"appearance": {
|
||||
"message": "Appearance"
|
||||
},
|
||||
"vaultTimeoutPolicyMaximumError": {
|
||||
"message": "Timeout exceeds the restriction set by your organization: $HOURS$ hour(s) and $MINUTES$ minute(s) maximum",
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
"content": "$1",
|
||||
"example": "5"
|
||||
},
|
||||
"minutes": {
|
||||
"content": "$2",
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export enum FeatureFlag {
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
|
||||
/* Tools */
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
@@ -136,6 +137,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
|
||||
@@ -8,3 +8,4 @@ export {
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "./types/vault-timeout.type";
|
||||
export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
|
||||
export interface MaximumVaultTimeoutPolicyData {
|
||||
minutes: number;
|
||||
action?: VaultTimeoutAction;
|
||||
}
|
||||
@@ -5,6 +5,6 @@ import { TranslationService } from "./translation.service";
|
||||
export abstract class I18nService extends TranslationService {
|
||||
abstract userSetLocale$: Observable<string | undefined>;
|
||||
abstract locale$: Observable<string>;
|
||||
abstract setLocale(locale: string): Promise<void>;
|
||||
abstract setLocale(locale: string | null): Promise<void>;
|
||||
abstract init(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export class I18nService extends TranslationService implements I18nServiceAbstra
|
||||
this.locale$ = this.userSetLocale$.pipe(map((locale) => locale ?? this.translationLocale));
|
||||
}
|
||||
|
||||
async setLocale(locale: string): Promise<void> {
|
||||
async setLocale(locale: string | null): Promise<void> {
|
||||
await this.translationLocaleState.update(() => locale);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,3 +9,5 @@ export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.co
|
||||
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";
|
||||
export { RemovePasswordComponent } from "./key-connector/remove-password.component";
|
||||
export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-connector-domain.component";
|
||||
export { SessionTimeoutSettingsComponent } from "./session-timeout/components/session-timeout-settings.component";
|
||||
export { SessionTimeoutSettingsComponentService } from "./session-timeout/services/session-timeout-settings-component.service";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<div [formGroup]="formGroup">
|
||||
<auth-vault-timeout-input
|
||||
[vaultTimeoutOptions]="availableTimeoutOptions$ | async"
|
||||
[formControl]="formGroup.controls.timeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "sessionTimeoutSettingsAction" | i18n }}</bit-label>
|
||||
<bit-select
|
||||
id="timeoutAction"
|
||||
[formControl]="formGroup.controls.timeoutAction"
|
||||
[required]="false"
|
||||
>
|
||||
@for (action of availableTimeoutActions(); track action) {
|
||||
<bit-option [value]="action" [label]="action | i18n"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
|
||||
@if (!canLock) {
|
||||
<bit-hint>{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br /></bit-hint>
|
||||
}
|
||||
</bit-form-field>
|
||||
|
||||
@if (hasVaultTimeoutPolicy$ | async) {
|
||||
<bit-hint class="tw-mt-4">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,522 @@
|
||||
import { ComponentFixture, fakeAsync, flush, TestBed, waitForAsync } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
MaximumVaultTimeoutPolicyData,
|
||||
VaultTimeout,
|
||||
VaultTimeoutAction,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutSettingsService,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service";
|
||||
|
||||
import { SessionTimeoutSettingsComponent } from "./session-timeout-settings.component";
|
||||
|
||||
describe("SessionTimeoutSettingsComponent", () => {
|
||||
let component: SessionTimeoutSettingsComponent;
|
||||
let fixture: ComponentFixture<SessionTimeoutSettingsComponent>;
|
||||
|
||||
// Mock services
|
||||
let mockVaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let mockSessionTimeoutSettingsComponentService: MockProxy<SessionTimeoutSettingsComponentService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let accountService: FakeAccountService;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockInitialTimeout = 5;
|
||||
const mockInitialTimeoutAction = VaultTimeoutAction.Lock;
|
||||
let refreshTimeoutActionSettings$: BehaviorSubject<void>;
|
||||
let availableTimeoutOptions$: BehaviorSubject<VaultTimeoutOption[]>;
|
||||
|
||||
beforeEach(async () => {
|
||||
refreshTimeoutActionSettings$ = new BehaviorSubject<void>(undefined);
|
||||
availableTimeoutOptions$ = new BehaviorSubject<VaultTimeoutOption[]>([
|
||||
{ name: "oneMinute-used-i18n", value: 1 },
|
||||
{ name: "fiveMinutes-used-i18n", value: 5 },
|
||||
{ name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart },
|
||||
{ name: "onLocked-used-i18n", value: VaultTimeoutStringType.OnLocked },
|
||||
{ name: "onSleep-used-i18n", value: VaultTimeoutStringType.OnSleep },
|
||||
{ name: "onIdle-used-i18n", value: VaultTimeoutStringType.OnIdle },
|
||||
{ name: "never-used-i18n", value: VaultTimeoutStringType.Never },
|
||||
]);
|
||||
|
||||
mockVaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
mockSessionTimeoutSettingsComponentService = mock<SessionTimeoutSettingsComponentService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
accountService = mockAccountServiceWith(mockUserId, { email: mockEmail });
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockLogService = mock<LogService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
|
||||
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() =>
|
||||
of(mockInitialTimeout),
|
||||
);
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation(() =>
|
||||
of(mockInitialTimeoutAction),
|
||||
);
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
mockSessionTimeoutSettingsComponentService.availableTimeoutOptions$ =
|
||||
availableTimeoutOptions$.asObservable();
|
||||
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SessionTimeoutSettingsComponent,
|
||||
ReactiveFormsModule,
|
||||
VaultTimeoutInputComponent,
|
||||
NoopAnimationsModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: VaultTimeoutSettingsService, useValue: mockVaultTimeoutSettingsService },
|
||||
{
|
||||
provide: SessionTimeoutSettingsComponentService,
|
||||
useValue: mockSessionTimeoutSettingsComponentService,
|
||||
},
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(SessionTimeoutSettingsComponent, {
|
||||
set: {
|
||||
providers: [{ provide: DialogService, useValue: mockDialogService }],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SessionTimeoutSettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
fixture.componentRef.setInput("refreshTimeoutActionSettings", refreshTimeoutActionSettings$);
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("canLock", () => {
|
||||
it("should return true when Lock action is available", fakeAsync(() => {
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.canLock).toBe(true);
|
||||
}));
|
||||
|
||||
it("should return false when Lock action is not available", fakeAsync(() => {
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.canLock).toBe(false);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should initialize available timeout options", fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
const options = await firstValueFrom(
|
||||
component["availableTimeoutOptions$"].pipe(filter((options) => options.length > 0)),
|
||||
);
|
||||
|
||||
expect(options).toContainEqual({ name: "oneMinute-used-i18n", value: 1 });
|
||||
expect(options).toContainEqual({ name: "fiveMinutes-used-i18n", value: 5 });
|
||||
expect(options).toContainEqual({
|
||||
name: "onIdle-used-i18n",
|
||||
value: VaultTimeoutStringType.OnIdle,
|
||||
});
|
||||
expect(options).toContainEqual({
|
||||
name: "onSleep-used-i18n",
|
||||
value: VaultTimeoutStringType.OnSleep,
|
||||
});
|
||||
expect(options).toContainEqual({
|
||||
name: "onLocked-used-i18n",
|
||||
value: VaultTimeoutStringType.OnLocked,
|
||||
});
|
||||
expect(options).toContainEqual({
|
||||
name: "onRestart-used-i18n",
|
||||
value: VaultTimeoutStringType.OnRestart,
|
||||
});
|
||||
expect(options).toContainEqual({
|
||||
name: "never-used-i18n",
|
||||
value: VaultTimeoutStringType.Never,
|
||||
});
|
||||
}));
|
||||
|
||||
it("should initialize available timeout actions", fakeAsync(() => {
|
||||
const expectedActions = [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut];
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of(expectedActions),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component["availableTimeoutActions"]()).toEqual(expectedActions);
|
||||
}));
|
||||
|
||||
it("should initialize timeout and action", fakeAsync(() => {
|
||||
const expectedTimeout = 15;
|
||||
const expectedAction = VaultTimeoutAction.Lock;
|
||||
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() =>
|
||||
of(expectedTimeout),
|
||||
);
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation(() =>
|
||||
of(expectedAction),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.value.timeout).toBe(expectedTimeout);
|
||||
expect(component.formGroup.value.timeoutAction).toBe(expectedAction);
|
||||
}));
|
||||
|
||||
it("should fall back to OnRestart when current option is not available", fakeAsync(() => {
|
||||
availableTimeoutOptions$.next([
|
||||
{ name: "oneMinute-used-i18n", value: 1 },
|
||||
{ name: "fiveMinutes-used-i18n", value: 5 },
|
||||
{ name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart },
|
||||
]);
|
||||
|
||||
const unavailableTimeout = VaultTimeoutStringType.Never;
|
||||
|
||||
mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() =>
|
||||
of(unavailableTimeout),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.value.timeout).toBe(VaultTimeoutStringType.OnRestart);
|
||||
}));
|
||||
|
||||
it("should disable timeout action control when policy enforces action", fakeAsync(() => {
|
||||
const policyData: MaximumVaultTimeoutPolicyData = {
|
||||
minutes: 15,
|
||||
action: VaultTimeoutAction.LogOut,
|
||||
};
|
||||
mockPolicyService.policiesByType$.mockImplementation(() =>
|
||||
of([{ id: "1", data: policyData }] as Policy[]),
|
||||
);
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should disable timeout action control when only one action is available", fakeAsync(() => {
|
||||
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should disable timeout action control when policy enforces action and refreshed", fakeAsync(() => {
|
||||
const policies$ = new BehaviorSubject<Policy[]>([]);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(policies$);
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.enabled).toBe(true);
|
||||
|
||||
const policyData: MaximumVaultTimeoutPolicyData = {
|
||||
minutes: 15,
|
||||
action: VaultTimeoutAction.LogOut,
|
||||
};
|
||||
policies$.next([{ id: "1", data: policyData }] as Policy[]);
|
||||
|
||||
refreshTimeoutActionSettings$.next(undefined);
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should disable timeout action control when only one action is available and refreshed", fakeAsync(() => {
|
||||
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
|
||||
|
||||
const availableActions$ = new BehaviorSubject<VaultTimeoutAction[]>([
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutAction.LogOut,
|
||||
]);
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue(
|
||||
availableActions$,
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.enabled).toBe(true);
|
||||
|
||||
availableActions$.next([VaultTimeoutAction.Lock]);
|
||||
|
||||
refreshTimeoutActionSettings$.next(undefined);
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should enable timeout action control when multiple actions available and no policy and refreshed", fakeAsync(() => {
|
||||
mockPolicyService.policiesByType$.mockImplementation(() => of([]));
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock]),
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.disabled).toBe(true);
|
||||
|
||||
mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() =>
|
||||
of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]),
|
||||
);
|
||||
|
||||
refreshTimeoutActionSettings$.next(undefined);
|
||||
flush();
|
||||
|
||||
expect(component.formGroup.controls.timeoutAction.enabled).toBe(true);
|
||||
}));
|
||||
|
||||
it("should subscribe to timeout value changes", fakeAsync(() => {
|
||||
const saveSpy = jest.spyOn(component, "saveTimeout").mockResolvedValue(undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
const newTimeout = 30;
|
||||
component.formGroup.controls.timeout.setValue(newTimeout);
|
||||
flush();
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(mockInitialTimeout, newTimeout);
|
||||
}));
|
||||
|
||||
it("should subscribe to timeout action value changes", fakeAsync(() => {
|
||||
const saveSpy = jest.spyOn(component, "saveTimeoutAction").mockResolvedValue(undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
component.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.LogOut);
|
||||
flush();
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(VaultTimeoutAction.LogOut);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("saveTimeout", () => {
|
||||
it("should not save when form control timeout is invalid", fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
component.formGroup.controls.timeout.setValue(null);
|
||||
|
||||
await component.saveTimeout(mockInitialTimeout, 30);
|
||||
flush();
|
||||
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("should set new value and show confirmation dialog when setting timeout to Never and dialog confirmed", waitForAsync(async () => {
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const previousTimeout = component.formGroup.controls.timeout.value!;
|
||||
const newTimeout = VaultTimeoutStringType.Never;
|
||||
|
||||
await component.saveTimeout(previousTimeout, newTimeout);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "warning" },
|
||||
content: { key: "neverLockWarning" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
newTimeout,
|
||||
mockInitialTimeoutAction,
|
||||
);
|
||||
expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).toHaveBeenCalledWith(
|
||||
newTimeout,
|
||||
);
|
||||
}));
|
||||
|
||||
it("should revert to previous value when Never confirmation is declined", waitForAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
const previousTimeout = component.formGroup.controls.timeout.value!;
|
||||
const newTimeout = VaultTimeoutStringType.Never;
|
||||
|
||||
await component.saveTimeout(previousTimeout, newTimeout);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "warning" },
|
||||
content: { key: "neverLockWarning" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(component.formGroup.controls.timeout.value).toBe(previousTimeout);
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled();
|
||||
expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it.each([
|
||||
30,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
])(
|
||||
"should set new value when setting timeout to %s",
|
||||
fakeAsync(async (timeout: VaultTimeout) => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
const previousTimeout = component.formGroup.controls.timeout.value!;
|
||||
await component.saveTimeout(previousTimeout, timeout);
|
||||
flush();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
timeout,
|
||||
mockInitialTimeoutAction,
|
||||
);
|
||||
expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).toHaveBeenCalledWith(
|
||||
timeout,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("saveTimeoutAction", () => {
|
||||
it("should set new value and show confirmation dialog when setting action to LogOut and dialog confirmed", waitForAsync(async () => {
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.saveTimeoutAction(VaultTimeoutAction.LogOut);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "vaultTimeoutLogOutConfirmationTitle" },
|
||||
content: { key: "vaultTimeoutLogOutConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockInitialTimeout,
|
||||
VaultTimeoutAction.LogOut,
|
||||
);
|
||||
}));
|
||||
|
||||
it("should revert to Lock when LogOut confirmation is declined", waitForAsync(async () => {
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.saveTimeoutAction(VaultTimeoutAction.LogOut);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "vaultTimeoutLogOutConfirmationTitle" },
|
||||
content: { key: "vaultTimeoutLogOutConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(component.formGroup.controls.timeoutAction.value).toBe(VaultTimeoutAction.Lock);
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("should set timeout action to Lock value when setting timeout action to Lock", fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
component.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.LogOut, {
|
||||
emitEvent: false,
|
||||
});
|
||||
|
||||
await component.saveTimeoutAction(VaultTimeoutAction.Lock);
|
||||
flush();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockInitialTimeout,
|
||||
VaultTimeoutAction.Lock,
|
||||
);
|
||||
}));
|
||||
|
||||
it("should not save and show error toast when timeout has policy error", fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
component.formGroup.controls.timeout.setErrors({ policyError: true });
|
||||
|
||||
await component.saveTimeoutAction(VaultTimeoutAction.Lock);
|
||||
flush();
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "vaultTimeoutTooLarge-used-i18n",
|
||||
});
|
||||
expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, input, OnInit, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import {
|
||||
MaximumVaultTimeoutPolicyData,
|
||||
VaultTimeout,
|
||||
VaultTimeoutAction,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutSettingsService,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
CheckboxModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ItemModule,
|
||||
LinkModule,
|
||||
SelectModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-session-timeout-settings",
|
||||
templateUrl: "session-timeout-settings.component.html",
|
||||
imports: [
|
||||
CheckboxModule,
|
||||
CommonModule,
|
||||
FormFieldModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
IconButtonModule,
|
||||
ItemModule,
|
||||
JslibModule,
|
||||
LinkModule,
|
||||
RouterModule,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
VaultTimeoutInputComponent,
|
||||
],
|
||||
})
|
||||
export class SessionTimeoutSettingsComponent implements OnInit {
|
||||
// TODO remove once https://bitwarden.atlassian.net/browse/PM-27283 is completed
|
||||
// This is because vaultTimeoutSettingsService.availableVaultTimeoutActions$ is not reactive, hence the change detection
|
||||
// needs to be manually triggered to refresh available timeout actions
|
||||
readonly refreshTimeoutActionSettings = input<Observable<void>>(
|
||||
new BehaviorSubject<void>(undefined),
|
||||
);
|
||||
|
||||
formGroup = new FormGroup({
|
||||
timeout: new FormControl<VaultTimeout | null>(null, [Validators.required]),
|
||||
timeoutAction: new FormControl<VaultTimeoutAction>(VaultTimeoutAction.Lock, [
|
||||
Validators.required,
|
||||
]),
|
||||
});
|
||||
protected readonly availableTimeoutActions = signal<VaultTimeoutAction[]>([]);
|
||||
protected readonly availableTimeoutOptions$ =
|
||||
this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe(
|
||||
startWith([] as VaultTimeoutOption[]),
|
||||
);
|
||||
protected hasVaultTimeoutPolicy$: Observable<boolean> = of(false);
|
||||
|
||||
private userId!: UserId;
|
||||
|
||||
constructor(
|
||||
private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private readonly sessionTimeoutSettingsComponentService: SessionTimeoutSettingsComponentService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly toastService: ToastService,
|
||||
private readonly policyService: PolicyService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dialogService: DialogService,
|
||||
private readonly logService: LogService,
|
||||
private readonly destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
get canLock() {
|
||||
return this.availableTimeoutActions().includes(VaultTimeoutAction.Lock);
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const availableTimeoutOptions = await firstValueFrom(
|
||||
this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$,
|
||||
);
|
||||
|
||||
this.logService.debug(
|
||||
"[SessionTimeoutSettings] Available timeout options",
|
||||
availableTimeoutOptions,
|
||||
);
|
||||
|
||||
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
const maximumVaultTimeoutPolicy$ = this.policyService
|
||||
.policiesByType$(PolicyType.MaximumVaultTimeout, this.userId)
|
||||
.pipe(getFirstPolicy);
|
||||
|
||||
this.hasVaultTimeoutPolicy$ = maximumVaultTimeoutPolicy$.pipe(map((policy) => policy != null));
|
||||
|
||||
let timeout = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(this.userId),
|
||||
);
|
||||
|
||||
// Fallback if current timeout option is not available on this platform
|
||||
// Only applies to string-based timeout types, not numeric values
|
||||
const hasCurrentOption = availableTimeoutOptions.some((opt) => opt.value === timeout);
|
||||
if (!hasCurrentOption && typeof timeout !== "number") {
|
||||
this.logService.debug(
|
||||
"[SessionTimeoutSettings] Current timeout option not available, falling back from",
|
||||
{ timeout },
|
||||
);
|
||||
timeout = VaultTimeoutStringType.OnRestart;
|
||||
}
|
||||
|
||||
this.formGroup.patchValue(
|
||||
{
|
||||
timeout: timeout,
|
||||
timeoutAction: await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
|
||||
),
|
||||
},
|
||||
{ emitEvent: false },
|
||||
);
|
||||
|
||||
this.refreshTimeoutActionSettings()
|
||||
.pipe(
|
||||
startWith(undefined),
|
||||
switchMap(() =>
|
||||
combineLatest([
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId),
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
|
||||
maximumVaultTimeoutPolicy$,
|
||||
]),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe(([availableActions, action, policy]) => {
|
||||
this.availableTimeoutActions.set(availableActions);
|
||||
this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false });
|
||||
|
||||
const policyData = policy?.data as MaximumVaultTimeoutPolicyData | undefined;
|
||||
|
||||
// Enable/disable the action control based on policy or available actions
|
||||
if (policyData?.action != null || availableActions.length <= 1) {
|
||||
this.formGroup.controls.timeoutAction.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.formGroup.controls.timeoutAction.enable({ emitEvent: false });
|
||||
}
|
||||
});
|
||||
|
||||
this.formGroup.controls.timeout.valueChanges
|
||||
.pipe(
|
||||
startWith(timeout), // emit to init pairwise
|
||||
filter((value) => value != null),
|
||||
distinctUntilChanged(),
|
||||
pairwise(),
|
||||
concatMap(async ([previousValue, newValue]) => {
|
||||
await this.saveTimeout(previousValue, newValue);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.formGroup.controls.timeoutAction.valueChanges
|
||||
.pipe(
|
||||
filter((value) => value != null),
|
||||
map(async (value) => {
|
||||
await this.saveTimeoutAction(value);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async saveTimeout(previousValue: VaultTimeout, newValue: VaultTimeout) {
|
||||
this.formGroup.controls.timeout.markAllAsTouched();
|
||||
if (this.formGroup.controls.timeout.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.debug("[SessionTimeoutSettings] Saving timeout", { previousValue, newValue });
|
||||
|
||||
if (newValue === VaultTimeoutStringType.Never) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "warning" },
|
||||
content: { key: "neverLockWarning" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
this.formGroup.controls.timeout.setValue(previousValue, { emitEvent: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const vaultTimeoutAction = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId),
|
||||
);
|
||||
|
||||
await this.vaultTimeoutSettingsService.setVaultTimeoutOptions(
|
||||
this.userId,
|
||||
newValue,
|
||||
vaultTimeoutAction,
|
||||
);
|
||||
|
||||
this.sessionTimeoutSettingsComponentService.onTimeoutSave(newValue);
|
||||
}
|
||||
|
||||
async saveTimeoutAction(value: VaultTimeoutAction) {
|
||||
this.logService.debug("[SessionTimeoutSettings] Saving timeout action", value);
|
||||
|
||||
if (value === VaultTimeoutAction.LogOut) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "vaultTimeoutLogOutConfirmationTitle" },
|
||||
content: { key: "vaultTimeoutLogOutConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
this.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.Lock, {
|
||||
emitEvent: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.formGroup.controls.timeout.hasError("policyError")) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("vaultTimeoutTooLarge"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.vaultTimeoutSettingsService.setVaultTimeoutOptions(
|
||||
this.userId,
|
||||
this.formGroup.controls.timeout.value!,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { VaultTimeout, VaultTimeoutOption } from "@bitwarden/common/key-management/vault-timeout";
|
||||
|
||||
export abstract class SessionTimeoutSettingsComponentService {
|
||||
abstract availableTimeoutOptions$: Observable<VaultTimeoutOption[]>;
|
||||
|
||||
abstract onTimeoutSave(timeout: VaultTimeout): void;
|
||||
}
|
||||
Reference in New Issue
Block a user