June 17, 2019
•Last updated November 5, 2023
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. View the course!
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.