1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-28 18:43:26 +00:00

Merge branch 'main' into dev/kreynolds/tunnel_proto

This commit is contained in:
Katherine Reynolds
2025-11-11 10:38:31 -08:00
62 changed files with 2340 additions and 349 deletions

View File

@@ -796,6 +796,12 @@
"onLocked": {
"message": "On system lock"
},
"onIdle": {
"message": "On system idle"
},
"onSleep": {
"message": "On system sleep"
},
"onRestart": {
"message": "On browser restart"
},
@@ -5815,5 +5821,8 @@
},
"cardNumberLabel": {
"message": "Card number"
},
"sessionTimeoutSettingsAction": {
"message": "Timeout action"
}
}

View File

@@ -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,46 +60,60 @@
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>
<bit-section-header>
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
</bit-section-header>
@if (consolidatedSessionTimeoutComponent$ | async) {
<bit-section-header>
<h2 bitTypography="h6">
{{ "sessionTimeoutHeader" | i18n }}
</h2>
</bit-section-header>
<bit-card>
<auth-vault-timeout-input
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</auth-vault-timeout-input>
<bit-card>
<bit-session-timeout-settings [refreshTimeoutActionSettings]="refreshTimeoutSettings$" />
</bit-card>
} @else {
<bit-section-header>
<h2 bitTypography="h6">
{{ "vaultTimeoutHeader" | i18n }}
</h2>
</bit-section-header>
<bit-form-field disableMargin>
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
<bit-option
*ngFor="let action of availableVaultTimeoutActions"
[value]="action"
[label]="action | i18n"
>
</bit-option>
</bit-select>
<bit-card>
<auth-vault-timeout-input
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</auth-vault-timeout-input>
<bit-hint *ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)">
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
<bit-form-field disableMargin>
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
<bit-option
*ngFor="let action of availableVaultTimeoutActions"
[value]="action"
[label]="action | i18n"
>
</bit-option>
</bit-select>
<bit-hint *ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)">
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
</bit-hint>
</bit-form-field>
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
</bit-hint>
</bit-form-field>
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
</bit-hint>
</bit-card>
</bit-card>
}
</bit-section>
<bit-section>

View File

@@ -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, {

View File

@@ -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() &&

View File

@@ -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");
}
}
}

View File

@@ -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({

View File

@@ -31,36 +31,50 @@
</h2>
<ng-container *ngIf="showSecurity">
<bit-section disableMargin>
<bit-section-header>
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
</bit-section-header>
@if (consolidatedSessionTimeoutComponent$ | async) {
<bit-section-header>
<h2 bitTypography="h6">{{ "sessionTimeoutHeader" | i18n }}</h2>
</bit-section-header>
<auth-vault-timeout-input
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</auth-vault-timeout-input>
<bit-session-timeout-settings
[refreshTimeoutActionSettings]="refreshTimeoutSettings$"
/>
} @else {
<bit-section-header>
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
</bit-section-header>
<bit-form-field disableMargin>
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
<bit-option
*ngFor="let action of availableVaultTimeoutActions"
[value]="action"
[label]="action | i18n"
<auth-vault-timeout-input
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</auth-vault-timeout-input>
<bit-form-field disableMargin>
<bit-label for="vaultTimeoutAction">{{
"vaultTimeoutAction1" | i18n
}}</bit-label>
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
<bit-option
*ngFor="let action of availableVaultTimeoutActions"
[value]="action"
[label]="action | i18n"
>
</bit-option>
</bit-select>
<bit-hint
*ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)"
>
</bit-option>
</bit-select>
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
</bit-hint>
</bit-form-field>
<bit-hint *ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)">
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
</bit-hint>
</bit-form-field>
<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">

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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({

View File

@@ -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 {}
}

View File

@@ -4220,5 +4220,11 @@
},
"upgradeToPremium": {
"message": "Upgrade to Premium"
},
"sessionTimeoutSettingsAction": {
"message": "Timeout action"
},
"sessionTimeoutHeader": {
"message": "Session timeout"
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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();

View File

@@ -1,8 +1,10 @@
import { Component, input, output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import {
PersonalSubscriptionPricingTierId,
@@ -58,6 +60,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
let component: UnifiedUpgradeDialogComponent;
let fixture: ComponentFixture<UnifiedUpgradeDialogComponent>;
const mockDialogRef = mock<DialogRef>();
const mockRouter = mock<Router>();
const mockPremiumInterestStateService = mock<PremiumInterestStateService>();
const mockAccount: Account = {
id: "user-id" as UserId,
@@ -74,11 +78,16 @@ describe("UnifiedUpgradeDialogComponent", () => {
};
beforeEach(async () => {
// Reset mocks
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -121,6 +130,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -161,6 +172,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -191,11 +204,11 @@ describe("UnifiedUpgradeDialogComponent", () => {
});
describe("previousStep", () => {
it("should go back to plan selection and clear selected plan", () => {
it("should go back to plan selection and clear selected plan", async () => {
component["step"].set(UnifiedUpgradeDialogStep.Payment);
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
component["previousStep"]();
await component["previousStep"]();
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
@@ -222,6 +235,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
@@ -241,4 +256,169 @@ describe("UnifiedUpgradeDialogComponent", () => {
expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true);
});
});
describe("onComplete with premium interest", () => {
it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => {
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true);
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
mockRouter.navigate.mockResolvedValue(true);
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
organizationId: null,
};
await component["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToPremium",
organizationId: null,
});
});
it("should not clear premium interest when upgrading to families", async () => {
const result: UpgradePaymentResult = {
status: "upgradedToFamilies",
organizationId: "org-123",
};
await component["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled();
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToFamilies",
organizationId: "org-123",
});
});
it("should use standard redirect when no premium interest exists", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
redirectOnCompletion: true,
};
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
mockRouter.navigate.mockResolvedValue(true);
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
const result: UpgradePaymentResult = {
status: "upgradedToPremium",
organizationId: null,
};
await customComponent["onComplete"](result);
expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledWith([
"/settings/subscription/user-subscription",
]);
expect(mockDialogRef.close).toHaveBeenCalledWith({
status: "upgradedToPremium",
organizationId: null,
});
});
});
describe("onCloseClicked with premium interest", () => {
it("should clear premium interest when modal is closed", async () => {
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
await component["onCloseClicked"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
describe("previousStep with premium interest", () => {
it("should NOT clear premium interest when navigating between steps", async () => {
component["step"].set(UnifiedUpgradeDialogStep.Payment);
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
await component["previousStep"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled();
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
});
it("should clear premium interest when backing out of dialog completely", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
};
mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue();
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
{ provide: Router, useValue: mockRouter },
{ provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
await customComponent["previousStep"]();
expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith(
mockAccount.id,
);
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
});
});
});

View File

@@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, signal } from "@angular/core";
import { Router } from "@angular/router";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
@@ -94,6 +95,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
private dialogRef: DialogRef<UnifiedUpgradeDialogResult>,
@Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams,
private router: Router,
private premiumInterestStateService: PremiumInterestStateService,
) {}
ngOnInit(): void {
@@ -110,7 +112,9 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.selectedPlan.set(planId);
this.nextStep();
}
protected onCloseClicked(): void {
protected async onCloseClicked(): Promise<void> {
// Clear premium interest when user closes/abandons modal
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
}
@@ -124,18 +128,20 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
}
}
protected previousStep(): void {
protected async previousStep(): Promise<void> {
// If we are on the payment step and there was no initial step, go back to plan selection this is to prevent
// going back to payment step if the dialog was opened directly to payment step
if (this.step() === UnifiedUpgradeDialogStep.Payment && this.params?.initialStep == null) {
this.step.set(UnifiedUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(null);
} else {
// Clear premium interest when backing out of dialog completely
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
}
}
protected onComplete(result: UpgradePaymentResult): void {
protected async onComplete(result: UpgradePaymentResult): Promise<void> {
let status: UnifiedUpgradeDialogStatus;
switch (result.status) {
case "upgradedToPremium":
@@ -153,6 +159,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.close({ status, organizationId: result.organizationId });
// Check premium interest and route to vault for marketing-initiated premium upgrades
if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest(
this.params.account.id,
);
if (hasPremiumInterest) {
await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id);
await this.router.navigate(["/vault"]);
return; // Exit early, don't use redirectOnCompletion
}
}
// Use redirectOnCompletion for standard upgrade flows
if (
this.params.redirectOnCompletion &&
(status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
@@ -162,7 +181,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
? `/organizations/${result.organizationId}/vault`
: "/settings/subscription/user-subscription";
void this.router.navigate([redirectUrl]);
await this.router.navigate([redirectUrl]);
}
}

View File

@@ -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({

View File

@@ -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 {}
}

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -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>
<bit-nav-item [text]="'preferences' | i18n" route="settings/preferences"></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"

View File

@@ -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() {

View File

@@ -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,
},
{

View 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>

View 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();
}));
});
});

View 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();
}
}

View File

@@ -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"

View File

@@ -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);

View File

@@ -550,15 +550,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
await this.editCipherId(cipherId);
}
} else {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("unknownCipher"),
});
await this.router.navigate([], {
queryParams: { itemId: null, cipherId: null },
queryParamsHandling: "merge",
});
await this.handleUnknownCipher();
}
}
}),
@@ -714,6 +706,18 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
}
async handleUnknownCipher() {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("unknownCipher"),
});
await this.router.navigate([], {
queryParams: { itemId: null, cipherId: null },
queryParamsHandling: "merge",
});
}
async archive(cipher: C) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
@@ -997,6 +1001,10 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
async editCipherId(id: string, cloneMode?: boolean) {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const cipher = await this.cipherService.get(id, activeUserId);
if (!cipher) {
await this.handleUnknownCipher();
return;
}
if (
cipher &&
@@ -1034,6 +1042,10 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
async viewCipherById(id: string) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.cipherService.get(id, activeUserId);
if (!cipher) {
await this.handleUnknownCipher();
return;
}
// If cipher exists (cipher is null when new) and MP reprompt
// is on for this cipher, then show password reprompt.
if (

View File

@@ -187,6 +187,13 @@ describe("WebVaultPremiumUpgradePromptService", () => {
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
});
it("should close dialog when redirecting to subscription page", async () => {
await service.promptForPremium();
expect(dialogRefMock.close).toHaveBeenCalledWith(VaultItemDialogResult.PremiumUpgrade);
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
});
});
describe("when not self-hosted", () => {

View File

@@ -107,6 +107,9 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
private async redirectToSubscriptionPage() {
await this.router.navigate([this.subscriptionPageRoute]);
if (this.dialog) {
this.dialog.close(VaultItemDialogResult.PremiumUpgrade);
}
}
private async openUpgradeDialog(account: Account) {

View File

@@ -373,6 +373,21 @@
"noNewApplicationsToReviewAtThisTime": {
"message": "No new applications to review at this time"
},
"organizationHasItemsSavedForApplications": {
"message": "Your organization has items saved for $COUNT$ applications",
"placeholders": {
"count": {
"content": "$1",
"example": "310"
}
}
},
"reviewApplicationsToSecureItems": {
"message": "Review applications to secure the items most critical to your organization's security"
},
"reviewApplications": {
"message": "Review applications"
},
"prioritizeCriticalApplications": {
"message": "Prioritize critical applications"
},
@@ -12095,7 +12110,7 @@
"encryptionKeySettingsAlgorithmPopoverArgon2Id": {
"message": "Argon2id offers stronger protection against modern attacks. Best for advanced users with powerful devices."
},
"zipPostalCodeLabel": {
"zipPostalCodeLabel": {
"message": "ZIP / Postal code"
},
"cardNumberLabel": {
@@ -12103,5 +12118,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"
}
}
}
}