A simple way to accomplish all of this is to have Role model and then let each user have 0 or more roles associated with them. Here is how you can do this, assuming that you've already got an authentication system as described in the previous post.
Data Model
First, create database tables, which will just consist of a Role table with a name column, and then a mapping table to map roles to users.
rails generate model Role name:string
rails generate model RolesUser role_id:integer user_id:integer
rake db:migrate
Then tell the User model about the roles, by adding the following line to the User class (app/models/user.rb):
has_and_belongs_to_many :roles, uniq=>true
Now that we have a Role model, we need a way to add and delete roles. Since this is functionality that we'll want to reserve only to top level admins, let's put this functionality in an admin directory.
rails generate controller Admin::Roles index
This generated the controller and view, but not the right routes. So delete the
get "roles/index"
line that was added to routes.rb file and replace it with:
namespace :admin do resources :roles, :only=>[:index, :create, :destroy] endThis allows a showing of roles and the RESTful creation and destruction of roles. Since roles are just a name, we don't really need the separate show, edit, and update paths.
The controller (at app/controllers/admin/roles_controller.rb) looks like:
class Admin::RolesController < ApplicationController def index @roles = Role.all @new_role = Role.new end def create Role.create params[:role] redisplay_roles end def destroy Role.find(params[:id]).destroy redisplay_roles end private def redisplay_roles redirect_to admin_roles_path end endThe method redisplay_roles is a placeholder for now. A little bit further down we'll modify this to support adding of roles via Ajax calls.
So now we have to create the HTML, app/views/admin/roles/index.html.haml
%h1 Roles = render 'role_list' %br = form_for [:admin, @new_role] do |f| = f.label :name, 'Name of new role:' = f.text_field :name = f.submit
There are two things of note here. On line 4, the array [:admin, @new_role] results in the URL for creating a Role object in the admin namespace, which is what we want. On line 2, we render the partial 'role_list' (app/views/admin/roles/_role_list.html.haml), which is shown below. The reason it is a partial is to make it easier to Ajaxify everything.
#RoleList %h2 There #{is_pluralize(@roles.length, 'role')} %ul - @roles.each do |role| %li %b= role.name [#{link_to 'x', [:admin, role], :method=>:delete, :confirm=>'You sure?'}]
is_pluralize on line 2 is a helper function I wrote (in application_helper.rb) that uses the appropriate form of to be along with the pluralized noun. It looks like:
def is_pluralize(count, noun) verb = (count == 1) ? "is" : "are" "#{verb} #{pluralize(count, noun)}" end
The rest of the partial is just looping through the roles and showing them. On line 7, a link to the destroy action is created, by specifying the method as 'delete'.
AJAX
The above is all you need to be able to add and delete roles. Since we are redirecting back to the index page after each add or delete, I thought it would be nice to make those Ajax actions, and just stay on the page. Turns out this is pretty easy.
As a first step, if you haven't already, install jQuery. (yes you could use prototype or many other javascript libraries, but jQuery is what I am using, so it's what I'll describe.) To do this, put the line
gem 'jquery-rails'
in your Gemfile and then run the commands:
bundle install
rails g jquery:install
Now that jQuery is installed, modify the add/remove calls to be AJAX calls by adding the parameter ":remote=>true" to the link_to call on line 7 of _role_list.html.haml and to the form_for call on line 4 of index.html.haml.
Now we modify redisplay_roles to look like
def redisplay_roles respond_to do |format| format.html { redirect_to admin_roles_path } format.js { @roles = Role.all render :redisplay_roles } end end
Line 3 handles the case if the method is called in a non-Ajax way. Line 5 ensures that @roles variable is set. Line 6 is where the magic happens. Rather than returning html like normal, we will return javascript, so we create a view file app/views/admin/roles/redisplay_roles.js.haml. This javascript HAML file just has one line:
$('#RoleList').replaceWith("#{escape_javascript(render :partial=>'role_list')}")$('#RoleList') is the jQuery selector to choose the div we are modifying and replaceWith is a jQuery function which will replace the contents of that div with the argument that is passed in. To create that argument, we just render the partial 'role_list' again.
With just those simple changes, now the adding and removing of roles is done via Ajax calls.
User Views
Now that we can create roles, we need to be able to add roles to users. This will be very similar to what we did with Roles above. The command:
rails generate controller Admin::Users index show
generates our controllers and views. Again, we will delete what got added to routes.rb and replace it with:
namespace :admin do resources :users do member do post :add_role delete :delete_role end end resources :roles, :only=>[:index, :create, :destroy] end
This includes the route for Roles from above. While we aren't using the edit and update routes, we will leave them in for future enhancements. We are also adding the routes add_role and delete_role which will do what you'd expect. The users_controller looks like:
class Admin::UsersController < ApplicationController before_filter :load_user, :except=>[:index] def index @users = User.all end def show end def add_role role = Role.find_by_name params[:role] @user.roles.push role if role redisplay_roles end def delete_role @user.roles.delete(Role.find params[:role]) redisplay_roles end private def load_user @user = User.find params[:id] end def redisplay_roles respond_to do |format| format.html { redirect_to [:admin, @user] } format.js { render :redisplay_roles } end end end
The index.html.haml just shows a list of all the users with links to show them.
%h1 Users %h2 There #{is_pluralize(@users.length, 'user')} %ul - @users.each do |user| %li= link_to user.email, [:admin, user]
show.html.haml looks like:
%h1 User %b Id: = @user.identifier_url %br %b Name: #{@user.last_name}, #{@user.first_name} %br %b Email: = @user.email %hr %h2 Roles = render :partial=>'role_list' #addRole = form_tag [:add_role, :admin, @user], :remote=>true do = label_tag :role, 'Add Role:' = text_field_tag :role = submit_tag 'Add' %hr = link_to 'Return to User List', [:admin, User]
Just like above, we put the roles listing in a partial. _role_list.html.haml looks very similar to the one in the roles view:
#RoleList This user has #{pluralize @user.roles.length, 'role'} %ul - @user.roles.each do |role| %li = role.name [#{link_to 'x', delete_role_admin_user_path(@user, :role=>role.id), :method=>:delete, :confirm=>'Are you sure?', :remote=>true}]
which means that redisplay_roles.js.haml is identical to the Roles version of this. I suppose I could pull this into the shared directory and let it be shared, but I am not convinced that it will always remain identical, so I'll leave the duplication for now.
Authorization
Now that we can create roles, and add users to roles we need a way to use this information. First we'll add a method to User to determine if a user has any of a given role or roles. I would like this to work if the passed in roles are Role objects or strings or symbols defining the role name. To do this I overrode to_s in Role
def to_s self.name endand added the following methods to User
def has_role?(*role_names) self.roles.index {|role| includes_role role_names, role} end def includes_role(role_list, role) role_list.index {|r| r.to_s == role.name} endAs you can see, has_role? takes a variable number of roles and returns true if the user has any of those roles. Now that we can make this determination, we will create a method called ensure_role. We could place it in the controller, but since we will be using it from multiple controllers we will be placing it in the authentication_helper.rb that was created in the previous post. It looks like:
def ensure_role(*role_names) if signed_in? role_names.push 'admin' unless current_user.has_role? *role_names flash[:error] = 'You do not have permission to view this page' redirect_to actions_index_path end else ensure_signed_in end endOn line 3, we push the role 'admin' onto the list of roles. We do this, because we want the 'admin' role to have all permissions and this way we don't have to explicitly list it every time we are ensuring a role. We add the line
before_filter { ensure_role 'admin' }to our controllers in the admin directory to ensure that only users with the admin role can access these pages. Technically we could leave off the 'admin' argument, since it is implicitly added, but I think it makes the controller much more readable to have it explicitly there in the case where 'admin' is the only role allowed.
Rake Task
Here's a brief quiz. Do you see the problem that we've created?
We now have a chicken and egg problem. You can't create an admin role or assign it to yourself unless you already have it. The way we'll solve this is by creating rake tasks that you can run at the command line to create roles and assign them to users. All the details of rake are beyond the scope of this blog post which has gone on for way to long already, so I'll just tell you what I did. I created a file lib/tasks/roles.rake which looks like:
namespace :roles do desc 'Creates a role' task :create, [:role_name] => :environment do |cmd, args| Role.create :name=>args[:role_name] puts "Created role #{args[:role_name]}" end desc 'Add User to Role' task :add_user, [:email, :role_name] => :environment do |cmd, args| user = User.find_by_email args[:email] role = Role.find_by_name args[:role_name] unless user puts "No such user #{args[:email]}" return end unless role puts "No such role #{args[:role_name]}" return end user.roles.push role puts "added #{role.name} to #{user.last_name}, #{user.first_name}" end end
Now you can run the commands:
rake roles:create[admin]
rake roles:create[<your email address>,admin]
to create an admin role and assign your user to it.
Conclusion
Wow, this post went on a lot longer than I meant it to. I guess there was a lot more ground to cover than I realized. I'll try to keep my tutorial based posts shorter in the future...
2 comments:
Thanks for the great post, this is exactly what I need for my (first) RoR site.
Shouldn't it be
rake roles:create[admin]
rake roles:add_user[,admin]
?
Post a Comment