Add passkey option on TOTP page and auto-trigger passkey for TOTP users

When a user has both passkeys and TOTP configured, auto-trigger the
passkey flow on login to save them from the password→TOTP path. Also
add a "Use Passkey Instead" button on the TOTP verification page as
an escape hatch for users who end up there.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-03-05 23:09:01 +11:00
parent 9dbde8ea31
commit c5898bd9a4
4 changed files with 38 additions and 3 deletions

View File

@@ -147,6 +147,10 @@ class SessionsController < ApplicationController
nil
end
# Pass data to the view for passkey option
@user_has_webauthn = user&.can_authenticate_with_webauthn?
@pending_email = user&.email_address
# Just render the form
end

View File

@@ -148,7 +148,8 @@ class WebauthnController < ApplicationController
# Only return minimal necessary info - no user_id or preferred_method
render json: {
has_webauthn: user.can_authenticate_with_webauthn?,
requires_webauthn: user.require_webauthn?
requires_webauthn: user.require_webauthn?,
has_totp: user.totp_enabled?
}
end

View File

@@ -49,8 +49,9 @@ export default class extends Controller {
}
});
// Auto-trigger passkey authentication if required
if (data.requires_webauthn) {
// Auto-trigger passkey authentication if required, or if user has both
// webauthn and TOTP (to save them from the password→TOTP flow)
if (data.requires_webauthn || (data.has_webauthn && data.has_totp)) {
setTimeout(() => this.authenticate(), 100);
}
} else {
@@ -289,6 +290,10 @@ export default class extends Controller {
if (!emailInput) {
emailInput = document.querySelector('input[name="user[email_address]"]');
}
// Fallback to hidden webauthn_email field (e.g., on TOTP verification page)
if (!emailInput) {
emailInput = document.querySelector('input[name="webauthn_email"]');
}
return emailInput ? emailInput.value.trim() : "";
}

View File

@@ -34,6 +34,31 @@
</div>
<% end %>
<% if @user_has_webauthn %>
<div data-controller="webauthn" data-webauthn-check-url-value="/webauthn/check">
<input type="hidden" name="webauthn_email" value="<%= @pending_email %>">
<div class="mt-4">
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or</span>
</div>
</div>
<button type="button"
data-action="click->webauthn#authenticate"
class="w-full rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Use Passkey Instead
</button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
</div>
</div>
<% end %>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">