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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']")
|
||||
}
|
||||
|
||||
get form() {
|
||||
return this.querySelector("form")
|
||||
}
|
||||
}).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 creationOptions() {
|
||||
return JSON.parse(this.getAttribute("creation-options"))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 challengeUrl() {
|
||||
return this.getAttribute("challenge-url")
|
||||
}
|
||||
|
||||
if (form) {
|
||||
button.disabled = true
|
||||
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||
// 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"]')
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
# 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+)
|
||||
# - +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_creation_button(name = nil, url = nil, param: :passkey, 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_options = form.reverse_merge(method: :post, action: url, class: "button_to")
|
||||
|
||||
tag.form(**form_options) do
|
||||
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" }, **options)
|
||||
tag.button(name, type: :button, data: { passkey: "create" }, **button_options)
|
||||
end
|
||||
|
||||
form_tag + passkey_error_messages(error_message, cancelled_message)
|
||||
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-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, param: :passkey, mediation: nil, form: {}, **options, &block)
|
||||
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_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
|
||||
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" }, **options)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user