Use WebComponents for buttons

This simplifies the loading and cleanup logic, while providing ubiquitous support across JS frameworks. Buttons can now be added in any way imaginable and still work without requiring additional initialization. The upside of this aproach is that it doesn't require a mutation observer nor a global click listener, and is supported by all browsers that also support passkeys.
This commit is contained in:
Stanko K.R.
2026-03-25 10:40:40 +01:00
parent 70c08877b7
commit 4fb1cddb4c
5 changed files with 223 additions and 268 deletions
+3 -3
View File
@@ -32,12 +32,12 @@
opacity: 0;
}
[data-passkey-errors] [data-passkey-error] {
:is(rails-passkey-creation-button, rails-passkey-sign-in-button) [data-passkey-error] {
display: none;
}
[data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"],
[data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] {
: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;
}
}
+144 -182
View File
@@ -1,143 +1,183 @@
// JS companion for the ActionPack::Passkey Ruby helpers.
// Web components for the ActionPack::Passkey Ruby helpers.
//
// Binds click handlers to passkey buttons and manages the WebAuthn ceremony
// lifecycle (challenge refresh, credential creation/authentication, form submission).
// <rails-passkey-creation-button> — wraps a registration ceremony form
// <rails-passkey-sign-in-button> — wraps an authentication ceremony form
//
// Expected data attributes:
// [data-passkey="create"] — triggers the registration ceremony
// [data-passkey="sign_in"] — triggers the authentication ceremony
// [data-passkey-mediation="conditional"] — on a <form>, enables autofill-assisted sign in
// [data-passkey-errors] — container whose data-passkey-error-state is set on failure
// [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state
// [data-passkey-field="..."] — hidden fields populated before form submission
// The Ruby form helpers render the component markup including the inner form,
// hidden fields, button, and error messages. The components handle the WebAuthn
// ceremony lifecycle (challenge refresh, credential creation/authentication,
// form submission) and error state toggling.
//
// Custom events (all bubble):
// passkey:start — ceremony begun
// passkey:success — credential obtained, form about to submit
// passkey:error — ceremony failed; detail: { error, cancelled }
//
// Meta tags (rendered by the Ruby form helpers):
// <meta name="passkey-creation-options"> — JSON WebAuthn creation options
// <meta name="passkey-request-options"> — JSON WebAuthn request options
// <meta name="passkey-challenge-url"> — endpoint to refresh the challenge nonce
// 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)
import { register, authenticate } from "lib/action_pack/webauthn"
const mediated = new WeakSet()
const mediationSelector = 'form[data-passkey-mediation="conditional"]'
// Delegate click events for passkey buttons. Walks up from the click target
// to find the nearest [data-passkey] element, like Rails UJS does.
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-passkey]")
if (button) {
if (button.dataset.passkey === "create") createPasskey(button)
else if (button.dataset.passkey === "sign_in") signInWithPasskey(button)
}
})
// Set error state on the nearest [data-passkey-errors] container.
// The app's CSS is responsible for showing/hiding children based on
// the data-passkey-error-state attribute ("error" or "cancelled").
document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => {
const container = target.closest("[data-passkey-errors]")
if (container) {
container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error"
}
})
// Clean up transient DOM state before Turbo caches the page snapshot.
document.addEventListener("turbo:before-cache", () => {
for (const button of document.querySelectorAll("[data-passkey][disabled]")) {
button.disabled = false
class PasskeyCreationButton extends HTMLElement {
connectedCallback() {
this.button.addEventListener("click", this.#create)
}
for (const container of document.querySelectorAll("[data-passkey-errors]")) {
delete container.dataset.passkeyErrorState
disconnectedCallback() {
this.button.removeEventListener("click", this.#create)
this.button.disabled = false
delete this.dataset.passkeyErrorState
}
})
// Attempt conditional mediation when a form with [data-passkey-mediation="conditional"]
// appears in the DOM. Uses a MutationObserver so it works regardless of how the form
// is inserted (initial page load, Turbo Drive, Turbo Streams, or plain JS).
new MutationObserver((mutations) => {
for (const { addedNodes } of mutations) {
for (const node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) mediateIn(node)
}
get button() {
return this.querySelector("[data-passkey='create']")
}
}).observe(document.documentElement, { childList: true, subtree: true })
mediateIn(document)
function mediateIn(root) {
const form = root.matches?.(mediationSelector) ? root : root.querySelector?.(mediationSelector)
if (form && !mediated.has(form)) {
mediated.add(form)
attemptConditionalMediation()
get form() {
return this.querySelector("form")
}
}
// Run the WebAuthn registration ceremony: refresh the challenge, prompt the
// browser to create a credential, fill the form's hidden fields, and submit.
async function createPasskey(button) {
const form = button.closest("form")
get creationOptions() {
return JSON.parse(this.getAttribute("creation-options"))
}
if (form) {
button.disabled = true
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
get challengeUrl() {
return this.getAttribute("challenge-url")
}
// Arrow function to preserve `this` binding for addEventListener/removeEventListener.
#create = async () => {
this.button.disabled = true
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 creationOptions = getCreationOptions()
if (!creationOptions) throw new Error("Missing passkey creation options")
const options = this.creationOptions
await refreshChallenge(options, this.challengeUrl)
const passkey = await register(options)
await refreshChallenge(creationOptions)
const passkey = await register(creationOptions)
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillCreateForm(form, passkey)
form.submit()
this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillCreateForm(this.form, passkey)
this.form.submit()
} catch (error) {
button.disabled = false
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
this.button.disabled = false
this.#handleError(error)
}
}
#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 } }))
}
}
class PasskeySignInButton extends HTMLElement {
connectedCallback() {
this.button.addEventListener("click", this.#signIn)
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 }))
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)
}
}
async #attemptConditionalMediation() {
if (await this.#conditionalMediationAvailable()) {
const options = this.requestOptions
this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
try {
await refreshChallenge(options, this.challengeUrl)
const passkey = await authenticate(options, { mediation: this.mediation })
this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillSignInForm(this.form, passkey)
this.form.submit()
} catch (error) {
this.#handleError(error)
}
}
}
async #conditionalMediationAvailable() {
return this.requestOptions &&
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)
customElements.define("rails-passkey-sign-in-button", PasskeySignInButton)
// -- Shared helpers ----------------------------------------------------------
function passkeysAvailable() {
return !!window.PublicKeyCredential
}
// Read WebAuthn creation options from the <meta> tag rendered by
// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing.
function getCreationOptions() {
return getOptions("passkey-creation-options")
}
// Parse and return the JSON content of a <meta> tag by name.
function getOptions(name) {
const meta = document.querySelector(`meta[name="${name}"]`)
if (meta) {
return JSON.parse(meta.content)
}
}
// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks
// when the page has been open for a while before the user initiates the ceremony.
async function refreshChallenge(options) {
const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content
if (!url) throw new Error("Missing passkey challenge URL")
async function refreshChallenge(options, challengeUrl) {
if (!challengeUrl) throw new Error("Missing passkey challenge URL")
const token = document.querySelector('meta[name="csrf-token"]')?.content
const response = await fetch(url, {
const response = await fetch(challengeUrl, {
method: "POST",
credentials: "same-origin",
headers: {
@@ -152,8 +192,6 @@ async function refreshChallenge(options) {
options.challenge = challenge
}
// Populate the registration form's hidden fields with the credential response.
// Clones the transports template input for each reported transport.
function fillCreateForm(form, passkey) {
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
@@ -167,85 +205,9 @@ function fillCreateForm(form, passkey) {
template.remove()
}
// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the
// browser to sign with an existing credential, fill the form, and submit.
async function signInWithPasskey(button) {
const form = button.closest("form")
if (form) {
button.disabled = true
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
try {
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
const requestOptions = getRequestOptions()
if (!requestOptions) throw new Error("Missing passkey request options")
await refreshChallenge(requestOptions)
const passkey = await authenticate(requestOptions)
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillSignInForm(form, passkey)
form.submit()
} catch (error) {
button.disabled = false
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
}
}
}
// Read WebAuthn request options from the <meta> tag rendered by
// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing.
function getRequestOptions() {
return getOptions("passkey-request-options")
}
// Populate the authentication form's hidden fields with the assertion response.
function fillSignInForm(form, passkey) {
form.querySelector('[data-passkey-field="id"]').value = passkey.id
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
form.querySelector('[data-passkey-field="signature"]').value = passkey.signature
}
// Start the conditional mediation (autofill) ceremony if the page opts in with
// a form[data-passkey-mediation="conditional"] and the browser supports it.
// Unlike the button-driven ceremonies, this runs automatically on page load.
async function attemptConditionalMediation() {
if (await conditionalMediationAvailable()) {
const form = document.querySelector('form[data-passkey-mediation="conditional"]')
form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
const requestOptions = getRequestOptions()
try {
await refreshChallenge(requestOptions)
const passkey = await authenticate(requestOptions, { mediation: "conditional" })
form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillSignInForm(form, passkey)
form.submit()
} catch (error) {
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
}
}
}
// Check all preconditions for conditional mediation: the page has opted in,
// request options are present, the browser supports passkeys, and the browser
// supports the conditional mediation UI (autofill).
async function conditionalMediationAvailable() {
return isConditionalMediationFormPresent() &&
getRequestOptions() &&
passkeysAvailable() &&
await window.PublicKeyCredential.isConditionalMediationAvailable?.()
}
function isConditionalMediationFormPresent() {
return !!document.querySelector('form[data-passkey-mediation="conditional"]')
}
+2 -15
View File
@@ -1,9 +1,5 @@
<% @page_title = "Passkeys" %>
<% content_for :head do %>
<%= passkey_creation_options_meta_tag(@creation_options) %>
<% end %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
@@ -12,7 +8,7 @@
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<section class="panel panel--wide shadow center" data-passkey-errors>
<section class="panel panel--wide shadow center">
<p class="font-weight-medium margin-none-block-start">Passkeys let you sign in securely without a password or email code.</p>
<% if @passkeys.any? %>
@@ -22,7 +18,7 @@
<% end %>
<footer>
<%= passkey_creation_button my_passkeys_path, class: "btn btn--link center txt-medium" do %>
<%= passkey_creation_button my_passkeys_path, options: @creation_options, class: "btn btn--link center txt-medium" do %>
<%= icon_tag "add" %>
<span>Register a passkey</span>
<% end %>
@@ -30,14 +26,5 @@
<p class="txt-small txt-subtle txt-balance margin-none-block-end">
Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key
</p>
<p data-passkey-error="error" class="txt-negative">
Something went wrong while registering your passkey.
</p>
<p data-passkey-error="cancelled" class="txt-subtle">
Passkey registration was cancelled.
Try again when you are ready.
</p>
</footer>
</section>
+1 -5
View File
@@ -1,9 +1,5 @@
<% @page_title = "Enter your email" %>
<% content_for :head do %>
<%= passkey_request_options_meta_tag(@request_options) %>
<% end %>
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
@@ -26,7 +22,7 @@
</button>
<% end %>
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, options: @request_options, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
</div>
+73 -63
View File
@@ -1,82 +1,87 @@
# View helpers for rendering passkey forms and meta tags.
# View helpers for rendering passkey web components.
#
# Include this module in your helper or ApplicationHelper to get access to:
#
# - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a <meta>
# tag containing the JSON-serialized WebAuthn options for the browser credential API.
# - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony.
# - +passkey_sign_in_button+ — render a form with hidden fields for the authentication
# ceremony.
# - +passkey_creation_button+ — render a <rails-passkey-creation-button> web component with
# a form, hidden fields, and error messages for the registration ceremony.
# - +passkey_sign_in_button+ — render a <rails-passkey-sign-in-button> web component with
# a form, hidden fields, and error messages for the authentication ceremony.
module ActionPack::Passkey::FormHelper
# Renders +<meta>+ tags containing JSON-serialized creation options and the challenge endpoint
# URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call
# +navigator.credentials.create()+.
def passkey_creation_options_meta_tag(creation_options, challenge_url: nil)
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
tag.meta(name: "passkey-creation-options", content: creation_options.to_json)
end
# Renders +<meta>+ tags containing JSON-serialized request options and the challenge endpoint
# URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to
# call +navigator.credentials.get()+.
def passkey_request_options_meta_tag(request_options, challenge_url: nil)
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
tag.meta(name: "passkey-request-options", content: request_options.to_json)
end
# Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to
# +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and
# +transports+ — populated by the Stimulus controller after the browser credential API
# resolves. Accepts a +label+ string or a block for button content.
#
# Options:
# - +param+: the form parameter namespace (default: +:passkey+)
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_creation_button(name = nil, url = nil, param: :passkey, form: {}, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
tag.button(name, type: :button, data: { passkey: "create" }, **options)
end
end
# Renders a form with hidden fields for the passkey authentication ceremony. The form POSTs to
# +url+ and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and
# +signature+
# Renders a +<rails-passkey-creation-button>+ web component containing a form with hidden
# fields for the passkey registration ceremony and error messages. The form POSTs to +url+ and
# includes hidden fields for +client_data_json+, +attestation_object+, and +transports+ —
# populated by the web component after the browser credential API resolves.
# Accepts a +label+ string or a block for button content.
#
# Options:
# - +options+: WebAuthn creation options (JSON-serializable hash)
# - +challenge_url+: endpoint to refresh the challenge nonce
# - +param+: the form parameter namespace (default: +:passkey+)
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
# - +error_message+: message shown on ceremony failure
# - +cancelled_message+: message shown when ceremony is cancelled
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_sign_in_button(name = nil, url = nil, param: :passkey, mediation: nil, form: {}, **options, &block)
def passkey_creation_button(name = nil, url = nil, options:, challenge_url: nil, param: :passkey, error_message: "Something went wrong while registering your passkey.", cancelled_message: "Passkey registration was cancelled. Try again when you are ready.", form: {}, **button_options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_data = {}
form_data[:passkey_mediation] = mediation if mediation
form_options = form.reverse_merge(method: :post, action: url, class: "button_to", data: form_data)
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
tag.button(name, type: :button, data: { passkey: "sign_in" }, **options)
component_options = {
"creation-options": options.to_json,
"challenge-url": challenge_url || default_passkey_challenge_url
}
tag.send(:"rails-passkey-creation-button", **component_options) do
form_tag = tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
tag.button(name, type: :button, data: { passkey: "create" }, **button_options)
end
form_tag + passkey_error_messages(error_message, cancelled_message)
end
end
# Renders a +<rails-passkey-sign-in-button>+ web component containing a form with hidden
# fields for the passkey authentication ceremony and error messages. The form POSTs to +url+
# and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and +signature+.
# Accepts a +label+ string or a block for button content.
#
# Options:
# - +options+: WebAuthn request options (JSON-serializable hash)
# - +challenge_url+: endpoint to refresh the challenge nonce
# - +param+: the form parameter namespace (default: +:passkey+)
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
# - +error_message+: message shown on ceremony failure
# - +cancelled_message+: message shown when ceremony is cancelled
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_sign_in_button(name = nil, url = nil, options:, challenge_url: nil, param: :passkey, mediation: nil, error_message: "Something went wrong while signing in with your passkey.", cancelled_message: "Passkey sign in was cancelled. Try again when you are ready.", form: {}, **button_options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
component_options = {
"request-options": options.to_json,
"challenge-url": challenge_url || default_passkey_challenge_url
}
component_options[:mediation] = mediation if mediation
tag.send(:"rails-passkey-sign-in-button", **component_options) do
form_tag = tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
tag.button(name, type: :button, data: { passkey: "sign_in" }, **button_options)
end
form_tag + passkey_error_messages(error_message, cancelled_message)
end
end
private
def passkey_challenge_url_meta_tag(challenge_url: nil)
tag.meta(name: "passkey-challenge-url", content: challenge_url || default_passkey_challenge_url)
end
def default_passkey_challenge_url
if challenge_url = Rails.configuration.action_pack.passkey.challenge_url
instance_exec(&challenge_url)
@@ -84,4 +89,9 @@ module ActionPack::Passkey::FormHelper
passkey_challenge_path
end
end
def passkey_error_messages(error_message, cancelled_message)
tag.p(error_message, data: { passkey_error: "error" }, class: "txt-negative") +
tag.p(cancelled_message, data: { passkey_error: "cancelled" }, class: "txt-subtle")
end
end