mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-18 01:41:27 +01: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:
parent
dab60dbaea
commit
9ff1db7573
@ -604,6 +604,15 @@
|
|||||||
"yourVaultIsLocked": {
|
"yourVaultIsLocked": {
|
||||||
"message": "Your vault is locked. Verify your identity to continue."
|
"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": {
|
"unlock": {
|
||||||
"message": "Unlock"
|
"message": "Unlock"
|
||||||
},
|
},
|
||||||
@ -1936,6 +1945,9 @@
|
|||||||
"unlockWithBiometrics": {
|
"unlockWithBiometrics": {
|
||||||
"message": "Unlock with biometrics"
|
"message": "Unlock with biometrics"
|
||||||
},
|
},
|
||||||
|
"unlockWithMasterPassword": {
|
||||||
|
"message": "Unlock with master password"
|
||||||
|
},
|
||||||
"awaitDesktop": {
|
"awaitDesktop": {
|
||||||
"message": "Awaiting confirmation from desktop"
|
"message": "Awaiting confirmation from desktop"
|
||||||
},
|
},
|
||||||
@ -3623,6 +3635,9 @@
|
|||||||
"typePasskey": {
|
"typePasskey": {
|
||||||
"message": "Passkey"
|
"message": "Passkey"
|
||||||
},
|
},
|
||||||
|
"accessing": {
|
||||||
|
"message": "Accessing"
|
||||||
|
},
|
||||||
"passkeyNotCopied": {
|
"passkeyNotCopied": {
|
||||||
"message": "Passkey will not be copied"
|
"message": "Passkey will not be copied"
|
||||||
},
|
},
|
||||||
|
@ -59,7 +59,7 @@ export class CurrentAccountComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async currentAccountClicked() {
|
async currentAccountClicked() {
|
||||||
if (this.route.snapshot.data.state.includes("account-switcher")) {
|
if (this.route.snapshot.data?.state?.includes("account-switcher")) {
|
||||||
this.location.back();
|
this.location.back();
|
||||||
} else {
|
} else {
|
||||||
await this.router.navigate(["/account-switcher"]);
|
await this.router.navigate(["/account-switcher"]);
|
||||||
|
@ -17,6 +17,8 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh
|
|||||||
import {
|
import {
|
||||||
AnonLayoutWrapperComponent,
|
AnonLayoutWrapperComponent,
|
||||||
AnonLayoutWrapperData,
|
AnonLayoutWrapperData,
|
||||||
|
LockIcon,
|
||||||
|
LockV2Component,
|
||||||
PasswordHintComponent,
|
PasswordHintComponent,
|
||||||
RegistrationFinishComponent,
|
RegistrationFinishComponent,
|
||||||
RegistrationStartComponent,
|
RegistrationStartComponent,
|
||||||
@ -181,6 +183,7 @@ const routes: Routes = [
|
|||||||
path: "lock",
|
path: "lock",
|
||||||
component: LockComponent,
|
component: LockComponent,
|
||||||
canActivate: [lockGuard()],
|
canActivate: [lockGuard()],
|
||||||
|
canMatch: [extensionRefreshRedirect("/lockV2")],
|
||||||
data: { state: "lock", doNotSaveUrl: true } satisfies RouteDataProperties,
|
data: { state: "lock", doNotSaveUrl: true } satisfies RouteDataProperties,
|
||||||
},
|
},
|
||||||
...twofactorRefactorSwap(
|
...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: "",
|
path: "",
|
||||||
component: AnonLayoutWrapperComponent,
|
component: AnonLayoutWrapperComponent,
|
||||||
|
@ -16,7 +16,7 @@ import {
|
|||||||
CLIENT_TYPE,
|
CLIENT_TYPE,
|
||||||
} from "@bitwarden/angular/services/injection-tokens";
|
} from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
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 { LockService, PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.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 { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider";
|
||||||
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
|
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
|
||||||
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
|
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 { ForegroundVaultTimeoutService } from "../../services/vault-timeout/foreground-vault-timeout.service";
|
||||||
import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service";
|
import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service";
|
||||||
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
|
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
|
||||||
@ -536,6 +537,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
provide: CLIENT_TYPE,
|
provide: CLIENT_TYPE,
|
||||||
useValue: ClientType.Browser,
|
useValue: ClientType.Browser,
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: LockComponentService,
|
||||||
|
useClass: ExtensionLockComponentService,
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: Fido2UserVerificationService,
|
provide: Fido2UserVerificationService,
|
||||||
useClass: Fido2UserVerificationService,
|
useClass: Fido2UserVerificationService,
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
117
apps/browser/src/services/extension-lock-component.service.ts
Normal file
117
apps/browser/src/services/extension-lock-component.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -11,9 +11,12 @@ import {
|
|||||||
unauthGuardFn,
|
unauthGuardFn,
|
||||||
} from "@bitwarden/angular/auth/guards";
|
} from "@bitwarden/angular/auth/guards";
|
||||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||||
|
import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect";
|
||||||
import {
|
import {
|
||||||
AnonLayoutWrapperComponent,
|
AnonLayoutWrapperComponent,
|
||||||
AnonLayoutWrapperData,
|
AnonLayoutWrapperData,
|
||||||
|
LockIcon,
|
||||||
|
LockV2Component,
|
||||||
PasswordHintComponent,
|
PasswordHintComponent,
|
||||||
RegistrationFinishComponent,
|
RegistrationFinishComponent,
|
||||||
RegistrationStartComponent,
|
RegistrationStartComponent,
|
||||||
@ -62,6 +65,7 @@ const routes: Routes = [
|
|||||||
path: "lock",
|
path: "lock",
|
||||||
component: LockComponent,
|
component: LockComponent,
|
||||||
canActivate: [lockGuard()],
|
canActivate: [lockGuard()],
|
||||||
|
canMatch: [extensionRefreshRedirect("/lockV2")],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "login",
|
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",
|
path: "set-password-jit",
|
||||||
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
|
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
|
||||||
|
@ -19,7 +19,7 @@ import {
|
|||||||
CLIENT_TYPE,
|
CLIENT_TYPE,
|
||||||
} from "@bitwarden/angular/services/injection-tokens";
|
} from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||||
import { SetPasswordJitService } from "@bitwarden/auth/angular";
|
import { LockComponentService, SetPasswordJitService } from "@bitwarden/auth/angular";
|
||||||
import {
|
import {
|
||||||
InternalUserDecryptionOptionsServiceAbstraction,
|
InternalUserDecryptionOptionsServiceAbstraction,
|
||||||
PinServiceAbstraction,
|
PinServiceAbstraction,
|
||||||
@ -86,6 +86,7 @@ import { ElectronRendererStorageService } from "../../platform/services/electron
|
|||||||
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
||||||
import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging";
|
import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging";
|
||||||
import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme";
|
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 { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service";
|
||||||
import { NativeMessageHandlerService } from "../../services/native-message-handler.service";
|
import { NativeMessageHandlerService } from "../../services/native-message-handler.service";
|
||||||
import { NativeMessagingService } from "../../services/native-messaging.service";
|
import { NativeMessagingService } from "../../services/native-messaging.service";
|
||||||
@ -277,6 +278,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
useClass: NativeMessagingManifestService,
|
useClass: NativeMessagingManifestService,
|
||||||
deps: [],
|
deps: [],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: LockComponentService,
|
||||||
|
useClass: DesktopLockComponentService,
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: CLIENT_TYPE,
|
provide: CLIENT_TYPE,
|
||||||
useValue: ClientType.Desktop,
|
useValue: ClientType.Desktop,
|
||||||
|
@ -918,6 +918,18 @@
|
|||||||
"yourVaultIsLocked": {
|
"yourVaultIsLocked": {
|
||||||
"message": "Your vault is locked. Verify your identity to continue."
|
"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": {
|
"unlock": {
|
||||||
"message": "Unlock"
|
"message": "Unlock"
|
||||||
},
|
},
|
||||||
@ -2256,6 +2268,9 @@
|
|||||||
"locked": {
|
"locked": {
|
||||||
"message": "Locked"
|
"message": "Locked"
|
||||||
},
|
},
|
||||||
|
"yourVaultIsLockedV2": {
|
||||||
|
"message": "Your vault is locked"
|
||||||
|
},
|
||||||
"unlocked": {
|
"unlocked": {
|
||||||
"message": "Unlocked"
|
"message": "Unlocked"
|
||||||
},
|
},
|
||||||
@ -2608,6 +2623,9 @@
|
|||||||
"important": {
|
"important": {
|
||||||
"message": "Important:"
|
"message": "Important:"
|
||||||
},
|
},
|
||||||
|
"accessing": {
|
||||||
|
"message": "Accessing"
|
||||||
|
},
|
||||||
"accessTokenUnableToBeDecrypted": {
|
"accessTokenUnableToBeDecrypted": {
|
||||||
"message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue."
|
"message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue."
|
||||||
},
|
},
|
||||||
|
377
apps/desktop/src/services/desktop-lock-component.service.spec.ts
Normal file
377
apps/desktop/src/services/desktop-lock-component.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
129
apps/desktop/src/services/desktop-lock-component.service.ts
Normal file
129
apps/desktop/src/services/desktop-lock-component.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
export * from "./webauthn-login";
|
export * from "./webauthn-login";
|
||||||
export * from "./set-password-jit";
|
export * from "./set-password-jit";
|
||||||
export * from "./registration";
|
export * from "./registration";
|
||||||
|
export * from "./web-lock-component.service";
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -20,6 +20,7 @@ import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.
|
|||||||
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
|
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
|
||||||
import {
|
import {
|
||||||
RegistrationFinishService as RegistrationFinishServiceAbstraction,
|
RegistrationFinishService as RegistrationFinishServiceAbstraction,
|
||||||
|
LockComponentService,
|
||||||
SetPasswordJitService,
|
SetPasswordJitService,
|
||||||
} from "@bitwarden/auth/angular";
|
} from "@bitwarden/auth/angular";
|
||||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
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 { BiometricsService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
import { PolicyListService } from "../admin-console/core/policy-list.service";
|
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 { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
|
||||||
import { HtmlStorageService } from "../core/html-storage.service";
|
import { HtmlStorageService } from "../core/html-storage.service";
|
||||||
import { I18nService } from "../core/i18n.service";
|
import { I18nService } from "../core/i18n.service";
|
||||||
@ -197,6 +202,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
PolicyService,
|
PolicyService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: LockComponentService,
|
||||||
|
useClass: WebLockComponentService,
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: SetPasswordJitService,
|
provide: SetPasswordJitService,
|
||||||
useClass: WebSetPasswordJitService,
|
useClass: WebSetPasswordJitService,
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
unauthGuardFn,
|
unauthGuardFn,
|
||||||
} from "@bitwarden/angular/auth/guards";
|
} from "@bitwarden/angular/auth/guards";
|
||||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||||
|
import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap";
|
||||||
import {
|
import {
|
||||||
AnonLayoutWrapperComponent,
|
AnonLayoutWrapperComponent,
|
||||||
AnonLayoutWrapperData,
|
AnonLayoutWrapperData,
|
||||||
@ -20,6 +21,7 @@ import {
|
|||||||
RegistrationStartSecondaryComponentData,
|
RegistrationStartSecondaryComponentData,
|
||||||
SetPasswordJitComponent,
|
SetPasswordJitComponent,
|
||||||
RegistrationLinkExpiredComponent,
|
RegistrationLinkExpiredComponent,
|
||||||
|
LockV2Component,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
UserLockIcon,
|
UserLockIcon,
|
||||||
} from "@bitwarden/auth/angular";
|
} from "@bitwarden/auth/angular";
|
||||||
@ -337,6 +339,9 @@ const routes: Routes = [
|
|||||||
pageTitle: "logIn",
|
pageTitle: "logIn",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...extensionRefreshSwap(
|
||||||
|
LockComponent,
|
||||||
|
LockV2Component,
|
||||||
{
|
{
|
||||||
path: "lock",
|
path: "lock",
|
||||||
canActivate: [deepLinkGuard(), lockGuard()],
|
canActivate: [deepLinkGuard(), lockGuard()],
|
||||||
@ -352,6 +357,23 @@ const routes: Routes = [
|
|||||||
showReadonlyHostname: true,
|
showReadonlyHostname: true,
|
||||||
} satisfies AnonLayoutWrapperData,
|
} satisfies AnonLayoutWrapperData,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "lock",
|
||||||
|
canActivate: [deepLinkGuard(), lockGuard()],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
component: LockV2Component,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
data: {
|
||||||
|
pageTitle: "yourAccountIsLocked",
|
||||||
|
pageIcon: LockIcon,
|
||||||
|
showReadonlyHostname: true,
|
||||||
|
} satisfies AnonLayoutWrapperData,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "2fa",
|
path: "2fa",
|
||||||
canActivate: [unauthGuardFn()],
|
canActivate: [unauthGuardFn()],
|
||||||
|
@ -1099,6 +1099,9 @@
|
|||||||
"yourVaultIsLockedV2": {
|
"yourVaultIsLockedV2": {
|
||||||
"message": "Your vault is locked"
|
"message": "Your vault is locked"
|
||||||
},
|
},
|
||||||
|
"yourAccountIsLocked": {
|
||||||
|
"message": "Your account is locked"
|
||||||
|
},
|
||||||
"uuid":{
|
"uuid":{
|
||||||
"message" : "UUID"
|
"message" : "UUID"
|
||||||
},
|
},
|
||||||
@ -3169,6 +3172,10 @@
|
|||||||
"incorrectPin": {
|
"incorrectPin": {
|
||||||
"message": "Incorrect PIN"
|
"message": "Incorrect PIN"
|
||||||
},
|
},
|
||||||
|
"pin": {
|
||||||
|
"message": "PIN",
|
||||||
|
"description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device."
|
||||||
|
},
|
||||||
"exportedVault": {
|
"exportedVault": {
|
||||||
"message": "Vault exported"
|
"message": "Vault exported"
|
||||||
},
|
},
|
||||||
@ -7463,6 +7470,15 @@
|
|||||||
"or": {
|
"or": {
|
||||||
"message": "or"
|
"message": "or"
|
||||||
},
|
},
|
||||||
|
"unlockWithBiometrics": {
|
||||||
|
"message": "Unlock with biometrics"
|
||||||
|
},
|
||||||
|
"unlockWithPin": {
|
||||||
|
"message": "Unlock with PIN"
|
||||||
|
},
|
||||||
|
"unlockWithMasterPassword": {
|
||||||
|
"message": "Unlock with master password"
|
||||||
|
},
|
||||||
"licenseAndBillingManagement": {
|
"licenseAndBillingManagement": {
|
||||||
"message": "License and billing management"
|
"message": "License and billing management"
|
||||||
},
|
},
|
||||||
|
@ -38,6 +38,8 @@ export const authGuard: CanActivateFn = async (
|
|||||||
if (routerState != null) {
|
if (routerState != null) {
|
||||||
messagingService.send("lockedUrl", { url: routerState.url });
|
messagingService.send("lockedUrl", { url: routerState.url });
|
||||||
}
|
}
|
||||||
|
// TODO PM-9674: when extension refresh is finished, remove promptBiometric
|
||||||
|
// as it has been integrated into the component as a default feature.
|
||||||
return router.createUrlTree(["lock"], { queryParams: { promptBiometric: true } });
|
return router.createUrlTree(["lock"], { queryParams: { promptBiometric: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,5 +43,9 @@ export * from "./registration/registration-env-selector/registration-env-selecto
|
|||||||
export * from "./registration/registration-finish/registration-finish.service";
|
export * from "./registration/registration-finish/registration-finish.service";
|
||||||
export * from "./registration/registration-finish/default-registration-finish.service";
|
export * from "./registration/registration-finish/default-registration-finish.service";
|
||||||
|
|
||||||
|
// lock
|
||||||
|
export * from "./lock/lock.component";
|
||||||
|
export * from "./lock/lock-component.service";
|
||||||
|
|
||||||
// vault timeout
|
// vault timeout
|
||||||
export * from "./vault-timeout-input/vault-timeout-input.component";
|
export * from "./vault-timeout-input/vault-timeout-input.component";
|
||||||
|
48
libs/auth/src/angular/lock/lock-component.service.ts
Normal file
48
libs/auth/src/angular/lock/lock-component.service.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
|
export enum BiometricsDisableReason {
|
||||||
|
NotSupportedOnOperatingSystem = "NotSupportedOnOperatingSystem",
|
||||||
|
EncryptedKeysUnavailable = "BiometricsEncryptedKeysUnavailable",
|
||||||
|
SystemBiometricsUnavailable = "SystemBiometricsUnavailable",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ex: type UnlockOptionValue = "masterPassword" | "pin" | "biometrics"
|
||||||
|
export type UnlockOptionValue = (typeof UnlockOption)[keyof typeof UnlockOption];
|
||||||
|
|
||||||
|
export const UnlockOption = Object.freeze({
|
||||||
|
MasterPassword: "masterPassword",
|
||||||
|
Pin: "pin",
|
||||||
|
Biometrics: "biometrics",
|
||||||
|
}) satisfies { [Prop in keyof UnlockOptions as Capitalize<Prop>]: Prop };
|
||||||
|
|
||||||
|
export type UnlockOptions = {
|
||||||
|
masterPassword: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
pin: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
biometrics: {
|
||||||
|
enabled: boolean;
|
||||||
|
disableReason: BiometricsDisableReason | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LockComponentService is a service which allows the single libs/auth LockComponent to delegate all
|
||||||
|
* client specific functionality to client specific services implementations of LockComponentService.
|
||||||
|
*/
|
||||||
|
export abstract class LockComponentService {
|
||||||
|
// Extension
|
||||||
|
abstract getBiometricsError(error: any): string | null;
|
||||||
|
abstract getPreviousUrl(): string | null;
|
||||||
|
|
||||||
|
// Desktop only
|
||||||
|
abstract isWindowVisible(): Promise<boolean>;
|
||||||
|
abstract getBiometricsUnlockBtnText(): string;
|
||||||
|
|
||||||
|
// Multi client
|
||||||
|
abstract getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions>;
|
||||||
|
}
|
191
libs/auth/src/angular/lock/lock.component.html
Normal file
191
libs/auth/src/angular/lock/lock.component.html
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
<ng-template #loading>
|
||||||
|
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
|
||||||
|
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-container *ngIf="unlockOptions; else loading">
|
||||||
|
<!-- Biometrics Unlock -->
|
||||||
|
<ng-container
|
||||||
|
*ngIf="unlockOptions.biometrics.enabled && activeUnlockOption === UnlockOption.Biometrics"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="primary"
|
||||||
|
class="tw-mb-3"
|
||||||
|
[disabled]="unlockingViaBiometrics"
|
||||||
|
[loading]="unlockingViaBiometrics"
|
||||||
|
block
|
||||||
|
(click)="unlockViaBiometrics()"
|
||||||
|
>
|
||||||
|
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||||
|
<p class="tw-text-center tw-mb-0">{{ "or" | i18n }}</p>
|
||||||
|
|
||||||
|
<ng-container *ngIf="unlockOptions.pin.enabled">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="secondary"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption = UnlockOption.Pin"
|
||||||
|
>
|
||||||
|
{{ "unlockWithPin" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="secondary"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption = UnlockOption.MasterPassword"
|
||||||
|
>
|
||||||
|
{{ "unlockWithMasterPassword" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<button type="button" bitButton block (click)="logOut()">
|
||||||
|
{{ "logOut" | i18n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- PIN Unlock -->
|
||||||
|
<ng-container *ngIf="unlockOptions.pin.enabled && activeUnlockOption === UnlockOption.Pin">
|
||||||
|
<form [bitSubmit]="submit" [formGroup]="formGroup">
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "pin" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
formControlName="pin"
|
||||||
|
bitInput
|
||||||
|
appAutofocus
|
||||||
|
name="pin"
|
||||||
|
class="tw-font-mono"
|
||||||
|
required
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton
|
||||||
|
bitSuffix
|
||||||
|
bitPasswordInputToggle
|
||||||
|
[(toggled)]="showPassword"
|
||||||
|
></button>
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||||
|
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||||
|
{{ "unlock" | i18n }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||||
|
|
||||||
|
<ng-container *ngIf="unlockOptions.biometrics.enabled">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="secondary"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption = UnlockOption.Biometrics"
|
||||||
|
>
|
||||||
|
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="secondary"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption = UnlockOption.MasterPassword"
|
||||||
|
>
|
||||||
|
{{ "unlockWithMasterPassword" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||||
|
{{ "logOut" | i18n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- MP Unlock -->
|
||||||
|
<ng-container
|
||||||
|
*ngIf="
|
||||||
|
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<form [bitSubmit]="submit" [formGroup]="formGroup">
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
formControlName="masterPassword"
|
||||||
|
bitInput
|
||||||
|
appAutofocus
|
||||||
|
name="masterPassword"
|
||||||
|
class="tw-font-mono"
|
||||||
|
required
|
||||||
|
appInputVerbatim
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitIconButton
|
||||||
|
bitSuffix
|
||||||
|
bitPasswordInputToggle
|
||||||
|
[(toggled)]="showPassword"
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<!-- [attr.aria-pressed]="showPassword" -->
|
||||||
|
</bit-form-field>
|
||||||
|
|
||||||
|
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||||
|
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||||
|
{{ "unlock" | i18n }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||||
|
|
||||||
|
<ng-container *ngIf="unlockOptions.biometrics.enabled">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="secondary"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption = UnlockOption.Biometrics"
|
||||||
|
>
|
||||||
|
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="unlockOptions.pin.enabled">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="secondary"
|
||||||
|
block
|
||||||
|
(click)="activeUnlockOption = UnlockOption.Pin"
|
||||||
|
>
|
||||||
|
{{ "unlockWithPin" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||||
|
{{ "logOut" | i18n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
638
libs/auth/src/angular/lock/lock.component.ts
Normal file
638
libs/auth/src/angular/lock/lock.component.ts
Normal file
@ -0,0 +1,638 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
import { BehaviorSubject, firstValueFrom, Subject, switchMap, take, takeUntil } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||||
|
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||||
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||||
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
|
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||||
|
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||||
|
import {
|
||||||
|
MasterPasswordVerification,
|
||||||
|
MasterPasswordVerificationResponse,
|
||||||
|
} from "@bitwarden/common/auth/types/verification";
|
||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||||
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
|
import {
|
||||||
|
AsyncActionsModule,
|
||||||
|
ButtonModule,
|
||||||
|
DialogService,
|
||||||
|
FormFieldModule,
|
||||||
|
IconButtonModule,
|
||||||
|
ToastService,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
import { BiometricStateService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
|
import { PinServiceAbstraction } from "../../common/abstractions";
|
||||||
|
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
||||||
|
|
||||||
|
import {
|
||||||
|
UnlockOption,
|
||||||
|
LockComponentService,
|
||||||
|
UnlockOptions,
|
||||||
|
UnlockOptionValue,
|
||||||
|
} from "./lock-component.service";
|
||||||
|
|
||||||
|
const BroadcasterSubscriptionId = "LockComponent";
|
||||||
|
|
||||||
|
const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
||||||
|
[ClientType.Web]: "vault",
|
||||||
|
[ClientType.Desktop]: "vault",
|
||||||
|
[ClientType.Browser]: "/tabs/current",
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-lock",
|
||||||
|
templateUrl: "lock.component.html",
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
JslibModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ButtonModule,
|
||||||
|
FormFieldModule,
|
||||||
|
AsyncActionsModule,
|
||||||
|
IconButtonModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class LockV2Component implements OnInit, OnDestroy {
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
activeAccount: { id: UserId | undefined } & AccountInfo;
|
||||||
|
|
||||||
|
clientType: ClientType;
|
||||||
|
ClientType = ClientType;
|
||||||
|
|
||||||
|
unlockOptions: UnlockOptions = null;
|
||||||
|
|
||||||
|
UnlockOption = UnlockOption;
|
||||||
|
|
||||||
|
private _activeUnlockOptionBSubject: BehaviorSubject<UnlockOptionValue> =
|
||||||
|
new BehaviorSubject<UnlockOptionValue>(null);
|
||||||
|
|
||||||
|
activeUnlockOption$ = this._activeUnlockOptionBSubject.asObservable();
|
||||||
|
|
||||||
|
set activeUnlockOption(value: UnlockOptionValue) {
|
||||||
|
this._activeUnlockOptionBSubject.next(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeUnlockOption(): UnlockOptionValue {
|
||||||
|
return this._activeUnlockOptionBSubject.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private invalidPinAttempts = 0;
|
||||||
|
|
||||||
|
biometricUnlockBtnText: string;
|
||||||
|
|
||||||
|
// masterPassword = "";
|
||||||
|
showPassword = false;
|
||||||
|
private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined;
|
||||||
|
|
||||||
|
forcePasswordResetRoute = "update-temp-password";
|
||||||
|
|
||||||
|
formGroup: FormGroup;
|
||||||
|
|
||||||
|
// Desktop properties:
|
||||||
|
private deferFocus: boolean = null;
|
||||||
|
private biometricAsked = false;
|
||||||
|
|
||||||
|
// Browser extension properties:
|
||||||
|
private isInitialLockScreen = (window as any).previousPopupUrl == null;
|
||||||
|
|
||||||
|
defaultUnlockOptionSetForUser = false;
|
||||||
|
|
||||||
|
unlockingViaBiometrics = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private accountService: AccountService,
|
||||||
|
private pinService: PinServiceAbstraction,
|
||||||
|
private userVerificationService: UserVerificationService,
|
||||||
|
private cryptoService: CryptoService,
|
||||||
|
private platformUtilsService: PlatformUtilsService,
|
||||||
|
private router: Router,
|
||||||
|
private dialogService: DialogService,
|
||||||
|
private messagingService: MessagingService,
|
||||||
|
private biometricStateService: BiometricStateService,
|
||||||
|
private ngZone: NgZone,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||||
|
private logService: LogService,
|
||||||
|
private deviceTrustService: DeviceTrustServiceAbstraction,
|
||||||
|
private syncService: SyncService,
|
||||||
|
private policyService: InternalPolicyService,
|
||||||
|
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private toastService: ToastService,
|
||||||
|
|
||||||
|
private lockComponentService: LockComponentService,
|
||||||
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||||
|
|
||||||
|
// desktop deps
|
||||||
|
private broadcasterService: BroadcasterService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
this.listenForActiveUnlockOptionChanges();
|
||||||
|
|
||||||
|
// Listen for active account changes
|
||||||
|
this.listenForActiveAccountChanges();
|
||||||
|
|
||||||
|
// Identify client
|
||||||
|
this.clientType = this.platformUtilsService.getClientType();
|
||||||
|
|
||||||
|
if (this.clientType === "desktop") {
|
||||||
|
await this.desktopOnInit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base component methods
|
||||||
|
private listenForActiveUnlockOptionChanges() {
|
||||||
|
this.activeUnlockOption$
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((activeUnlockOption: UnlockOptionValue) => {
|
||||||
|
if (activeUnlockOption === UnlockOption.Pin) {
|
||||||
|
this.buildPinForm();
|
||||||
|
} else if (activeUnlockOption === UnlockOption.MasterPassword) {
|
||||||
|
this.buildMasterPasswordForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMasterPasswordForm() {
|
||||||
|
this.formGroup = this.formBuilder.group(
|
||||||
|
{
|
||||||
|
masterPassword: ["", [Validators.required]],
|
||||||
|
},
|
||||||
|
{ updateOn: "submit" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPinForm() {
|
||||||
|
this.formGroup = this.formBuilder.group(
|
||||||
|
{
|
||||||
|
pin: ["", [Validators.required]],
|
||||||
|
},
|
||||||
|
{ updateOn: "submit" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private listenForActiveAccountChanges() {
|
||||||
|
this.accountService.activeAccount$
|
||||||
|
.pipe(
|
||||||
|
switchMap((account) => {
|
||||||
|
return this.handleActiveAccountChange(account);
|
||||||
|
}),
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleActiveAccountChange(activeAccount: { id: UserId | undefined } & AccountInfo) {
|
||||||
|
this.activeAccount = activeAccount;
|
||||||
|
|
||||||
|
this.resetDataOnActiveAccountChange();
|
||||||
|
|
||||||
|
this.setEmailAsPageSubtitle(activeAccount.email);
|
||||||
|
|
||||||
|
this.unlockOptions = await firstValueFrom(
|
||||||
|
this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.setDefaultActiveUnlockOption(this.unlockOptions);
|
||||||
|
|
||||||
|
if (this.unlockOptions.biometrics.enabled) {
|
||||||
|
await this.handleBiometricsUnlockEnabled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetDataOnActiveAccountChange() {
|
||||||
|
this.defaultUnlockOptionSetForUser = false;
|
||||||
|
this.unlockOptions = null;
|
||||||
|
this.activeUnlockOption = null;
|
||||||
|
this.formGroup = null; // new form group will be created based on new active unlock option
|
||||||
|
|
||||||
|
// Desktop properties:
|
||||||
|
this.biometricAsked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setEmailAsPageSubtitle(email: string) {
|
||||||
|
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||||
|
pageSubtitle: {
|
||||||
|
subtitle: email,
|
||||||
|
translate: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setDefaultActiveUnlockOption(unlockOptions: UnlockOptions) {
|
||||||
|
// Priorities should be Biometrics > Pin > Master Password for speed
|
||||||
|
if (unlockOptions.biometrics.enabled) {
|
||||||
|
this.activeUnlockOption = UnlockOption.Biometrics;
|
||||||
|
} else if (unlockOptions.pin.enabled) {
|
||||||
|
this.activeUnlockOption = UnlockOption.Pin;
|
||||||
|
} else if (unlockOptions.masterPassword.enabled) {
|
||||||
|
this.activeUnlockOption = UnlockOption.MasterPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleBiometricsUnlockEnabled() {
|
||||||
|
this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText();
|
||||||
|
|
||||||
|
const autoPromptBiometrics = await firstValueFrom(
|
||||||
|
this.biometricStateService.promptAutomatically$,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: PM-12546 - we need to make our biometric autoprompt experience consistent between the
|
||||||
|
// desktop and extension.
|
||||||
|
if (this.clientType === "desktop") {
|
||||||
|
if (autoPromptBiometrics) {
|
||||||
|
await this.desktopAutoPromptBiometrics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.clientType === "browser") {
|
||||||
|
if (
|
||||||
|
this.unlockOptions.biometrics.enabled &&
|
||||||
|
autoPromptBiometrics &&
|
||||||
|
this.isInitialLockScreen // only autoprompt biometrics on initial lock screen
|
||||||
|
) {
|
||||||
|
await this.unlockViaBiometrics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: this submit method is only used for unlock methods that require a form and user input.
|
||||||
|
// For biometrics unlock, the method is called directly.
|
||||||
|
submit = async (): Promise<void> => {
|
||||||
|
if (this.activeUnlockOption === UnlockOption.Pin) {
|
||||||
|
return await this.unlockViaPin();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.unlockViaMasterPassword();
|
||||||
|
};
|
||||||
|
|
||||||
|
async logOut() {
|
||||||
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
|
title: { key: "logOut" },
|
||||||
|
content: { key: "logOutConfirmation" },
|
||||||
|
acceptButtonText: { key: "logOut" },
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
this.messagingService.send("logout", { userId: this.activeAccount.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlockViaBiometrics(): Promise<void> {
|
||||||
|
this.unlockingViaBiometrics = true;
|
||||||
|
|
||||||
|
if (!this.unlockOptions.biometrics.enabled) {
|
||||||
|
this.unlockingViaBiometrics = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.biometricStateService.setUserPromptCancelled();
|
||||||
|
const userKey = await this.cryptoService.getUserKeyFromStorage(
|
||||||
|
KeySuffixOptions.Biometric,
|
||||||
|
this.activeAccount.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If user cancels biometric prompt, userKey is undefined.
|
||||||
|
if (userKey) {
|
||||||
|
await this.setUserKeyAndContinue(userKey, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unlockingViaBiometrics = false;
|
||||||
|
} catch (e) {
|
||||||
|
// Cancelling is a valid action.
|
||||||
|
if (e?.message === "canceled") {
|
||||||
|
this.unlockingViaBiometrics = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let biometricTranslatedErrorDesc;
|
||||||
|
|
||||||
|
if (this.clientType === "browser") {
|
||||||
|
const biometricErrorDescTranslationKey = this.lockComponentService.getBiometricsError(e);
|
||||||
|
|
||||||
|
if (biometricErrorDescTranslationKey) {
|
||||||
|
biometricTranslatedErrorDesc = this.i18nService.t(biometricErrorDescTranslationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no translation key found, show generic error message
|
||||||
|
if (!biometricTranslatedErrorDesc) {
|
||||||
|
biometricTranslatedErrorDesc = this.i18nService.t("unexpectedError");
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
|
title: { key: "error" },
|
||||||
|
content: biometricTranslatedErrorDesc,
|
||||||
|
acceptButtonText: { key: "tryAgain" },
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
// try again
|
||||||
|
await this.unlockViaBiometrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unlockingViaBiometrics = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePassword() {
|
||||||
|
this.showPassword = !this.showPassword;
|
||||||
|
const input = document.getElementById(
|
||||||
|
this.unlockOptions.pin.enabled ? "pin" : "masterPassword",
|
||||||
|
);
|
||||||
|
if (this.ngZone.isStable) {
|
||||||
|
input.focus();
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||||
|
this.ngZone.onStable.pipe(take(1)).subscribe(() => input.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePin(): boolean {
|
||||||
|
if (this.formGroup.invalid) {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorOccurred"),
|
||||||
|
message: this.i18nService.t("pinRequired"),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unlockViaPin() {
|
||||||
|
if (!this.validatePin()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin = this.formGroup.controls.pin.value;
|
||||||
|
|
||||||
|
const MAX_INVALID_PIN_ENTRY_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userKey = await this.pinService.decryptUserKeyWithPin(pin, this.activeAccount.id);
|
||||||
|
|
||||||
|
if (userKey) {
|
||||||
|
await this.setUserKeyAndContinue(userKey);
|
||||||
|
return; // successfully unlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure state: invalid PIN or failed decryption
|
||||||
|
this.invalidPinAttempts++;
|
||||||
|
|
||||||
|
// Log user out if they have entered an invalid PIN too many times
|
||||||
|
if (this.invalidPinAttempts >= MAX_INVALID_PIN_ENTRY_ATTEMPTS) {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: null,
|
||||||
|
message: this.i18nService.t("tooManyInvalidPinEntryAttemptsLoggingOut"),
|
||||||
|
});
|
||||||
|
this.messagingService.send("logout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorOccurred"),
|
||||||
|
message: this.i18nService.t("invalidPin"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorOccurred"),
|
||||||
|
message: this.i18nService.t("unexpectedError"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateMasterPassword(): boolean {
|
||||||
|
if (this.formGroup.invalid) {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorOccurred"),
|
||||||
|
message: this.i18nService.t("masterPasswordRequired"),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unlockViaMasterPassword() {
|
||||||
|
if (!this.validateMasterPassword()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterPassword = this.formGroup.controls.masterPassword.value;
|
||||||
|
|
||||||
|
const verification = {
|
||||||
|
type: VerificationType.MasterPassword,
|
||||||
|
secret: masterPassword,
|
||||||
|
} as MasterPasswordVerification;
|
||||||
|
|
||||||
|
let passwordValid = false;
|
||||||
|
let masterPasswordVerificationResponse: MasterPasswordVerificationResponse;
|
||||||
|
try {
|
||||||
|
masterPasswordVerificationResponse =
|
||||||
|
await this.userVerificationService.verifyUserByMasterPassword(
|
||||||
|
verification,
|
||||||
|
this.activeAccount.id,
|
||||||
|
this.activeAccount.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse(
|
||||||
|
masterPasswordVerificationResponse.policyOptions,
|
||||||
|
);
|
||||||
|
passwordValid = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.logService.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordValid) {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: this.i18nService.t("errorOccurred"),
|
||||||
|
message: this.i18nService.t("invalidMasterPassword"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
|
||||||
|
masterPasswordVerificationResponse.masterKey,
|
||||||
|
);
|
||||||
|
await this.setUserKeyAndContinue(userKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) {
|
||||||
|
await this.cryptoService.setUserKey(key, this.activeAccount.id);
|
||||||
|
|
||||||
|
// Now that we have a decrypted user key in memory, we can check if we
|
||||||
|
// need to establish trust on the current device
|
||||||
|
await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id);
|
||||||
|
|
||||||
|
await this.doContinue(evaluatePasswordAfterUnlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
|
||||||
|
await this.biometricStateService.resetUserPromptCancelled();
|
||||||
|
this.messagingService.send("unlocked");
|
||||||
|
|
||||||
|
if (evaluatePasswordAfterUnlock) {
|
||||||
|
try {
|
||||||
|
// If we do not have any saved policies, attempt to load them from the service
|
||||||
|
if (this.enforcedMasterPasswordOptions == undefined) {
|
||||||
|
this.enforcedMasterPasswordOptions = await firstValueFrom(
|
||||||
|
this.policyService.masterPasswordPolicyOptions$(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.requirePasswordChange()) {
|
||||||
|
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||||
|
await this.masterPasswordService.setForceSetPasswordReason(
|
||||||
|
ForceSetPasswordReason.WeakMasterPassword,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
await this.router.navigate([this.forcePasswordResetRoute]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Do not prevent unlock if there is an error evaluating policies
|
||||||
|
this.logService.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service.
|
||||||
|
await this.syncService.fullSync(false);
|
||||||
|
|
||||||
|
if (this.clientType === "browser") {
|
||||||
|
const previousUrl = this.lockComponentService.getPreviousUrl();
|
||||||
|
if (previousUrl) {
|
||||||
|
await this.router.navigateByUrl(previousUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine success route based on client type
|
||||||
|
const successRoute = clientTypeToSuccessRouteRecord[this.clientType];
|
||||||
|
await this.router.navigate([successRoute]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the master password meets the enforced policy requirements
|
||||||
|
* If not, returns false
|
||||||
|
*/
|
||||||
|
private requirePasswordChange(): boolean {
|
||||||
|
if (
|
||||||
|
this.enforcedMasterPasswordOptions == undefined ||
|
||||||
|
!this.enforcedMasterPasswordOptions.enforceOnLogin
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterPassword = this.formGroup.controls.masterPassword.value;
|
||||||
|
|
||||||
|
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
|
||||||
|
masterPassword,
|
||||||
|
this.activeAccount.email,
|
||||||
|
)?.score;
|
||||||
|
|
||||||
|
return !this.policyService.evaluateMasterPassword(
|
||||||
|
passwordStrength,
|
||||||
|
masterPassword,
|
||||||
|
this.enforcedMasterPasswordOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------
|
||||||
|
// Desktop methods:
|
||||||
|
// -----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async desktopOnInit() {
|
||||||
|
// TODO: move this into a WindowService and subscribe to messages via MessageListener service.
|
||||||
|
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
|
||||||
|
this.ngZone.run(() => {
|
||||||
|
switch (message.command) {
|
||||||
|
case "windowHidden":
|
||||||
|
this.onWindowHidden();
|
||||||
|
break;
|
||||||
|
case "windowIsFocused":
|
||||||
|
if (this.deferFocus === null) {
|
||||||
|
this.deferFocus = !message.windowIsFocused;
|
||||||
|
if (!this.deferFocus) {
|
||||||
|
this.focusInput();
|
||||||
|
}
|
||||||
|
} else if (this.deferFocus && message.windowIsFocused) {
|
||||||
|
this.focusInput();
|
||||||
|
this.deferFocus = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.messagingService.send("getWindowIsFocused");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async desktopAutoPromptBiometrics() {
|
||||||
|
if (!this.unlockOptions?.biometrics?.enabled || this.biometricAsked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent the biometric prompt from showing if the user has already cancelled it
|
||||||
|
if (await firstValueFrom(this.biometricStateService.promptCancelled$)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowVisible = await this.lockComponentService.isWindowVisible();
|
||||||
|
|
||||||
|
if (windowVisible) {
|
||||||
|
this.biometricAsked = true;
|
||||||
|
await this.unlockViaBiometrics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowHidden() {
|
||||||
|
this.showPassword = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusInput() {
|
||||||
|
if (this.unlockOptions) {
|
||||||
|
document.getElementById(this.unlockOptions.pin.enabled ? "pin" : "masterPassword")?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
|
||||||
|
if (this.clientType === "desktop") {
|
||||||
|
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user