From 8e4f9ce5e7a1e733ee4d6ee2ba45f1edafafe958 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 25 Mar 2026 15:00:49 +0100 Subject: [PATCH] Cleanup form helpers --- app/assets/stylesheets/credentials.css | 8 -- app/javascript/lib/action_pack/passkey.js | 130 ++++++++---------- app/views/my/passkeys/index.html.erb | 2 +- lib/action_pack/passkey/form_helper.rb | 97 ++++++++----- .../public_key_credential/creation_options.rb | 4 +- .../public_key_credential/request_options.rb | 4 +- 6 files changed, 123 insertions(+), 122 deletions(-) diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css index 61a4c0585..55199af7e 100644 --- a/app/assets/stylesheets/credentials.css +++ b/app/assets/stylesheets/credentials.css @@ -32,12 +32,4 @@ opacity: 0; } - :is(rails-passkey-creation-button, rails-passkey-sign-in-button) [data-passkey-error] { - display: none; - } - - :is(rails-passkey-creation-button, rails-passkey-sign-in-button)[data-passkey-error-state="error"] [data-passkey-error="error"], - :is(rails-passkey-creation-button, rails-passkey-sign-in-button)[data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] { - display: block; - } } diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js index 057713fed..41c0d0c2b 100644 --- a/app/javascript/lib/action_pack/passkey.js +++ b/app/javascript/lib/action_pack/passkey.js @@ -14,34 +14,37 @@ // passkey:error — ceremony failed; detail: { error, cancelled } // // Attributes (rendered by the Ruby form helpers): -// creation-options — JSON WebAuthn creation options (on rails-passkey-creation-button) -// request-options — JSON WebAuthn request options (on rails-passkey-sign-in-button) -// challenge-url — endpoint to refresh the challenge nonce (on both) -// mediation — WebAuthn mediation hint, e.g. "conditional" (on rails-passkey-sign-in-button) +// options — JSON WebAuthn options (creation or request, on both) +// challenge-url — endpoint to refresh the challenge nonce (on both) +// mediation — WebAuthn mediation hint, e.g. "conditional" (on rails-passkey-sign-in-button) import { register, authenticate } from "lib/action_pack/webauthn" -class PasskeyCreationButton extends HTMLElement { +// Base class for passkey web components. Manages the shared ceremony lifecycle: +// challenge refresh, button state, error display, and event dispatch. +// Subclasses implement `perform()` to run the specific WebAuthn ceremony +// and `fillForm()` to populate hidden fields before submission. +class PasskeyButton extends HTMLElement { connectedCallback() { - this.button.addEventListener("click", this.#create) + this.button.addEventListener("click", this.#perform) } disconnectedCallback() { - this.button.removeEventListener("click", this.#create) + this.button.removeEventListener("click", this.#perform) this.button.disabled = false - delete this.dataset.passkeyErrorState + this.#hideErrors() } get button() { - return this.querySelector("[data-passkey='create']") + return this.querySelector("[data-passkey]") } get form() { return this.querySelector("form") } - get creationOptions() { - return JSON.parse(this.getAttribute("creation-options")) + get options() { + return JSON.parse(this.getAttribute("options")) } get challengeUrl() { @@ -49,20 +52,22 @@ class PasskeyCreationButton extends HTMLElement { } // Arrow function to preserve `this` binding for addEventListener/removeEventListener. - #create = async () => { + #perform = async () => { this.button.disabled = true + this.#hideErrors() this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) try { - if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") - if (!this.creationOptions) throw new Error("Missing passkey creation options") + const options = this.options + + if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") + if (!options) throw new Error("Missing passkey options") - const options = this.creationOptions await refreshChallenge(options, this.challengeUrl) - const passkey = await register(options) + const passkey = await this.perform(options) this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) - fillCreateForm(this.form, passkey) + this.fillForm(passkey) this.form.submit() } catch (error) { this.button.disabled = false @@ -71,97 +76,76 @@ class PasskeyCreationButton extends HTMLElement { } #handleError(error) { + console.error("Passkey ceremony failed", error) const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" - this.dataset.passkeyErrorState = cancelled ? "cancelled" : "error" + this.#showError(cancelled ? "cancelled" : "error") this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) } + + #showError(type) { + const el = this.querySelector(`[data-passkey-error="${type}"]`) + if (el) el.hidden = false + } + + #hideErrors() { + for (const el of this.querySelectorAll("[data-passkey-error]")) el.hidden = true + } } -class PasskeySignInButton extends HTMLElement { +class PasskeyCreationButton extends PasskeyButton { + async perform(options) { + return await register(options) + } + + fillForm(passkey) { + fillCreateForm(this.form, passkey) + } +} + +class PasskeySignInButton extends PasskeyButton { connectedCallback() { - this.button.addEventListener("click", this.#signIn) - + super.connectedCallback() if (this.mediation === "conditional") this.#attemptConditionalMediation() } - disconnectedCallback() { - this.button.removeEventListener("click", this.#signIn) - this.button.disabled = false - delete this.dataset.passkeyErrorState - } - - get button() { - return this.querySelector("[data-passkey='sign_in']") - } - - get form() { - return this.querySelector("form") - } - - get requestOptions() { - return JSON.parse(this.getAttribute("request-options")) - } - - get challengeUrl() { - return this.getAttribute("challenge-url") - } - get mediation() { return this.getAttribute("mediation") } - // Arrow function to preserve `this` binding for addEventListener/removeEventListener. - #signIn = async () => { - this.button.disabled = true - this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) + async perform(options, { mediation } = {}) { + return await authenticate(options, { mediation }) + } - try { - if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") - if (!this.requestOptions) throw new Error("Missing passkey request options") - - const options = this.requestOptions - await refreshChallenge(options, this.challengeUrl) - const passkey = await authenticate(options) - - this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) - fillSignInForm(this.form, passkey) - this.form.submit() - } catch (error) { - this.button.disabled = false - this.#handleError(error) - } + fillForm(passkey) { + fillSignInForm(this.form, passkey) } async #attemptConditionalMediation() { if (await this.#conditionalMediationAvailable()) { - const options = this.requestOptions + const options = this.options this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) try { await refreshChallenge(options, this.challengeUrl) - const passkey = await authenticate(options, { mediation: this.mediation }) + const passkey = await this.perform(options, { mediation: this.mediation }) this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) - fillSignInForm(this.form, passkey) + this.fillForm(passkey) this.form.submit() } catch (error) { - this.#handleError(error) + console.error("Passkey conditional mediation failed", error) + const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" + this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) } } } async #conditionalMediationAvailable() { - return this.requestOptions && + return this.options && passkeysAvailable() && await window.PublicKeyCredential.isConditionalMediationAvailable?.() } - - #handleError(error) { - const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" - this.dataset.passkeyErrorState = cancelled ? "cancelled" : "error" - this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) - } } customElements.define("rails-passkey-creation-button", PasskeyCreationButton) diff --git a/app/views/my/passkeys/index.html.erb b/app/views/my/passkeys/index.html.erb index 8f6b8a176..e0c381b85 100644 --- a/app/views/my/passkeys/index.html.erb +++ b/app/views/my/passkeys/index.html.erb @@ -18,7 +18,7 @@ <% end %>