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:
- We extend
Devise::InvitationsController
so we'd have all of it's actions as our base. - 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 theinvitation_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 aRails.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 callingsuper
) 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 newEmber.Object
with the properties we care about stubbed. - We’ve got an
afterModel
hook to pull ourinvitation_token
out of the URL and set it on our model object for later. Thisinvitation_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 underlyingDevise::InvitationsController
is expecting (all properties under theuser
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.