Monday, January 18, 2016

Invitations (part 1)

I took a little break for the holidays but am back now. I view learning as more of a journey than a one time event. Therefore it is not enough to follow a tutorial to learn a technology.

Additionally as a professional developer, what I have found is that when I am trying to solve a problem I have to piece together multiple tutorials and examples from many different sources. With that said, I am going to try a new format for this post, where I will CLEARLY state the problem I am trying to solve and then write about how I went about (the journey) solving the problem. Here we go:

THE PROBLEM: From within my web app (as a signed in user) I need to be able to invite others to join me as users of the application. I would like the system to send an email automatically once I provide their email address, but for now if it just generates a URL, I could paste in an email, then that would be acceptable for now.

WHAT I EXPECT TO LEARN:

  • How to integrate invitations in authentication
  • A better understanding of devise

THE TECHNOLOGY STACK:

  • Ruby on Rails ver 4.0
  • PostgreSQL
  • Devise gem
  • DeviseInvitable gem

RESOURCES:

THE JOURNEY

I started by creating a new rails project called “devisetest”. My idea is to have users create posts only they can see. Then when they invite others, those others, (once signed up) can automatically see the posts of the user they were invited by. Subsequent users can invite existing users and those users would then be able to see those posts as well.

So I created a new test app:

rails new devisetest

Next I added the Devise and PostgreSQL gems to the Gemfile

gem 'pg'
gem 'devise'

I next installed Devise

rails generate devise:install

I followed the manual steps shown on the screen after rails installs Devise to ensure everything was setup correctly

I created a Post model and controller. A Post just consists of some text.

rails g scaffold post post_text:string

Added before action to PostsController.rb to make it secure

before_action :authenticated_user!

Updated database.yml for correct database connections, then created the database and ran the migrations

rake db:create
rake db:migrate

After running the migration I have two models, User and Post. User was created by installing Devise. The User object is used by Devise for authentication. See the Devise documentation if you want to use a different model.

Next I ran the server to test everything out

rails s

At this point I could sign-up a user and then login. I could add posts but I could not logout as a link to logout was not on my posts index page. I didn’t expect it to be there but I had to figure out how to add it.

To do that I added this to the posts/index.html.erb view

<%= link_to 'Signout', destroy_user_session_path, method: :delete %>

Now I can login, logout and create posts.

Next step was to add the DeviseInvitable gem to the Gemfile file.

gem 'devise_invitable'

Then run bundle install

bundle install

Next install devise_invitable

rails generate devise_invitable:install

Add devise_invitable to the User model

rails generate devise_invitable User

Run migrations

rake db:migrate

Finally, I copied the invitable views over as I think I will need to change them.

rails generate devise_invitable:views

Now the work begins.

The first question is how to generate an invite that I could email to someone. Since I don’t have ActionMailer set up, I will be happy with just generating the URL for now. I first looked at the routes that were now generated:

  accept_user_invitation GET    /users/invitation/accept(.:format) devise/invitations#edit
  remove_user_invitation GET    /users/invitation/remove(.:format) devise/invitations#destroy
     user_invitation POST   /users/invitation(.:format)        devise/invitations#create
 new_user_invitation GET    /users/invitation/new(.:format)    devise/invitations#new
                     PATCH  /users/invitation(.:format)        devise/invitations#update
                     PUT    /users/invitation(.:format)        devise/invitations#update

Looks like the “user_invitation” one is the most important at this point.

So how do I generate a invitation? I added these lines to my posts/index.html.erb file:

<%
user = User.invite!(:email => "new_user@example.com") do |u|
  u.skip_invitation = true
end
%>
Invite URL: <%= accept_user_invitation_url(:invitation_token => user.raw_invitation_token) %>

When I run this code I get the following on the web page:

Invite URL: http://localhost:3000/users/invitation/accept?invitation_token=P_JPFgFDY1w15y5pznSL

Looking in the database I see this record was created for a new user:

At this point I now know that invoking the User.invite! method creates the “user to invite” record in the database. Then either an email can be sent by the “invitable” gem or a link can be generated. Here I am calling accept_user_invitation_url to generate the link.

Either way this link is communicated to the user in some way. For an issue I will address shortly, it is important to note the User that was created in the database has an email attribute already set to the email that was used when the invite was created.

On the receiving end the user can use the URL and they will be taken to an “accept invite” form where they will be asked to set their password. Once that is done successfully then Devise will automatically log them into the system.

DeviseInvitable takes care of removing the User’s invite token after the user successfully sets their password (by submitting the form on the edit.html.erb view).

At this point I have several new questions:

  • How do I know who invited the new user?
  • How can I add other information when a new user accepts an invitation?
  • How could another user invite the same user to their posts as well?

How do I know who invited the new user?
To answer this question, I decided I needed to mimic how a real web app would work so I refactored the code by changing the code in posts/index.html.erb into a form that would collect the email address of the user to invite.

<%= form_tag("/invite_teammate", method:"post") do %>
<div class="field">
    <%= label_tag(:email, "Teammate Email:") %><br>
    <%= text_field_tag :email %>
</div>
<div class="actions">
    <%= submit_tag("Get Invite URL") %>
</div>
<% end %>

I created a named route to handle the form submission and placed it in my routes.rb file

 post 'invite_teammate', to:'posts#invite'

I added the implementation of the “invite” method in the posts_controller:

  def invite
   @newuser = User.invite!({:email => params['email'], :skip_invitation => true}, current_user)
   @newuser
  end

Finally I added the view ‘invite.html.erb’ to the views/posts directory:

<p id="notice"><%= notice %></p>
Invite URL: <%= accept_user_invitation_url(:invitation_token => @newuser.raw_invitation_token) %><br />
<%= link_to 'Signout', destroy_user_session_path, method: :delete %>
<%= link_to 'Back', posts_path %>

Notice in addition to showing the ‘accept invite’ URL, I also added ‘Back’ and ‘Signout’ actions so as to better use the test app.

The fix to my original question (how to know who did the inviting) was solved by passing the current_user field into the User.invite! method call in the posts_controller.invite method. But the refactoring will be helpful later.

On to my next question.

How can I add other information when a new user accepts an invitation?
The DeviseInvitable defaults to just asking the invited user for their password and confirmation password when they accept the invitation. But that really isn’t what I wanted, I really want the user to have the ability to use any email they have.

The start of the fix for this can be found in the “Configuring controllers” section of the DeviseInvitable documentation (see resources above).

To start, I needed to create a subclass of the InvitationsController. Since I was subclassing the User model it belongs in the app/controllers/users directory. (I had to create the users subdircectory)

Here is what it looks like:

class Users::InvitationsController < Devise::InvitationsController

  def update
    super
  end

  def edit 
    super
  end

  private 
  def accept_resource 
    resource = resource_class.accept_invitation!(update_resource_params)
    resource
  end
end

The ‘update’ method is the key here. It recieves the parameters from the edit.html.erb page when the ‘invited’ user uses the link they were provided. I overrode it here as I thought here is where I would accept the additional parameters.

I also overrode the ‘edit’ method as I thought I might need to add custom logic to it later. It is called when the ‘invited’ user navigates to the link provided and shows the form to collect their password information (and additional parameters we will see shortly).

The ‘accept_resource’ method is called when accepting invitations. Again I overrode this as I was thinking that I might need to add custom code here.

Next I overrode the default controller for invitations to point to my custom one in the routes.rb file:

  devise_for :users, :controllers => { 
    :invitations => 'users/invitations' 
  }

Finally, I needed to copy the default views for the devise_invitable’s invitations per the documentation:

rails generate devise_invitable:views users/invitations

This creates an ‘invitations’ and ‘mailer’ directory under views/users. There are two views under ‘invitations’, edit.html.erb and new.html.erb

I modified edit.html.erb to collect the ‘email’ attribute as well:

<h2><%= t 'devise.invitations.edit.header' %></h2>

<%= form_for resource, :as => resource_name, :url => invitation_path(resource_name), :html => { :method => :put } do |f| %>
  <%= devise_error_messages! %>
  <%= f.hidden_field :invitation_token %>

  <p><%= f.label :email %><br />
  <%= f.text_field :email %></p>

  <p><%= f.label :password %><br />
  <%= f.password_field :password %></p>

  <p><%= f.label :password_confirmation %><br />
  <%= f.password_field :password_confirmation %></p>

  <p><%= f.submit t("devise.invitations.edit.submit_button") %></p>
<% end %>

The default behaviour of the update method only allows the password and password_confirmation attributes, so to allow the email to be passed in (and updated) as well, we have to get past the strong parameters restriction on Rails. To do this I added a before_filter to application_controller.rb:

before_filter :configure_permitted_parameters, if: :devise_controller?

And the implementation of the new protected method ‘configure_permitted_parameters’ to allow the email parameter is:

def configure_permitted_parameters
    #Only add some parameters
    devise_parameter_sanitizer.for(:accept_invitation).concat [:email]
    #Override accepted parameters
    devise_parameter_sanitizer.for(:accept_invitation) do |u| 
      u.permit(:email, :password, :password_confirmation, :invitation_token)
    end
end

Now along with the password, the email is changed in the user’s record when the User record is updated.

The final question I have at this point, I will defer to the next post. You can see all the code here

Till next time.

No comments:

Post a Comment