User authentication
Source code: GitHub
In a real world application, it's common to have the notion of a signed-in user. When it comes to routing, it's often useful to only allow signed-in users to visit specific pages.
It would be wonderful if we could define logic in one place that guarantees only signed-in users could view those pages:
case currentVisitor of SignedIn user -> ProvidePageWith user NotSignedIn -> RedirectTo Route.SignIn
Great news: This is exactly what we can do in elm-spa!
Protected pages
At the end of the pages docs, we learned that there are also protected
versions of every page type.
These protected pages have slightly different signatures:
Page.sandbox : { init : Model , update : Msg -> Model -> Model , view : Model -> View Msg } Page.protected.sandbox : User -> { init : Model , update : Msg -> Model -> Model , view : Model -> View Msg }
Protected pages are guaranteed to have access to a User
, so you don't need to handle the impossible case where you are viewing a page without one.
Following along
Feel free to follow along by creating a new elm-spa project:
npm install -g elm-spa@latest
elm-spa new
This will create a new project that you can run with the elm-spa server
command!
Ejecting Auth.elm
There's a default file that has this code stubbed out for you in the .elm-spa/defaults
folder. Let's eject that file into our src
folder so we can edit it:
.elm-spa/ |- defaults/ |- Auth.elm -- move into src/ |- Auth.elm
Now that we have Auth.elm
in our src
folder, we can start adding the code that makes elm-spa protect certain pages.
The Auth.elm
file only needs to expose two things:
- User - The type that we want to provide all protected pages.
- beforeProtectedInit - The logic that runs before any
Page.protected.*
page loads
module Auth exposing (User, beforeProtectedInit) import Gen.Route import ElmSpa.Internals as ElmSpa import Request exposing (Request) import Shared type alias User = () beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route beforeProtectedInit shared req = ElmSpa.RedirectTo Gen.Route.NotFound
By default, this code redirects all protected pages to the NotFound
page. Instead we want something like this:
beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route beforeProtectedInit shared req = case shared.user of Just user -> ElmSpa.Provide user Nothing -> ElmSpa.RedirectTo Gen.Route.SignIn
But before that code will work we need to take care of two things:
- Updating Shared.elm
- Adding a sign in page
Updating Shared.elm
If you haven't already ejected Shared.elm
, you should move it from .elm-spa/defaults
into your src
folder. The shared state docs cover this file in depth, but we'll provide all the code you'll need to change here.
Let's change Shared.Model
to keep track of a Maybe User
, the value that can either be a user or nothing:
-- src/Shared.elm type alias Model = { user : Maybe User } type alias User = { name : String }
For now, a user is just going to have a
name
field. This might also store anprofilePictureUrl
, ortoken
too.
Next, we should initially set our user to Nothing
when our Elm application starts up:
-- src/Shared.elm init : Request -> Flags -> ( Model, Cmd Msg ) init _ _ = ( { user = Nothing } , Cmd.none )
To make sure that Auth.elm
is using the same type, let's expose the User
type from our Shared
module and reuse it:
-- src/Shared.elm module Shared exposing ( ..., User )
-- src/Auth.elm type alias User = Shared.User
As the final update to Shared
, lets add some sign in/sign out logic
module Shared exposing ( ..., Msg(..)) import Gen.Route -- ... type Msg = SignIn User | SignOut update : Request -> Msg -> Model -> ( Model, Cmd Msg ) update req msg model = case msg of SignIn user -> ( { model | user = Just user } , Request.pushRoute Gen.Route.Home_ req ) SignOut -> ( { model | user = Nothing } , Cmd.none )
Make sure that you expose
Msg(..)
as shown above (instead of justMsg
). This allowsSignIn
andSignOut
to be available to pages that send shared updates.
Great work! Let's use that SignIn
message on a new sign in page.
Adding a sign in page
With elm-spa, adding a new page from the terminal is easy:
elm-spa add /sign-in advanced
Here we'll start with an "advanced" page, because we'll need to send Shared.Msg
to sign in and sign out users.
Let's add a few lines of code to src/Pages/SignIn.elm
:
-- Import some HTML import Html exposing (..) import Html.Events as Events
-- Replace Msg with this type Msg = ClickedSignIn
-- Replace update with this update : Msg -> Model -> ( Model, Effect Msg ) update msg model = case msg of ClickedSignIn -> ( model , Effect.fromShared (Shared.SignIn { name = "Ryan"} ) )
-- Make view show a sign out button -- We must return the concrete type `Msg` because we use ClickedSignIn view : Model -> View Msg view model = { title = "Sign In" , body = [ button [ Events.onClick ClickedSignIn ] [ text "Sign in" ] ]
Nice work- we're only a step away from getting auth set up!
Final touches to Auth.elm
Now that we have a shared.user
and a SignIn
route, let's bring it all together in the Auth.elm
file
-- src/Auth.elm beforeProtectedInit : Shared.Model -> Request -> ElmSpa.Protected User Route beforeProtectedInit shared req = case shared.user of Just user -> ElmSpa.Provide user Nothing -> ElmSpa.RedirectTo Gen.Route.SignIn
Now visiting http://localhost:1234/sign-in will show us our sign in page, complete with a sign in button!
Clicking the "Sign in" button signs in the user when clicked. Because of the logic we added in Shared.elm
, this also redirects the user to the homepage after sign in!
Protecting our homepage
Let's make it so the homepage is only available to signed in users.
Let's create a fresh homepage with the elm-spa add:
elm-spa add / advanced
Now that Auth.elm
is set up, we only need to change the page
function to guarantee signed-in users are viewing the homepage:
-- src/Pages/Home_.elm Page.advanced { init = init , update = update , view = view , subscriptions = subscriptions } -- this becomes Page.protected.advanced (\user -> { init = init , update = update , view = view , subscriptions = subscriptions } )
If you want to pass a User
into any of these functions, you can do it like this:
-- Only the view is passed a user Page.protected.advanced (\user -> { init = init , update = update , view = view user , subscriptions = subscriptions } ) view : User -> Model -> View Msg view user model = ...
Let's use that user
so the homepage greets them by name:
-- src/Pages/Home_.elm import Html exposing (..) import Html.Events as Events -- ... view : User -> Model -> View Msg view user model = { title = "Homepage" , body = [ h1 [] [ text ("Hello, " ++ user.name ++ "!") ] ] }
Try it out!
Now if we visit http://localhost:1234, we will immediately be redirected to /sign-in
, because we haven't signed in yet!
Clicking the "Sign in" button takes us back to the homepage, and we should see "Hello, Ryan!" printed on the screen.
The cherry on top
Let's wrap things up by wiring up a "Sign out" button to the homepage:
-- src/Pages/Home_.elm type Msg = ClickedSignOut update : Msg -> Model -> ( Model, Effect Msg ) update msg model = case msg of ClickedSignOut -> ( model , Effect.fromShared Shared.SignOut ) -- ... view : User -> Model -> View Msg view user model = { title = "Homepage" , body = [ h1 [] [ text ("Hello, " ++ user.name ++ "!") ] , button [ Events.onClick ClickedSignOut ] [ text "Sign out" ] ] }
Now everything is working! Visiting the /sign-in
page and clicking "Sign In" signs in the user and redirects to the homepage. Clicking "Sign out" on the homepage signs out the user, and our Auth.elm
logic automatically redirects to the SignIn
page.
Persisting the user
When we refresh the page, the user is signed out... how can we keep them signed in after refresh? Let's tweak the Storage.elm
file we made in the last example:
-- src/Storage.elm type alias Storage = { user : Maybe User }
If we store this on the Shared.Model
we can ensure the user is still signed in after they refresh their browser, or visit the app later.
-- src/Shared.elm type alias Model = { storage : Storage }
For more explanation of how this works, check out the Storage example in the last section, it will give a better basic understanding of how this mechanism works!
Next up: More examples