import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, 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"; @Component({ standalone: true, imports: [ CommonModule, RouterModule, SectionHeaderComponent, BitIconButtonComponent, TableModule, JslibModule, IconModule, ButtonModule, DialogModule, SectionComponent, ItemModule, BadgeModule, ], templateUrl: "fido2-create.component.html", changeDetection: ChangeDetectionStrategy.OnPush, }) 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; } // If we want to hide the UI while prompting for UV from the OS, we cannot call closeModal(). // await this.closeModal(); } async confirmPasskey(): Promise { try { if (!this.session) { throw new Error("Missing session"); } const username = await this.session.getUserName(); const isConfirmed = await this.session.promptForUserVerification( username, "Verify it's you to create a new credential", ); this.session.notifyConfirmCreateCredential(isConfirmed); } catch { await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey); return; } 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(); } const username = cipher.login.username ?? cipher.name; return this.session.promptForUserVerification( username, "Verify it's you to overwrite a credential", ); } private async showErrorDialog(config: SimpleDialogOptions): Promise { await this.dialogService.openSimpleDialog(config); await this.closeModal(); } }