Introduction
In this post, I want to share my experiences in using Devise as an authentication solution for my company’s Web app.
While building Motonow, the team had to make a couple of though decisions and choosing the right authentication solution was one of them. To be honest, most of the projects I’ve joined had already overcome this phase so I’ve never participated in such conversations until Motonow.
After doing some prototypes, we’ve decided to use Devise because:
- It has most of the features we want to implement already in place;
- It’s really straightforward to start with;
- It’s well documented;
- It’s widely adopted in the Rails community;
Everything was going well until the domain model started to grow in the sense that Devise’s basic configuration was not enough for us. At that point, we needed to extend Devise, more specifically the RegistrationsController.
Domain Model
At the beginning we only had a User model so integrating with devise was pretty easy. After a while, a new Company model was necessary and integrating with Devise was not as straightforward as we would like to. The code below shows how both classes are defined.
User.rb
class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable, :trackable, :validatable belongs_to :company validates :name, presence: true validates :cpf, presence: true validates :cpf, uniqueness: true end
Company.rb
class Company < ActiveRecord::Base has_many :users validates :cnpj, presence: true validates :cnpj, uniqueness: true end
Our challenge is to create a signup form with user and company information so both can be validated and saved (take a look at the signup form so you can have an idea). To make it work as expected, we have to pass through the following steps:
Rails fields_form tag
The first step is setting up the view. We need to provide to the final user inputs related to both User (email, password) and Company (in this case CNPJ which is its unique identifier). So, the registration view looks something like this:
app/views/devise/registrations/new.html.erb
<%= form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| %> <div class="controls"> <%= f.fields_for :company do |company_form| %> <%= company_form.text_field :cnpj %> <%= render partial: "devise/shared/field_error", locals: { model: resource.company, field: :cnpj } %> <% end %> </div> <div class="controls"> <%= f.text_field :cpf %> <%= render partial: "devise/shared/field_error", locals: { model: resource, field: :cpf } %> </div> <div class="controls"> <%= f.text_field :email %> <%= render partial: "devise/shared/field_error", locals: { model: resource, field: :email } %> </div> <div class="controls"> <%= f.password_field :password %> <%= render partial: "devise/shared/field_error", locals: { model: resource, field: :password } %> </div> <div class="controls"> <%= f.password_field :password_confirmation %> <%= render partial: "devise/shared/field_error", locals: { model: resource, field: :password_confirmation } %> </div> <%= f.submit "Submit" %> <% end %>
No surprises here. We are using existing Rails form_for and fields_for FormHelper to build the form for User model and its nested Company model.
Unfortunately, when trying to load the page we get a NoMethodError: undefined method `cnpj’ for nil:NilClass. Basically, we have to initialize User’s company attribute. The default approach would be doing that in the controller, in this case the RegistrationController#new method. And that’s exactly what we did.
Extending Devise RegistrationsController.rb
The first thing we did was creating a RegistrationsController class that extends Devise’s controller to solve the NoMethodError problem.
RegistrationsController.rb
class RegistrationsController < Devise::RegistrationsController def new build_resource({}) self.resource.company = Company.new respond_with self.resource end end
As you can see, we just initialize the company attribute inside the index action. Next, we need to setup Devise to use our new controller as opposed to the default one. This work is done in the routes file.
routes.rb
Motonow::Application.routes.draw do devise_for :users, controllers: { registrations: "registrations" } # other routes end
Cool. Now, the page seems to load fine.
However, when posting the form not all the parameters will be allowed for mass-assignment. As explained in Devise’s documentation, due to Rails 4 strong parameters plugin we need to permit additional parameters explicitly.
The team decided to do that in the RegistrationsController itself instead of the ApplicationController. By using this approach, we believe that all Devise related changes are encapsulated in one place instead of spread through different files. Here’s the new registrations controller:
RegistrationsController.rb
class RegistrationsController < Devise::RegistrationsController def new build_resource({}) self.resource.company = Company.new respond_with self.resource end def create super end private def sign_up_params allow = [:email, :password, :password_confirmation, :cpf, [company_attributes: [:cnpj]] params.require(resource_name).permit(allow) end end
Rails accepts_nested_attributes_for method
Finally, we need to change the User model to use accepts_nested_attributes_for so the Company is saved through its parent in the moment of the registration. That’s easy.
User.rb
class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable, :trackable, :validatable belongs_to :company validates :name, presence: true validates :cpf, presence: true validates :cpf, uniqueness: true accepts_nested_attributes_for :company end
Now, we are good. Everything seems to work fine, including validating and saving both User and Company.
Tests
All the development was done driven by automated tests. I am posting the relevant parts of our tests so you can use it as a guide.
require 'spec_helper' describe RegistrationsController do render_views before(:each) do @request.env["devise.mapping"] = Devise.mappings[:user] end context "create" do before(:each) do @user_params = { cpf: "047.696.094-11", email: "usermail@gmail.com", password: "AbCdEfGh9876", password_confirmation: "AbCdEfGh9876", company_attributes: { cnpj: "10.508.998/0001-79", } } end it "should create new user" do assert_difference "User.count" do post :create, user: @user_params end user = User.find_by_cpf(@user_params[:cpf]) expect(user).not_to be_nil end it "should create new company" do assert_difference "Company.count" do post :create, user: @user_params end user = User.find_by_cpf(@user_params[:cpf]) expect(user.company).not_to be_nil expect(user.company.cnpj).to eq(@user_params[:company_attributes][:cnpj]) end context "invalid user attributes" do before(:each) do @user_params[:email] = "" end it "should not create user nor company" do assert_no_difference "User.count" do assert_no_difference "Company.count" do post :create, user: @user_params end end end it "should display error messages in the view" do post :create, user: @user_params user = assigns(:user) expect(user.errors[:email]).to_not be_empty assert_select "div.field_error", html: user.errors[:email].first end end context "invalid company attributes" do before(:each) do @user_params[:company_attributes][:cnpj] = "" end it "should not create user nor company" do assert_no_difference "User.count" do assert_no_difference "Company.count" do post :create, user: @user_params end end end it "should display error messages in the view" do post :create, user: @user_params user = assigns(:user) expect(user.errors["company.cnpj"]).to_not be_empty assert_select "div.field_error", html: user.errors["company.cnpj"].first end end end end
Conclusion
In this post, I show the approach we took while implementing the authentication solution for Motonow. As we decided to use Devise, we had some challenges to solve. Because we spent a decent amount of time to implement the validation/creation of our models when registering a user, I decided to share the findings with the community. Also, I am pretty sure that there are better ways to solve the same problem, so I am curious the hear your feedback.
Of course, our code is a bit bigger and more complex than I describe in the examples. But I hope they can help you to overcome the challenges you and your company have when using Devise (which is pretty awesome).
Cheers,
Reblogged this on crgolden.
This is a nice elegant way to do this. Thanks for posting.