June 23, 2019
•Last updated November 5, 2023
Extending Devise Series - Confirmation Emails
Continuing my Let's Build with Ruby on Rails - Extending Devise series I take a look at adding confirmation emails to a given Ruby on Rails application.
Download the Kickoff Tailwind Template I reference in this tutorial (optional):
https://github.com/justalever/kickoff_tailwind
In this guide, you'll learn how to install Devise, configure it with default settings, and then later extend it to include a feature called :confirmable
.
To summarize, :confirmable
appends three new fields to your User
table (or whatever model you chose to initialize Devise with) in the database. From there, once a new user signs up to the app they are sent a confirmation email. That email then contains a link with a unique token that once clicked signals to your application to create the new user account. The perk of this is increased security. The person signing up will likely be entering their own email address. In doing so they can successfully visit their inbox to see the new confirmation email that is unique to their own email and session inside the application.
Testing E-mails locally
Testing e-mail delivery locally with Ruby on Rails comes with some configuration workarounds. I make use of a gem called mailcatcher.me which acts as a buffer on a separate port to intercept any e-mails sent locally in your given Ruby on Rails app. Devise also has a configuration required for the config/environments/development.rb
file. Ultimately those settings are as follows:
# config/environments/development.rb
Rails.application.configure do
...
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = { :address => "localhost", :port => 1025 }
end
Mailcacher is a daemon that runs in the background. Once installed you can run $ mailcatcher
to get it up and running. If you don't have it installed you can do so by running $ gem install mailcatcher
.
On top of that configuration, we need to install the Devise gem:
# Gemfile
gem 'devise', '~> 4.6', '>= 4.6.2'
Then run:
$ bundle install
After Devise is installed we can run a generator provided by the gem
$ rails generate devise:install
This creates a new initializer file within config/initializers
. I'll change two options in that file.
# config/initializers/devise.rb
Devise.setup do |config|
...
# ==> Mailer Configuration
# Configure the e-mail address which will be shown in Devise::Mailer,
# note that it will be overwritten if you use your own mailer class
# with default "from" parameter.
config.mailer_sender = '[email protected]'
...
# If true, requires any email changes to be confirmed (exactly the same way as
# initial account confirmation) to be applied. Requires additional unconfirmed_email
# db field (see migrations). Until confirmed, new email is stored in
# unconfirmed_email column, and copied to email column on successful confirmation.
config.reconfirmable = false
...
end
With those settings saved, we can generate the resource (model) that Devise will append it's logic to. In most cases, this is often the User
model but any model would suffice. Think Account
, Admin
, Profile
, etc...
$ rails generate devise User
This will create a user.rb
file within app/models/user.rb
that contains some logic by default. This generator also creates a new routing mechanism in our routes file for devise to hook in to.
The main thing to take notice of is the migration file this command generates in db/migrate/
. Mine looks like the following.
# db/migrate/20190622200524_devise_create_users.rb
# frozen_string_literal: true
class DeviseCreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
## Database authenticatable
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
## Recoverable
t.string :reset_password_token
t.datetime :reset_password_sent_at
## Rememberable
t.datetime :remember_created_at
## Trackable
# t.integer :sign_in_count, default: 0, null: false
# t.datetime :current_sign_in_at
# t.datetime :last_sign_in_at
# t.string :current_sign_in_ip
# t.string :last_sign_in_ip
## Confirmable
# t.string :confirmation_token
# t.datetime :confirmed_at
# t.datetime :confirmation_sent_at
# t.string :unconfirmed_email # Only if using reconfirmable
## Lockable
# t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
# t.string :unlock_token # Only if unlock strategy is :email or :both
# t.datetime :locked_at
t.timestamps null: false
end
add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
# add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true
end
end
Notice the commented out declarations. By default, Devise ships with these ready to roll if you just created a new install of the gem. You can simply uncomment those fields you require and run rails db:migrate
. In our specific case we only nee the confirmable
fields. So I'll uncomment all but the last. My file now looks like the following:
# db/migrate/20190622200524_devise_create_users.rb
# frozen_string_literal: true
class DeviseCreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
## Database authenticatable
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
## Recoverable
t.string :reset_password_token
t.datetime :reset_password_sent_at
## Rememberable
t.datetime :remember_created_at
## Trackable
# t.integer :sign_in_count, default: 0, null: false
# t.datetime :current_sign_in_at
# t.datetime :last_sign_in_at
# t.string :current_sign_in_ip
# t.string :last_sign_in_ip
## Confirmable
t.string :confirmation_token
t.datetime :confirmed_at
t.datetime :confirmation_sent_at
# t.string :unconfirmed_email # Only if using reconfirmable
## Lockable
# t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
# t.string :unlock_token # Only if unlock strategy is :email or :both
# t.datetime :locked_at
t.timestamps null: false
end
add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
# add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true
end
end
Be sure to save that file and then run:
$ rails db:migrate
With that in place we can refer to the schema.rb
file in the db/
directory for what our Database currently has in place:
Here' mine.
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `rails
# db:schema:load`. When creating a new database, `rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_06_22_200524) do
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
t.string "reset_password_token"
t.datetime "reset_password_sent_at"
t.datetime "remember_created_at"
t.string "confirmation_token"
t.datetime "confirmed_at"
t.datetime "confirmation_sent_at"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
end
With this migrated we can now hook into the confirmation_token
, confirmation_sent_at
, and confirmed_at
fields on the users
database table that don't come by default.
One final addition is within your main layout file. I'll add the following to app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
<title>DeviseConfirmableExample</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<%= notice %>
<%= alert %>
<%= yield %>
</body>
</html>
I've added the <%= notice %>
and <%= alert %>
lines to display any feedback devise/our app has.
Creating Authenticated routes
Even though we are close to being complete we still need a way to restrict users from some path prior to logging in. I'll create two new controllers that will feature basic index actions and views. These are merely for example purposes and serve no real dynamic data but you can learn how to lock down more advanced scenarios using the code I've utilized here.
$ rails g controller home index
$ rails g controller dashboard index
The idea behind these two controllers is that the home
controller will be a public facing page anyone can view whether they are signed in or not and the dashboard controller is only for logged in users.
Routing
Up until now, we have only basic routing in place inside config/routes.rb
. I prefer to make this more extendable in the event we want to expand to more than the index
action for both the dashboard
or home
controllers in the future. In doing so I've updated my routes file to the following:
# config/routes.rb
Rails.application.routes.draw do
devise_for :users
resources :dashboard, only: [:index]
resources :home, only: [:index]
root to: "home#index"
end
Here I defined a resources
declaration for both the dashboard
and home
routes. This is a handy way to generate fully RESTful routing if we so desire in the future. To keep too many routes from getting generated unnecessarily we can pass an only: [:index]
option to signal the framework to only create the url/path helpers for the given resources' action. You can view all your routes by running rails routes
or heading to localhost:3000/rails/info/routes
if you have rails server
running.
We also made a new root
path which tells our app what page is indeed the entry point root to: "home#index
declares the index action on the home controller our entry point.
Finally devise_for :users
is another handy method that generates all the necessary routing that the Devise gem needs to operate.
Making Confirmable Work
I'll update our generic controllers to reflect the feature we are building. The home_controller.rb
file will go unchanged but feature this code.
# app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
end
end
The dashboard_controller.rb
file will be similar to the home_controller.rb
file with one exception. I'll add an action that comes from the devise gem called authenticate_user!
. This acts as a gatekeeper to those not signed in that visit a specific action in the controller. In the dashboard's case, I don't want any logged out user to see the page so we can simply write before_action :autheticate_user!
. If we wanted to only block a handful of actions instead of every action you could write something like before_action :authenticate_user!, only:[:edit, :destroy]
for example.
class DashboardController < ApplicationController
before_action :authenticate_user!
def index
end
end
Besides the migration file we modified earlier, nothing has really changed from the average Devise installation on a Rails application. We need a way to hook into the confirmable
option to tell both our app and Devise what to do next. This means inheriting some controller logic from the devise gem and passing some options at runtime.
To do this we can start by modifying our routes slightly:
Rails.application.routes.draw do
devise_for :users, controllers: {
confirmations: 'confirmations'
}
resources :dashboard, only: [:index]
resources :home, only: [:index]
root to: "home#index"
end
The devise_for
method accepts quite a few options. One of those is a hash of controllers of which you can signal to the gem that you too have controller logic that Devise needs to make use of.
Since we are extending the confirmation logic we can pass our own confirmations
controller as an option here. This means that Devise will look for a controller inside app/controllers
for a confirmations_controller.rb
file. Naming is important here. The name you pass as a string inside your routes must match the controller name in app/controllers/
.
So we can create that file. Inside you'll notice a little different syntax.
class ConfirmationsController < Devise::ConfirmationsController
private
def after_confirmation_path_for(resource_name, resource)
sign_in(resource)
dashboard_path
end
end
The confirmations controller we create needs to inherit the parent confirmations controller from the Devise gem. Think of this as a subclass of a subclass. Inside the class, I created a private method that Devise understands called after_confirmation_path_for
. We can pass in the resource_name, and resource to get information on the person who recently confirmed their account. If they created an account, visited their email, and clicked on the link to confirm we can use the token to find the given resource and officially grant them access to the application. After they get signed in we can tell designate where they head next. I added the dashboard_path
as the place I want to take the user.
Testing our work
You'll need both rails server
and mailcatcher
running at this point. Assuming all is well in the app you can create a new user by heading to localhost:3000/users/sign_up
. There will be a new form (that's really ugly) that you can supply your email, password, and password confirmation on. Once you click sign up you should see a new email show up in mailcatcher. Within the email, you can visit the link to activate your account.
Congrats if you made it this far! Confirmable should be working well. In your production environment, you obviously wouldn't use mailcatcher but a third-party email service provider like Postmark, Sendgrid, etc... Those configurations can be added to config/environments/production.rb
just like we did with the development configurations. Ruby on Rails makes it quite easy to hook into other services and spend next to no time on configuration and more time where it matters, on your features and products.
The Series So Far
Shameless plug time
I have a new course called Hello Rails. Hello Rails is a modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. Sign up to get notified today!
Follow @hello_rails and myself @justalever on Twitter.
Categories
Collection
Part of the Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.