36

First, I've searched intensely with Google and Yahoo and I've found several replies on topics like mine, but they all don't really cover what I need to know.

I've got several user models in my app, for now it's Customers, Designers, Retailers and it seems there are yet more to come. They all have different data stored in their tables and several areas on the site they're allowed to or not. So I figured to go the devise+CanCan way and to try my luck with polymorphic associations, so I got the following models setup:

class User < AR
  belongs_to :loginable, :polymorphic => true
end

class Customer < AR
  has_one :user, :as => :loginable
end

class Designer < AR
  has_one :user, :as => :loginable
end

class Retailer < AR
  has_one :user, :as => :loginable
end

For the registration I've got customized views for each different User type and my routes are setup like this:

devise_for :customers, :class_name => 'User'
devise_for :designers, :class_name => 'User'
devise_for :retailers, :class_name => 'User'

For now the registrations controller is left as standard (which is "devise/registrations"), but I figured, since I got different data to store in different models I'd have to customize this behaviour as well!?

But with this setup I got helpers like customer_signed_in? and designer_signed_in?, but what I'd really need is a general helper like user_signed_in? for the areas on the site that are accessible to all users, no matter which user type.

I'd also like a routes helper like new_user_session_path instead of the several new_*type*_session_path and so on. In fact all I need to be different is the registration process...

So I was wondering IF THIS IS THE WAY TO GO for this problem? Or is there a better/easier/less must-customize solution for this?

Jonathan Leffler
  • 730,956
  • 141
  • 904
  • 1,278
Vapire
  • 4,568
  • 3
  • 24
  • 41

3 Answers3

36

Okay, so I worked it through and came to the following solution.
I needed to costumize devise a little bit, but it's not that complicated.

The User model

# user.rb
class User < ActiveRecord::Base
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable

  attr_accessible :email, :password, :password_confirmation, :remember_me

  belongs_to :rolable, :polymorphic => true
end

The Customer model

# customer.rb
class Customer < ActiveRecord::Base
  has_one :user, :as => :rolable
end

The Designer model

# designer.rb
class Designer < ActiveRecord::Base
  has_one :user, :as => :rolable
end

So the User model has a simple polymorphic association, defining if it's a Customer or a Designer.
The next thing I had to do was to generate the devise views with rails g devise:views to be part of my application. Since I only needed the registration to be customized I kept the app/views/devise/registrations folder only and removed the rest.

Then I customized the registrations view for new registrations, which can be found in app/views/devise/registrations/new.html.erb after you generated them.

<h2>Sign up</h2>

<%
  # customized code begin

  params[:user][:user_type] ||= 'customer'

  if ["customer", "designer"].include? params[:user][:user_type].downcase
    child_class_name = params[:user][:user_type].downcase.camelize
    user_type = params[:user][:user_type].downcase
  else
    child_class_name = "Customer"
    user_type = "customer"
  end

  resource.rolable = child_class_name.constantize.new if resource.rolable.nil?

  # customized code end
%>

<%= form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| %>
  <%= my_devise_error_messages!    # customized code %>

  <div><%= f.label :email %><br />
  <%= f.email_field :email %></div>

  <div><%= f.label :password %><br />
  <%= f.password_field :password %></div>

  <div><%= f.label :password_confirmation %><br />
  <%= f.password_field :password_confirmation %></div>

  <% # customized code begin %>
  <%= fields_for resource.rolable do |rf| %>
    <% render :partial => "#{child_class_name.underscore}_fields", :locals => { :f => rf } %>
  <% end %>

  <%= hidden_field :user, :user_type, :value => user_type %>
  <% # customized code end %>

  <div><%= f.submit "Sign up" %></div>
<% end %>

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

For each User type I created a separate partial with the custom fields for that specific User type, i.e. Designer --> _designer_fields.html

<div><%= f.label :label_name %><br />
<%= f.text_field :label_name %></div>

Then I setup the routes for devise to use the custom controller on registrations

devise_for :users, :controllers => { :registrations => 'UserRegistrations' }

Then I generated a controller to handle the customized registration process, copied the original source code from the create method in the Devise::RegistrationsController and modified it to work my way (don't forget to move your view files to the appropriate folder, in my case app/views/user_registrations

class UserRegistrationsController < Devise::RegistrationsController
  def create
    build_resource

    # customized code begin

    # crate a new child instance depending on the given user type
    child_class = params[:user][:user_type].camelize.constantize
    resource.rolable = child_class.new(params[child_class.to_s.underscore.to_sym])

    # first check if child instance is valid
    # cause if so and the parent instance is valid as well
    # it's all being saved at once
    valid = resource.valid?
    valid = resource.rolable.valid? && valid

    # customized code end

    if valid && resource.save    # customized code
      if resource.active_for_authentication?
        set_flash_message :notice, :signed_up if is_navigational_format?
        sign_in(resource_name, resource)
        respond_with resource, :location => redirect_location(resource_name, resource)
      else
        set_flash_message :notice, :inactive_signed_up, :reason => inactive_reason(resource) if is_navigational_format?
        expire_session_data_after_sign_in!
        respond_with resource, :location => after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords(resource)
      respond_with_navigational(resource) { render_with_scope :new }
    end
  end
end

What this all basically does is that the controller determines which user type must be created according to the user_type parameter that's delivered to the controller's create method by the hidden field in the view which uses the parameter by a simple GET-param in the URL.

For example:
If you go to /users/sign_up?user[user_type]=designer you can create a Designer.
If you go to /users/sign_up?user[user_type]=customer you can create a Customer.

The my_devise_error_messages! method is a helper method which also handles validation errors in the associative model, based on the original devise_error_messages! method

module ApplicationHelper
  def my_devise_error_messages!
    return "" if resource.errors.empty? && resource.rolable.errors.empty?

    messages = rolable_messages = ""

    if !resource.errors.empty?
      messages = resource.errors.full_messages.map { |msg| content_tag(:li, msg) }.join
    end

    if !resource.rolable.errors.empty?
      rolable_messages = resource.rolable.errors.full_messages.map { |msg| content_tag(:li, msg) }.join
    end

    messages = messages + rolable_messages   
    sentence = I18n.t("errors.messages.not_saved",
                      :count => resource.errors.count + resource.rolable.errors.count,
                      :resource => resource.class.model_name.human.downcase)

    html = <<-HTML
    <div id="error_explanation">
    <h2>#{sentence}</h2>
    <ul>#{messages}</ul>
    </div>
    HTML

    html.html_safe
  end
end

UPDATE:

To be able to support routes like /designer/sign_up and /customer/sign_up you can do the following in your routes file:

# routes.rb
match 'designer/sign_up' => 'user_registrations#new', :user => { :user_type => 'designer' }
match 'customer/sign_up' => 'user_registrations#new', :user => { :user_type => 'customer' }

Any parameter that's not used in the routes syntax internally gets passed to the params hash. So :user gets passed to the params hash.

So... that's it. With a little tweeking here and there I got it working in a quite general way, that's easily extensible with many other User models sharing a common User table.

Hope someone finds it useful.

Vapire
  • 4,568
  • 3
  • 24
  • 41
  • Thanks for posting this solution - I think it is quite elegant. The only thing I don't quite like about it is the sign_up URLs with the parameters. I wouldn't necessarily want my site users to see the parameters and would prefer routes like /customers/sign_up and /designers/sign_up. Is that something that could be easily provided for? Perhaps by having sign_up actions on Customers and Designers controllers that set the correct parameter and then redirect or render the appropriate view? Or can it be done via some rails routing magic? – OzBandit Nov 12 '11 at 01:01
  • 5
    Hey David! Actually I've wondered the same thing and I played a little with rails routes and tried to do some "magic". What I didn't know: You can pass any symbol/value pair to the match method and if it's not a reserved symbol name it's just passed as a a key in the params hash. Thus having `match 'designers/sign_up' => 'user#new', :type => 'Designer'` and `match 'customers/sign_up' => 'user#new', :type => 'Customer'` in your routes file passes the :type key in the params hash to your new method of the user controller – Vapire Nov 16 '11 at 19:29
  • Thanks Vapire, this is the same thing that I ended up doing after reading the documentation on routes more thoroughly. Thanks again for the general solution though - it works great! – OzBandit Nov 21 '11 at 17:21
  • Thanks so much Vapire for this great and very flexible solution! I have just a question regarding latest comments. I've tried to customize routes having 'designers/sign_up' and 'customers/sign_up' passing them in a devise_scope block. However it returns a nil object error when I tried to access to one of these routes. Did I have to change something in params? Again thanks! – benoitr Jan 07 '12 at 05:59
  • Also could you please provide which fields you added in your User table (do you have user_type:string and rolable_id:integer?) It will help me to understand it fully ;) – benoitr Jan 07 '12 at 06:48
  • Hey benoitr! Regarding your first comment: could you post your actual route code, please? For your second comment: The columns you need to add in the user table are `rolable_type:string` and `rolable_id:integer` which describe the polymorphic association. – Vapire Jan 08 '12 at 13:29
  • Hi Vapire thanks for your feedback! Please see my routes (sorry for the identation... I've also adapted the routes regarding your current example): devise_scope :user do match 'designers/sign_up' => 'UserRegistrations#new', :type => 'Designer' match 'customers/sign_up' => 'UserRegistrations#new', :type => 'Customer' end and I have devise_for :users, :controllers => { :registrations => 'UserRegistrations' } – benoitr Jan 08 '12 at 21:42
  • Hmmm... I don't see anything wrong... What's your error message exactly? – Vapire Jan 11 '12 at 11:10
  • sorry for my late reply, I've tried it again with your update. Everything works ok! Regards, Benoit – benoitr Jan 22 '12 at 22:48
  • This is exactly what i need as I have subscribers and assignees (those who the subscriber has assigned to a task), I've tried the above, following line-by-line only to be met with: Unknown action The action 'new"' could not be found for Users::RegistrationsController my routes.rb is: devise_for :user, :controllers => { :registrations => "users/registrations" } devise_scope :user do match 'overflow/sign_up', :to=> 'users/registrations#new"', :user => { :user_type => 'assignee' } match 'subscriber/sign_up', :to => 'users/registrations#new"', :user => { :user_type => 'subscriber' } – user464180 Mar 08 '12 at 17:58
  • registrations_controller (found under controllers/users): class Users::RegistrationsController < Devise::RegistrationsController def index end def new logger.debug "***" logger.debug "UGH" logger.debug "***" end def create build_resource ... Any and all help is greatly appreciated. – user464180 Mar 08 '12 at 17:59
  • Also, do I need a rolable model? If so, what columns are in it? Thanks. – user464180 Mar 08 '12 at 18:06
  • Sorry for my late reply. I think it's best to open a new question with this, rather than posting it in the comments. In a question you can post your code well formatted and others will be able to answer as well. You can post a link to your question here in the comments... – Vapire Mar 12 '12 at 09:23
  • @Gavin: I have the same sign in form for all my roles, but I created my own SessionsController for redirecting to different pages for each role. Is this what you're looking for? – Vapire May 22 '12 at 17:26
  • @Vapire: I just deleted my comment. After looking again at your models, I realized the problem I mentioned doesn't apply. If you have Customer < User and Designer < User, and devise_for is on User, then you will run into a Devise scope problem when attempting to sign in unless you know in advance which role the user will sign in as. But your doing this with a has_one relationship, so this problem doesn't apply. – Gavin May 23 '12 at 04:19
  • @Vapire: I have followed your solution and also i have created the same sign in form form for both models, can you please tell me what you have in your SessionsController and how are they redirected to different pages based on their roles? Please Help – Sushil Jul 30 '13 at 15:57
  • I didn't modify my `SessionsController` for that, maybe this will suffice for you: https://github.com/plataformatec/devise/wiki/How-To:-Redirect-to-a-specific-page-after-a-successful-sign-in-or-sign-out – Vapire Jul 30 '13 at 17:50
  • 1
    @Vapire can you create a github repository for the code you used. I realize it's a whole year later but if you wouldn't mind creating a github repository that would be extremely beneficial... – eNN Jul 03 '14 at 01:50
  • 1
    how is `build_resource` in the `create` getting the user params. I tried the same thing, but it doesn't populate the resource. Only if I pass in `params[:user]` to `build_resource` it is populating the resource. This is also giving me a problem saying the `user_type` is unknown as it is sent as a hidden param through the form. How are you managing to store user_type as rolable_type? – Vishnu Oct 05 '14 at 13:23
6

I didn't manage to find any way of commenting for the accepted answer, so I'm just gonna write here.

There are a couple of things that don't work exactly as the accepted answer states, probably because it is out of date.

Anyway, some of the things that I had to work out myself:

  1. For the UserRegistrationsController, render_with_scope doesn't exist any more, just use render :new
  2. The first line in the create function, again in the UserRegistrationsController doesn't work as stated. Just try using

    # Getting the user type that is send through a hidden field in the registration form.
    user_type = params[:user][:user_type]
    
    # Deleting the user_type from the params hash, won't work without this.
    params[:user].delete(:user_type)
    
    # Building the user, I assume.
    build_resource
    

instead of simply build_resource. Some mass-assignment error was coming up when unchanged.

  1. If you want to have all the user information in Devise's current_user method, make these modifications:

class ApplicationController < ActionController::Base protect_from_forgery

  # Overriding the Devise current_user method
  alias_method :devise_current_user, :current_user
  def current_user
    # It will now return either a Company or a Customer, instead of the plain User.
    super.rolable
  end
end

cgf
  • 3,369
  • 7
  • 45
  • 65
2

I was following the above instructions and found out some gaps and that instructions were just out of date when I was implementing it.

So after struggling with it the whole day, let me share with you what worked for me - and hopefully it will save you few hours of sweat and tears

  • First of all, if you are not that familiar with RoR polymorphism, please go over this guide: http://astockwell.com/blog/2014/03/polymorphic-associations-in-rails-4-devise/ After following it you will have devise and user users models installed and you will be able to start working.

  • After that please follow Vapire's great tutorial for generating the views with all the partails.

  • What I found most frustrating was that dut to using the latest version of Devise (3.5.1), RegistrationController refused to work. Here is the code that will make it work again:

    def create 
    
      meta_type = params[:user][:meta_type]
      meta_type_params = params[:user][meta_type]
    
      params[:user].delete(:meta_type)
      params[:user].delete(meta_type)
    
      build_resource(sign_up_params)
    
      child_class = meta_type.camelize.constantize
      child_class.new(params[child_class.to_s.underscore.to_sym])
      resource.meta = child_class.new(meta_type_params)
    
      # first check if child intance is valid
      # cause if so and the parent instance is valid as well
      # it's all being saved at once
      valid = resource.valid?
      valid = resource.meta.valid? && valid
    
      # customized code end
      if valid && resource.save    # customized code
        yield resource if block_given?
        if resource.persisted?
          if resource.active_for_authentication?
            set_flash_message :notice, :signed_up if is_flashing_format?
            sign_up(resource_name, resource)
            respond_with resource, location: after_sign_up_path_for(resource)
          else
            set_flash_message :notice, :"signed_up_but_#{resource.inactive_message}" if is_flashing_format?
            expire_data_after_sign_in!
            respond_with resource, location: after_inactive_sign_up_path_for(resource)
          end
        else
          clean_up_passwords resource
          set_minimum_password_length
          respond_with resource
        end
      end
    end
    
  • and also add these overrides so that the redirections will work fine:

    protected
    
      def after_sign_up_path_for(resource)
        after_sign_in_path_for(resource)
      end
    
      def after_update_path_for(resource)
        case resource
        when :user, User
          resource.meta? ? another_path : root_path
        else
          super
        end
      end
    
  • In order that devise flash messages will keep working you'll need to update config/locales/devise.en.yml instead of the overridden RegistraionsControlloer by UserRegistraionsControlloer all you'll need to do is add this new section:

    user_registrations:
      signed_up: 'Welcome! You have signed up successfully.'
    

Hope that will save you guys few hours.

Guy Dubrovski
  • 1,542
  • 1
  • 20
  • 25