Project DoList: User Authentication

Install and Setup Devise

This article will describe how to setup user authentication (sign up, sign in, forgot password) using Devise (https://github.com/heartcombo/devise).

Install Devise

To begin, perform the following steps:

  1. Add gem "devise" to the Gemfile

  2. Run bundle install

  3. Run bin/rails generate devise:install to generate a config file

To enable emails to be sent from Devise (ex. password reset) perform the following steps:

  1. In config/environments/development.rb add
config.action_mailer.default_url_options = { host: 'http://localhost', port: 3000 }
  1. In config/application.rb uncomment line 10: require "action_mailer/railtie"

Create the User Model

Let's now create a User DB model that will store information about authenticated users.

Run the following commands:

  1. rails generate devise User

  2. bin/rails db:migrate

Note that running those commands adds the following line in config/routes.rb: devise_for :users. Adding that line creates all of the routes necessary for user authentication.

To view all defined routes visit http://localhost:3000/rails/info/routes. The page looks like this:

Style the Authentication Views

Be sure to restart your local server before moving forward.

Let's add "Sign Up" and "Sign In" buttons to the homepage. Replace app/views/pages/home.html.erb with:

<div class="container mx-auto mt-28 px-5">
  <h1 class="font-bold text-8xl mb-3 tracking-tighter">DoList.</h1>
  <p class="text-gray-800 text-xl tracking-tight">
    A free and open-source Todoist alternative
  </p>

  <div class="mt-10 flex items-center gap-x-6">
    <%= link_to 'Sign Up',
        new_user_registration_path,
        class: 'rounded-md bg-indigo-600 px-3.5 py-2.5 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
    %>

    <%= link_to 'Sign In',
        new_user_session_path,
        class: 'rounded-md bg-white px-3.5 py-2.5 font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50'
    %>
  </div>
</div>

The homepage will now look like this:

You'll notice that if you visit either of those links you will see unstyled forms on each page. So that we can customize each of those pages let's run a command that will copy the templates from the Devise library into our app.

Run the following command: bin/rails generate devise:views. You'll now see a new directory: app/views/devise. This directory contains all the views for each authentication component (sign up, sign in, forgot password, etc).

Replace the following files with styled markup:

File: app/views/devise/registrations/new.html.erb

<div class="h-full bg-gray-50">
  <div class="flex flex-col justify-center py-12 sm:px-6 lg:px-8">
    <div class="sm:mx-auto sm:w-full sm:max-w-md">
      <% if !flash.empty? %>
        <div class="mb-6">
          <%= render 'layouts/flash_messages' %>
        </div>
      <% end %>

      <%= render "devise/shared/error_messages", resource: resource %>

      <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
        Create a new account
      </h2>

      <p class="mt-2 text-center text-sm text-gray-600">
        Or
        <%= link_to 'login to your account', new_user_session_path, class: 'font-medium text-indigo-600 hover:text-indigo-500' %>
      </p>
    </div>

    <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
      <div class="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
        <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
          <div class="space-y-6">
            <div>
              <%= f.label :email, class: 'block text-sm font-medium leading-6 text-gray-900' %>
              <div class="mt-2">
                <%= f.email_field :email, required: true, autofocus: false, autocomplete: "email", class: 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6' %>
              </div>
            </div>

            <div>
              <%= f.label :password, class: 'block text-sm font-medium leading-6 text-gray-900' %>
              <div class="mt-2">
                <%= f.password_field :password, required: true, autocomplete: "current-password", class: 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6' %>
              </div>
            </div>

            <div>
              <%= f.label :password_confirmation, class: 'block text-sm font-medium leading-6 text-gray-900' %>
              <div class="mt-2">
                <%= f.password_field :password_confirmation, required: true, autocomplete: "current-password", class: 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6' %>
              </div>
            </div>

            <div>
              <button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
                Create account
              </button>
            </div>
          </div>
        <% end %>
      </div>
    </div>
  </div>
</div>

File: app/views/devise/sessions/new.html.erb

<div class="h-full bg-gray-50">
  <div class="flex flex-col justify-center py-12 sm:px-6 lg:px-8">
    <div class="sm:mx-auto sm:w-full sm:max-w-md">
      <%= render 'layouts/flash_messages' %>
      <%= render "devise/shared/error_messages", resource: resource %>

      <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
        Sign in to your account
      </h2>

      <% if devise_mapping.registerable? %>
        <p class="mt-2 text-center text-sm text-gray-600">
          Or
          <%= link_to 'create a new account', new_user_registration_path, class: 'font-medium text-indigo-600 hover:text-indigo-500' %>
        </p>
      <% end %>
    </div>

    <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
      <div class="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
        <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
          <div class="space-y-6">
            <div>
              <%= f.label :email, class: 'block text-sm font-medium leading-6 text-gray-900' %>
              <div class="mt-2">
                <%= f.email_field :email, autofocus: false, autocomplete: "email", class: 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6' %>
              </div>
            </div>

            <div>
              <%= f.label :password, class: 'block text-sm font-medium leading-6 text-gray-900' %>
              <div class="mt-2">
                <%= f.password_field :password, autocomplete: "current-password", class: 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6' %>
              </div>
            </div>

            <div class="flex items-center justify-between">
              <div class="flex items-center">
                <%= f.check_box :remember_me, class: 'h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600' %>
                <%= f.label :remember_me, class: 'ml-2 block text-sm text-gray-900' %>
              </div>

              <div class="text-sm">
                <%= link_to 'Forgot your password?', new_user_password_path, class: 'font-medium text-indigo-600 hover:text-indigo-500' %>
              </div>
            </div>

            <div>
              <button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
                Sign in
              </button>
            </div>
          </div>
        <% end %>
      </div>
    </div>
  </div>
</div>

File: app/views/devise/passwords/new.html.erb

<div class="h-full bg-gray-50">
  <div class="flex flex-col justify-center py-12 sm:px-6 lg:px-8">
    <div class="sm:mx-auto sm:w-full sm:max-w-md">
      <% if !flash.empty? %>
        <div class="mb-6">
          <%= render 'layouts/flash_messages' %>
        </div>
      <% end %>

      <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
        Reset Password
      </h2>
    </div>

    <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
      <div class="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
        <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
          <div class="space-y-6">
            <div>
              <%= f.label :email, class: 'block text-sm font-medium leading-6 text-gray-900' %>
              <div class="mt-2">
                <%= f.email_field :email, required: true, autofocus: false, autocomplete: "email", class: 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6' %>
              </div>
            </div>

            <div>
              <button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
                Submit
              </button>
            </div>
          </div>
        <% end %>

        <div class="mt-6 flex justify-center">
          <%= link_to 'Cancel', new_session_path(resource_name), class: 'font-medium text-indigo-600 hover:text-indigo-500' %>
        </div>
      </div>
    </div>
  </div>
</div>

Here is what the sign up page looks like now:

Lastly lets add styling to the flash messages that appear when a validation has failed or an error has occurred. First, add the following file: app/layouts/_flash_messages.html.erb and add the contents:

<% if flash[:alert].present? %>
  <div class="rounded-md bg-red-50 p-4 mb-8 border border-red-300">
    <div class="flex">
      <div class="flex-shrink-0">
        <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
        </svg>
      </div>
      <div class="ml-3">
        <div class="text-sm text-red-700">
          <%= flash[:alert] %>
        </div>
      </div>
    </div>
  </div>
<% end %>

<% if flash[:notice].present? %>
  <div class="rounded-md bg-green-50 p-4 mb-8 border border-green-400">
    <div class="flex">
      <div class="flex-shrink-0">
        <svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
        </svg>
      </div>
      <div class="ml-3">
        <div class="text-sm text-green-700">
          <%= flash[:notice] %>
        </div>
      </div>
    </div>
  </div>
<% end %>

Next replace the file app/views/devise/shared/_error_messages.html.erb with:

<% if resource.errors.any? %>
  <div class="rounded-md bg-red-50 p-4 mb-8 border border-red-300">
    <div class="flex">
      <div class="flex-shrink-0">
        <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
        </svg>
      </div>
      <div class="ml-3">
        <h3 class="text-sm font-medium text-red-800">
          <%= I18n.t("errors.messages.not_saved", count: resource.errors.count, resource: resource.class.model_name.human.downcase) %>
        </h3>

        <div class="mt-2 text-sm text-red-700">
          <ul role="list" class="list-disc space-y-1 pl-5">
            <% resource.errors.full_messages.each do |message| %>
              <li><%= message %></li>
            <% end %>
          </ul>
        </div>
      </div>
    </div>
  </div>
<% end %>

Now error messages look like this:

Create an Authenticated Area

Next lets create a "Dashboard" section that only authenticated users can see. If an unauthenticated user attempts to view the dashboard they will be redirected to the login page.

First let's create a base controller that all other "user" controllers will inherit from. Create the file app/controllers/users/application_controller.rb with the following contents:

class Users::ApplicationController < ApplicationController
  layout 'users'
  before_action :authenticate_user!
end

Note that layout 'users' instructs Rails to use the file app/views/layouts/users.html.erb as the base template for the controller views.

Also note that before_action :authenticate_user! ensures that the user is authenticated before serving any of the pages. If the user is not authenticated they will be redirected to the login page.

Let's create the layout file that will be used for all the user pages: app/views/layouts/users.html.erb with the following content:

<!DOCTYPE html>
<html class="h-full">
  <head>
    <title>DoList: A free and open source alternative to Todoist</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
    <%= stylesheet_link_tag "application" %>
  </head>

  <body class="h-full p-10">
    <div class="flex gap-4 mb-10">
      <div>
        Welcome <%= current_user.email %>
      </div>

      <div>
        <%= link_to 'Sign Out', destroy_user_session_path %>
      </div>
    </div>

    <%= yield %>
  </body>
</html>

Let's make a quick change to allow "Sign Out" links to work with a GET request instead of the default DELETE request. In the file config/initializers/devise.rb uncomment line 269: config.sign_out_via = :get. Be sure to restart your local server.

Next let's create a placeholder dashboard page that will act as the authenticated area. Run the following generator command:

bin/rails g controller user/dashboard index

This creates two files:

  • app/controllers/users/dashboard_controller.rb

  • app/views/users/dashboard/index.html.erb

In app/controllers/users/dashboard_controller.rb be sure to change line 1 so that the file contents are:

class Users::DashboardController < Users::ApplicationController
  def index
  end
end

We must inherit from Users::ApplicationController since that is the class that defines the layout and the authentication check.

Finally we must define where the user should be redirected to on sign in and sign out. Replace the contents of the file app/controllers/application_controller.rb with:

class ApplicationController < ActionController::Base
  private

  def after_sign_in_path_for(resource)
    users_dashboard_index_path
  end

  def after_sign_out_path_for(resource)
    new_user_session_path
  end
end

That's it! Trying logging in and out and view the bare-bones dashboard page. It looks like this:

Summary

In this article we accomplished the following tasks:

  • Installed and configured the Devise gem

  • Styled the authentication screens using Tailwind

  • Created a protected dashboard page that only authenticated users can access

Next up we'll get to the fun bits that are the core of the business requirements (adding and completing tasks) and we'll start using Hotwire and Turbo to make a slick UI.