Extending Devise Series – Adding Custom Fields

Devise is a ruby gem I use in nearly any Rails app. Authentication is a not so simple concept to master and as a result Devise has stood to be one of the most popular to help with this repetitive problem on Ruby on Rails applications.

This post is the first of many that will take a look at extending devise to work with you rather than against. My goal is to start off with the basics and then move into more advanced customizations like OmniAuth, inviting users, acting as users, and much more. I’ll be republishing this post as new installments get complete so in the end, there will be a larger collection.

To kick things off I’ll touch on adding custom fields to a given Devise install. Rather than adhering to the defaults that ship with the gem, we will extend it to include two new fields. One is a terms and conditions checkbox and the other being the location of a given user.

All of these fields are presented to the user when first signing up. I’ll extend the already generated devise view to include the new fields. As an added bonus, I’ll add in some geo-location (thanks to another gem) to pinpoint the user’s location during sign up.

Prerequisite

In this tutorial I’m using a template I created previously called kickoff_tailwind. You can download it here. It comes with some general configuration taken care of as well as installing the Devise gem itself. If you are brand new to devise I recommend installing it on your own first to understand how it hooks into a given model within your Rails app.

Let’s Begin

When I ran my installation I pass a template which I mentioned prior that comes from my kickoff_tailwind Github repo. If you’re following along step-by-step you should download this to your system before proceeding.

After a successful install, we pinpointed our User model to target with Devise. In doing so new fields are generated by default. We need to extend this to add two new fields terms and location.

Create a new migration

$ rails g migration addFieldsToUsers terms:boolean location:string

Then run rails db:migrate to update the database schema.

Update the Application Controller

Next, we need to allow those new fields to enter the database once a user submits the sign-up form. Rails uses this concept of permitted parameters for forms. You need to classify what fields you want to accept during the submission otherwise they will not enter the database. This is a great security feature.

To pass the new fields as valid we need to update our ApplicationController to the following:

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, :terms, :location])
      devise_parameter_sanitizer.permit(:account_update, keys: [:username, :name, :terms, :location])
    end
end

We had :username and :name here by default thanks to the kickoff_tailwind Rails template. Your mileage may vary if you’re not using the template.

Update views to reflect new fields

Our views need to reflect the new fields added. There are two in particular that need attention:

<!-- app/views/devise/registrations/edit.html.erb -->
<% content_for :devise_form do %>
  <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>

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

    <div class="mb-6">
      <%= f.label :email, class:"label" %>
      <%= f.email_field :email, autocomplete: "email", class:"input" %>
    </div>

    <div class="mb-6">
      <%= f.label :location, class:"label" %>
      <%= f.text_field :location, class:"input", value: city %>
    </div>

    <div class="mb-6">
      <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
        <div>Currently waiting confirmation for: <%= resource.unconfirmed_email %></div>
      <% end %>
    </div>

    <div class="mb-6">
      <%= f.label :password, class:"label" %>
      <%= f.password_field :password, autocomplete: "new-password", class:"input" %>
      <p class="text-sm text-grey-dark pt-1 italic"> <% if @minimum_password_length %>
        <%= @minimum_password_length %> characters minimum <% end %> (leave blank if you don't want to change it) </p>

    </div>

    <div class="mb-6">
      <%= f.label :password_confirmation, class: "label" %>
      <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input" %>
    </div>

    <div class="mb-6">
      <%= f.label :current_password, class: "label" %>
      <%= f.password_field :current_password, autocomplete: "current-password", class: "input" %>
      <p class="text-sm text-grey-dark pt-2 italic">(we need your current password to confirm your changes)</p>
    </div>

    <div class="mb-6">
      <%= f.submit "Update", class: "btn btn-default" %>
    </div>
    <% end %>

    <hr class="border mt-6 mb-3" />

    <h3 class="heading text-xl font-bold mb-4">Cancel my account</h3>

    <div class="flex justify-between items-center">
      <div class="flex-1"><p class="py-4">Unhappy?</p></div>

      <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete, class: "btn btn-default" %>
    </div>

<% end %>

<%= render 'devise/shared/form_wrap' %>

Notice that this has been highly customized once again thanks to the kickoff_tailwind Rails template of mine.

<!-- app/views/devise/registrations/new.html.erb -->
<% content_for :devise_form do %>

  <h2 class="heading text-4xl font-bold pt-4 mb-8">Sign up</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>

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

    <div class="mb-6">
      <%= f.label :email, class:"label" %>
      <%= f.email_field :email, autocomplete: "email", class:"input" %>
    </div>

    <div class="mb-6">
      <%= f.label :location, class:"label" %>
      <%= f.text_field :location, class:"input", value: city  %>
    </div>

    <div class="mb-6">
      <div class="flex">
        <%= f.label :password, class: "label" %>
        <% if @minimum_password_length %>
        <span class="text-xs pl-1 text-grey-dark"><em>(<%= @minimum_password_length %> characters minimum)</em></span>
        <% end %>
      </div>
      <%= f.password_field :password, autocomplete: "new-password", class: "input" %>
    </div>

    <div class="mb-6">
      <%= f.label :password_confirmation, class:"label" %>
      <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input" %>
    </div>

    <div class="mb-6">
      <%= f.check_box :terms, class: "mr-2" %>
      <%= f.label :terms, "I agree to terms and conditions" %>
    </div>

    <div class="mb-6">
      <%= f.submit "Sign up", class: "btn btn-default block w-full text-center" %>
    </div>

    <hr class="border mt-6" />

  <% end %>

  <%= render "devise/shared/links" %>

<% end %>

<%= render "devise/shared/form_wrap" %>

Bonus: Geo Location

Unfortunately, it’s not quite trivial to add geo location to an app. Most attempts at this require some kind of service you’ll need to subscribe to. How it works is relative to the given requests IP address. From that IP address more data can be discovered and mapped to cities, countries, system info and more. We simply want the city name to display in the location field by default when a user goes to sign up for an account. In doing so I’ll reach for a service call https://ipinfo.io/. They happen to have a Rails gem we can utilize to get going quickly – Check that out here.

You’ll need to add that gem to your Gemfile and run bundle install.

 # Gemfile

gem 'ipinfo-rails'

You also will need an account on https://ipinfo.io/ to gain access to the API. They require an access token to use the gem.

Open your config/environment.rb file or your preferred file in the config/environment directory. Add the following code to your chosen configuration file. I chose config/environment/development.rb by example. I recommend just running this in production or on a live server though since your local environment won’t have the same data we need. To work around this we’ll install another tool called ngrok coming up. First, we need to configure the ipinfo gem.

# config/environment/development.rb
require 'ipinfo-rails'

Rails.application.configure do
  ...
  config.middleware.use(IPinfoMiddleware, {
     token: Rails.application.credentials.dig(:ipinfo_token) 
  })
  ...
end

Note: if editing config/environment.rb, this needs to come before Rails.application.initialize! and with Rails.application. prepended to config, otherwise, you’ll get runtime errors.

Make sure you add your access token to your encrypted credentials.

$ rails credentials:edit

Restart your development server.

Testing Locally

To test locally we need to forward our port out to ngrok. You’ll need an auth token (create a free account to grab one).

Install ngrok by clicking the bash script. It will install in your user level account. You can create an alias to it via zsh or bash_profile. I chose to install it via yarn globally by running yarn add ngrok. This adds a zip file within a .ngrok folder on your user account within your machine. You’ll need to unzip that zip file.

I created an alias to that directory for quick access using .zsh. (optional)

Run ./ngrok http 3000 while having rails server running on localhost:3000 in a new terminal tab.

Pass the ngrok url as a permitted host in development.rb

 config.hosts << "7c8b6b27.ngrok.io" # example url
 config.middleware.use(IPinfoMiddleware, {
   token: Rails.application.credentials.dig(:ipinfo)
 })

Create a helper around the logic

Our helper with make use of the new request.env['ipinfo'] logic we get from the new gem. We can wrap this up so our views won’t be cluttered.

Add a new city helper in application_helper.rb

# app/helpers/application_helper.rb

 def city
  request.env['ipinfo'].city if request.env['ipinfo'].city
 end

Finally we can update form fields to have a predefined value using the new helper

<!-- update in both app/views/registrations/edit.html.erb and app/views/registrations/new.html.erb -->
 <div class="mb-6">
   <%= f.label :location, class:"label" %>
   <%= f.text_field :location, class:"input", value: city %>
</div>

Shameless plug time

I have a new course called Hello Rails. Hello Rails is 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.