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
- 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
- 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.
There's a lot of information to be associated with the invitation, so we need a model for it.
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
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
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
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
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
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
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
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 :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
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.
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
:acceptedboolean 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_invitesparameter 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.
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