Scoped Invitations for User Group

with Rails

ProjectList


ProjectList's Models look a little like this


    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, but ProjectList doesn't have any app-wide views or functions. Everything is based within the scope of an organization, so not only did these gems not work for my problem, but they weren't even a good starting point.

    Solving the Problem

    Criteria

    • A user can invite someone to join an organization by providing an email
    • If the user exists, they become a member of the organization
    • If the use 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

    In addition to the following, you will need to set up a very basic mailer and add :invites to your routes

    Invitation Model

    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 :organization
      belongs_to :sender, :class_name => 'User'
      belongs_to :recipient, :class_name => 'User'
    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
    						

    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.

    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 %>
    						

    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.

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

    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 constructed thusly:

    
    new_user_registration_path(:invite_token => @invite.token) 
    #outputs -> http://yourapp.com/users/sign_up?invite_token=075eeb1ac0165950f9af3e523f207d0204a9efef
    						

    Registering an invited user

    Registering an invited user is a little different than a brand new user

    First, we need to modify our user registration controller to read the parameter from the url

    
    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.

    Handling existing users

    Add a check to the 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

    Modify the 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 neat things

    • 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.

    Links

    Scoped User Group Invitation System for Rails

    By Jessica Biggs

    Scoped User Group Invitation System for Rails

    • 5,402