1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +00:00

Auth/PM-9449 - UI Refresh + Client component consolidation into new LockV2 Component (#10451)

* PM-9449 - Init stub of new lock comp

* PM-9449 - (1) Add new lock screen title to all clients (2) Add to temp web routing module config

* PM-9449 - LockV2Comp - Building now with web HTML

* PM-9449 - Libs/Auth LockComp - bring in all desktop ts code; WIP, need to stand up LockCompService to facilitate ipc communication.

* PM-9449 - Create LockComponentService for facilitating client logic; potentially will decompose later.

* PM-9449 - Add extension lock comp service.

* PM-9449 - Libs/auth LockComp - bring in browser extension logic

* PM-9449 - Libs/auth LockComp html start

* PM-9449 - Libs/Auth LockComp - (1) Remove unused dep (2) Update setEmailAsPageSubtitle to work.

* PM-9449 - Add getBiometricsError to lock comp service for extension.

* PM-9449 - LockComp - (1) Save off client type as public comp var (2) Rename biometricLock as biometricLockSet

* PM-9449 - Work on lock comp service getAvailableUnlockOptions

* PM-9449 - WIP libs/auth LockComp

* PM-9449 - (1) Remove default lock comp svc (2) Add web lock comp svc.

* PM-9449 - UnlockOptions - replace incorrect type

* PM-9449 - DesktopLockComponentService -get most of observable based getAvailableUnlockOptions$ logic in place.

* PM-9449 - LockCompSvc - getAvailableUnlockOptions in place for all clients.

* PM-9449 - Add getBiometricsUnlockBtnText to LockCompSvc and put TODO for wiring it up later

* PM-9449 - Lock Comp - Replace all manual bools with unlock options.

* PM-9449 - Desktop Lock Comp Svc - adjust spacing

* PM-9449 - LockCompSvc - remove biometricsEnabled method

* PM-9449 - LockComp - Clean up commented out code

* PM-9449 - LockComp - webVaultHostname --> envHostName

* PM-9449 - Fix lock comp svc deps

* PM-9449 - LockComp - HTML progress

* PM-9449 - LockComp cleanup

* PM-9449 - Web Routing Module - wire up lock vs lockv2 using extension swap

* PM-9449 - Wire up loading state

* PM-9449 - LockComp - start wiring up listenForActiveUnlockOptionChanges logic with reactivity

* PM-9449 - Update desktop & extension lock comp service to use new biometrics service vs platform utils for biometrics information.

* PM-9449 - LockV2 - Swap platform util usage with toast svc

* PM-9449 - LockV2Comp - Bring over user id logic from PM-8933

* PM-9449 - LockV2Comp - Adjust everything to use activeAccount.id.

* PM-9449 - LockV2Comp - Progress on wiring up unlock option reactive stream.

* PM-9449 - LockComp ts - some refactoring and minor progress.

* PM-9449 - LockComp HTML - refactoring based on new idea to keep unlock options as separate as possible.

* PM-9449 - Add PIN translation to web

* PM-9449 - (1) Lock HTML refactor to make as independent verticals as possible (2) Refactor Lock ts (3) LockSvc - replace type with enum.

* PM-9449 - LockV2Comp - remove hardcoded await.

* PM-9449 - LockComp HTML - add todo

* PM-9449 - Web - Routing module - cleanup commented out stuff

* PM-9449 - LockV2Comp - Wire up biometrics + mild refactor.

* PM-9449 - Desktop - Wire up lockV2 redirection

* PM-9449 - LockV2 - Desktop - don't focus until unlock opts defined.

* PM-9449 - Fix accidental check in

* PM-9449 - LockV2 - loading state depends on unlock opts

* PM-9449 - LockV2 comp - remove unnecessary hr

* PM-9449 - Migrate  "yourVaultIsLockedV2" translation to desktop & browser.

* PM-9449 - LockV2 - Layout tweaks for biometrics

* PM-9449 - LockV2 - Biometric btn text

* PM-9449 - LockV2 - Wire up biometrics loading / disable state + remove unnecessary conditions around biometricsUnlockBtnText

* PM-9449 - DesktopLockSvc - Per discussion with Bernd, remove interval polling and just check once for biometric support and availability.

* PM-9449 - AuthGuard - Add todo to remove promptBiometric

* PM-9449 - LockV2 - Refactor primary and desktop init logic + misc clean up

* PM-9449 - LockV2 - Reorder init methods

* PM-9449 - LockV2 - Per discussion with Product, deprecate windows biometric settings update warning

* PM-9449 - Add TODO per discussion with Justin and remove TODO

* PM-9449 - LockV2 - Restore hide password on desktop window hidden functionality.

* PM-9449 - Clean up accomplished todo

* PM-9449 - LockV2 - Refactor func name.

* PM-9449 - LockV2 Comp - (1) TODO cleanup (2) Add browser logic to handleBiometricsUnlockEnabled

* PM-9449 - LockCompSvc changes - (1) Observability for isFido2Session (2) Adjust errors and returns per discussion with Justin

* PM-9449 - Per product, no longer need to support special fido2 case on extension.

* PM-9449 - LockCompSvc - add getPreviousUrl support

* PM-9449 - LockV2 - Continued ts cleanup

* PM-9449 - LockV2Comp - clean up unused props

* PM-9449 - LockV2Comp - Rename response to masterPasswordVerificationResponse

* PM-9449 - LockV2 - Remove unused formPromise prop

* PM-9449 - Add missing translations + update desktop to showReadonlyHostName

* PM-9449 - LockV2 - cleanup TODO

* PM-9449 - LockV2 - more cleanup

* PM-9449 - Desktop Routing Module - only allow LockV2 access if extension refresh flag is enabled.

* PM-9449 - Extension - AppRoutingModule - Add extension redirect + new lockV2 route.

* PM-9449 - Extension - AppRoutingModule - Add lockV2 to the ExtensionAnonLayoutWrapperComponent intead of the regular one.

* PM-9449 - Extension - CurrentAccountComp - add null checks as anon layout components don't have a state today. This prevents the account switcher from working on the new lockV2 comp.

* PM-9449 - Extension AppRoutingModule - LockV2 should use ExtensionAnonLayoutWrapperData

* PM-9449 - LockComp - BiometricUnlock - cancelling is a valid action.

* PM-9449 - LockV2 - Biometric autoprompt cleanup

* PM-9449 - LockV2 - (1) Add TODO for KM team (2) Fix submit logic.

* PM-9449 - Tweak TODO to add task #

* PM-9449 - Test WebLockComponentService

* PM-9449 - ExtensionLockComponentService tested

* PM-9449 - Tweak extension lock comp svc test

* PM-9449 - DesktopLockComponentService tested

* PM-9449 - Add task # to TODO

* PM-9449 - Update apps/browser/src/services/extension-lock-component.service.ts per PR feedback

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>

* PM-9449 - Per PR feedback, replace from with defer for better reactive execution of promise based functions.

* PM-9449 - Per PR feedback replace enum with type.

* PM-9449 - Fix imports and tests due to key management file moves.

* PM-9449 - Another test file import fix

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
Jared Snider
2024-10-01 16:06:18 -04:00
committed by GitHub
parent dab60dbaea
commit 9ff1db7573
22 changed files with 2139 additions and 21 deletions

View File

@@ -604,6 +604,15 @@
"yourVaultIsLocked": {
"message": "Your vault is locked. Verify your identity to continue."
},
"yourVaultIsLockedV2": {
"message": "Your vault is locked"
},
"yourAccountIsLocked": {
"message": "Your account is locked"
},
"or": {
"message": "or"
},
"unlock": {
"message": "Unlock"
},
@@ -1936,6 +1945,9 @@
"unlockWithBiometrics": {
"message": "Unlock with biometrics"
},
"unlockWithMasterPassword": {
"message": "Unlock with master password"
},
"awaitDesktop": {
"message": "Awaiting confirmation from desktop"
},
@@ -3623,6 +3635,9 @@
"typePasskey": {
"message": "Passkey"
},
"accessing": {
"message": "Accessing"
},
"passkeyNotCopied": {
"message": "Passkey will not be copied"
},

View File

@@ -59,7 +59,7 @@ export class CurrentAccountComponent {
}
async currentAccountClicked() {
if (this.route.snapshot.data.state.includes("account-switcher")) {
if (this.route.snapshot.data?.state?.includes("account-switcher")) {
this.location.back();
} else {
await this.router.navigate(["/account-switcher"]);

View File

@@ -17,6 +17,8 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
LockIcon,
LockV2Component,
PasswordHintComponent,
RegistrationFinishComponent,
RegistrationStartComponent,
@@ -181,6 +183,7 @@ const routes: Routes = [
path: "lock",
component: LockComponent,
canActivate: [lockGuard()],
canMatch: [extensionRefreshRedirect("/lockV2")],
data: { state: "lock", doNotSaveUrl: true } satisfies RouteDataProperties,
},
...twofactorRefactorSwap(
@@ -438,6 +441,28 @@ const routes: Routes = [
],
},
),
{
path: "",
component: ExtensionAnonLayoutWrapperComponent,
children: [
{
path: "lockV2",
canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()],
data: {
pageIcon: LockIcon,
pageTitle: "yourVaultIsLockedV2",
showReadonlyHostname: true,
showAcctSwitcher: true,
} satisfies ExtensionAnonLayoutWrapperData,
children: [
{
path: "",
component: LockV2Component,
},
],
},
],
},
{
path: "",
component: AnonLayoutWrapperComponent,

View File

@@ -16,7 +16,7 @@ import {
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular";
import { AnonLayoutWrapperDataService, LockComponentService } from "@bitwarden/auth/angular";
import { LockService, PinServiceAbstraction } from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@@ -117,6 +117,7 @@ import { ForegroundTaskSchedulerService } from "../../platform/services/task-sch
import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider";
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
import { ExtensionLockComponentService } from "../../services/extension-lock-component.service";
import { ForegroundVaultTimeoutService } from "../../services/vault-timeout/foreground-vault-timeout.service";
import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service";
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
@@ -536,6 +537,11 @@ const safeProviders: SafeProvider[] = [
provide: CLIENT_TYPE,
useValue: ClientType.Browser,
}),
safeProvider({
provide: LockComponentService,
useClass: ExtensionLockComponentService,
deps: [],
}),
safeProvider({
provide: Fido2UserVerificationService,
useClass: Fido2UserVerificationService,

View File

@@ -0,0 +1,325 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/auth/angular";
import {
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsService } from "@bitwarden/key-management";
import { BrowserRouterService } from "../platform/popup/services/browser-router.service";
import { ExtensionLockComponentService } from "./extension-lock-component.service";
describe("ExtensionLockComponentService", () => {
let service: ExtensionLockComponentService;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let biometricsService: MockProxy<BiometricsService>;
let pinService: MockProxy<PinServiceAbstraction>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let cryptoService: MockProxy<CryptoService>;
let routerService: MockProxy<BrowserRouterService>;
beforeEach(() => {
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
platformUtilsService = mock<PlatformUtilsService>();
biometricsService = mock<BiometricsService>();
pinService = mock<PinServiceAbstraction>();
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
cryptoService = mock<CryptoService>();
routerService = mock<BrowserRouterService>();
TestBed.configureTestingModule({
providers: [
ExtensionLockComponentService,
{
provide: UserDecryptionOptionsServiceAbstraction,
useValue: userDecryptionOptionsService,
},
{
provide: PlatformUtilsService,
useValue: platformUtilsService,
},
{
provide: BiometricsService,
useValue: biometricsService,
},
{
provide: PinServiceAbstraction,
useValue: pinService,
},
{
provide: VaultTimeoutSettingsService,
useValue: vaultTimeoutSettingsService,
},
{
provide: CryptoService,
useValue: cryptoService,
},
{
provide: BrowserRouterService,
useValue: routerService,
},
],
});
service = TestBed.inject(ExtensionLockComponentService);
});
it("instantiates", () => {
expect(service).not.toBeFalsy();
});
describe("getPreviousUrl", () => {
it("returns the previous URL", () => {
routerService.getPreviousUrl.mockReturnValue("previousUrl");
expect(service.getPreviousUrl()).toBe("previousUrl");
});
});
describe("getBiometricsError", () => {
it("returns a biometric error description when given a valid error type", () => {
expect(
service.getBiometricsError({
message: "startDesktop",
}),
).toBe("startDesktopDesc");
});
it("returns null when given an invalid error type", () => {
expect(
service.getBiometricsError({
message: "invalidError",
}),
).toBeNull();
});
it("returns null when given a null input", () => {
expect(service.getBiometricsError(null)).toBeNull();
});
});
describe("isWindowVisible", () => {
it("throws an error", async () => {
await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented.");
});
});
describe("getBiometricsUnlockBtnText", () => {
it("returns the biometric unlock button text", () => {
expect(service.getBiometricsUnlockBtnText()).toBe("unlockWithBiometrics");
});
});
describe("getAvailableUnlockOptions$", () => {
interface MockInputs {
hasMasterPassword: boolean;
osSupportsBiometric: boolean;
biometricLockSet: boolean;
hasBiometricEncryptedUserKeyStored: boolean;
platformSupportsSecureStorage: boolean;
pinDecryptionAvailable: boolean;
}
const table: [MockInputs, UnlockOptions][] = [
[
// MP + PIN + Biometrics available
{
hasMasterPassword: true,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: true,
},
{
masterPassword: {
enabled: true,
},
pin: {
enabled: true,
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// PIN + Biometrics available
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: true,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: true,
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// Biometrics available: user key stored with no secure storage
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: false,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// Biometrics available: no user key stored with no secure storage
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: false,
platformSupportsSecureStorage: false,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// Biometrics not available: biometric lock not set
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: false,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
},
},
],
[
// Biometrics not available: user key not stored
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: false,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
},
},
],
[
// Biometrics not available: OS doesn't support
{
hasMasterPassword: false,
osSupportsBiometric: false,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
},
},
],
];
test.each(table)("returns unlock options", async (mockInputs, expectedOutput) => {
const userId = "userId" as UserId;
const userDecryptionOptions = {
hasMasterPassword: mockInputs.hasMasterPassword,
};
// MP
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of(userDecryptionOptions),
);
// Biometrics
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
cryptoService.hasUserKeyStored.mockResolvedValue(
mockInputs.hasBiometricEncryptedUserKeyStored,
);
platformUtilsService.supportsSecureStorage.mockReturnValue(
mockInputs.platformSupportsSecureStorage,
);
// PIN
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
expect(unlockOptions).toEqual(expectedOutput);
});
});
});

View File

@@ -0,0 +1,117 @@
import { inject } from "@angular/core";
import { combineLatest, defer, map, Observable } from "rxjs";
import {
BiometricsDisableReason,
LockComponentService,
UnlockOptions,
} from "@bitwarden/auth/angular";
import {
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsService } from "@bitwarden/key-management";
import { BiometricErrors, BiometricErrorTypes } from "../models/biometricErrors";
import { BrowserRouterService } from "../platform/popup/services/browser-router.service";
export class ExtensionLockComponentService implements LockComponentService {
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
private readonly platformUtilsService = inject(PlatformUtilsService);
private readonly biometricsService = inject(BiometricsService);
private readonly pinService = inject(PinServiceAbstraction);
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private readonly cryptoService = inject(CryptoService);
private readonly routerService = inject(BrowserRouterService);
getPreviousUrl(): string | null {
return this.routerService.getPreviousUrl();
}
getBiometricsError(error: any): string | null {
const biometricsError = BiometricErrors[error?.message as BiometricErrorTypes];
if (!biometricsError) {
return null;
}
return biometricsError.description;
}
async isWindowVisible(): Promise<boolean> {
throw new Error("Method not implemented.");
}
getBiometricsUnlockBtnText(): string {
return "unlockWithBiometrics";
}
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
const hasBiometricEncryptedUserKeyStored = await this.cryptoService.hasUserKeyStored(
KeySuffixOptions.Biometric,
userId,
);
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
return (
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
);
}
private getBiometricsDisabledReason(
osSupportsBiometric: boolean,
biometricLockSet: boolean,
): BiometricsDisableReason | null {
if (!osSupportsBiometric) {
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
} else if (!biometricLockSet) {
return BiometricsDisableReason.EncryptedKeysUnavailable;
}
return null;
}
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
return combineLatest([
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
defer(() => this.biometricsService.supportsBiometric()),
defer(() => this.isBiometricLockSet(userId)),
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
]).pipe(
map(
([
supportsBiometric,
isBiometricsLockSet,
userDecryptionOptions,
pinDecryptionAvailable,
]) => {
const disableReason = this.getBiometricsDisabledReason(
supportsBiometric,
isBiometricsLockSet,
);
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions.hasMasterPassword,
},
pin: {
enabled: pinDecryptionAvailable,
},
biometrics: {
enabled: supportsBiometric && isBiometricsLockSet,
disableReason: disableReason,
},
};
return unlockOpts;
},
),
);
}
}

View File

@@ -11,9 +11,12 @@ import {
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect";
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
LockIcon,
LockV2Component,
PasswordHintComponent,
RegistrationFinishComponent,
RegistrationStartComponent,
@@ -62,6 +65,7 @@ const routes: Routes = [
path: "lock",
component: LockComponent,
canActivate: [lockGuard()],
canMatch: [extensionRefreshRedirect("/lockV2")],
},
{
path: "login",
@@ -190,6 +194,21 @@ const routes: Routes = [
},
],
},
{
path: "lockV2",
canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()],
data: {
pageIcon: LockIcon,
pageTitle: "yourVaultIsLockedV2",
showReadonlyHostname: true,
} satisfies AnonLayoutWrapperData,
children: [
{
path: "",
component: LockV2Component,
},
],
},
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],

View File

@@ -19,7 +19,7 @@ import {
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { SetPasswordJitService } from "@bitwarden/auth/angular";
import { LockComponentService, SetPasswordJitService } from "@bitwarden/auth/angular";
import {
InternalUserDecryptionOptionsServiceAbstraction,
PinServiceAbstraction,
@@ -86,6 +86,7 @@ import { ElectronRendererStorageService } from "../../platform/services/electron
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging";
import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme";
import { DesktopLockComponentService } from "../../services/desktop-lock-component.service";
import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service";
import { NativeMessageHandlerService } from "../../services/native-message-handler.service";
import { NativeMessagingService } from "../../services/native-messaging.service";
@@ -277,6 +278,11 @@ const safeProviders: SafeProvider[] = [
useClass: NativeMessagingManifestService,
deps: [],
}),
safeProvider({
provide: LockComponentService,
useClass: DesktopLockComponentService,
deps: [],
}),
safeProvider({
provide: CLIENT_TYPE,
useValue: ClientType.Desktop,

View File

@@ -918,6 +918,18 @@
"yourVaultIsLocked": {
"message": "Your vault is locked. Verify your identity to continue."
},
"yourAccountIsLocked": {
"message": "Your account is locked"
},
"or": {
"message": "or"
},
"unlockWithBiometrics": {
"message": "Unlock with biometrics"
},
"unlockWithMasterPassword": {
"message": "Unlock with master password"
},
"unlock": {
"message": "Unlock"
},
@@ -2256,6 +2268,9 @@
"locked": {
"message": "Locked"
},
"yourVaultIsLockedV2": {
"message": "Your vault is locked"
},
"unlocked": {
"message": "Unlocked"
},
@@ -2608,6 +2623,9 @@
"important": {
"message": "Important:"
},
"accessing": {
"message": "Accessing"
},
"accessTokenUnableToBeDecrypted": {
"message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue."
},

View File

@@ -0,0 +1,377 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/auth/angular";
import {
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { DeviceType } from "@bitwarden/common/enums";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsService } from "@bitwarden/key-management";
import { DesktopLockComponentService } from "./desktop-lock-component.service";
// ipc mock global
const isWindowVisibleMock = jest.fn();
const biometricEnabledMock = jest.fn();
(global as any).ipc = {
keyManagement: {
biometric: {
enabled: biometricEnabledMock,
},
},
platform: {
isWindowVisible: isWindowVisibleMock,
},
};
describe("DesktopLockComponentService", () => {
let service: DesktopLockComponentService;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let biometricsService: MockProxy<BiometricsService>;
let pinService: MockProxy<PinServiceAbstraction>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let cryptoService: MockProxy<CryptoService>;
beforeEach(() => {
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
platformUtilsService = mock<PlatformUtilsService>();
biometricsService = mock<BiometricsService>();
pinService = mock<PinServiceAbstraction>();
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
cryptoService = mock<CryptoService>();
TestBed.configureTestingModule({
providers: [
DesktopLockComponentService,
{
provide: UserDecryptionOptionsServiceAbstraction,
useValue: userDecryptionOptionsService,
},
{
provide: PlatformUtilsService,
useValue: platformUtilsService,
},
{
provide: BiometricsService,
useValue: biometricsService,
},
{
provide: PinServiceAbstraction,
useValue: pinService,
},
{
provide: VaultTimeoutSettingsService,
useValue: vaultTimeoutSettingsService,
},
{
provide: CryptoService,
useValue: cryptoService,
},
],
});
service = TestBed.inject(DesktopLockComponentService);
});
it("instantiates", () => {
expect(service).not.toBeFalsy();
});
// getBiometricsError
describe("getBiometricsError", () => {
it("returns null when given null", () => {
const result = service.getBiometricsError(null);
expect(result).toBeNull();
});
it("returns null when given an unknown error", () => {
const result = service.getBiometricsError({ message: "unknown" });
expect(result).toBeNull();
});
});
describe("getPreviousUrl", () => {
it("returns null", () => {
const result = service.getPreviousUrl();
expect(result).toBeNull();
});
});
describe("isWindowVisible", () => {
it("returns the window visibility", async () => {
isWindowVisibleMock.mockReturnValue(true);
const result = await service.isWindowVisible();
expect(result).toBe(true);
});
});
describe("getBiometricsUnlockBtnText", () => {
it("returns the correct text for Mac OS", () => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.MacOsDesktop);
const result = service.getBiometricsUnlockBtnText();
expect(result).toBe("unlockWithTouchId");
});
it("returns the correct text for Windows", () => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
const result = service.getBiometricsUnlockBtnText();
expect(result).toBe("unlockWithWindowsHello");
});
it("returns the correct text for Linux", () => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.LinuxDesktop);
const result = service.getBiometricsUnlockBtnText();
expect(result).toBe("unlockWithPolkit");
});
it("throws an error for an unsupported platform", () => {
platformUtilsService.getDevice.mockReturnValue("unsupported" as any);
expect(() => service.getBiometricsUnlockBtnText()).toThrowError("Unsupported platform");
});
});
describe("getAvailableUnlockOptions$", () => {
interface MockInputs {
hasMasterPassword: boolean;
osSupportsBiometric: boolean;
biometricLockSet: boolean;
biometricReady: boolean;
hasBiometricEncryptedUserKeyStored: boolean;
platformSupportsSecureStorage: boolean;
pinDecryptionAvailable: boolean;
}
const table: [MockInputs, UnlockOptions][] = [
[
// MP + PIN + Biometrics available
{
hasMasterPassword: true,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: true,
},
{
masterPassword: {
enabled: true,
},
pin: {
enabled: true,
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// PIN + Biometrics available
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: true,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: true,
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// Biometrics available: user key stored with no secure storage
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: false,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// Biometrics available: no user key stored with no secure storage
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: false,
biometricReady: true,
platformSupportsSecureStorage: false,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// Biometrics not available: biometric not ready
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: false,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.SystemBiometricsUnavailable,
},
},
],
[
// Biometrics not available: biometric lock not set
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: false,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
},
},
],
[
// Biometrics not available: user key not stored
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: false,
biometricReady: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
},
},
],
[
// Biometrics not available: OS doesn't support
{
hasMasterPassword: false,
osSupportsBiometric: false,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
},
},
],
];
test.each(table)("returns unlock options", async (mockInputs, expectedOutput) => {
const userId = "userId" as UserId;
const userDecryptionOptions = {
hasMasterPassword: mockInputs.hasMasterPassword,
};
// MP
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of(userDecryptionOptions),
);
// Biometrics
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
cryptoService.hasUserKeyStored.mockResolvedValue(
mockInputs.hasBiometricEncryptedUserKeyStored,
);
platformUtilsService.supportsSecureStorage.mockReturnValue(
mockInputs.platformSupportsSecureStorage,
);
biometricEnabledMock.mockResolvedValue(mockInputs.biometricReady);
// PIN
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
expect(unlockOptions).toEqual(expectedOutput);
});
});
});

View File

@@ -0,0 +1,129 @@
import { inject } from "@angular/core";
import { combineLatest, defer, map, Observable } from "rxjs";
import {
BiometricsDisableReason,
LockComponentService,
UnlockOptions,
} from "@bitwarden/auth/angular";
import {
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { DeviceType } from "@bitwarden/common/enums";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsService } from "@bitwarden/key-management";
export class DesktopLockComponentService implements LockComponentService {
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
private readonly platformUtilsService = inject(PlatformUtilsService);
private readonly biometricsService = inject(BiometricsService);
private readonly pinService = inject(PinServiceAbstraction);
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private readonly cryptoService = inject(CryptoService);
constructor() {}
getBiometricsError(error: any): string | null {
return null;
}
getPreviousUrl(): string | null {
return null;
}
async isWindowVisible(): Promise<boolean> {
return ipc.platform.isWindowVisible();
}
getBiometricsUnlockBtnText(): string {
switch (this.platformUtilsService.getDevice()) {
case DeviceType.MacOsDesktop:
return "unlockWithTouchId";
case DeviceType.WindowsDesktop:
return "unlockWithWindowsHello";
case DeviceType.LinuxDesktop:
return "unlockWithPolkit";
default:
throw new Error("Unsupported platform");
}
}
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
const hasBiometricEncryptedUserKeyStored = await this.cryptoService.hasUserKeyStored(
KeySuffixOptions.Biometric,
userId,
);
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
return (
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
);
}
private async isBiometricsSupportedAndReady(
userId: UserId,
): Promise<{ supportsBiometric: boolean; biometricReady: boolean }> {
const supportsBiometric = await this.biometricsService.supportsBiometric();
const biometricReady = await ipc.keyManagement.biometric.enabled(userId);
return { supportsBiometric, biometricReady };
}
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
return combineLatest([
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
defer(() => this.isBiometricsSupportedAndReady(userId)),
defer(() => this.isBiometricLockSet(userId)),
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
]).pipe(
map(
([biometricsData, isBiometricsLockSet, userDecryptionOptions, pinDecryptionAvailable]) => {
const disableReason = this.getBiometricsDisabledReason(
biometricsData.supportsBiometric,
isBiometricsLockSet,
biometricsData.biometricReady,
);
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions.hasMasterPassword,
},
pin: {
enabled: pinDecryptionAvailable,
},
biometrics: {
enabled:
biometricsData.supportsBiometric &&
isBiometricsLockSet &&
biometricsData.biometricReady,
disableReason: disableReason,
},
};
return unlockOpts;
},
),
);
}
private getBiometricsDisabledReason(
osSupportsBiometric: boolean,
biometricLockSet: boolean,
biometricReady: boolean,
): BiometricsDisableReason | null {
if (!osSupportsBiometric) {
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
} else if (!biometricLockSet) {
return BiometricsDisableReason.EncryptedKeysUnavailable;
} else if (!biometricReady) {
return BiometricsDisableReason.SystemBiometricsUnavailable;
}
return null;
}
}

View File

@@ -1,3 +1,4 @@
export * from "./webauthn-login";
export * from "./set-password-jit";
export * from "./registration";
export * from "./web-lock-component.service";

View File

@@ -0,0 +1,94 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { UserId } from "@bitwarden/common/types/guid";
import { WebLockComponentService } from "./web-lock-component.service";
describe("WebLockComponentService", () => {
let service: WebLockComponentService;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
beforeEach(() => {
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
TestBed.configureTestingModule({
providers: [
WebLockComponentService,
{
provide: UserDecryptionOptionsServiceAbstraction,
useValue: userDecryptionOptionsService,
},
],
});
service = TestBed.inject(WebLockComponentService);
});
it("instantiates", () => {
expect(service).not.toBeFalsy();
});
describe("getBiometricsError", () => {
it("throws an error when given a null input", () => {
expect(() => service.getBiometricsError(null)).toThrow(
"Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$",
);
});
it("throws an error when given a non-null input", () => {
expect(() => service.getBiometricsError("error")).toThrow(
"Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$",
);
});
});
describe("getPreviousUrl", () => {
it("returns null", () => {
expect(service.getPreviousUrl()).toBeNull();
});
});
describe("isWindowVisible", () => {
it("throws an error", async () => {
await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented.");
});
});
describe("getBiometricsUnlockBtnText", () => {
it("throws an error", () => {
expect(() => service.getBiometricsUnlockBtnText()).toThrow(
"Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$",
);
});
});
describe("getAvailableUnlockOptions$", () => {
it("returns an observable of unlock options", async () => {
const userId = "user-id" as UserId;
const userDecryptionOptions = {
hasMasterPassword: true,
};
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValueOnce(
of(userDecryptionOptions),
);
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
expect(unlockOptions).toEqual({
masterPassword: {
enabled: true,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: null,
},
});
});
});
});

View File

@@ -0,0 +1,55 @@
import { inject } from "@angular/core";
import { map, Observable } from "rxjs";
import { LockComponentService, UnlockOptions } from "@bitwarden/auth/angular";
import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { UserId } from "@bitwarden/common/types/guid";
export class WebLockComponentService implements LockComponentService {
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
constructor() {}
getBiometricsError(error: any): string | null {
throw new Error(
"Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$",
);
}
getPreviousUrl(): string | null {
return null;
}
async isWindowVisible(): Promise<boolean> {
throw new Error("Method not implemented.");
}
getBiometricsUnlockBtnText(): string {
throw new Error(
"Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$",
);
}
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe(
map((userDecryptionOptions: UserDecryptionOptions) => {
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions.hasMasterPassword,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: null,
},
};
return unlockOpts;
}),
);
}
}

View File

@@ -20,6 +20,7 @@ import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
import {
RegistrationFinishService as RegistrationFinishServiceAbstraction,
LockComponentService,
SetPasswordJitService,
} from "@bitwarden/auth/angular";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
@@ -62,7 +63,11 @@ import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/va
import { BiometricsService } from "@bitwarden/key-management";
import { PolicyListService } from "../admin-console/core/policy-list.service";
import { WebRegistrationFinishService, WebSetPasswordJitService } from "../auth";
import {
WebSetPasswordJitService,
WebRegistrationFinishService,
WebLockComponentService,
} from "../auth";
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
@@ -197,6 +202,11 @@ const safeProviders: SafeProvider[] = [
PolicyService,
],
}),
safeProvider({
provide: LockComponentService,
useClass: WebLockComponentService,
deps: [],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: WebSetPasswordJitService,

View File

@@ -10,6 +10,7 @@ import {
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap";
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
@@ -20,6 +21,7 @@ import {
RegistrationStartSecondaryComponentData,
SetPasswordJitComponent,
RegistrationLinkExpiredComponent,
LockV2Component,
LockIcon,
UserLockIcon,
} from "@bitwarden/auth/angular";
@@ -337,21 +339,41 @@ const routes: Routes = [
pageTitle: "logIn",
},
},
{
path: "lock",
canActivate: [deepLinkGuard(), lockGuard()],
children: [
{
path: "",
component: LockComponent,
},
],
data: {
pageTitle: "yourVaultIsLockedV2",
pageIcon: LockIcon,
showReadonlyHostname: true,
} satisfies AnonLayoutWrapperData,
},
...extensionRefreshSwap(
LockComponent,
LockV2Component,
{
path: "lock",
canActivate: [deepLinkGuard(), lockGuard()],
children: [
{
path: "",
component: LockComponent,
},
],
data: {
pageTitle: "yourVaultIsLockedV2",
pageIcon: LockIcon,
showReadonlyHostname: true,
} satisfies AnonLayoutWrapperData,
},
{
path: "lock",
canActivate: [deepLinkGuard(), lockGuard()],
children: [
{
path: "",
component: LockV2Component,
},
],
data: {
pageTitle: "yourAccountIsLocked",
pageIcon: LockIcon,
showReadonlyHostname: true,
} satisfies AnonLayoutWrapperData,
},
),
{
path: "2fa",
canActivate: [unauthGuardFn()],

View File

@@ -1099,8 +1099,11 @@
"yourVaultIsLockedV2": {
"message": "Your vault is locked"
},
"uuid": {
"message": "UUID"
"yourAccountIsLocked": {
"message": "Your account is locked"
},
"uuid":{
"message" : "UUID"
},
"unlock": {
"message": "Unlock"
@@ -3169,6 +3172,10 @@
"incorrectPin": {
"message": "Incorrect PIN"
},
"pin": {
"message": "PIN",
"description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device."
},
"exportedVault": {
"message": "Vault exported"
},
@@ -7463,6 +7470,15 @@
"or": {
"message": "or"
},
"unlockWithBiometrics": {
"message": "Unlock with biometrics"
},
"unlockWithPin": {
"message": "Unlock with PIN"
},
"unlockWithMasterPassword": {
"message": "Unlock with master password"
},
"licenseAndBillingManagement": {
"message": "License and billing management"
},