mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +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:
@@ -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>
|
||||
<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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12095,7 +12095,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 +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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user