Files
fizzy/lib/action_pack/passkey/form_helper.rb
T
Stanko K.R. 4fb1cddb4c 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.
2026-03-25 17:26:49 +01:00

98 lines
5.4 KiB
Ruby

# View helpers for rendering passkey web components.
#
# Include this module in your helper or ApplicationHelper to get access to:
#
# - +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 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, 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")
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 default_passkey_challenge_url
if challenge_url = Rails.configuration.action_pack.passkey.challenge_url
instance_exec(&challenge_url)
else
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