Updated to reflect changes in InertiaJS 1.0
In our previous tutorial, we learned how to create CRUD pages for an Inertia Rails app.
In today's tutorial will be adding authentication (i.e user login) to our new app.
If you have ever built a SPA app, then you will understand how easy it is to get tripped by auth. Luckily with Inertia, we don't have to worry about such thing as client side authentication - everything is taken care of on the server side.
Add Devise gem to the Gemfile
gem "devise"
Install the gem
bundle install
Run the Devise install script
rails generate devise:install
Follow the message to complete the setup:
In config/environments/development.rb
, add
# for devise
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
The Devise gem is now ready to use. Let's create our user model.
rails g devise User
We will add a few fields to the user table, so we can also save user names and admin status.
/db/migrate/..._devise_create_users.rb
# custom fields
t.string :first_name
t.string :last_name
t.boolean :admin
Run the migration. Now we should have our user model ready.
rails db:migrate
We will create a Rails concern to handle our auth logic.
app/controller/concerns/auth.rb
require 'active_support/concern'
module Auth
extend ActiveSupport::Concern
included do
before_action :authenticate_user!
end
private
def after_sign_in_path_for(resource)
stored_location_for(resource) || root_path
end
def after_sign_out_path_for(_resource_or_scope)
new_user_session_path
end
end
Then attach the concern to where we want to implement auth, which is articles controller.
app/controller/articles_controller.rb
class ArticlesController < ApplicationController
include Auth
...
end
You may have noticed: the Auth module should be included in the application controller. But since we are not adding authorization yet, adding check on the one controller that needs auth for now is good enough.
We will add full authorization using Pundit in our next tutorial.
And... Done!
Go to the dev app and click on the Articles link, we should see a message appear asking us to login.
Now is time to add the login page.
But before we can add the login page, we must tell Devise we are using components, otherwise Devise will redirect to its HTML templates.
In Devise, a login page is a new_user_session page. So we will first customize the sessions controller.
Create Devise sessions controller with this command:
rails g devise:controllers users -c=sessions
Notice the -c=sessions
option, this tells the generator to only create sessions controller.
We will then update the sessions controller with our own routes:
app/controller/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
# GET /login
def new
render inertia: 'Auth/Login', props: {}
end
end
Notice we tell the controller our login page is an Inertia component located at Auth/Login
. We will create this component later.
We will also update our route to reflect the paths we have chosen.
config/routes.rb
# replace this generated route:
# devise_for :users
# with the new route below:
# devise routes
devise_for :users, skip: [ :sessions, :passwords, :registrations ]
as :user do
get 'login', to: 'users/sessions#new', as: :new_user_session
post 'login', to: 'users/sessions#create', as: :user_session
match 'logout', to: 'users/sessions#destroy', as: :destroy_user_session, via: Devise.mappings[:user].sign_out_via
end
And that's it for the Rails side.
If you have ever setup Devise for Rails before, you will notice there aren't many differences.
Onward to the frontend side!
As promised before in the sessions controller, we will create our login page as Auth/Login
.
frontend/pages/Auth/Login.svelte
<svelte:head>
<title>Login</title>
</svelte:head>
<script>
import { useForm } from '@inertiajs/svelte'
let form = useForm({
user: {
email: null,
password: null,
remember: false,
}
})
function submit() {
$form.post('/login')
}
</script>
<form on:submit|preventDefault={submit}>
<input type="text" bind:value={$form.user.email} />
{#if $form.errors.email}
<div class="form-error">{$form.errors.email}</div>
{/if}
<input type="text" bind:value={$form.user.password} />
{#if $form.errors.password}
<div class="form-error">{$form.errors.password}</div>
{/if}
<input type="checkbox" bind:checked={$form.user.remember} /> Remember Me
<button type="submit" disabled={$form.processing}>Login</button>
</form>
Notice the <svelte:head>
tag is just to tell Svelte to update the page title when this component is mounted.
Remember we attached CSRF token to our axios requests? That was a quick way to provide CSRF token, but with our app growing, we will need a better way to handle it.
We can use a Rails concern to address this.
app/controller/concerns/inertia_csrf.rb
require 'active_support/concern'
# Store the CSRF token in a non-session cookie so Axios can access it
# Name it as XSRF-TOKEN, because this is the Axios default
#
# More info: https://pragmaticstudio.com/tutorials/rails-session-cookies-for-api-authentication
#
module InertiaCsrf
extend ActiveSupport::Concern
included do
before_action :set_csrf_cookie
rescue_from ActionController::InvalidAuthenticityToken do
redirect_back fallback_location: '/', notice: 'The page expired, please try again.'
end
end
# Rails uses HTTP_X_CSRF_TOKEN, but axios sends HTTP_X_XSRF_TOKEN (different name, X instead of C)
# By overriding `request_authenticity_tokens` we can tell Rails to check HTTP_X_XSRF_TOKEN, too
# Source: https://github.com/rails/rails/blob/v6.0.3.2/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L305-L308
def request_authenticity_tokens
super << request.headers['HTTP_X_XSRF_TOKEN']
end
private
def set_csrf_cookie
cookies['XSRF-TOKEN'] = {
value: form_authenticity_token,
same_site: 'Strict'
}
end
end
Include this concern in application controller.
app/controller/application_controller.rb
class ApplicationController < ActionController::Base
include InertiaCsrf
end
Then remove the previous code in our entrypoint file.
frontend/entrypoints/application.js
// replaced by InertiaCsrf.rb
// import axios from 'axios'
// const csrfToken = document.querySelector('meta[name=csrf-token]').content
// axios.defaults.headers.common['X-CSRF-Token'] = csrfToken
Now we will never have to worry about CSRF token again!
Create a test user
rails c
User.create! email: 'test@users.com', password: 'foobar', password_confirmation: 'foobar'
We will first pass our login status using Inertia's shared object.
app/controller/application_controller.rb
class ApplicationController < ActionController::Base
include InertiaCsrf
inertia_share auth: -> {
if user_signed_in?
{
user: current_user.email
}
end
}
end
What we did above is to check the user_signed_in?
status from Devise, then if the user is signed in, pass the current_user
email to the client.
We can then access these objects on the client side by calling $page.props
, which is what we are going to do next.
Add login page link to our main navigation:
frontend/components/Layouts/MainNav.svelte
<script>
import { Link, page, inertia } from '@inertiajs/svelte'
</script>
<nav class="max-w-7xl mx-auto p-6 flex justify-between">
<ul class="flex items-center space-x-8">
<li>
<Link href="/">Home</Link>
</li>
<li>
<Link href="/articles">Articles</Link>
</li>
<li>
<Link href="/about">About</Link>
</li>
</ul>
<div>
{#if $page.props.auth}
<button use:inertia="{{ href: '/logout', method: 'delete' }}"
type="button">
Sign out
</button>
{:else}
<Link href="/login">Sign in</Link>
{/if}
</div>
</nav>
Notice how we can access the login status by checking $page.props.auth
.
And that's all! You should have a working sign in / sign out now!
A quick note: if you found when you submitted wrong login information, and a popup appears - don't worry about it for now. We will cover that issue in the following authorization tutorial by adding flash messages.
Usually, we will want to allow all users to be able to view our articles, even when not logged in. We can achieve this very easily with just a few changes to our code.
First, we want to lift the login check on articles controller for only index and show actions.
app/controller/articles_controller.rb
class ArticlesController < ApplicationController
include Auth
before_action :set_article, only: %i[ show edit update destroy ]
skip_before_action :authenticate_user!, only: %i[ index show ]
If you check the dev app, you should be able to see the articles index and show page can be accessed without login. Now we just need to hide the "admin only" parts of the page, which is very easy to do with Inertia and Svelte.
Can you figure out the missing part in the page below?
frontend/pages/Articles/Index.svelte
<script>
import New from './New.svelte'
import { inertia, page, Link } from '@inertiajs/inertia-svelte'
let admin = $page.props.auth
let show = false
function toggleShow() {
show = !show
}
export let articles
</script>
<h1 class="font-bold text-2xl mb-6">All Articles</h1>
{#if FILL_IN}
<button on:click={toggleShow} >New Article</button>
{#if show}
<New/>
{/if}
{/if}
<div class="mt-6">
{#each articles as article}
<div>
<Link href="/articles/{article.id}">{article.title}</Link>
{#if FILL_IN}
<Link href="/articles/{article.id}/edit">Edit</Link>
<button use:inertia="{{ href: '/articles/'+article.id, method: 'delete' }}" type="button" class="text-indigo-600 hover:text-indigo-900">Delete</button>
{/if}
</div>
{/each}
</div>
In case you need it, you can find the full code in this repository: https://github.com/planetaska/irvst-app2/tree/authentication
And that wraps up the tutorial for today.
I hope this helps someone. Thank you for reading!