From 4ca955fa5db1dead1806a773bd54e8e2072a0a74 Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Fri, 12 Jun 2026 18:55:08 -0400 Subject: [PATCH] Let users resend the confirmation email New users who lose or expire their confirmation email had no recovery path: they cannot sign in until confirmed and cannot re-register because emails are globally unique. Add a dedicated unauthenticated page (and a link from the sign-in page) where they enter their email to re-send the confirmation link, mirroring the existing password-reset flow. Only unconfirmed accounts are sent mail, and the action always shows the same generic notice so it never reveals whether an email exists or is already confirmed. Reuses the existing RegistrationMailer.confirmation and the on-demand email_confirmation token. Co-Authored-By: Claude Opus 4.8 --- .../email_confirmations_controller.rb | 16 +++++++- app/views/email_confirmations/new.html.erb | 15 ++++++++ app/views/sessions/new.html.erb | 2 + config/routes.rb | 2 +- .../email_confirmations_controller_test.rb | 37 +++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 app/views/email_confirmations/new.html.erb diff --git a/app/controllers/email_confirmations_controller.rb b/app/controllers/email_confirmations_controller.rb index 6671553..96dc96b 100644 --- a/app/controllers/email_confirmations_controller.rb +++ b/app/controllers/email_confirmations_controller.rb @@ -1,5 +1,19 @@ class EmailConfirmationsController < ApplicationController - allow_unauthenticated_access only: :show + allow_unauthenticated_access only: %i[ new create show ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_email_confirmation_path, alert: "Try again later." } + + def new + end + + def create + user = User.find_by(email_address: params[:email_address]) + + if user && !user.confirmed? + RegistrationMailer.confirmation(user, Current.organization).deliver_later + end + + redirect_to new_session_path, notice: "Confirmation instructions sent (if an unconfirmed account with that email address exists)." + end def show if user = User.find_by_token_for(:email_confirmation, params[:token]) diff --git a/app/views/email_confirmations/new.html.erb b/app/views/email_confirmations/new.html.erb new file mode 100644 index 0000000..890d1f6 --- /dev/null +++ b/app/views/email_confirmations/new.html.erb @@ -0,0 +1,15 @@ +
+
+

Resend confirmation email

+ + <%= form_with url: email_confirmation_path, class: "contents" do |form| %> +
+ <%= 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" %> +
+ +
+ <%= form.submit "Email confirmation instructions", 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" %> +
+ <% end %> +
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 3dfb024..5e84305 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -20,6 +20,8 @@ <%= link_to "Sign up", new_registration_path, class: "text-ink hover:text-brand underline" %> · <%= link_to "Forgot password?", new_password_path, class: "text-ink hover:text-brand underline" %> + · + <%= link_to "Resend confirmation", new_email_confirmation_path, class: "text-ink hover:text-brand underline" %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 92341a8..a4b6a72 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,7 +3,7 @@ resource :home resources :passwords, param: :token resource :registration, only: %i[ new create ] - resource :email_confirmation, only: :show + resource :email_confirmation, only: %i[ new create show ] resource :session resources :scenarios do diff --git a/test/controllers/email_confirmations_controller_test.rb b/test/controllers/email_confirmations_controller_test.rb index c8bc226..64c587f 100644 --- a/test/controllers/email_confirmations_controller_test.rb +++ b/test/controllers/email_confirmations_controller_test.rb @@ -6,6 +6,38 @@ class EmailConfirmationsControllerTest < ActionDispatch::IntegrationTest @user = users(:unconfirmed) end + test "new" do + get new_email_confirmation_path + assert_response :success + end + + test "create for an unconfirmed user enqueues a confirmation email" do + post email_confirmation_path, params: { email_address: @user.email_address } + assert_enqueued_email_with RegistrationMailer, :confirmation, args: [ @user, organizations(:arlington) ] + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "Confirmation instructions sent" + end + + test "create for an already-confirmed user sends no mail" do + post email_confirmation_path, params: { email_address: users(:one).email_address } + assert_enqueued_emails 0 + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "Confirmation instructions sent" + end + + test "create for an unknown email sends no mail and reveals nothing" do + post email_confirmation_path, params: { email_address: "missing-user@example.com" } + assert_enqueued_emails 0 + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "Confirmation instructions sent" + end + test "show with a valid token confirms the user and signs them in" do token = @user.generate_token_for(:email_confirmation) @@ -33,4 +65,9 @@ class EmailConfirmationsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to new_session_path assert_nil cookies[:session_id] end + + private + def assert_notice(text) + assert_select "div", /#{text}/ + end end