rqjjca
Last Updated: September 20, 2017
·
14.19K
· jlego
F772c81f76a7db8cdb21526314746afd

Creating a Scoped Invitation System for Rails

Context

Assuming we have an app with some models that look like this:

class User < ActiveRecord::Association
   has_many :memberships
   has_many :user_groups, through: :memberships
end

class UserGroup < ActiveRecord::Association #(Could be a company, club, circle, etc.)
    has_many :memberships
    has_many :users, through: :memberships
end

class Membership <  ActiveRecord::Association #(Pass through model)
    belongs_to :user
    belongs_to :user_group
end

Criteria

  • A user can invite someone to join an user group by providing an email
  • If the user exists, that user is added as a member of the user group
  • If the user does not exist, the app sends an email with a link to sign up, and automatically creates a membership for the new user, giving them access to the user group
  • The invitation grants the invited user access to only the user group they were invited to

Prerequisites

  • Some sort of Authentication system with a User model.(Devise, Sorcery)
  • Models set up in the aformentioned way. The User & User Group models should be associated in a many-to-many way. The above example uses has_many :through, but it doesn't have to.
  • Permissions set up that allow Users to view User Groups only if they are a member of that User Group. CanCan makes this wonderfully simple.

Getting Started

There's a lot of information to be associated with the invitation, so we need a model for it.

Models

class Invite < ActiveRecord::Base
      belongs_to :user_group
      belongs_to :sender, :class_name => 'User'
      belongs_to :recipient, :class_name => 'User'
 end

class User < AciveRecord::Base
    has_many :invitations, :class_name => "Invite", :foreign_key => 'recipient_id'
    has_many :sent_invites, :class_name => "Invite", :foreign_key => 'sender_id'
end

class UserGroup < ActiveRecord:Base
   has_many :invites
end

Migration

class CreateInvites < ActiveRecord::Migration
  def change
   create_table :invites do |t|
     t.string :email 
     t.integer :user_group_id
     t.integer :sender_id
     t.integer :recipient_id
     t.string :token
     t.timestamps
    end
   end 
 end

Routes

resources :invites

Now we have a nice way of keeping track of invitations, and if we need to add features like invitation limits or expiration time, we can do so easily.

Let's create a quick form for an existing user to send an invite. I put this form on the edit view for the User Group, but it could go anywhere.

Send Invitation Form

<%= form_for @invite , :url => invites_path do |f| %>
    <%= f.hidden_field :user_group_id, :value => @invite.user_group_id %>
    <%= f.label :email %>
    <%= f.email_field :email %>
    <%= f.submit 'Send' %>
<% end %>

The form only has one input, the email of the person being invited. There is also a hidden field that specifies the user group that the person is being invited to have access to, which is the current user group since I'm placing it on the user_group#edit view.

We'll also need a Mailer to send the email. The invitation mailer is very basic, so I'm not going to go into a lot of detail here, but it will send to the :email of the newly created invitation and include an invite URL that we will construct later. The mailer will have 2 methods, one for sending he invitation email to new users and one for sending a notification email to existing users.

Making a New Invitation

When a user submits the form to make a new invite, we not only need to send the email invite, but we need to generate a token as well. The token is used in the invite URL to (more) securely identify the invite when the new user clicks to register.

To generate a token before the invite is saved, let's add a before_create filter to our Invite model.

before_create :generate_token

def generate_token
   self.token = Digest::SHA1.hexdigest([self.user_group_id, Time.now, rand].join)
end

Here, I'm using the :user_group_id and the current time plus a random number to generate a random token. You can use whatever you want, and the more complex the token is, the (arguably) more secure the invites will be.

So now when we create a new invite, it will generate the token automagically. Now, in our create action we need to fire off an invite email (controlled by our Mailer), but ONLY if the invite saved successfully.

 def create
   @invite = Invite.new(invite_params) # Make a new Invite
   @invite.sender_id = current_user.id # set the sender to the current user
   if @invite.save
      InviteMailer.new_user_invite(@invite, new_user_registration_path(:invite_token => @invite.token)).deliver #send the invite data to our mailer to deliver the email
   else
      # oh no, creating an new invitation failed
   end
end

Here the InviteMailer takes 2 parameters, the invite and the invite URL which is consrtucted thusly:

new_user_registration_path(:invite_token => @invite.token) #new_user_registration_path is a Devise path. Use the correct registration route for your app
#outputs -> http://yourapp.com/users/sign_up?invite_token=075eeb1ac0165950f9af3e523f207d0204a9efef

Now if we fill out our invitation form, we can look in our server log to see that an email was generated with a constructed url similar to the above. To actually get the email to be sent, you probably need to setup a third-party email service like Postmark or Mandrill.

Newly Invited user registration

Now when someone clicks on the invite link, they're taken to the registration page for your app. However, registering an invited user is going to be a little different than registering a brand new user. We need to attach this invited user to the user group they were invited to during registration. That's why we need the token parameter in the url, because now we have a way to identify and attach the user to the correct user group.

First, we need to modify our user registration controller to read the parameter from the url in the new action:

def new
   @token = params[:invite_token] #<-- pulls the value from the url query string
end

Next we need to modify our view to put that parameter into a hidden field that gets submitted when the user submits the registration form. We can use a conditional statement within the new registration view to output this field when an :invite_token parameter is present in the url.

<% if @token != nil %>
    <%= hidden_field_tag :invite_token, @token %>
<% end %>

Next we need to modify the user create action to accept this unmapped :invite_token parameter.

def create
  @newUser = build_user(user_params)
  @newUser.save
  @token = params[:invite_token]
  if @token != nil
     org =  Invite.find_by_token(@token).user_group #find the user group attached to the invite
     @newUser.user_groups.push(org) #add this user to the new user group as a member
  else
    # do normal registration things #
  end
end

Now when the user registers, they'll automatically have access to the user group they were invited to, as expected.

What if the email is already a registered user?

We don't want to send the same invitation email that we would for a non-existing user. This user doesn't need to register again, they're already using our app, we just want to give them access to another part of it. We need to add a check to our Invite model via a before_save filter:

before_save :check_user_existence

 def check_user_existence
    recipient = User.find_by_email(email)
   if recipient
      self.recipient_id = recipient.id
   end
 end

This method will look for a user with the submitted email, and if found it will attach that user's ID to the invitation as the :recipient_id
That in and of itself does not do much. We need to modify our Invite controller to do something different if the user already exists:

def create
  @invite = Invite.new(invite_params)
  @invite.sender_id = current_user.id
  if @invite.save

    #if the user already exists
    if @invite.recipient != nil 

       #send a notification email
       InviteMailer.existing_user_invite(@invite).deliver 

       #Add the user to the user group
       @invite.recipient.user_groups.push(@invite.user_group)
    else
       InviteMailer.new_user_invite(@invite, new_user_registration_path(:invite_token => @invite.token)).deliver
    end
  else
     # oh no, creating an new invitation failed
  end
end

Now if the user exists, he/she will automatically become a member of the user group.

Going further

That was just a basic implementation, and if you're using this in production you may want to add some additional things to better secure the system and improve user experience. Luckily, since we have a separate Invite model, we can do those things fairly easily.

  • Add an additional check to the create User action to make sure the email submitted with the registration matches the one on the token's Invite.
  • Add an :accepted boolean to the Invites table, and allow existing users the ability to accept or deny an invitation, instead of being automatically granted access to the user group.
  • Give existing users a limited number of invitations that they can send, and check against the :sent_invites parameter that we added on the User model. (Additional tweaking required to have a limit per user group)
  • Set an expiration time on invitations and automatically destroy invitations after a certain period of time, rendering them unusable.

Update

Sending multiple invites at once

A couple of people asked about sending multiple invites at once. I implemented this in the current app I'm working on by doing the following:

# UserGroupsController
def invite_to
  emails = params[:invite_emails].split(', ')
  emails.each do |email|
    invite = Invite.new(:sender_id => current_user.id, :email => email, user_group_id => @user_group.id)
    if invite.save
      if invite.recipient != nil
        InviteMailer.existing_user_invite(invite).deliver
      else
        InviteMailer.new_user_invite(invite, new_user_registration_path(:invite_token => @invite.token))
      end
    end
end
Say Thanks
Respond

17 Responses
Add your response

13755
Amit1 normal

Thanks for this really nice rundown Jessica! This is even better than the railscast on beta invitations, particularly your nice simple explanation of how and where to use a unique token.

I'm wondering, what might you do if you want to invite several people at once? For instance, rather than sending a single invitation at a time, this might be more like an admin user of a SaaS service inviting a list of people from their company to join the service under that company's account.

over 1 year ago ·
13891
F772c81f76a7db8cdb21526314746afd

I have thought about this, as I will need to implement this in an app I'm currently working on. I'm not completely sure at the moment, but I will be sure to post the code here once I've gotten it smoothed out.

over 1 year ago ·
14612
Cequ12fy normal

Agreed with Alubling! Really informative and clear tutorial. Been looking for something like this for a while, similar to Basecamp and other SaaS apps that allow a registered user to invite others to their account. Great work! Thank you!

over 1 year ago ·
15190
Xw3m zyy normal

Hi ,
Nice , well explained , i have a doubt did you forget to add usergroupid in invites migration ? as invites and user_groups has one to many relation ! or any other reason ?

over 1 year ago ·
15201
F772c81f76a7db8cdb21526314746afd

@mhmenon910 Ah, I believe you are correct. I've updated the tip with the additional line in the migration.

over 1 year ago ·
15204
F772c81f76a7db8cdb21526314746afd

@alubling @fritzsbm I've updated the tip with a small snippet on how I implemented sending multiple invites at once in the app I'm currently building. I stripped some things out, so the code has not been tested, but hopefully it's a good starting point.

over 1 year ago ·
16074
D05728a0613658cb5b2f652eb6f36783

Hi, thanks for the tutorial. A few questions where I'm getting stuck:

  1. InviteMailer - For email url, don't you need to use newuserregistrationurl, not newuserregistrationpath to send fulll link to new user?

  2. Devise User Controller - Are you using Devise in this example. And if so, doesn't that mean you have to override controller?

Thanks again.

over 1 year ago ·
16461
F772c81f76a7db8cdb21526314746afd

@jberczel
You are correct in both instances.
You do need to use url and not path. I remember finding this bug on several emails after I posted this tip. Also, I am using Devise and I do have an overridden registrations controller.

over 1 year ago ·
17104
None

This is really great stuff, thanks Jessica! Would it be possible to have the code available for download?

over 1 year ago ·
22644
None

This is excellent. I've been trying to wrap my head around multitenancy and invites, and this is the best explanation I've found. Devise Invitable does so much behind the scenes, it's nice to go through building it out yourself.

over 1 year ago ·
22661
2f83db0a8756c683dbb5ff9dc10d7033 normal

Very nice tutorial, thank you very much @jlego for putting it together. We are going to use it as an inspiration to build our invite system. One question though (that may be out of the scope of this tutorial): we have a role field in the memberships table, and we want to let the user who sends the invites define a specific role for each new user he/she is inviting. We are thinking of updating the invite model, the invite form and the Invites#Create. Does that make sense or do we need to update other files too?

over 1 year ago ·
27325
Cb2525ec6155e479bdefe9ed6c437d07

I think there may be a mass-assignment vulnerability here. You're permitting the Invite to be created without ensuring user_group_id actually belongs the to the user sending the invitation. By altering the request, one could allow any user to join a group he/she didn't have access to.

Anyhow, sorry in advance if I'm missing something.

over 1 year ago ·
27754

Hello Jessica Biggs, I want to execute similar functionalities using mongoid, but having problem with executing it. Is it possible to have this with mongoDB like databases.

over 1 year ago ·
28462

Great post, thanks! I have a question though. If @invite never saves for new Users, how is the invite supposed to be foundbytoken (org = Invite.findbytoken(@token).user_group ) in the new registration create action?

11 months ago ·
28493

Looks like Rails 5 requires optional: true

belongsto :recipient, :classname => 'User', optional: true

10 months ago ·
28650
F772c81f76a7db8cdb21526314746afd

@alubing I implemented this in the project I created this for. All I did was have a textarea field and do a split by comma of the value, then firing off an invite for each address obtained from that.

9 months ago ·
29314

Hi,
where it says "in our create action we need to fire off an invite email (controlled by our Mailer)" - which controller exactly do I add the corresponding code into?

Thanks

about 2 months ago ·
Awesome Job

B6c77ad0 c95b 11e7 8263 8f89080b8db6
Ruby Developer Backend
·
Austria (Vienna or Linz)
·
Full Time