@@ -140,6 +140,7 @@ export class AppComponent implements OnInit, OnDestroy {
@ViewChild("loginApproval", { read: ViewContainerRef, static: true })
loginApprovalModalRef: ViewContainerRef;
+ showHeader$ = this.accountService.showHeader$;
loading = false;
private lastActivity: Date = null;
diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts
deleted file mode 100644
index f1f52dae439..00000000000
--- a/apps/desktop/src/app/components/fido2placeholder.component.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { CommonModule } from "@angular/common";
-import { Component, OnDestroy, OnInit } from "@angular/core";
-import { Router } from "@angular/router";
-import { BehaviorSubject, Observable } from "rxjs";
-
-import {
- DesktopFido2UserInterfaceService,
- DesktopFido2UserInterfaceSession,
-} from "../../autofill/services/desktop-fido2-user-interface.service";
-import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
-
-// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
-// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
-@Component({
- standalone: true,
- imports: [CommonModule],
- template: `
-
-
Select your passkey
-
-
-
-
-
-
-
-
-
- `,
-})
-export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
- session?: DesktopFido2UserInterfaceSession = null;
- private cipherIdsSubject = new BehaviorSubject
([]);
- cipherIds$: Observable;
-
- constructor(
- private readonly desktopSettingsService: DesktopSettingsService,
- private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
- private readonly router: Router,
- ) {}
-
- ngOnInit() {
- this.session = this.fido2UserInterfaceService.getCurrentSession();
- this.cipherIds$ = this.session?.availableCipherIds$;
- }
-
- async chooseCipher(cipherId: string) {
- // For now: Set UV to true
- this.session?.confirmChosenCipher(cipherId, true);
-
- await this.router.navigate(["/"]);
- await this.desktopSettingsService.setModalMode(false);
- }
-
- ngOnDestroy() {
- this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject
- }
-
- async confirmPasskey() {
- try {
- // Retrieve the current UI session to control the flow
- if (!this.session) {
- // todo: handle error
- throw new Error("No session found");
- }
-
- // If we want to we could submit information to the session in order to create the credential
- // const cipher = await session.createCredential({
- // userHandle: "userHandle2",
- // userName: "username2",
- // credentialName: "zxsd2",
- // rpId: "webauthn.io",
- // userVerification: true,
- // });
-
- this.session.notifyConfirmNewCredential(true);
-
- // Not sure this clean up should happen here or in session.
- // The session currently toggles modal on and send us here
- // But if this route is somehow opened outside of session we want to make sure we clean up?
- await this.router.navigate(["/"]);
- await this.desktopSettingsService.setModalMode(false);
- } catch {
- // TODO: Handle error appropriately
- }
- }
-
- async closeModal() {
- await this.router.navigate(["/"]);
- await this.desktopSettingsService.setModalMode(false);
-
- this.session.notifyConfirmNewCredential(false);
- // little bit hacky:
- this.session.confirmChosenCipher(null);
- }
-}
diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts
index a0ee33a459c..d6f29b122ea 100644
--- a/apps/desktop/src/app/services/services.module.ts
+++ b/apps/desktop/src/app/services/services.module.ts
@@ -336,6 +336,7 @@ const safeProviders: SafeProvider[] = [
ConfigService,
Fido2AuthenticatorServiceAbstraction,
AccountService,
+ AuthService,
],
}),
safeProvider({
diff --git a/apps/desktop/src/autofill/guards/reactive-vault-guard.ts b/apps/desktop/src/autofill/guards/reactive-vault-guard.ts
new file mode 100644
index 00000000000..d16787ef46a
--- /dev/null
+++ b/apps/desktop/src/autofill/guards/reactive-vault-guard.ts
@@ -0,0 +1,42 @@
+import { inject } from "@angular/core";
+import { CanActivateFn, Router } from "@angular/router";
+import { combineLatest, map, switchMap, distinctUntilChanged } from "rxjs";
+
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
+import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
+
+import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
+
+/**
+ * Reactive route guard that redirects to the unlocked vault.
+ * Redirects to vault when unlocked in main window.
+ */
+export const reactiveUnlockVaultGuard: CanActivateFn = () => {
+ const router = inject(Router);
+ const authService = inject(AuthService);
+ const accountService = inject(AccountService);
+ const desktopSettingsService = inject(DesktopSettingsService);
+
+ return combineLatest([accountService.activeAccount$, desktopSettingsService.modalMode$]).pipe(
+ switchMap(([account, modalMode]) => {
+ if (!account) {
+ return [true];
+ }
+
+ // Monitor when the vault has been unlocked.
+ return authService.authStatusFor$(account.id).pipe(
+ distinctUntilChanged(),
+ map((authStatus) => {
+ // If vault is unlocked and we're not in modal mode, redirect to vault
+ if (authStatus === AuthenticationStatus.Unlocked && !modalMode?.isModalModeActive) {
+ return router.createUrlTree(["/vault"]);
+ }
+
+ // Otherwise keep user on the lock screen
+ return true;
+ }),
+ );
+ }),
+ );
+};
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html
new file mode 100644
index 00000000000..67fc76aa317
--- /dev/null
+++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+ {{ "savePasskeyQuestion" | i18n }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ "noMatchingLoginsForSite" | i18n }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ "saveNewPasskey" | i18n }}
+
+
+
+
+
+
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts
new file mode 100644
index 00000000000..778215895ee
--- /dev/null
+++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts
@@ -0,0 +1,238 @@
+import { TestBed } from "@angular/core/testing";
+import { Router } from "@angular/router";
+import { mock, MockProxy } from "jest-mock-extended";
+import { BehaviorSubject, of } from "rxjs";
+
+import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
+import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { UserId } from "@bitwarden/common/types/guid";
+import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
+import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
+import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+import { DialogService } from "@bitwarden/components";
+import { PasswordRepromptService } from "@bitwarden/vault";
+
+import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
+import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
+import {
+ DesktopFido2UserInterfaceService,
+ DesktopFido2UserInterfaceSession,
+} from "../../services/desktop-fido2-user-interface.service";
+
+import { Fido2CreateComponent } from "./fido2-create.component";
+
+describe("Fido2CreateComponent", () => {
+ let component: Fido2CreateComponent;
+ let mockDesktopSettingsService: MockProxy;
+ let mockFido2UserInterfaceService: MockProxy;
+ let mockAccountService: MockProxy;
+ let mockCipherService: MockProxy;
+ let mockDesktopAutofillService: MockProxy;
+ let mockDialogService: MockProxy;
+ let mockDomainSettingsService: MockProxy;
+ let mockLogService: MockProxy;
+ let mockPasswordRepromptService: MockProxy;
+ let mockRouter: MockProxy;
+ let mockSession: MockProxy;
+ let mockI18nService: MockProxy;
+
+ const activeAccountSubject = new BehaviorSubject({
+ id: "test-user-id" as UserId,
+ email: "test@example.com",
+ emailVerified: true,
+ name: "Test User",
+ });
+
+ beforeEach(async () => {
+ mockDesktopSettingsService = mock();
+ mockFido2UserInterfaceService = mock();
+ mockAccountService = mock();
+ mockCipherService = mock();
+ mockDesktopAutofillService = mock();
+ mockDialogService = mock();
+ mockDomainSettingsService = mock();
+ mockLogService = mock();
+ mockPasswordRepromptService = mock();
+ mockRouter = mock();
+ mockSession = mock();
+ mockI18nService = mock();
+
+ mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
+ mockAccountService.activeAccount$ = activeAccountSubject;
+
+ await TestBed.configureTestingModule({
+ providers: [
+ Fido2CreateComponent,
+ { provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
+ { provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
+ { provide: AccountService, useValue: mockAccountService },
+ { provide: CipherService, useValue: mockCipherService },
+ { provide: DesktopAutofillService, useValue: mockDesktopAutofillService },
+ { provide: DialogService, useValue: mockDialogService },
+ { provide: DomainSettingsService, useValue: mockDomainSettingsService },
+ { provide: LogService, useValue: mockLogService },
+ { provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
+ { provide: Router, useValue: mockRouter },
+ { provide: I18nService, useValue: mockI18nService },
+ ],
+ }).compileComponents();
+
+ component = TestBed.inject(Fido2CreateComponent);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ function createMockCiphers(): CipherView[] {
+ const cipher1 = new CipherView();
+ cipher1.id = "cipher-1";
+ cipher1.name = "Test Cipher 1";
+ cipher1.type = CipherType.Login;
+ cipher1.login = {
+ username: "test1@example.com",
+ uris: [{ uri: "https://example.com", match: null }],
+ matchesUri: jest.fn().mockReturnValue(true),
+ get hasFido2Credentials() {
+ return false;
+ },
+ } as any;
+ cipher1.reprompt = CipherRepromptType.None;
+ cipher1.deletedDate = null;
+
+ return [cipher1];
+ }
+
+ describe("ngOnInit", () => {
+ beforeEach(() => {
+ mockSession.getRpId.mockResolvedValue("example.com");
+ Object.defineProperty(mockDesktopAutofillService, "lastRegistrationRequest", {
+ get: jest.fn().mockReturnValue({
+ userHandle: new Uint8Array([1, 2, 3]),
+ }),
+ configurable: true,
+ });
+ mockDomainSettingsService.getUrlEquivalentDomains.mockReturnValue(of(new Set()));
+ });
+
+ it("should initialize session and set show header to false", async () => {
+ const mockCiphers = createMockCiphers();
+ mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
+
+ await component.ngOnInit();
+
+ expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
+ expect(component.session).toBe(mockSession);
+ });
+
+ it("should show error dialog when no active session found", async () => {
+ mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
+ mockDialogService.openSimpleDialog.mockResolvedValue(false);
+
+ await component.ngOnInit();
+
+ expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
+ title: { key: "unableToSavePasskey" },
+ content: { key: "closeThisBitwardenWindow" },
+ type: "danger",
+ acceptButtonText: { key: "closeThisWindow" },
+ acceptAction: expect.any(Function),
+ cancelButtonText: null,
+ });
+ });
+ });
+
+ describe("addCredentialToCipher", () => {
+ beforeEach(() => {
+ component.session = mockSession;
+ });
+
+ it("should add passkey to cipher", async () => {
+ const cipher = createMockCiphers()[0];
+
+ await component.addCredentialToCipher(cipher);
+
+ expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
+ });
+
+ it("should not add passkey when password reprompt is cancelled", async () => {
+ const cipher = createMockCiphers()[0];
+ cipher.reprompt = CipherRepromptType.Password;
+ mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
+
+ await component.addCredentialToCipher(cipher);
+
+ expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
+ });
+
+ it("should call openSimpleDialog when cipher already has a fido2 credential", async () => {
+ const cipher = createMockCiphers()[0];
+ Object.defineProperty(cipher.login, "hasFido2Credentials", {
+ get: jest.fn().mockReturnValue(true),
+ });
+ mockDialogService.openSimpleDialog.mockResolvedValue(true);
+
+ await component.addCredentialToCipher(cipher);
+
+ expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
+ title: { key: "overwritePasskey" },
+ content: { key: "alreadyContainsPasskey" },
+ type: "warning",
+ });
+ expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
+ });
+
+ it("should not add passkey when user cancels overwrite dialog", async () => {
+ const cipher = createMockCiphers()[0];
+ Object.defineProperty(cipher.login, "hasFido2Credentials", {
+ get: jest.fn().mockReturnValue(true),
+ });
+ mockDialogService.openSimpleDialog.mockResolvedValue(false);
+
+ await component.addCredentialToCipher(cipher);
+
+ expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
+ });
+ });
+
+ describe("confirmPasskey", () => {
+ beforeEach(() => {
+ component.session = mockSession;
+ });
+
+ it("should confirm passkey creation successfully", async () => {
+ await component.confirmPasskey();
+
+ expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true);
+ });
+
+ it("should call openSimpleDialog when session is null", async () => {
+ component.session = null;
+ mockDialogService.openSimpleDialog.mockResolvedValue(false);
+
+ await component.confirmPasskey();
+
+ expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
+ title: { key: "unableToSavePasskey" },
+ content: { key: "closeThisBitwardenWindow" },
+ type: "danger",
+ acceptButtonText: { key: "closeThisWindow" },
+ acceptAction: expect.any(Function),
+ cancelButtonText: null,
+ });
+ });
+ });
+
+ describe("closeModal", () => {
+ it("should close modal and notify session", async () => {
+ component.session = mockSession;
+
+ await component.closeModal();
+
+ expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
+ expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
+ });
+ });
+});
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts
new file mode 100644
index 00000000000..3bb8fe4f418
--- /dev/null
+++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts
@@ -0,0 +1,219 @@
+import { CommonModule } from "@angular/common";
+import { Component, OnInit, OnDestroy } from "@angular/core";
+import { RouterModule, Router } from "@angular/router";
+import { combineLatest, map, Observable, Subject, switchMap } from "rxjs";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
+import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
+import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
+import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
+import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+import {
+ DialogService,
+ BadgeModule,
+ ButtonModule,
+ DialogModule,
+ IconModule,
+ ItemModule,
+ SectionComponent,
+ TableModule,
+ SectionHeaderComponent,
+ BitIconButtonComponent,
+ SimpleDialogOptions,
+} from "@bitwarden/components";
+import { PasswordRepromptService } from "@bitwarden/vault";
+
+import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
+import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
+import {
+ DesktopFido2UserInterfaceService,
+ DesktopFido2UserInterfaceSession,
+} from "../../services/desktop-fido2-user-interface.service";
+
+// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
+@Component({
+ standalone: true,
+ imports: [
+ CommonModule,
+ RouterModule,
+ SectionHeaderComponent,
+ BitIconButtonComponent,
+ TableModule,
+ JslibModule,
+ IconModule,
+ ButtonModule,
+ DialogModule,
+ SectionComponent,
+ ItemModule,
+ BadgeModule,
+ ],
+ templateUrl: "fido2-create.component.html",
+})
+export class Fido2CreateComponent implements OnInit, OnDestroy {
+ session?: DesktopFido2UserInterfaceSession = null;
+ ciphers$: Observable;
+ private destroy$ = new Subject();
+ readonly Icons = { BitwardenShield, NoResults };
+
+ private get DIALOG_MESSAGES() {
+ return {
+ unexpectedErrorShort: {
+ title: { key: "unexpectedErrorShort" },
+ content: { key: "closeThisBitwardenWindow" },
+ type: "danger",
+ acceptButtonText: { key: "closeThisWindow" },
+ cancelButtonText: null as null,
+ acceptAction: async () => this.dialogService.closeAll(),
+ },
+ unableToSavePasskey: {
+ title: { key: "unableToSavePasskey" },
+ content: { key: "closeThisBitwardenWindow" },
+ type: "danger",
+ acceptButtonText: { key: "closeThisWindow" },
+ cancelButtonText: null as null,
+ acceptAction: async () => this.dialogService.closeAll(),
+ },
+ overwritePasskey: {
+ title: { key: "overwritePasskey" },
+ content: { key: "alreadyContainsPasskey" },
+ type: "warning",
+ },
+ } as const satisfies Record;
+ }
+
+ constructor(
+ private readonly desktopSettingsService: DesktopSettingsService,
+ private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
+ private readonly accountService: AccountService,
+ private readonly cipherService: CipherService,
+ private readonly desktopAutofillService: DesktopAutofillService,
+ private readonly dialogService: DialogService,
+ private readonly domainSettingsService: DomainSettingsService,
+ private readonly passwordRepromptService: PasswordRepromptService,
+ private readonly router: Router,
+ ) {}
+
+ async ngOnInit(): Promise {
+ this.session = this.fido2UserInterfaceService.getCurrentSession();
+
+ if (this.session) {
+ const rpid = await this.session.getRpId();
+ this.initializeCiphersObservable(rpid);
+ } else {
+ await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
+ }
+ }
+
+ async ngOnDestroy(): Promise {
+ this.destroy$.next();
+ this.destroy$.complete();
+ await this.closeModal();
+ }
+
+ async addCredentialToCipher(cipher: CipherView): Promise {
+ const isConfirmed = await this.validateCipherAccess(cipher);
+
+ try {
+ if (!this.session) {
+ throw new Error("Missing session");
+ }
+
+ this.session.notifyConfirmCreateCredential(isConfirmed, cipher);
+ } catch {
+ await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
+ return;
+ }
+
+ await this.closeModal();
+ }
+
+ async confirmPasskey(): Promise {
+ try {
+ if (!this.session) {
+ throw new Error("Missing session");
+ }
+
+ this.session.notifyConfirmCreateCredential(true);
+ } catch {
+ await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
+ }
+
+ await this.closeModal();
+ }
+
+ async closeModal(): Promise {
+ await this.desktopSettingsService.setModalMode(false);
+ await this.accountService.setShowHeader(true);
+
+ if (this.session) {
+ this.session.notifyConfirmCreateCredential(false);
+ this.session.confirmChosenCipher(null);
+ }
+
+ await this.router.navigate(["/"]);
+ }
+
+ private initializeCiphersObservable(rpid: string): void {
+ const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest;
+
+ if (!lastRegistrationRequest || !rpid) {
+ return;
+ }
+
+ const userHandle = Fido2Utils.bufferToString(
+ new Uint8Array(lastRegistrationRequest.userHandle),
+ );
+
+ this.ciphers$ = combineLatest([
+ this.accountService.activeAccount$.pipe(map((a) => a?.id)),
+ this.domainSettingsService.getUrlEquivalentDomains(rpid),
+ ]).pipe(
+ switchMap(async ([activeUserId, equivalentDomains]) => {
+ if (!activeUserId) {
+ return [];
+ }
+
+ try {
+ const allCiphers = await this.cipherService.getAllDecrypted(activeUserId);
+ return allCiphers.filter(
+ (cipher) =>
+ cipher != null &&
+ cipher.type == CipherType.Login &&
+ cipher.login?.matchesUri(rpid, equivalentDomains) &&
+ Fido2Utils.cipherHasNoOtherPasskeys(cipher, userHandle) &&
+ !cipher.deletedDate,
+ );
+ } catch {
+ await this.showErrorDialog(this.DIALOG_MESSAGES.unexpectedErrorShort);
+ return [];
+ }
+ }),
+ );
+ }
+
+ private async validateCipherAccess(cipher: CipherView): Promise {
+ if (cipher.login.hasFido2Credentials) {
+ const overwriteConfirmed = await this.dialogService.openSimpleDialog(
+ this.DIALOG_MESSAGES.overwritePasskey,
+ );
+
+ if (!overwriteConfirmed) {
+ return false;
+ }
+ }
+
+ if (cipher.reprompt) {
+ return this.passwordRepromptService.showPasswordPrompt();
+ }
+
+ return true;
+ }
+
+ private async showErrorDialog(config: SimpleDialogOptions): Promise {
+ await this.dialogService.openSimpleDialog(config);
+ await this.closeModal();
+ }
+}
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html
new file mode 100644
index 00000000000..792934deedc
--- /dev/null
+++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ {{ "savePasskeyQuestion" | i18n }}
+
+
+
+
+ {{ "close" | i18n }}
+
+
+
+
+
+
+
+
+
+ {{ "passkeyAlreadyExists" | i18n }}
+ {{ "applicationDoesNotSupportDuplicates" | i18n }}
+
+
+ {{ "close" | i18n }}
+
+
+
+
+
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts
new file mode 100644
index 00000000000..6a465136458
--- /dev/null
+++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts
@@ -0,0 +1,78 @@
+import { NO_ERRORS_SCHEMA } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { Router } from "@angular/router";
+import { mock, MockProxy } from "jest-mock-extended";
+
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+
+import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
+import {
+ DesktopFido2UserInterfaceService,
+ DesktopFido2UserInterfaceSession,
+} from "../../services/desktop-fido2-user-interface.service";
+
+import { Fido2ExcludedCiphersComponent } from "./fido2-excluded-ciphers.component";
+
+describe("Fido2ExcludedCiphersComponent", () => {
+ let component: Fido2ExcludedCiphersComponent;
+ let fixture: ComponentFixture;
+ let mockDesktopSettingsService: MockProxy;
+ let mockFido2UserInterfaceService: MockProxy;
+ let mockAccountService: MockProxy;
+ let mockRouter: MockProxy;
+ let mockSession: MockProxy;
+ let mockI18nService: MockProxy;
+
+ beforeEach(async () => {
+ mockDesktopSettingsService = mock();
+ mockFido2UserInterfaceService = mock();
+ mockAccountService = mock();
+ mockRouter = mock