diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c6db7..9e2bfa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,23 @@ - Options for getting or creating passkeys and security keys are now served by dedicated Rails controllers and retrieved via JavaScript fetch requests. [#73](https://github.com/cedarcode/devise-webauthn/pull/73) [@nicolastemciuc] - BREAKING!: Remove helpers for generating WebAuthn options. [#106](https://github.com/cedarcode/devise-webauthn/pull/115) [@nicolastemciuc] +- BREAKING: `login_with_passkey_button` and `login_with_security_key_button` helpers have been renamed to `login_with_passkey_form_for` and `login_with_security_key_form_for`. They now take a block and no longer generate the submit button automatically. You need to explicitly add the button inside the block: +```erb +<%# Before %> +<%%= login_with_passkey_button(:user, "Log in with passkeys") %> + +<%# After %> +<%%= login_with_passkey_form_for(:user) do |form| %> + <%%= form.submit "Log in with passkeys" %> +<%% end %> +``` + ### Fixed - Fix form helpers (`passkey_creation_form_for`, `login_with_passkey_button`, `security_key_creation_form_for`, `login_with_security_key_button`) to accept a `resource_name` instead of requiring the `resource` object from the view context. [#114](https://github.com/cedarcode/devise-webauthn/pull/114) [@RenzoMinelli] + + ## [v0.3.0](https://github.com/cedarcode/devise-webauthn/compare/v0.2.2...v0.3.0/) - 2026-01-16 ### Added diff --git a/README.md b/README.md index cc2c5ae..e688e9e 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,18 @@ Devise::Webauthn provides helpers that can be used in your views. These helpers For example, for a resource named `user`, you can use the following helpers: -To add a button for logging in with passkeys: +To add a form for logging in with passkeys: ```erb -<%= login_with_passkey_button_for(:user, "Log in with passkeys") %> +<%= login_with_passkey_form_for(:user) do |form| %> + <%= form.submit "Log in with passkeys" %> +<% end %> +``` + +To add a form for logging in with security keys (2FA): +```erb +<%= login_with_security_key_form_for(@resource) do |form| %> + <%= form.submit "Use security key" %> +<% end %> ``` To add a passkeys creation form: diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index f0178ee..1fb2289 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -23,6 +23,8 @@ <% end %> -<%= login_with_passkey_button_for(resource_name, "Log in with passkeys") %> +<%= login_with_passkey_form_for(resource_name) do |form| %> + <%= form.submit "Log in with passkeys" %> +<% end %> <%= render "devise/shared/links" %> diff --git a/app/views/devise/two_factor_authentications/new.html.erb b/app/views/devise/two_factor_authentications/new.html.erb index af02ad8..45ac604 100644 --- a/app/views/devise/two_factor_authentications/new.html.erb +++ b/app/views/devise/two_factor_authentications/new.html.erb @@ -1 +1,3 @@ -<%= login_with_security_key_button_for(resource_name, 'Use security key') %> +<%= login_with_security_key_form_for(resource_name) do |form| %> + <%= form.submit 'Use security key' %> +<% end %> diff --git a/lib/devise/webauthn/helpers/credentials_helper.rb b/lib/devise/webauthn/helpers/credentials_helper.rb index a65eda5..cbb686b 100644 --- a/lib/devise/webauthn/helpers/credentials_helper.rb +++ b/lib/devise/webauthn/helpers/credentials_helper.rb @@ -16,8 +16,7 @@ def passkey_creation_form_for(resource_or_resource_name, form_classes: nil, &blo end end - def login_with_passkey_button_for(resource_or_resource_name, text = nil, button_classes: nil, - form_classes: nil, &block) + def login_with_passkey_form_for(resource_or_resource_name, form_classes: nil, &block) form_with( url: session_path(resource_or_resource_name), method: :post, @@ -25,8 +24,7 @@ def login_with_passkey_button_for(resource_or_resource_name, text = nil, button_ ) do |f| tag.webauthn_get(data: { options_url: passkey_authentication_options_path(resource_or_resource_name) }) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) - - concat f.button(text, type: "submit", class: button_classes, &block) + concat capture(f, &block) end end end @@ -46,8 +44,7 @@ def security_key_creation_form_for(resource_or_resource_name, form_classes: nil, end end - def login_with_security_key_button_for(resource_or_resource_name, text = nil, button_classes: nil, - form_classes: nil, &block) + def login_with_security_key_form_for(resource_or_resource_name, form_classes: nil, &block) form_with( url: two_factor_authentication_path(resource_or_resource_name), method: :post, @@ -57,19 +54,10 @@ def login_with_security_key_button_for(resource_or_resource_name, text = nil, bu options_url: security_key_authentication_options_path(resource_or_resource_name) }) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) - concat f.button(text, type: "submit", class: button_classes, &block) + concat capture(f, &block) end end end - - private - - def resource_human_palatable_identifier - authentication_keys = resource.class.authentication_keys - authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash) - - authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first - end end end end diff --git a/spec/helpers/devise/webauthn/credentials_helper_spec.rb b/spec/helpers/devise/webauthn/credentials_helper_spec.rb new file mode 100644 index 0000000..035f1b0 --- /dev/null +++ b/spec/helpers/devise/webauthn/credentials_helper_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/MultipleExpectations +RSpec.describe Devise::Webauthn::CredentialsHelper, type: :helper do + let(:user) do + Account.create!( + email: "testuser@example.com", + password: "$3cretp@ssword123" + ) + end + + before do + require "devise/version" + Rails.application.reload_routes_unless_loaded if Rails::VERSION::MAJOR >= 8 && Devise::VERSION < "5" + end + + def parse(html) + Capybara.string(html) + end + + def have_hidden_credential_field + have_css( + "input[type='hidden'][name='public_key_credential'][data-webauthn-target='response']", + visible: :hidden + ) + end + + describe "#passkey_creation_form_for" do + it "renders a form with webauthn_create element and hidden credential field" do + html = helper.passkey_creation_form_for(user) do |form| + form.submit "Create Passkey" + end + + page = parse(html) + expect(page).to have_css("form") + expect(page).to have_css("webauthn-create[data-options-url='/accounts/passkey_registration_options']") + expect(page).to have_hidden_credential_field + expect(page).to have_button("Create Passkey") + end + + it "accepts form_classes option" do + html = helper.passkey_creation_form_for(user, form_classes: "custom-form") do |form| + form.submit "Create" + end + + expect(parse(html)).to have_css("form.custom-form") + end + + it "allows custom content in the block" do + html = helper.passkey_creation_form_for(user) do |form| + helper.content_tag(:div, class: "button-wrapper") do + form.submit "Create", class: "btn-primary" + end + end + + page = parse(html) + expect(page).to have_css("div.button-wrapper") + expect(page).to have_css("input.btn-primary[type='submit']") + end + end + + describe "#login_with_passkey_form_for" do + it "renders a form with webauthn_get element and hidden credential field" do + html = helper.login_with_passkey_form_for(:account) do |form| + form.submit "Log in with passkeys" + end + + page = parse(html) + expect(page).to have_css("form[action='/accounts/sign_in']") + expect(page).to have_css("webauthn-get[data-options-url='/accounts/passkey_authentication_options']") + expect(page).to have_hidden_credential_field + expect(page).to have_button("Log in with passkeys") + end + + it "accepts form_classes option" do + html = helper.login_with_passkey_form_for(:account, form_classes: "passkey-form") do |form| + form.submit "Login" + end + + expect(parse(html)).to have_css("form.passkey-form") + end + + it "allows custom content in the block" do + html = helper.login_with_passkey_form_for(:account) do |form| + # artifact of the test, concat simulates ERB's <%= %> + helper.concat form.submit("Sign in", class: "btn-primary") + helper.concat form.check_box(:remember_me) + helper.concat form.label(:remember_me, "Remember me") + end + + page = parse(html) + + expect(page).to have_css("input.btn-primary[type='submit']") + expect(page).to have_css("input[type='checkbox'][name='remember_me']") + expect(page).to have_css("label[for='remember_me']", text: "Remember me") + end + end + + describe "#security_key_creation_form_for" do + it "renders a form with webauthn_create element" do + html = helper.security_key_creation_form_for(user) do |form| + form.submit "Add Security Key" + end + + page = parse(html) + expect(page).to have_css("form") + expect(page).to have_css("webauthn-create[data-options-url='/accounts/security_key_registration_options']") + expect(page).to have_hidden_credential_field + expect(page).to have_button("Add Security Key") + end + + it "accepts form_classes option" do + html = helper.security_key_creation_form_for(user, form_classes: "security-key-form") do |form| + form.submit "Add" + end + + expect(parse(html)).to have_css("form.security-key-form") + end + + it "allows custom content in the block" do + html = helper.security_key_creation_form_for(user) do |form| + helper.content_tag(:div, class: "button-wrapper") do + form.submit "Add", class: "btn-primary" + end + end + + page = parse(html) + expect(page).to have_css("div.button-wrapper") + expect(page).to have_css("input.btn-primary[type='submit']") + end + end + + describe "#login_with_security_key_form_for" do + it "renders a form with webauthn_get element" do + html = helper.login_with_security_key_form_for(:account) do |form| + form.submit "Use security key" + end + + page = parse(html) + expect(page).to have_css("form") + expect(page).to have_css("webauthn-get[data-options-url='/accounts/security_key_authentication_options']") + expect(page).to have_hidden_credential_field + expect(page).to have_button("Use security key") + end + + it "accepts form_classes option" do + html = helper.login_with_security_key_form_for(:account, form_classes: "two-factor-form") do |form| + form.submit "Verify" + end + + expect(parse(html)).to have_css("form.two-factor-form") + end + + it "allows custom content in the block" do + html = helper.login_with_security_key_form_for(:account) do |form| + helper.safe_join([helper.content_tag(:p, "Authenticate with your security key"), form.submit("Authenticate")]) + end + + page = parse(html) + expect(page).to have_css("p", text: "Authenticate with your security key") + expect(page).to have_button("Authenticate") + end + end +end +# rubocop:enable RSpec/MultipleExpectations