Skip to content

Instantly share code, notes, and snippets.

@bigglesrocks
Last active February 24, 2023 05:51
Show Gist options
  • Star 20 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save bigglesrocks/9026919 to your computer and use it in GitHub Desktop.
Save bigglesrocks/9026919 to your computer and use it in GitHub Desktop.
Rails Scoped Invitation System

#Scoped Invitation System for User Groups with Rails#

Starting out with the following models and associations:

####User

  • has_many :memberships
  • has_many :organizations through :memberships

####Organization (User Group)

  • has_many :memberships
  • has_many :users through :memberships
  • has_one owner (:user)

####Membership (Pass through model)

  • belongs_to :user
  • belongs_to :organization

##The Issue

Conceptually, users with appropriate permissions should be able to invite other users, either existing or by email, to join an organization they are a part of. There are plenty of gems out there that take care of application-wide invitation systems, however when you don't have any app-wide views or functions, this presents an issue. ##Criteria

  • A user can invite someone to join an organization by providing an email
  • If the user exists, that user is added as a member of the organization
  • 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
  • The invitation grants the invited user access to only the organization they were invited to

##Prerequisites

  • Some sort of Authentication system with a User model. I used Devise.
  • A second model for the User Group that is associated with the user model in a many-to-many way. I've used has_many :through with a third model. Perhaps polymorphic associations could also be used?

##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 :organization
  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 Organization < ActiveRecord:Base
   has_many :invites
end

Migration

class CreateInvites < ActiveRecord::Migration
  def change
   create_table :invites do |t|
     t.string :email 
     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 organziation, but it could go anywhere.

Send Invitation Form

<%= form_for @invite , :url => invites_path do |f| %>
	<%= f.hidden_field :organization_id, :value => @invite.organization_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 organization that the person is being invited to have access to, which is the current organization since I'm placing it on the organizations#edit view.

We'll also need a Mailer to send the email. Mailers are great, and I'm sure you guys know all about them. The invitation mailer is very basic, so I'm not going to go into details here, but it will send to the :email of the newly created invitation and include an invite URL that we will construct later.

##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.organization_id, Time.now, rand].join)
end

Here, I'm using the :organization_id and the current time plus a random number to generate a random token.

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) 
#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 sent with a constructed url like so.

##Newly Invited user registration

Now when someone clicks on the invite link, they'r 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 organization 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 organization.

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. I used a conditional statement within my users#new 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).organization #find the organization attached to the invite
     @newUser.organizations.push(org) #add this user to the new organization as a member
  else
    # do normal registration things #
  end
end

Now when the user registers, they'll automatically have access to the organization 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 organization
       @invite.recipient.organizations.push(@invite.organization)
    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 wil automatically become a member of the organization.

##Other features

  • Add an :accepted boolean to the Invites table, and allow existing users the ability to accept or deny an invitation.
  • Add a check in the user registration to validate not only the token but that the email the user is registering with matches the one attached to the invite.
class Invite < ActiveRecord::Base
belongs_to :organization
belongs_to :sender, :class_name => 'User'
belongs_to :recipient, :class_name => 'User'
before_create :generate_token
before_save :check_user_existence
def generate_token
self.token = Digest::SHA1.hexdigest([self.organization_id, Time.now, rand].join)
end
def check_user_existence
recipient = User.find_by_email(email)
if recipient
self.recipient_id = recipient.id
end
end
end
class InvitesController < ApplicationController
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 organization
@invite.recipient.organizations.push(@invite.organization)
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
end
class CreateInvites < ActiveRecord::Migration
def change
create_table :invites do |t|
t.string :email
t.integer :sender_id
t.integer :recipient_id
t.string :token
t.timestamps
end
end
end
<%= form_for @invite , :url => invites_path do |f| %>
<%= f.hidden_field :organization_id, :value => @invite.organization_id %>
<%= f.label :email %>
<%= f.email_field :email %>
<%= f.submit 'Send' %>
<% end %>
<%= form_tag @user, :action => 'create', :url => registration_path(@user), :html => { :multipart => true } %>
<% if @token != nil %>
<%= hidden_field_tag :invite_token, @token %>
<% end %>
<%= label_tag :user, 'First Name' %><br />
<%= text_field :user, :first_name %>
<%= label_tag :user, 'Last Name' %><br />
<%= text_field :user, :last_name %>
<%= label_tag :user, 'Email' %><br />
<%= email_field :user, :email, value: @invite.email %>
<%= label_tag :user, 'Choose Password' %><br />
<%= password_field :user, :password' %>
<%= label_tag :user, 'Confirm Password' %><br />
<%= password_field :user, :password_confirmation %>
<%= submit_tag "Sign up" %>
<% end %>
def create
@newUser = build_user(user_params)
@newUser.save
@token = params[:invite_token]
if @token != nil
org = Invite.find_by_token(@token).organization #find the organization attached to the invite
@newUser.organizations.push(org) #add this user to the new organization as a member
else
# do normal registration things #
end
end
@Deanskee
Copy link

Hey thanks for this but where is the code to invite_params that you call in the invites controller??

@StanBoyet
Copy link

It might be a method like this one :

def invite_params
      params.require(:invite).permit(: organization_id, :email)
end

@Sanchezdav
Copy link

Sorry, where is the code of InviteMailer?

@timup
Copy link

timup commented Jan 29, 2016

@Sanchezdav:

We'll also need a Mailer to send the email. Mailers are great, and I'm sure you guys know all about them. The invitation mailer is very basic, so I'm not going to go into details here, but it will send to the :email of the newly created invitation and include an invite URL that we will construct later.

http://guides.rubyonrails.org/action_mailer_basics.html

@tomichj
Copy link

tomichj commented Apr 10, 2016

Thanks very much for this! This gist inspired me to write a gem around the core concept. If anyone's interested, please check out:

https://github.com/tomichj/invitation

Any comments or contributions would be very welcome.

@simonlehmann
Copy link

simonlehmann commented Sep 9, 2016

Hi, Great way of doing it, although I'm having trouble getting @invite initialized for the new invitation form. Did you have something like this in your organizations#edit action?

@invite = Invite.new
@invite.organization_id = @organization.id

if not, how did you get the @invite variable set up?

Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment