Skip to content

Commit ac575eb

Browse files
kcdragonclaude
andauthored
Add passwordless magic-link sign-up and sign-in (#38)
Let users sign up and sign in with email only (no password) via a 30-minute magic link, alongside the existing password flow. Passwordless accounts can add a password later from a new account settings page. - Make password_digest nullable; User uses has_secure_password validations: false with its own length/confirmation validations and a :magic_link token tied to password_salt (auto-invalidates on password change) - MagicLinksController + MagicLinkMailer for requesting/consuming sign-in links, with honeypot, rate limiting, org-membership gating, and non-disclosure parity - Users::PasswordsController settings page to add/change a password - Dev-only "Mailer previews" nav link Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 172d63f commit ac575eb

24 files changed

Lines changed: 476 additions & 9 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
class MagicLinksController < ApplicationController
2+
allow_unauthenticated_access only: %i[ new create show ]
3+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_magic_link_path, alert: "Try again later." }
4+
5+
def new
6+
end
7+
8+
def create
9+
# Honeypot: real users never fill this hidden field, bots do
10+
if params[:nickname].present?
11+
redirect_to new_session_path, notice: generic_notice
12+
return
13+
end
14+
15+
user = User.find_by(email_address: params[:email_address])
16+
17+
# Only send to members of this organization
18+
if user&.member_of?(Current.organization)
19+
MagicLinkMailer.sign_in_link(user, Current.organization).deliver_later
20+
end
21+
22+
redirect_to new_session_path, notice: generic_notice
23+
end
24+
25+
def show
26+
user = User.find_by_token_for(:magic_link, params[:token])
27+
28+
if user&.member_of?(Current.organization)
29+
user.confirm! unless user.confirmed?
30+
start_new_session_for user
31+
redirect_to after_authentication_url, notice: "You're signed in."
32+
else
33+
redirect_to new_session_path, alert: "Sign-in link is invalid or has expired."
34+
end
35+
end
36+
37+
private
38+
39+
def generic_notice
40+
"If an account exists for that email, we've sent a sign-in link."
41+
end
42+
end

app/controllers/registrations_controller.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def create
1010
# Honeypot: real users never fill this hidden field, bots do. Silently
1111
# pretend success so spammers can't tell their submission was rejected.
1212
if params[:nickname].present?
13-
redirect_to new_session_path, notice: "Check your email to confirm your account before signing in."
13+
redirect_to new_session_path, notice: confirmation_notice
1414
return
1515
end
1616

@@ -29,15 +29,25 @@ def create
2929
Current.organization.organization_memberships.create!(user: @user)
3030
end
3131

32-
RegistrationMailer.confirmation(@user, Current.organization).deliver_later
33-
redirect_to new_session_path, notice: "Check your email to confirm your account before signing in."
32+
if @user.password_set?
33+
RegistrationMailer.confirmation(@user, Current.organization).deliver_later
34+
redirect_to new_session_path, notice: confirmation_notice
35+
else
36+
# Passwordless signup: send a magic link that confirms and signs them in.
37+
MagicLinkMailer.sign_in_link(@user, Current.organization).deliver_later
38+
redirect_to new_session_path, notice: "Check your email for a sign-in link."
39+
end
3440
else
3541
render :new, status: :unprocessable_entity
3642
end
3743
end
3844

3945
private
4046

47+
def confirmation_notice
48+
"Check your email to confirm your account before signing in."
49+
end
50+
4151
def registration_params
4252
params.require(:user).permit(:email_address, :password, :password_confirmation)
4353
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
class Users::PasswordsController < ApplicationController
2+
def show
3+
@user = Current.user
4+
end
5+
6+
def update
7+
@user = Current.user
8+
9+
# Users who already have a password must confirm it before changing it.
10+
# Passwordless users are adding their first password, so none is required.
11+
if @user.password_set? && !@user.authenticate(params[:current_password].to_s)
12+
@user.errors.add(:current_password, "is incorrect")
13+
render :show, status: :unprocessable_entity
14+
return
15+
end
16+
17+
if @user.update(password_params)
18+
redirect_to users_password_path, notice: "Password saved."
19+
else
20+
render :show, status: :unprocessable_entity
21+
end
22+
end
23+
24+
private
25+
26+
def password_params
27+
params.require(:user).permit(:password, :password_confirmation)
28+
end
29+
end

app/mailers/magic_link_mailer.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class MagicLinkMailer < ApplicationMailer
2+
def sign_in_link(user, organization)
3+
@user = user
4+
@organization = organization
5+
mail subject: "Your sign-in link", to: user.email_address
6+
end
7+
end

app/models/user.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
class User < ApplicationRecord
2-
has_secure_password
2+
# validations: false so a password isn't required on create — passwordless
3+
# (magic-link) accounts have none. The presence-on-create check is dropped;
4+
# the length/confirmation validations below replace the rest.
5+
has_secure_password validations: false
36
has_many :sessions, dependent: :destroy
47
has_many :organization_memberships, dependent: :destroy
58
has_many :organizations, through: :organization_memberships
@@ -9,15 +12,26 @@ class User < ApplicationRecord
912
confirmed_at
1013
end
1114

15+
# Magic-link sign-in token. Tied to the password salt so the link
16+
# auto-invalidates the moment a password is set or changed.
17+
generates_token_for :magic_link, expires_in: 30.minutes do
18+
password_salt&.last(10)
19+
end
20+
1221
normalizes :email_address, with: ->(e) { e.strip.downcase }
1322

1423
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
15-
validates :password, length: { minimum: 8 }, allow_nil: true
24+
validates :password, length: { minimum: 8, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED }, allow_nil: true
25+
validates :password, confirmation: true, allow_blank: true
1626

1727
def member_of?(organization)
1828
organization && organizations.exists?(organization.id)
1929
end
2030

31+
def password_set?
32+
password_digest.present?
33+
end
34+
2135
def confirmed?
2236
confirmed_at.present?
2337
end

app/views/layouts/application.html.erb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@
3131
<nav class="sticky top-0 z-50 flex h-13 items-center border-b border-line bg-surface px-6 sm:px-9">
3232
<%= link_to (Current.organization&.name || "Community Foundations"), root_path,
3333
class: "font-serif text-lg tracking-tight text-ink hover:text-ink" %>
34+
35+
<% if Rails.env.development? || authenticated? %>
36+
<div class="ml-auto flex items-center gap-4 text-sm">
37+
<% if Rails.env.development? %>
38+
<%= link_to "Mailer previews", "/rails/mailers", class: "text-ink-soft hover:text-ink" %>
39+
<% end %>
40+
<% if authenticated? %>
41+
<%= link_to "Settings", users_password_path, class: "text-ink-soft hover:text-ink" %>
42+
<%= button_to "Sign out", session_path, method: :delete, class: "text-ink-soft hover:text-ink cursor-pointer" %>
43+
<% end %>
44+
</div>
45+
<% end %>
3446
</nav>
3547

3648
<%= render "shared/flash" %>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<p>
2+
Sign in by visiting
3+
<%= link_to "this sign-in page", magic_link_url(token: @user.generate_token_for(:magic_link), subdomain: @organization.subdomain) %>.
4+
5+
This link will expire in 30 minutes.
6+
</p>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Sign in by visiting
2+
<%= magic_link_url(token: @user.generate_token_for(:magic_link), subdomain: @organization.subdomain) %>
3+
4+
This link will expire in 30 minutes.

app/views/magic_links/new.html.erb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<div class="mx-auto max-w-md w-full px-4 py-16">
2+
<div class="rounded-2xl border border-line bg-surface p-8 shadow-sm">
3+
<h1 class="font-serif font-medium text-3xl tracking-tight text-ink">Email me a sign-in link</h1>
4+
5+
<p class="mt-3 text-sm text-ink-soft">We'll email you a link that signs you in — no password needed.</p>
6+
7+
<%= form_with url: magic_link_path, class: "contents" do |form| %>
8+
<%# Honeypot: hidden from real users, but bots tend to fill every field. Submissions with this set are silently dropped. %>
9+
<div class="absolute left-[-9999px]" aria-hidden="true">
10+
<%= label_tag :nickname, "Leave this field blank" %>
11+
<%= text_field_tag :nickname, nil, tabindex: -1, autocomplete: "off" %>
12+
</div>
13+
14+
<div class="my-5">
15+
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
16+
</div>
17+
18+
<div class="mt-6 sm:flex sm:items-center sm:gap-4">
19+
<div class="inline">
20+
<%= form.submit "Email me a sign-in link", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-accent hover:bg-[#444] text-white inline-block font-medium cursor-pointer transition" %>
21+
</div>
22+
23+
<div class="mt-4 text-sm text-ink-soft sm:mt-0">
24+
<%= link_to "Sign in with a password", new_session_path, class: "text-ink hover:text-brand underline" %>
25+
</div>
26+
</div>
27+
<% end %>
28+
</div>
29+
</div>

app/views/registrations/new.html.erb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@
2424
</div>
2525

2626
<div class="my-5">
27-
<%= form.password_field :password, required: true, autocomplete: "new-password", minlength: 8, placeholder: "Enter your password (min. 8 characters)", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
27+
<%= form.password_field :password, autocomplete: "new-password", minlength: 8, placeholder: "Choose a password (min. 8 characters)", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
28+
<p class="mt-2 text-sm text-ink-soft">Optional — leave blank to sign in with a link emailed to you instead.</p>
2829
</div>
2930

3031
<div class="my-5">
31-
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
32+
<%= form.password_field :password_confirmation, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-line bg-surface px-3 py-2 mt-2 w-full focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
3233
</div>
3334

3435
<div class="mt-6 sm:flex sm:items-center sm:gap-4">

0 commit comments

Comments
 (0)