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:

  1. Updating Shared.elm
  2. 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 an email, profilePictureUrl, or token 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 just Msg). This allows SignIn and SignOut 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