DeviseInvitable + Rails API

Published Jul 19, 2017
DeviseInvitable + Rails API

In a recent project, I put together a Rails API back-end and an Ember CLI front-end for one of my awesome clients. With Rails5, Devise, and Ember Simple Auth Devise, it’s a beaut of a project — it's a pleasure to work in as well as an environment that is highly productive. One tiny hill I had to run up while working on this project was using the DeviseInvitable (DV) plugin to create an email invite sign up flow for the app’s users. DV adds some out-of-the-box routes and a mailer to your application so you can simply call User.invite!(email: 'morty@ricknmortyforever1000years.com') to create a user, assign user an invite token, and set it up so the user can sign up via the email that they receive.

In my project, I needed to make this system work with my Ember-only front-end. This was a simple enough process but it requires a bit of overriding the right points to make it work correctly. The reason is because DV (really Devise in general) is built primarily for those who use Rails’ rusty, old views/ folder.

This post is setup as a helper for those who are building their top of the line front-end app on Rails and Devise and are looking to use that :invitable goodness. I'll provide a quick walkthrough on how to override the important parts to make it as painless as possible and you'll be onto your next user story in no time. We're going to focus on using Ember CLI and handlebars, but in case you're using React, Angular, or whatever else you're into, it'd be easy enough to take the back-end code and retrofit for your front-end of choice. Let's get to it!

Setup

This post assumes you’ve got your Rails API, Ember CLI, Devise, and Ember Simple Auth App already setup to the point where you can use rails console to create a new User record and then sign them in from the front-end. If not, then I suggest you follow this Ember + Devise tutorial and check back here once you're able to log in. It'll also assume you've done the scambra/devise_invitable install, so be sure to follow the steps in that README.

Lastly, on the setup front, if you’re not setup to send emails locally from your Rails app then I suggest checking out Letter Opener. A handy little Gem originally created by Ryan Bates: It’ll open any email Rails sends locally in your web browser so you can test things out without any hassle. Very nice for development.

The Back-end

We’ll start off by overriding the DV bits that need to be updated to work in an API-only environment. This main bit of code here is our override of DV’s Devise::InvitationsController (read if you're interested in what is going on under the hood). We're going to put our own UsersInvitationsController in its place, so first let's generate that via rails g controller users_invitations and we'll end up with something like this:

# backend/app/controllers/users_invitations_controller.rb
class UsersInvitationsController < ApplicationController
end

Let’s update that real quick…

# backend/app/controllers/users_invitations_controller.rb
class UsersInvitationsController < Devise::InvitationsController

  def edit
    sign_out send("current_#{resource_name}") if send("#{resource_name}_signed_in?")
    set_minimum_password_length
    resource.invitation_token = params[:invitation_token]
    redirect_to "http://localhost:8080/users/invitation/accept?invitation_token=#{params[:invitation_token]}"
  end

  def update
    super do |resource|
      if resource.errors.empty?
        render json: { status: "Invitation Accepted!" }, status: 200 and return
      else
        render json: resource.errors, status: 401 and return
      end
    end
  end
end

Okay, now let’s break it down:

  1. We extend Devise::InvitationsController so we'd have all of it's actions as our base.
  2. Add some code to override the actions we need for our API-only setup:
  • edit: This is normally the Rails rendered HTML page the user goes to enter their passwords and confirm signup. But we don't want to serve HTML out of Rails, so we grab the invitation_token from the url that came in and then redirect them to our front-end. Here I'm using localhost, but you'll likely redirect to a Rails.config var, which is set per environment.
  • update: This is what the password form submits to and would normally render a new HTML page with a flash message like 'You're signed up!'. But we want a JSON API-only endpoint here, so we let the method do it's own thing (by calling super) and then take over to render a 200 or a 401 JSON blob according to if there was any errors or not.

Now that we understand what we’re doing there, we need to go ahead and tell Devise to use that controller instead of the existing Devise::InvitationsController. This is a simple edit to our routes.rb file:

# backend/config/routes.rb
Rails.application.routes.draw do
  devise_for :users, controllers: { invitations: 'users_invitations' }

  # ...

  root to: 'application#root'
end

So we’re now intercepting the important calls for our User invite process and turning them into JSON API endpoints. Cool!

Now let’s move on to wiring up our front-end to stitch that invite flow together.

The Front-end

Let’s start off with adding a new Ember route to host our “Accept Invitation” experience. Let’s generate that real quick via ember g route accept-invitation and then go ahead and give it a nicer URL (which also matches up to what we redirect to above in UsersInvitationsController#edit):

// frontend/app/router.js
import Ember from 'ember';
import config from './config/environment';

const Router = Ember.Router.extend({
  location: config.locationType,
  rootURL: config.rootURL
});

Router.map(function() {
  // ...
  this.route('accept-invitation', { path: '/users/invitation/accept' });
});

export default Router;

Awesome, we should now be able to invite a user via User.invite!(email: 'rick@ricknmortyforever1000years.com'), get an email via letter_opener, click our sign up link in our email, and then have a blank ember route show up with no errors in the console. Pretty awesome!

Next, we need to finish the invite loop by giving our user’s a password form and then POST some JSON back to our UsersInvitationsController#update endpoint. Here is the code that gets us there:

// frontend/app/routes/accept-invitation.js
import Ember from 'ember';
import config from '../config/environment';

const { inject: { service }, isEmpty } = Ember;

export default Ember.Route.extend({
  session: service('session'),
  beforeModel() {
    if (this.get('session.isAuthenticated')) {
      this.get('session').invalidate();
    }
  },

  model() {
    return Ember.Object.create({ password: null, password_confirmation: null, invitation_token: null });
  },

  afterModel(model, transition) {
    let invitationToken = transition.queryParams.invitation_token;
    if (isEmpty(invitationToken)) {
      this.transitionTo('dashboard');
    } else {
      model.set('invitation_token', invitationToken);
    }
  },

  actions: {
    submit(model) {
      let body = { user: model.getProperties('invitation_token', 'password', 'password_confirmation') };
      Ember.$.ajax({
        url: `${config.APP.API_HOST}/users/invitation`,
        type: 'PUT',
        data: body,
      }).then(() => {
        this.transitionTo('login');
      });
    }
  }
});

{{!-- app/templates/accept-invitation.hbs --}}
{{#bs-form model=model onSubmit=(route-action "submit" model) as |f|}}
  {{f.element controlType="password" label="Password" placeholder="Password" property="password" required=true}}
  {{f.element controlType="password" label="Password Confirmation" placeholder="Password Confirmation" property="password_confirmation" required=true}}
  {{bs-button defaultText="Submit" type="primary" buttonType="submit"}}
{{/bs-form}}

I’ll break down the above to make sure we’re clear on everything that's going on:

  • We’ve got a beforeModel hook to sign the user out if they somehow have an existing session.
  • We’ve got our model hook which returns a new Ember.Object with the properties we care about stubbed.
  • We’ve got an afterModel hook to pull our invitation_token out of the URL and set it on our model object for later. This invitation_token is originally from the email and we included it in the redirect to our Ember App.
  • Finally, our submit action. Here we pull the attributes from our model that we care about, build the specific JSON structure that the underlying Devise::InvitationsController is expecting (all properties under the user key is the important bit here), and then send it off to our #update Rails endpoint, which we overrode earlier. After this has been done, we can transition our user over to login as they've successfully completed the invite flow. Success!

Expanding our invite Form

There's one more thing I’ll add before I wrap things up. If you’re looking to expand your invite form to accept other info from your user, then you’ll need to do a bit more work. Devise has its own way of doing controller param sanitization and you’ll need to override that if you’d like to have your request get through with more than just the token and passwords. Here is the bit of code that you’ll want to put in place:

# backend/app/controllers/users_invitations_controller.rb
class UsersInvitationsController < Devise::InvitationsController
  before_action :configure_permitted_parameters

  # ...

  private

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:accept_invitation, keys: [:first_name, :last_name])
  end
end

Wrapping up

Okay, so we’ve done some overriding at the Rails layer, created a simple route and form to accept our User’s password info, and then we took that info and got it back to our server. We’re using the great, solid functionality of :invitable but without having to serve HTML from our Rails application, which is a good win. If we need to expand our invite form in the future, we've got a way to do that as well.

That about wraps it up. If you’ve got questions or comments, please don’t hesitate to ping me.


This post was originally published by the author here. This version has been edited for clarity and may appear different from the original post.

Discover and read more posts from Matt Gowie
get started