Add remember me checkbox, center and narrow sign-in form

- Add "Remember me for 30 days" checkbox (30-day vs 24-hour session expiry)
- Center heading and constrain form width to max-w-md
- Preserve remember_me preference through TOTP and WebAuthn auth flows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-04-11 11:22:51 +10:00
parent 2235924f37
commit 9197524c88
4 changed files with 29 additions and 9 deletions

View File

@@ -43,9 +43,9 @@ module Authentication
session.delete(:return_to_after_authenticating) || root_url session.delete(:return_to_after_authenticating) || root_url
end end
def start_new_session_for(user, acr: "1") def start_new_session_for(user, acr: "1", remember_me: false)
user.update!(last_sign_in_at: Time.current) user.update!(last_sign_in_at: Time.current)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session| user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|
Current.session = session Current.session = session
# Extract root domain for cross-subdomain cookies (required for forward auth) # Extract root domain for cross-subdomain cookies (required for forward auth)

View File

@@ -76,6 +76,7 @@ class SessionsController < ApplicationController
# TOTP is enabled, proceed to verification # TOTP is enabled, proceed to verification
# Store user ID in session temporarily for TOTP verification # Store user ID in session temporarily for TOTP verification
session[:pending_totp_user_id] = user.id session[:pending_totp_user_id] = user.id
session[:pending_remember_me] = remember_me?
# Preserve the redirect URL through TOTP verification (after validation) # Preserve the redirect URL through TOTP verification (after validation)
if params[:rd].present? if params[:rd].present?
validated_url = validate_redirect_url(params[:rd]) validated_url = validate_redirect_url(params[:rd])
@@ -86,7 +87,7 @@ class SessionsController < ApplicationController
end end
# Sign in successful (password only) # Sign in successful (password only)
start_new_session_for user, acr: "1" start_new_session_for user, acr: "1", remember_me: remember_me?
# Use status: :see_other to ensure browser makes a GET request # Use status: :see_other to ensure browser makes a GET request
# This prevents Turbo from converting it to a TURBO_STREAM request # This prevents Turbo from converting it to a TURBO_STREAM request
@@ -118,6 +119,8 @@ class SessionsController < ApplicationController
return return
end end
remember_me = session.delete(:pending_remember_me) || false
# Try TOTP verification first (password + TOTP = 2FA) # Try TOTP verification first (password + TOTP = 2FA)
if user.verify_totp(code) if user.verify_totp(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
@@ -125,7 +128,7 @@ class SessionsController < ApplicationController
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end end
start_new_session_for user, acr: "2" start_new_session_for user, acr: "2", remember_me: remember_me
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
return return
end end
@@ -137,7 +140,7 @@ class SessionsController < ApplicationController
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end end
start_new_session_for user, acr: "2" start_new_session_for user, acr: "2", remember_me: remember_me
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
return return
end end
@@ -189,6 +192,7 @@ class SessionsController < ApplicationController
# Store user ID in session for verification # Store user ID in session for verification
session[:pending_webauthn_user_id] = user.id session[:pending_webauthn_user_id] = user.id
session[:pending_remember_me] = remember_me?
# Store redirect URL if present # Store redirect URL if present
if params[:rd].present? if params[:rd].present?
@@ -284,12 +288,13 @@ class SessionsController < ApplicationController
# Clean up session # Clean up session
session.delete(:pending_webauthn_user_id) session.delete(:pending_webauthn_user_id)
remember_me = session.delete(:pending_remember_me) || false
if session[:webauthn_redirect_url].present? if session[:webauthn_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url) session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
end end
# Create session (WebAuthn/passkey = phishing-resistant, ACR = "2") # Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
start_new_session_for user, acr: "2" start_new_session_for user, acr: "2", remember_me: remember_me
render json: { render json: {
success: true, success: true,
@@ -310,6 +315,10 @@ class SessionsController < ApplicationController
private private
def remember_me?
ActiveModel::Type::Boolean.new.cast(params[:remember_me]) || false
end
def validate_redirect_url(url) def validate_redirect_url(url)
return nil unless url.present? return nil unless url.present?

View File

@@ -180,7 +180,8 @@ export default class extends Controller {
"X-CSRF-Token": this.getCSRFToken() "X-CSRF-Token": this.getCSRFToken()
}, },
body: JSON.stringify({ body: JSON.stringify({
email: this.getUserEmail() email: this.getUserEmail(),
remember_me: this.getRememberMe()
}) })
}); });
@@ -295,6 +296,11 @@ export default class extends Controller {
return emailInput ? emailInput.value.trim() : ""; return emailInput ? emailInput.value.trim() : "";
} }
getRememberMe() {
const checkbox = document.querySelector('input[name="remember_me"][type="checkbox"]');
return checkbox ? checkbox.checked : false;
}
isValidEmail(email) { isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
} }

View File

@@ -1,6 +1,6 @@
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check"> <div class="mx-auto max-w-md w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
<div class="mb-8"> <div class="mb-8">
<h1 class="font-bold text-4xl">Sign in to Clinch</h1> <h1 class="font-bold text-4xl text-center">Sign in to Clinch</h1>
</div> </div>
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %> <%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
@@ -53,6 +53,11 @@
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div> </div>
<div class="my-5 flex items-center">
<%= form.check_box :remember_me, { class: "rounded border-gray-400 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800" }, "1", "0" %>
<%= form.label :remember_me, "Remember me for 30 days", class: "ml-2 text-sm text-gray-600 dark:text-gray-400" %>
</div>
<div class="my-5"> <div class="my-5">
<%= form.submit "Sign in", <%= form.submit "Sign in",
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %> class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>