diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js index 103cbc72e..4714836b3 100644 --- a/app/javascript/lib/action_pack/passkey.js +++ b/app/javascript/lib/action_pack/passkey.js @@ -30,6 +30,7 @@ class PasskeyButton extends HTMLElement { } disconnectedCallback() { + this.abortConditionalMediation?.() this.button.removeEventListener("click", this.#perform) this.button.disabled = false this.#hideErrors() @@ -53,6 +54,7 @@ class PasskeyButton extends HTMLElement { // Arrow function to preserve `this` binding for addEventListener/removeEventListener. #perform = async () => { + await this.abortConditionalMediation?.() this.button.disabled = true this.#hideErrors() this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) @@ -105,6 +107,9 @@ class PasskeyRegistrationButton extends PasskeyButton { } class PasskeySignInButton extends PasskeyButton { + #conditionalMediationController = null + #conditionalMediationPromise = null + get purpose() { return "authentication" } connectedCallback() { @@ -116,32 +121,49 @@ class PasskeySignInButton extends PasskeyButton { return this.getAttribute("mediation") } - async perform(options, { mediation } = {}) { - return await authenticate(options, { mediation }) + async perform(options, { signal, mediation } = {}) { + return await authenticate(options, { signal, mediation }) } fillForm(passkey) { fillSignInForm(this.form, passkey) } + async abortConditionalMediation() { + if (this.#conditionalMediationController) { + this.#conditionalMediationController.abort() + await this.#conditionalMediationPromise + } + } + async #attemptConditionalMediation() { if (await this.#conditionalMediationAvailable()) { const options = this.options this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) - try { - await refreshChallenge(options, this.challengeUrl, this.purpose) - const passkey = await this.perform(options, { mediation: this.mediation }) + this.#conditionalMediationController = new AbortController() + this.#conditionalMediationPromise = this.#runConditionalMediation(options) + } + } - this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) - this.fillForm(passkey) - this.form.submit() - } catch (error) { - console.error("Passkey conditional mediation failed", error) - const type = errorType(error) - this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } })) - } + async #runConditionalMediation(options) { + try { + await refreshChallenge(options, this.challengeUrl, this.purpose) + const passkey = await this.perform(options, { signal: this.#conditionalMediationController.signal, mediation: this.mediation }) + + this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + this.fillForm(passkey) + this.form.submit() + } catch (error) { + if (error.name === "AbortError") return + + console.error("Passkey conditional mediation failed", error) + const type = errorType(error) + this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } })) + } finally { + this.#conditionalMediationController = null + this.#conditionalMediationPromise = null } } diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index c44980e54..aba27d540 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -22,7 +22,14 @@ <% end %> - <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, options: @authentication_options, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %> +