Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions app/controllers/magic_links_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
class MagicLinksController < ApplicationController
allow_unauthenticated_access only: %i[ new create show ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_magic_link_path, alert: "Try again later." }

def new
end

def create
# Honeypot: real users never fill this hidden field, bots do
if params[:nickname].present?
redirect_to new_session_path, notice: generic_notice
return
end

user = User.find_by(email_address: params[:email_address])

# Only send to members of this organization
if user&.member_of?(Current.organization)
MagicLinkMailer.sign_in_link(user, Current.organization).deliver_later
end

redirect_to new_session_path, notice: generic_notice
end

def show
user = User.find_by_token_for(:magic_link, params[:token])

if user&.member_of?(Current.organization)
user.confirm! unless user.confirmed?
start_new_session_for user
redirect_to after_authentication_url, notice: "You're signed in."
else
redirect_to new_session_path, alert: "Sign-in link is invalid or has expired."
end
end

private

def generic_notice
"If an account exists for that email, we've sent a sign-in link."
end
end
16 changes: 13 additions & 3 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def create
# Honeypot: real users never fill this hidden field, bots do. Silently
# pretend success so spammers can't tell their submission was rejected.
if params[:nickname].present?
redirect_to new_session_path, notice: "Check your email to confirm your account before signing in."
redirect_to new_session_path, notice: confirmation_notice
return
end

Expand All @@ -29,15 +29,25 @@ def create
Current.organization.organization_memberships.create!(user: @user)
end

RegistrationMailer.confirmation(@user, Current.organization).deliver_later
redirect_to new_session_path, notice: "Check your email to confirm your account before signing in."
if @user.password_set?
RegistrationMailer.confirmation(@user, Current.organization).deliver_later
redirect_to new_session_path, notice: confirmation_notice
else
# Passwordless signup: send a magic link that confirms and signs them in.
MagicLinkMailer.sign_in_link(@user, Current.organization).deliver_later
redirect_to new_session_path, notice: "Check your email for a sign-in link."
end
else
render :new, status: :unprocessable_entity
end
end

private

def confirmation_notice
"Check your email to confirm your account before signing in."
end

def registration_params
params.require(:user).permit(:email_address, :password, :password_confirmation)
end
Expand Down
29 changes: 29 additions & 0 deletions app/controllers/users/passwords_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class Users::PasswordsController < ApplicationController
def show
@user = Current.user
end

def update
@user = Current.user

# Users who already have a password must confirm it before changing it.
# Passwordless users are adding their first password, so none is required.
if @user.password_set? && !@user.authenticate(params[:current_password].to_s)
@user.errors.add(:current_password, "is incorrect")
render :show, status: :unprocessable_entity
return
end

if @user.update(password_params)
redirect_to users_password_path, notice: "Password saved."
else
render :show, status: :unprocessable_entity
end
end

private

def password_params
params.require(:user).permit(:password, :password_confirmation)
end
end
7 changes: 7 additions & 0 deletions app/mailers/magic_link_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class MagicLinkMailer < ApplicationMailer
def sign_in_link(user, organization)
@user = user
@organization = organization
mail subject: "Your sign-in link", to: user.email_address
end
end
18 changes: 16 additions & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
class User < ApplicationRecord
has_secure_password
# validations: false so a password isn't required on create — passwordless
# (magic-link) accounts have none. The presence-on-create check is dropped;
# the length/confirmation validations below replace the rest.
has_secure_password validations: false
has_many :sessions, dependent: :destroy
has_many :organization_memberships, dependent: :destroy
has_many :organizations, through: :organization_memberships
Expand All @@ -9,15 +12,26 @@ class User < ApplicationRecord
confirmed_at
end

# Magic-link sign-in token. Tied to the password salt so the link
# auto-invalidates the moment a password is set or changed.
generates_token_for :magic_link, expires_in: 30.minutes do
password_salt&.last(10)
end

normalizes :email_address, with: ->(e) { e.strip.downcase }

validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }, allow_nil: true
validates :password, length: { minimum: 8, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED }, allow_nil: true
validates :password, confirmation: true, allow_blank: true

def member_of?(organization)
organization && organizations.exists?(organization.id)
end

def password_set?
password_digest.present?
end

def confirmed?
confirmed_at.present?
end
Expand Down
12 changes: 12 additions & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@
<nav class="sticky top-0 z-50 flex h-13 items-center border-b border-line bg-surface px-6 sm:px-9">
<%= link_to (Current.organization&.name || "Community Foundations"), root_path,
class: "font-serif text-lg tracking-tight text-ink hover:text-ink" %>

<% if Rails.env.development? || authenticated? %>
<div class="ml-auto flex items-center gap-4 text-sm">
<% if Rails.env.development? %>
<%= link_to "Mailer previews", "/rails/mailers", class: "text-ink-soft hover:text-ink" %>
<% end %>
<% if authenticated? %>
<%= link_to "Settings", users_password_path, class: "text-ink-soft hover:text-ink" %>
<%= button_to "Sign out", session_path, method: :delete, class: "text-ink-soft hover:text-ink cursor-pointer" %>
<% end %>
</div>
<% end %>
</nav>

<%= render "shared/flash" %>
Expand Down
6 changes: 6 additions & 0 deletions app/views/magic_link_mailer/sign_in_link.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<p>
Sign in by visiting
<%= link_to "this sign-in page", magic_link_url(token: @user.generate_token_for(:magic_link), subdomain: @organization.subdomain) %>.

This link will expire in 30 minutes.
</p>
4 changes: 4 additions & 0 deletions app/views/magic_link_mailer/sign_in_link.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Sign in by visiting
<%= magic_link_url(token: @user.generate_token_for(:magic_link), subdomain: @organization.subdomain) %>

This link will expire in 30 minutes.
29 changes: 29 additions & 0 deletions app/views/magic_links/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div class="mx-auto max-w-md w-full px-4 py-16">
<div class="rounded-2xl border border-line bg-surface p-8 shadow-sm">
<h1 class="font-serif font-medium text-3xl tracking-tight text-ink">Email me a sign-in link</h1>

<p class="mt-3 text-sm text-ink-soft">We'll email you a link that signs you in — no password needed.</p>

<%= form_with url: magic_link_path, class: "contents" do |form| %>
<%# Honeypot: hidden from real users, but bots tend to fill every field. Submissions with this set are silently dropped. %>
<div class="absolute left-[-9999px]" aria-hidden="true">
<%= label_tag :nickname, "Leave this field blank" %>
<%= text_field_tag :nickname, nil, tabindex: -1, autocomplete: "off" %>
</div>

<div class="my-5">
<%= 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" %>
</div>

<div class="mt-6 sm:flex sm:items-center sm:gap-4">
<div class="inline">
<%= 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" %>
</div>

<div class="mt-4 text-sm text-ink-soft sm:mt-0">
<%= link_to "Sign in with a password", new_session_path, class: "text-ink hover:text-brand underline" %>
</div>
</div>
<% end %>
</div>
</div>
5 changes: 3 additions & 2 deletions app/views/registrations/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
</div>

<div class="my-5">
<%= 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" %>
<%= 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" %>
<p class="mt-2 text-sm text-ink-soft">Optional — leave blank to sign in with a link emailed to you instead.</p>
</div>

<div class="my-5">
<%= 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" %>
<%= 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" %>
</div>

<div class="mt-6 sm:flex sm:items-center sm:gap-4">
Expand Down
2 changes: 2 additions & 0 deletions app/views/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
<div class="mt-4 text-sm text-ink-soft sm:mt-0">
<%= link_to "Sign up", new_registration_path, class: "text-ink hover:text-brand underline" %>
&middot;
<%= link_to "Email me a sign-in link", new_magic_link_path, class: "text-ink hover:text-brand underline" %>
&middot;
<%= link_to "Forgot password?", new_password_path, class: "text-ink hover:text-brand underline" %>
&middot;
<%= link_to "Resend confirmation", new_email_confirmation_path, class: "text-ink hover:text-brand underline" %>
Expand Down
44 changes: 44 additions & 0 deletions app/views/users/passwords/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<div class="mx-auto max-w-md w-full px-4 py-16">
<div class="rounded-2xl border border-line bg-surface p-8 shadow-sm">
<h1 class="font-serif font-medium text-3xl tracking-tight text-ink">
<%= @user.password_set? ? "Change password" : "Add a password" %>
</h1>

<p class="mt-3 text-sm text-ink-soft">
<%= @user.password_set? ? "Update the password you use to sign in." : "Add a password so you can sign in without a magic link." %>
</p>

<%= form_with url: users_password_path, method: :patch, scope: :user, class: "contents" do |form| %>
<% if @user.errors.any? %>
<div class="mt-5 py-2 px-3 bg-danger-tint text-danger font-medium rounded-lg" id="error_explanation">
<ul class="list-disc list-inside">
<% @user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<% if @user.password_set? %>
<div class="my-5">
<%= label_tag :current_password, "Current password", class: "text-sm font-medium text-ink" %>
<%= password_field_tag :current_password, nil, required: true, autocomplete: "current-password", placeholder: "Enter your current 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" %>
</div>
<% end %>

<div class="my-5">
<%= form.label :password, "New password", class: "text-sm font-medium text-ink" %>
<%= form.password_field :password, required: true, 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" %>
</div>

<div class="my-5">
<%= form.label :password_confirmation, "Confirm new password", class: "text-sm font-medium text-ink" %>
<%= 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" %>
</div>

<div class="mt-6 inline">
<%= form.submit "Save password", 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" %>
</div>
<% end %>
</div>
</div>
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
resources :passwords, param: :token
resource :registration, only: %i[ new create ]
resource :email_confirmation, only: %i[ new create show ]
resource :magic_link, only: %i[ new create show ]
namespace :users do
resource :password, only: %i[ show update ]
end
resource :session

resources :scenarios do
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20260615120000_allow_null_password_digest.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AllowNullPasswordDigest < ActiveRecord::Migration[8.1]
def change
# Passwordless (magic-link) accounts have no password, so password_digest
# must be allowed to be NULL.
change_column_null :users, :password_digest, true
end
end
4 changes: 2 additions & 2 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading