Extending Devise – Login With Username or Email

Welcome to another installment of my Let’s Build with Ruby on Rails – Extending Devise series within a series. This post will teach you how to allow a user to login with either a username or email using the Devise gem.

Getting Started

In the video screencast, I reference my kickoff_tailwind Ruby on Rails application template. You can use that as I have or start from scratch. My template sets up Devise automatically so there’s less to configure upfront. I also add a new username and name column to the User database table which we’ll reference in this guide. If you’re not using the template be sure to at least add a username column on your table.

That might look like this:

$ rails generate migration add_username_to_users username:string

This command generates a new migration responsible for adding a username column to the users database table.

Creating a new :login attribute inside the User model

We need a way to denote if the new login id is either a username or email. Devise ships using only email as a valid default for creating a new session (logging in) per user. To extend this we need to tweak a bit of the inner-workings of Devise to accommodate. The user model becomes the following:

# app/models/user.rb

class User < ApplicationRecord
  attr_accessor :login

  # "getter"
  # def login
  #   @login
  # end

  # "setter"
  # def login=(str)
  #  @login = str
  # end

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  def self.find_for_database_authentication warden_condition
    conditions = warden_condition.dup
    login = conditions.delete(:login)
    where(conditions).where(
      ["lower(username) = :value OR lower(email) = :value",
      { value: login.strip.downcase}]).first
  end
end

I created a new attr_accessor called :login. This is a shorthand for the comments you see following that line of code above. It essentially creates a getter and a setter for you so you can keep your models cleaner. This is done so often that the shorthand was a great addition to ruby.

At the bottom of the class, you’ll find a new method self.find_for_database_authentication which extends Devise to query via warden. This uses some SQL to query for either the username or email fields given one or the other is supplied during form submission.

Permitting new fields

With the brains of the logic hashed out we can head to the controller layer to allow the :login attributes to submit securely.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

    def configure_permitted_parameters
      devise_parameter_sanitizer.permit(:sign_up,
        keys: [:username, :name, :email, :password, :password_confirmation])
      devise_parameter_sanitizer.permit(:sign_in,
        keys: [:login, :password, :password_confirmation])
      devise_parameter_sanitizer.permit(:account_update,
        keys: [:username, :name, :email, :password_confirmation, :current_password])
    end
end

Above I amend my application_controller.rb file to include newly configured parameters. This is essentially white-listing those fields so Rails knows to accept those into the session/database. I’ve added the :login attribute to the :sign_in permission respectively.

Updating the view layer

With those core changes in place above we can extend our view layer to include them.

<!-- app/views/devise/sessions/new.html.erb-->
<h2 class="heading text-4xl font-bold pt-4 mb-8">Log in</h2>
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>

  <div class="mb-6">
    <%= f.label :login, class:"label" %>
    <%= f.text_field :login, autofocus: true, class: "input" %>
  </div>

  <!-- additional fields  -->
<% end %>

If you haven’t already added the username fields to your Sign up views you can do that as well.

<!-- app/views/devise/registrations/edit.html.erb-->

<h2 class="heading text-4xl font-bold pt-4 mb-8">Edit <%= resource_name.to_s.humanize %></h2>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>

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

  <div class="mb-6">
    <%= f.label :username, class:"label" %>
    <%= f.text_field :username, autofocus: true, class:"input" %>
  </div>

<!-- additional fields  -->
<% end %>

<!-- app/views/devise/registrations/new.html.erb-->

<h2 class="heading text-4xl font-bold pt-4 mb-8">Edit <%= resource_name.to_s.humanize %></h2>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>

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

  <div class="mb-6">
    <%= f.label :username, class:"label" %>
    <%= f.text_field :username, autofocus: true, class:"input" %>
  </div>

<!-- additional fields  -->
<% end %>

And then finally I updated my application layout to accommodate the logged in and logged out states so it’s easier to log out.

<!DOCTYPE html>
<html>
  <head>
    <title>Kickoff</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <meta name="viewport" content="width=device-width, initial-scale=1">

    <%= stylesheet_link_tag  'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= stylesheet_pack_tag  'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag  'application', 'data-turbolinks-track': 'reload' %>

  </head>

 <body class="bg-white">

  <% flash.each do |type, message| %>
    <% if type == "alert" %>
      <div class="bg-red-500">
        <div class="container px-2 mx-auto py-4 text-white text-center font-medium font-sans"><%= message %></div>
      </div>
    <% end %>
    <% if type == "notice" %>
      <div class="bg-green-500">
        <div class="container px-2 mx-auto py-4 text-white text-center font-medium font-sans"><%= message %></div>
      </div>
    <% end %>
  <% end %>

    <header class="mb-4">
      <nav class="flex items-center justify-between flex-wrap bg-gray-100 py-3 lg:px-10 px-3 text-gray-700 border-b border-gray-400">
        <div class="flex items-center flex-no-shrink mr-6">
          <%= link_to "Kickoff", root_path, class:"link text-xl tracking-tight font-semibold" %>
        </div>
        <div class="block lg:hidden">
          <button class="flex items-center px-3 py-2 border rounded text-grey border-gray-500 hover:text-gray-600 hover:border-gray-600">
            <svg class="fill-current h-3 w-3" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><title>Menu</title><path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z"/></svg>
          </button>
        </div>
        <div class="w-full block lg:flex-1 lg:flex items-center text-center lg:text-left">
          <div class="lg:flex-grow">
            <%= link_to "Basic Link", "#", class: "block mt-4 lg:inline-block lg:mt-0 lg:mr-4 mb-2 lg:mb-0 link" %>
          </div>

          <div class="w-full block lg:flex lg:flex-row lg:flex-1 mt-2 lg:mt-0 text-center lg:text-left lg:justify-end items-center">
            <% if user_signed_in? %>
              <p class="lg:mr-2 px-4">Welcome, <%= current_user.username %></p>
              <%= link_to "Log out", destroy_user_session_path, method: :delete, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
            <% else %>
              <%= link_to "Login", new_user_session_path, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
              <%= link_to "Sign Up", new_user_registration_path, class:"btn btn-default block" %>
            <% end %>
          </div>
        </div>
      </nav>
    </header>

    <main class="lg:px-10 px-4">
      <%= content_for?(:content) ? yield(:content) : yield %>
    </main>
  </body>
</html>

Closing thoughts

Hopefully, this proved to be useful! I believe this will be a way forward for me in my own apps. People are forgetful and sometimes remembering their username or email can prove to be a challenge. One issue I didn’t account for here is if a user forgot their username. That’s often a pattern you see out in the wild. Devise (I don’t think) supports this type of pattern by default but It seems to mimic the “Forgot password” approach. Perhaps I’ll dig into how to resolve this in a future installment.

If you have feedback, questions, or anything else please let me know in the comments. Thanks for following along!

Buy Me A Coffee

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.