From f8ccf0cfb85b88d567f5f659fb37a1b0a246e8d6 Mon Sep 17 00:00:00 2001
From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com>
Date: Tue, 1 Oct 2024 11:48:59 -0500
Subject: [PATCH 1/5] [PM-12553][Defect] Delete Attachment button needs hover
state. (#11339)
* Remove tw-border-none class from delete button.
* Add transparent border
---
.../delete-attachment/delete-attachment.component.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html
index 38ece650b7..efbc1a0503 100644
--- a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html
+++ b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.html
@@ -3,7 +3,7 @@
buttonType="danger"
size="small"
type="button"
- class="tw-border-none"
+ class="tw-border-transparent"
[appA11yTitle]="'deleteAttachmentName' | i18n: attachment.fileName"
[bitAction]="delete"
>
From 256c6aef5ca48be1ee0dbf27f8333b0b3c5138ba Mon Sep 17 00:00:00 2001
From: Alex Yao <33379584+alexyao2015@users.noreply.github.com>
Date: Tue, 1 Oct 2024 12:40:28 -0500
Subject: [PATCH 2/5] native-messaging: Add chromium support (#11230)
---
apps/desktop/src/main/native-messaging.main.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts
index 036f35e61c..ec57ecdf7b 100644
--- a/apps/desktop/src/main/native-messaging.main.ts
+++ b/apps/desktop/src/main/native-messaging.main.ts
@@ -201,6 +201,13 @@ export class NativeMessagingMain {
chromeJson,
);
}
+
+ if (existsSync(`${this.homedir()}/.config/chromium/`)) {
+ await this.writeManifest(
+ `${this.homedir()}/.config/chromium/NativeMessagingHosts/com.8bit.bitwarden.json`,
+ chromeJson,
+ );
+ }
break;
default:
break;
From ab5a02f4830ae5f9d1b5c7f6767b40ffabb92820 Mon Sep 17 00:00:00 2001
From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com>
Date: Tue, 1 Oct 2024 11:46:10 -0700
Subject: [PATCH 3/5] [PM-12774] - don't display filters when no sends are
available (#11298)
* don't display filters when no sends are available
* move logic down. add conditional class
* fix logic for send filters
---
apps/browser/src/tools/popup/send-v2/send-v2.component.html | 2 +-
libs/tools/send/send-ui/src/services/send-items.service.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html
index 0baa43a4b5..23cc692a59 100644
--- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html
+++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html
@@ -10,7 +10,7 @@
{{ "sendDisabledWarning" | i18n }}
-
+
diff --git a/libs/tools/send/send-ui/src/services/send-items.service.ts b/libs/tools/send/send-ui/src/services/send-items.service.ts
index 66ad5b6786..6cef663f89 100644
--- a/libs/tools/send/send-ui/src/services/send-items.service.ts
+++ b/libs/tools/send/send-ui/src/services/send-items.service.ts
@@ -83,7 +83,7 @@ export class SendItemsService {
);
/**
- * Observable that indicates whether the user's vault is empty.
+ * Observable that indicates whether the user's send list is empty.
*/
emptyList$: Observable = this._sendList$.pipe(map((sends) => !sends.length));
From dab60dbaea503c91840b847871a0cc2d734e6899 Mon Sep 17 00:00:00 2001
From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com>
Date: Tue, 1 Oct 2024 12:58:00 -0700
Subject: [PATCH 4/5] [PM-11926] - send created redirect (#11140)
* send created redirect
* fix test
* fix test
* fix send form save
* return SendData from saveSend
* When saving a Send, bubble up a SendView which can be passed to the SendCreated component
* Use events to initiate navigation and move actual navigation into client-specific component
---------
Co-authored-by: Daniel James Smith
---
.../add-edit/send-add-edit.component.html | 3 +-
.../add-edit/send-add-edit.component.ts | 18 +++++++++--
.../send-created/send-created.component.html | 7 ++++-
.../send-created.component.spec.ts | 11 ++++---
.../send-created/send-created.component.ts | 12 ++++---
.../services/send-api.service.abstraction.ts | 2 +-
.../tools/send/services/send-api.service.ts | 3 +-
.../components/send-form.component.ts | 31 ++++++++++++-------
.../services/default-send-form.service.ts | 3 +-
9 files changed, 62 insertions(+), 28 deletions(-)
diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
index b3783bfed3..40c942539f 100644
--- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
+++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
@@ -4,7 +4,8 @@
diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts
index c84b9717df..20b472f97f 100644
--- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts
+++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts
@@ -2,12 +2,13 @@ import { CommonModule, Location } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
-import { ActivatedRoute, Params } from "@angular/router";
+import { ActivatedRoute, Params, Router } from "@angular/router";
import { map, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
+import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendId } from "@bitwarden/common/types/guid";
import {
@@ -95,14 +96,25 @@ export class SendAddEditComponent {
private sendApiService: SendApiService,
private toastService: ToastService,
private dialogService: DialogService,
+ private router: Router,
) {
this.subscribeToParams();
}
/**
- * Handles the event when the send is saved.
+ * Handles the event when the send is created.
*/
- onSendSaved() {
+ async onSendCreated(send: SendView) {
+ await this.router.navigate(["/send-created"], {
+ queryParams: { sendId: send.id },
+ });
+ return;
+ }
+
+ /**
+ * Handles the event when the send is updated.
+ */
+ onSendUpdated(send: SendView) {
this.location.back();
}
diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html
index 9b56fa74d9..c97d3da139 100644
--- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html
+++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html
@@ -1,6 +1,11 @@
-
+
diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts
index 413f22565e..bcc4d2e2cc 100644
--- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts
+++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts
@@ -1,6 +1,6 @@
import { CommonModule, Location } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
-import { ActivatedRoute, RouterLink } from "@angular/router";
+import { ActivatedRoute, Router, RouterLink } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
@@ -33,6 +33,7 @@ describe("SendCreatedComponent", () => {
let location: MockProxy;
let activatedRoute: MockProxy;
let environmentService: MockProxy;
+ let router: MockProxy;
const sendId = "test-send-id";
const deletionDate = new Date();
@@ -52,6 +53,7 @@ describe("SendCreatedComponent", () => {
location = mock();
activatedRoute = mock();
environmentService = mock();
+ router = mock();
Object.defineProperty(environmentService, "environment$", {
configurable: true,
get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })),
@@ -89,6 +91,7 @@ describe("SendCreatedComponent", () => {
{ provide: ConfigService, useValue: mock() },
{ provide: EnvironmentService, useValue: environmentService },
{ provide: PopupRouterCacheService, useValue: mock() },
+ { provide: Router, useValue: router },
],
}).compileComponents();
});
@@ -109,10 +112,10 @@ describe("SendCreatedComponent", () => {
expect(component["daysAvailable"]).toBe(7);
});
- it("should navigate back on close", () => {
+ it("should navigate back to send list on close", async () => {
fixture.detectChanges();
- component.close();
- expect(location.back).toHaveBeenCalled();
+ await component.close();
+ expect(router.navigate).toHaveBeenCalledWith(["/tabs/send"]);
});
describe("getDaysAvailable", () => {
diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts
index 92339774d0..4ed4da2f81 100644
--- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts
+++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts
@@ -1,7 +1,7 @@
-import { CommonModule, Location } from "@angular/common";
+import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
-import { ActivatedRoute, RouterLink } from "@angular/router";
+import { ActivatedRoute, Router, RouterLink, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -30,6 +30,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page
PopupHeaderComponent,
PopupPageComponent,
RouterLink,
+ RouterModule,
PopupFooterComponent,
IconModule,
],
@@ -45,10 +46,11 @@ export class SendCreatedComponent {
private sendService: SendService,
private route: ActivatedRoute,
private toastService: ToastService,
- private location: Location,
+ private router: Router,
private environmentService: EnvironmentService,
) {
const sendId = this.route.snapshot.queryParamMap.get("sendId");
+
this.sendService.sendViews$.pipe(takeUntilDestroyed()).subscribe((sendViews) => {
this.send = sendViews.find((s) => s.id === sendId);
if (this.send) {
@@ -62,8 +64,8 @@ export class SendCreatedComponent {
return Math.max(0, Math.ceil((send.deletionDate.getTime() - now) / (1000 * 60 * 60 * 24)));
}
- close() {
- this.location.back();
+ async close() {
+ await this.router.navigate(["/tabs/send"]);
}
async copyLink() {
diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts
index 100985c487..4109df1968 100644
--- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts
+++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts
@@ -36,5 +36,5 @@ export abstract class SendApiService {
renewSendFileUploadUrl: (sendId: string, fileId: string) => Promise;
removePassword: (id: string) => Promise;
delete: (id: string) => Promise;
- save: (sendData: [Send, EncArrayBuffer]) => Promise;
+ save: (sendData: [Send, EncArrayBuffer]) => Promise;
}
diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts
index 2cb2ff1c2f..ff71408bce 100644
--- a/libs/common/src/tools/send/services/send-api.service.ts
+++ b/libs/common/src/tools/send/services/send-api.service.ts
@@ -135,11 +135,12 @@ export class SendApiService implements SendApiServiceAbstraction {
return this.apiService.send("DELETE", "/sends/" + id, null, true, false);
}
- async save(sendData: [Send, EncArrayBuffer]): Promise {
+ async save(sendData: [Send, EncArrayBuffer]): Promise {
const response = await this.upload(sendData);
const data = new SendData(response);
await this.sendService.upsert(data);
+ return new Send(data);
}
async delete(id: string): Promise {
diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts
index 1d93804e11..07939ccb06 100644
--- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts
+++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts
@@ -85,9 +85,14 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
submitBtn?: ButtonComponent;
/**
- * Event emitted when the send is saved successfully.
+ * Event emitted when the send is created successfully.
*/
- @Output() sendSaved = new EventEmitter();
+ @Output() onSendCreated = new EventEmitter();
+
+ /**
+ * Event emitted when the send is updated successfully.
+ */
+ @Output() onSendUpdated = new EventEmitter();
/**
* The original send being edited or cloned. Null for add mode.
@@ -200,22 +205,26 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
return;
}
+ const sendView = await this.addEditFormService.saveSend(
+ this.updatedSendView,
+ this.file,
+ this.config,
+ );
+
+ if (this.config.mode === "add") {
+ this.onSendCreated.emit(sendView);
+ return;
+ }
+
if (Utils.isNullOrWhitespace(this.updatedSendView.password)) {
this.updatedSendView.password = null;
}
- await this.addEditFormService.saveSend(this.updatedSendView, this.file, this.config);
-
this.toastService.showToast({
variant: "success",
title: null,
- message: this.i18nService.t(
- this.config.mode === "edit" || this.config.mode === "partial-edit"
- ? "editedItem"
- : "addedItem",
- ),
+ message: this.i18nService.t("editedItem"),
});
-
- this.sendSaved.emit(this.updatedSendView);
+ this.onSendUpdated.emit(this.updatedSendView);
};
}
diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts
index 9b6a6360ac..9eb37b07e5 100644
--- a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts
+++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts
@@ -19,6 +19,7 @@ export class DefaultSendFormService implements SendFormService {
async saveSend(send: SendView, file: File | ArrayBuffer, config: SendFormConfig) {
const sendData = await this.sendService.encrypt(send, file, send.password, null);
- return await this.sendApiService.save(sendData);
+ const newSend = await this.sendApiService.save(sendData);
+ return await this.decryptSend(newSend);
}
}
From 9ff1db757318a81c594d40849c4bbe01ab5d8193 Mon Sep 17 00:00:00 2001
From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
Date: Tue, 1 Oct 2024 16:06:18 -0400
Subject: [PATCH 5/5] 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>
---
apps/browser/src/_locales/en/messages.json | 15 +
.../current-account.component.ts | 2 +-
apps/browser/src/popup/app-routing.module.ts | 25 +
.../src/popup/services/services.module.ts | 8 +-
.../extension-lock-component.service.spec.ts | 325 +++++++++
.../extension-lock-component.service.ts | 117 ++++
apps/desktop/src/app/app-routing.module.ts | 19 +
.../src/app/services/services.module.ts | 8 +-
apps/desktop/src/locales/en/messages.json | 18 +
.../desktop-lock-component.service.spec.ts | 377 +++++++++++
.../desktop-lock-component.service.ts | 129 ++++
apps/web/src/app/auth/core/services/index.ts | 1 +
.../web-lock-component.service.spec.ts | 94 +++
.../services/web-lock-component.service.ts | 55 ++
apps/web/src/app/core/core.module.ts | 12 +-
apps/web/src/app/oss-routing.module.ts | 52 +-
apps/web/src/locales/en/messages.json | 20 +-
libs/angular/src/auth/guards/auth.guard.ts | 2 +
libs/auth/src/angular/index.ts | 4 +
.../angular/lock/lock-component.service.ts | 48 ++
.../auth/src/angular/lock/lock.component.html | 191 ++++++
libs/auth/src/angular/lock/lock.component.ts | 638 ++++++++++++++++++
22 files changed, 2139 insertions(+), 21 deletions(-)
create mode 100644 apps/browser/src/services/extension-lock-component.service.spec.ts
create mode 100644 apps/browser/src/services/extension-lock-component.service.ts
create mode 100644 apps/desktop/src/services/desktop-lock-component.service.spec.ts
create mode 100644 apps/desktop/src/services/desktop-lock-component.service.ts
create mode 100644 apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts
create mode 100644 apps/web/src/app/auth/core/services/web-lock-component.service.ts
create mode 100644 libs/auth/src/angular/lock/lock-component.service.ts
create mode 100644 libs/auth/src/angular/lock/lock.component.html
create mode 100644 libs/auth/src/angular/lock/lock.component.ts
diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 5203edf0a4..ec0fac137d 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -604,6 +604,15 @@
"yourVaultIsLocked": {
"message": "Your vault is locked. Verify your identity to continue."
},
+ "yourVaultIsLockedV2": {
+ "message": "Your vault is locked"
+ },
+ "yourAccountIsLocked": {
+ "message": "Your account is locked"
+ },
+ "or": {
+ "message": "or"
+ },
"unlock": {
"message": "Unlock"
},
@@ -1936,6 +1945,9 @@
"unlockWithBiometrics": {
"message": "Unlock with biometrics"
},
+ "unlockWithMasterPassword": {
+ "message": "Unlock with master password"
+ },
"awaitDesktop": {
"message": "Awaiting confirmation from desktop"
},
@@ -3623,6 +3635,9 @@
"typePasskey": {
"message": "Passkey"
},
+ "accessing": {
+ "message": "Accessing"
+ },
"passkeyNotCopied": {
"message": "Passkey will not be copied"
},
diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts
index 6c7c1e7d92..12210b2b45 100644
--- a/apps/browser/src/auth/popup/account-switching/current-account.component.ts
+++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts
@@ -59,7 +59,7 @@ export class CurrentAccountComponent {
}
async currentAccountClicked() {
- if (this.route.snapshot.data.state.includes("account-switcher")) {
+ if (this.route.snapshot.data?.state?.includes("account-switcher")) {
this.location.back();
} else {
await this.router.navigate(["/account-switcher"]);
diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts
index d540ea39ed..9fd52470c0 100644
--- a/apps/browser/src/popup/app-routing.module.ts
+++ b/apps/browser/src/popup/app-routing.module.ts
@@ -17,6 +17,8 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
+ LockIcon,
+ LockV2Component,
PasswordHintComponent,
RegistrationFinishComponent,
RegistrationStartComponent,
@@ -181,6 +183,7 @@ const routes: Routes = [
path: "lock",
component: LockComponent,
canActivate: [lockGuard()],
+ canMatch: [extensionRefreshRedirect("/lockV2")],
data: { state: "lock", doNotSaveUrl: true } satisfies RouteDataProperties,
},
...twofactorRefactorSwap(
@@ -438,6 +441,28 @@ const routes: Routes = [
],
},
),
+ {
+ path: "",
+ component: ExtensionAnonLayoutWrapperComponent,
+ children: [
+ {
+ path: "lockV2",
+ canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()],
+ data: {
+ pageIcon: LockIcon,
+ pageTitle: "yourVaultIsLockedV2",
+ showReadonlyHostname: true,
+ showAcctSwitcher: true,
+ } satisfies ExtensionAnonLayoutWrapperData,
+ children: [
+ {
+ path: "",
+ component: LockV2Component,
+ },
+ ],
+ },
+ ],
+ },
{
path: "",
component: AnonLayoutWrapperComponent,
diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts
index 483bf86712..024b4f4631 100644
--- a/apps/browser/src/popup/services/services.module.ts
+++ b/apps/browser/src/popup/services/services.module.ts
@@ -16,7 +16,7 @@ import {
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
-import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular";
+import { AnonLayoutWrapperDataService, LockComponentService } from "@bitwarden/auth/angular";
import { LockService, PinServiceAbstraction } from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@@ -117,6 +117,7 @@ import { ForegroundTaskSchedulerService } from "../../platform/services/task-sch
import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider";
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
+import { ExtensionLockComponentService } from "../../services/extension-lock-component.service";
import { ForegroundVaultTimeoutService } from "../../services/vault-timeout/foreground-vault-timeout.service";
import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service";
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
@@ -536,6 +537,11 @@ const safeProviders: SafeProvider[] = [
provide: CLIENT_TYPE,
useValue: ClientType.Browser,
}),
+ safeProvider({
+ provide: LockComponentService,
+ useClass: ExtensionLockComponentService,
+ deps: [],
+ }),
safeProvider({
provide: Fido2UserVerificationService,
useClass: Fido2UserVerificationService,
diff --git a/apps/browser/src/services/extension-lock-component.service.spec.ts b/apps/browser/src/services/extension-lock-component.service.spec.ts
new file mode 100644
index 0000000000..f537897cf8
--- /dev/null
+++ b/apps/browser/src/services/extension-lock-component.service.spec.ts
@@ -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;
+ let platformUtilsService: MockProxy;
+ let biometricsService: MockProxy;
+ let pinService: MockProxy;
+ let vaultTimeoutSettingsService: MockProxy;
+ let cryptoService: MockProxy;
+ let routerService: MockProxy;
+
+ beforeEach(() => {
+ userDecryptionOptionsService = mock();
+ platformUtilsService = mock();
+ biometricsService = mock();
+ pinService = mock();
+ vaultTimeoutSettingsService = mock();
+ cryptoService = mock();
+ routerService = mock();
+
+ 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);
+ });
+ });
+});
diff --git a/apps/browser/src/services/extension-lock-component.service.ts b/apps/browser/src/services/extension-lock-component.service.ts
new file mode 100644
index 0000000000..58514fa2b1
--- /dev/null
+++ b/apps/browser/src/services/extension-lock-component.service.ts
@@ -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 {
+ throw new Error("Method not implemented.");
+ }
+
+ getBiometricsUnlockBtnText(): string {
+ return "unlockWithBiometrics";
+ }
+
+ private async isBiometricLockSet(userId: UserId): Promise {
+ 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 {
+ 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;
+ },
+ ),
+ );
+ }
+}
diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts
index 1e13be12a7..86a39163f3 100644
--- a/apps/desktop/src/app/app-routing.module.ts
+++ b/apps/desktop/src/app/app-routing.module.ts
@@ -11,9 +11,12 @@ import {
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
+import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect";
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
+ LockIcon,
+ LockV2Component,
PasswordHintComponent,
RegistrationFinishComponent,
RegistrationStartComponent,
@@ -62,6 +65,7 @@ const routes: Routes = [
path: "lock",
component: LockComponent,
canActivate: [lockGuard()],
+ canMatch: [extensionRefreshRedirect("/lockV2")],
},
{
path: "login",
@@ -190,6 +194,21 @@ const routes: Routes = [
},
],
},
+ {
+ path: "lockV2",
+ canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()],
+ data: {
+ pageIcon: LockIcon,
+ pageTitle: "yourVaultIsLockedV2",
+ showReadonlyHostname: true,
+ } satisfies AnonLayoutWrapperData,
+ children: [
+ {
+ path: "",
+ component: LockV2Component,
+ },
+ ],
+ },
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts
index d3d41d277b..c6b73fbbbc 100644
--- a/apps/desktop/src/app/services/services.module.ts
+++ b/apps/desktop/src/app/services/services.module.ts
@@ -19,7 +19,7 @@ import {
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
-import { SetPasswordJitService } from "@bitwarden/auth/angular";
+import { LockComponentService, SetPasswordJitService } from "@bitwarden/auth/angular";
import {
InternalUserDecryptionOptionsServiceAbstraction,
PinServiceAbstraction,
@@ -86,6 +86,7 @@ import { ElectronRendererStorageService } from "../../platform/services/electron
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging";
import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme";
+import { DesktopLockComponentService } from "../../services/desktop-lock-component.service";
import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service";
import { NativeMessageHandlerService } from "../../services/native-message-handler.service";
import { NativeMessagingService } from "../../services/native-messaging.service";
@@ -277,6 +278,11 @@ const safeProviders: SafeProvider[] = [
useClass: NativeMessagingManifestService,
deps: [],
}),
+ safeProvider({
+ provide: LockComponentService,
+ useClass: DesktopLockComponentService,
+ deps: [],
+ }),
safeProvider({
provide: CLIENT_TYPE,
useValue: ClientType.Desktop,
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index 9504ecb1fa..0b7a9c678c 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -918,6 +918,18 @@
"yourVaultIsLocked": {
"message": "Your vault is locked. Verify your identity to continue."
},
+ "yourAccountIsLocked": {
+ "message": "Your account is locked"
+ },
+ "or": {
+ "message": "or"
+ },
+ "unlockWithBiometrics": {
+ "message": "Unlock with biometrics"
+ },
+ "unlockWithMasterPassword": {
+ "message": "Unlock with master password"
+ },
"unlock": {
"message": "Unlock"
},
@@ -2256,6 +2268,9 @@
"locked": {
"message": "Locked"
},
+ "yourVaultIsLockedV2": {
+ "message": "Your vault is locked"
+ },
"unlocked": {
"message": "Unlocked"
},
@@ -2608,6 +2623,9 @@
"important": {
"message": "Important:"
},
+ "accessing": {
+ "message": "Accessing"
+ },
"accessTokenUnableToBeDecrypted": {
"message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue."
},
diff --git a/apps/desktop/src/services/desktop-lock-component.service.spec.ts b/apps/desktop/src/services/desktop-lock-component.service.spec.ts
new file mode 100644
index 0000000000..ff1f8328ea
--- /dev/null
+++ b/apps/desktop/src/services/desktop-lock-component.service.spec.ts
@@ -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;
+ let platformUtilsService: MockProxy;
+ let biometricsService: MockProxy;
+ let pinService: MockProxy;
+ let vaultTimeoutSettingsService: MockProxy;
+ let cryptoService: MockProxy;
+
+ beforeEach(() => {
+ userDecryptionOptionsService = mock();
+ platformUtilsService = mock();
+ biometricsService = mock();
+ pinService = mock();
+ vaultTimeoutSettingsService = mock();
+ cryptoService = mock();
+
+ 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);
+ });
+ });
+});
diff --git a/apps/desktop/src/services/desktop-lock-component.service.ts b/apps/desktop/src/services/desktop-lock-component.service.ts
new file mode 100644
index 0000000000..f31ee93a72
--- /dev/null
+++ b/apps/desktop/src/services/desktop-lock-component.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts
index c85f0f3204..9e433b87f3 100644
--- a/apps/web/src/app/auth/core/services/index.ts
+++ b/apps/web/src/app/auth/core/services/index.ts
@@ -1,3 +1,4 @@
export * from "./webauthn-login";
export * from "./set-password-jit";
export * from "./registration";
+export * from "./web-lock-component.service";
diff --git a/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts b/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts
new file mode 100644
index 0000000000..5eb26a8c76
--- /dev/null
+++ b/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts
@@ -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;
+
+ beforeEach(() => {
+ userDecryptionOptionsService = mock();
+
+ 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,
+ },
+ });
+ });
+ });
+});
diff --git a/apps/web/src/app/auth/core/services/web-lock-component.service.ts b/apps/web/src/app/auth/core/services/web-lock-component.service.ts
new file mode 100644
index 0000000000..e24f299e23
--- /dev/null
+++ b/apps/web/src/app/auth/core/services/web-lock-component.service.ts
@@ -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 {
+ 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 {
+ 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;
+ }),
+ );
+ }
+}
diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts
index 419794fe3b..c14c975047 100644
--- a/apps/web/src/app/core/core.module.ts
+++ b/apps/web/src/app/core/core.module.ts
@@ -20,6 +20,7 @@ import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
import {
RegistrationFinishService as RegistrationFinishServiceAbstraction,
+ LockComponentService,
SetPasswordJitService,
} from "@bitwarden/auth/angular";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
@@ -62,7 +63,11 @@ import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/va
import { BiometricsService } from "@bitwarden/key-management";
import { PolicyListService } from "../admin-console/core/policy-list.service";
-import { WebRegistrationFinishService, WebSetPasswordJitService } from "../auth";
+import {
+ WebSetPasswordJitService,
+ WebRegistrationFinishService,
+ WebLockComponentService,
+} from "../auth";
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
@@ -197,6 +202,11 @@ const safeProviders: SafeProvider[] = [
PolicyService,
],
}),
+ safeProvider({
+ provide: LockComponentService,
+ useClass: WebLockComponentService,
+ deps: [],
+ }),
safeProvider({
provide: SetPasswordJitService,
useClass: WebSetPasswordJitService,
diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts
index cae73e8159..983067823c 100644
--- a/apps/web/src/app/oss-routing.module.ts
+++ b/apps/web/src/app/oss-routing.module.ts
@@ -10,6 +10,7 @@ import {
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
+import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap";
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
@@ -20,6 +21,7 @@ import {
RegistrationStartSecondaryComponentData,
SetPasswordJitComponent,
RegistrationLinkExpiredComponent,
+ LockV2Component,
LockIcon,
UserLockIcon,
} from "@bitwarden/auth/angular";
@@ -337,21 +339,41 @@ const routes: Routes = [
pageTitle: "logIn",
},
},
- {
- path: "lock",
- canActivate: [deepLinkGuard(), lockGuard()],
- children: [
- {
- path: "",
- component: LockComponent,
- },
- ],
- data: {
- pageTitle: "yourVaultIsLockedV2",
- pageIcon: LockIcon,
- showReadonlyHostname: true,
- } satisfies AnonLayoutWrapperData,
- },
+ ...extensionRefreshSwap(
+ LockComponent,
+ LockV2Component,
+ {
+ path: "lock",
+ canActivate: [deepLinkGuard(), lockGuard()],
+ children: [
+ {
+ path: "",
+ component: LockComponent,
+ },
+ ],
+ data: {
+ pageTitle: "yourVaultIsLockedV2",
+ pageIcon: LockIcon,
+ showReadonlyHostname: true,
+ } satisfies AnonLayoutWrapperData,
+ },
+ {
+ path: "lock",
+ canActivate: [deepLinkGuard(), lockGuard()],
+ children: [
+ {
+ path: "",
+ component: LockV2Component,
+ },
+ ],
+ data: {
+ pageTitle: "yourAccountIsLocked",
+ pageIcon: LockIcon,
+ showReadonlyHostname: true,
+ } satisfies AnonLayoutWrapperData,
+ },
+ ),
+
{
path: "2fa",
canActivate: [unauthGuardFn()],
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 8e847dfb63..ab43c3af18 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -1099,8 +1099,11 @@
"yourVaultIsLockedV2": {
"message": "Your vault is locked"
},
- "uuid": {
- "message": "UUID"
+ "yourAccountIsLocked": {
+ "message": "Your account is locked"
+ },
+ "uuid":{
+ "message" : "UUID"
},
"unlock": {
"message": "Unlock"
@@ -3169,6 +3172,10 @@
"incorrectPin": {
"message": "Incorrect PIN"
},
+ "pin": {
+ "message": "PIN",
+ "description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device."
+ },
"exportedVault": {
"message": "Vault exported"
},
@@ -7463,6 +7470,15 @@
"or": {
"message": "or"
},
+ "unlockWithBiometrics": {
+ "message": "Unlock with biometrics"
+ },
+ "unlockWithPin": {
+ "message": "Unlock with PIN"
+ },
+ "unlockWithMasterPassword": {
+ "message": "Unlock with master password"
+ },
"licenseAndBillingManagement": {
"message": "License and billing management"
},
diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts
index b54f114d3d..1486b9b57d 100644
--- a/libs/angular/src/auth/guards/auth.guard.ts
+++ b/libs/angular/src/auth/guards/auth.guard.ts
@@ -38,6 +38,8 @@ export const authGuard: CanActivateFn = async (
if (routerState != null) {
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 } });
}
diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts
index bfb3a67aed..6de473c33e 100644
--- a/libs/auth/src/angular/index.ts
+++ b/libs/auth/src/angular/index.ts
@@ -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/default-registration-finish.service";
+// lock
+export * from "./lock/lock.component";
+export * from "./lock/lock-component.service";
+
// vault timeout
export * from "./vault-timeout-input/vault-timeout-input.component";
diff --git a/libs/auth/src/angular/lock/lock-component.service.ts b/libs/auth/src/angular/lock/lock-component.service.ts
new file mode 100644
index 0000000000..fe54db21ba
--- /dev/null
+++ b/libs/auth/src/angular/lock/lock-component.service.ts
@@ -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 };
+
+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;
+ abstract getBiometricsUnlockBtnText(): string;
+
+ // Multi client
+ abstract getAvailableUnlockOptions$(userId: UserId): Observable;
+}
diff --git a/libs/auth/src/angular/lock/lock.component.html b/libs/auth/src/angular/lock/lock.component.html
new file mode 100644
index 0000000000..5f5991c681
--- /dev/null
+++ b/libs/auth/src/angular/lock/lock.component.html
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ "or" | i18n }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libs/auth/src/angular/lock/lock.component.ts b/libs/auth/src/angular/lock/lock.component.ts
new file mode 100644
index 0000000000..7bea14f221
--- /dev/null
+++ b/libs/auth/src/angular/lock/lock.component.ts
@@ -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> = {
+ [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();
+
+ activeAccount: { id: UserId | undefined } & AccountInfo;
+
+ clientType: ClientType;
+ ClientType = ClientType;
+
+ unlockOptions: UnlockOptions = null;
+
+ UnlockOption = UnlockOption;
+
+ private _activeUnlockOptionBSubject: BehaviorSubject =
+ new BehaviorSubject(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 => {
+ 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 {
+ 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);
+ }
+ }
+}