Email change flow with Rails 8 auth

by Kim Laplume

Summary

With the Email verification in place, we build a flow that let's the user change his Email after verification

Lesson

Picking up

This lesson builds on Email verification with Rails 8 auth: Part 1 and Email verification with Rails 8 auth: Part 2

Change an Email

For this interaction, the model EmailChange fully comes to life, because it serves as an trace of a given User's Email addresses over time, and holds the value to which we need to update the field after successful (re)verification. One difference to the case with initial email verification is that this action needs to be triggered by the user. I'll illustrate the case where the user can go to a settings page, give his "new" address in a field and submit the form, there are of course many different places where this could be done instead. Let's add a route and controller

# config/routes.rb
#...
namespace :settings do
  resources :emails, only: [ :new, :create, :edit ], param: :token
end
class Settings::EmailsController < ApplicationController
  before_action :set_email_change_by_token, only: %i[ edit ]

  def new
  end

  def create
    return redirect_to new_settings_email_path, alert: "Please verify your email address first" unless Current.user.email_verified?
    return redirect_to new_settings_email_path, alert: "Can't change to this email address" if User.exists? email_address: params[:email_address]

    if EmailChange.where(user: Current.user, confirmed_at: nil, created_at: 1.day.ago..).exists?
      redirect_to new_settings_email_path, alert: "Please wait a little longer before requesting another email change. If the issue persists, please contact support at support@example.com"
    else
      Current.user.initiate_email_change params[:email_address]
      redirect_to new_settings_email_path, notice: "Email change instructions have been sent, please check your inbox for the new address."
    end
  end

  def edit
    if Current.user != @email_change.user
      return redirect_to new_settings_email_path, alert: "You are not authorized to change this email."
    end
    if @email_change.confirmed_at?
      return redirect_to new_settings_email_path, alert: "Email change has already been confirmed."
    end
    if Current.user.update email_address: @email_change.to
      @email_change.update confirmed_at: Time.zone.now
      redirect_to edit_settings_info_path, notice: "Email has been changed."
    else
      redirect_to edit_settings_info_path, alert: "Could not change email."
    end
  end

  private

  def set_email_change_by_token
    @email_change = EmailChange.find_by_token_for!(:email_change, params[:token])
  rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
    redirect_to new_settings_email_path, alert: "Email change link is invalid or has expired."
  end
end

The controller is a bit different from the EmailVerificationsController:
- we provide a new action to show a form to input the new address
- in the edit action, we don't change the email_verified field because the user needs to have his initial mail verified before making a change.
- we changing the email_address field instead

We need an additional method in User

def initiate_email_change(new_email)
  email_change = EmailChange.create(user: self, from: self.email_address, to: new_email)
  token = email_change.generate_token_for :email_change
  NotificationMailer.email_change(email_change, token).deliver_later
end

The new view can be as simple as this

<h1>Change your email</h1>
<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= form_with url: settings_emails_path do |form| %>
  <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your new email address", value: params[:email_address] %><br>
  <%= form.submit "Change Email" %>
<% end %>

Remember to also add a different mailer action email_change that has maybe some different copy.

With this, we can safely change a user's email, and have a trail of changes in the associated EmailChange model!