Elixir API and Elm SPA - Part 5

Part 5: Persisting session data to localStorage

We are going to save the session (the token) to the localStorage so on app startup, if it exists, the user can avoid login in again. We're also going to implement the Logout command and add a generic error page.

Series

  1. Part 1 - Elixir App creation
  2. Part 2 - Adds Guardian Authentication
  3. Part 3 - Elm App creation and Routing setup
  4. Part 4 - Adding Login and Register pages
  5. Part 5 - Persisting session data to localStorage

Persisting the session to localStorage

We are going to serialize the Session type we have and store it as JSON in the localStorage. Also, at app boostrap we're going to check if the localStorage has something in it, and if it does, we'll try to get it, to deserialize it using the Session.decoder and finally, put it on our model.

Start by adding some needed dependencies:

elm-app install lukewestby/elm-http-builder

Add this function to the app model:

-- src/Model.elm

decodeSessionFromJson : Value -> Maybe Session
decodeSessionFromJson json =
    json
        |> Decode.decodeValue Decode.string
        |> Result.toMaybe
        |> Maybe.andThen (Decode.decodeString Session.decoder >> Result.toMaybe)

This gets a Value that maybe has a string and then tries to decode it as a session object.

We are going to use this function on Elm app initialization to get a session from localStorage and put it on our model if it exists. Modify the initialModel function in Model.elm to look like this:

-- src/Model.elm

initialModel : Value -> Model
initialModel val =
    { session = decodeSessionFromJson val
    , pageState = Loaded Blank
    }

Adding Ports

As we're going to communicate with the JavaScript world, we need to set up some ports.

Add a Ports.elm file

-- src/Ports.elm

port module Ports exposing (onSessionChange, storeSession)

import Json.Encode exposing (Value)


port storeSession : Maybe String -> Cmd msg


port onSessionChange : (Value -> msg) -> Sub msg

This essentially register two functions with the Elm runtime that will be used to communicate with the JS world. The first one, storeSession takes a string (our encoded Session type) and sends it to JS world. The second one, onSessionChange, it will be called each time the localStorage value changes so that we can get notifications of that change and react appropriately in our Elm app.

These functions need their JS counterpart. Open index.js and replace its contents by this:

-- src/index.js

import './main.css';
import { Main } from './Main.elm';
import registerServiceWorker from './registerServiceWorker';

var app = Main.fullscreen(localStorage.session || null);

app.ports.storeSession.subscribe(function(session) {
    localStorage.session = session;
});

window.addEventListener(
    "storage",
    function(event) {
        if (event.storageArea === localStorage && event.key === "session") {
            app.ports.onSessionChange.send(event.newValue);
        }
    },
    false
);

registerServiceWorker();

We are changing the way we bootstrap the app. Previously, we had this:


Main.embed(document.getElementById('root'));

It looked for the DOM element with id set to root and replace its contents with the Elm app. Now we'll use the fullscreen function to use the whole screen. This returns us an app object that we can use to configure our ports.

The first one registers a callback to be called each time the storeSession is called. It receives a value and stores it on the localSession.session property. The second one is a little bit more complex. It adds an event listener to the storage property of the browser's window object and registers a callback to be called when something happens to the storage property. We only act when the event.storageArea is the localStorage and the event key is 'session'. If those conditions are met, we use the onSessionChange function to send the eventNewValue to the Elm port. In summary, we wait for changes to the session from the Elm app and write them to the localStorage in JS land and we react to events on the localStorage in JS land and we send the value to Elm land.

Given that we are bootstrapping the app in fullscreen mode, we don't need the id="root" element anymore. Let's remove it. Change the body of the index.html to this.

<!-- public/index.html -->

<body class="bg-white">
    <noscript>
        You need to enable JavaScript to run this app.
    </noscript>
</body>

Storing the session

Let's now add a function to save the session to the local storage using these ports we've just created. Add this function to Session/Model.elm and expose it

-- src/Session/Model.elm

module Session.Model exposing (Session, decoder, encode, storeSession)

-- ..

import Ports

-- ...

storeSession : Session -> Cmd msg
storeSession session =
    encode session
        |> Encode.encode 0
        |> Just
        |> Ports.storeSession

This takes a session, encodes it and passes it to the Ports.storeSession to be saved to localStorage.

Now let's use it on the Login and Register pages to store the session after a successful login:

-- src/Session/Login.elm

import Session.Model exposing (Session, storeSession)

-- ..

-- and change the Login (Ok session) branch of the update function to
LoginCompleted (Ok session) ->
    model
        => Cmd.batch [ storeSession session, Route.modifyUrl Route.Home ]
        => SetSession session


-- src/Session/Register.elm

import Session.Model exposing (Session, storeSession)

-- ..

-- and change the Register (Ok session) branch of the update function to
RegisterCompleted (Ok session) ->
    model
        => Cmd.batch [ storeSession session, Route.modifyUrl Route.Home ]
        => SetSession session

One last bit, we need to add a subscription on our Elm app for when a session change is triggered from the JS land. Lets add a new message to handle this:

-- src/Messages.elm

import Session.Model exposing (Session)

-- ..

type Msg
    = SetRoute (Maybe Route)
    | SetSession (Maybe Session)
    | LoginMsg Login.Msg
    | RegisterMsg Register.Msg

We'll send the SetSession message when the session changes. We'll add the subscription that sends this message when this happens:

-- src/Session/Model.elm
module Session.Model exposing (Session, decoder, encode, storeSession, sessionChangeSubscription)

-- ...

sessionChangeSubscription : Sub (Maybe Session)
sessionChangeSubscription =
    Ports.onSessionChange (Decode.decodeValue decoder >> Result.toMaybe)

And then we use that function in the Subscriptions.elm

-- src/Subscriptions.elm
import Session.Model exposing (sessionChangeSubscription)


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ pageSubscriptions (getPage model.pageState)
        , Sub.map SetSession sessionChangeSubscription
        ]

And add a branch to the updatePage function to handle this new message:

-- src/Update.elm

updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    case ( msg, page ) of
        -- ..

        ( SetSession newSession, _ ) ->
            let
                cmd =
                    -- If we just signed out, then redirect to Home.
                    if model.session /= Nothing && newSession == Nothing then
                        Route.modifyUrl Route.Home
                    else
                        Cmd.none
            in
                { model | session = newSession }
                    => cmd

That's it. We are storing our session on localStorage and passing the value in localStorage to the Elm app on bootstrap.

Go to the app, and use your browser's Developer Tools to look at the localStorage before and after logging in.

Before logging in the first time it is empty:

Browser Local Storage empty before logging in

After logging successfully in, it should have the session data in it:

Browser Local Storage after logging in

Adding a Logout action

We are now going to add a Logout action. This action will do several things:

  • make a DELETE request to the /api/sessions
  • set the session to Nothing in the Model
  • store Nothing in the localStorage
  • redirect to the Home route

Let's start by showing the Logout menu on the header if we are logged in and hide it when we're not. Add this function to Page.elm

-- src/Page/Page.elm

import Session.Model exposing (Session)

-- ..

viewNavBar : Maybe Session -> ActivePage -> List (Html msg)
viewNavBar session activePage =
    let
        linkTo =
            navbarLink activePage
    in
        case session of
            Nothing ->
                [ linkTo Route.Login [ text "Login" ]
                , linkTo Route.Register [ text "Register" ]
                ]

            Just session ->
                [ linkTo Route.Logout [ text "Logout" ]
                ]

and use it on the viewHeader function like this:

-- src/Page/Page.elm

viewHeader : Maybe Session -> ActivePage -> Bool -> Html msg
viewHeader session activePage isLoading =
    nav [ class "dt w-100 border-box pa3 ph5-ns" ]
        [ a [ class "dtc v-mid mid-gray link dim w-25", Route.href Route.Home, title "Home" ]
            [ text "Toltec" ]
        , div [ class "dtc v-mid w-75 tr" ] <|
            navbarLink activePage Route.Home [ text "Home" ]
                :: viewNavBar session activePage
        ]

As you can see, now we have a fixed Home link and a variable set of menus depending on whether the user is or is not logged in. As the signature changed, we need to also change the frame function:

-- src/Page/Page.elm

frame : Bool -> Maybe Session -> ActivePage -> Html msg -> Html msg
frame isLoading session activePage content =
    div []
        [ viewHeader session activePage isLoading
        , content
        ]

And this also forces us to change all the usages of frame:

-- src/View.elm

import Session.Model exposing (Session)

-- ..
view : Model -> Html Msg
view model =
    case model.pageState of
        Loaded page ->
            viewPage True model.session page

        TransitioningFrom page ->
            viewPage False model.session page


viewPage : Bool -> Maybe Session -> Page -> Html Msg
viewPage isLoading session page =
    let
        frame =
            Page.frame isLoading session
    in
        case page of
-- ..

Let's now add a new message

-- src/Messages.elm

import Http

-- ..

type Msg
    = SetRoute (Maybe Route)
    -- ..
    | LogoutCompleted (Result Http.Error ())

The logout request to the backend needs to include the auth token in it. Add this new helper function:

-- src/Helpers/Request.elm

module Helpers.Request exposing (apiUrl, withAuthorization)

import HttpBuilder exposing (RequestBuilder, withHeader)
import Session.AuthToken exposing (AuthToken(..))
import Session.Model exposing (Session)

-- ...

withAuthorization : Maybe Session -> RequestBuilder a -> RequestBuilder a
withAuthorization session builder =
    case session of
        Just s ->
            let
                (AuthToken token) =
                    s.token
            in
                builder
                    |> withHeader "authorization" ("Bearer " ++ token)

        Nothing ->
            builder

Change the module signature in AuthToken.elm

-- src/Session/AuthToken.elm

module Session.AuthToken exposing (AuthToken(..), decoder, encode)

And add the logout request function:

-- src/Session/Request.elm

module Session.Request exposing (login, register, logout)

-- ..
import Helpers.Request exposing (apiUrl, withAuthorization)
import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams)

-- ..

logout : Maybe Session -> Http.Request ()
logout session =
    let
        expectNothing =
            Http.expectStringResponse (\_ -> Ok ())
    in
        apiUrl "/sessions"
            |> HttpBuilder.delete
            |> HttpBuilder.withExpect expectNothing
            |> withAuthorization session
            |> HttpBuilder.toRequest

As you can see the logout function receives the session and builds a DELETE request to the "/api/sessions" endpoint, sets no body, expects no response body either and includes the authorization header with the token in it.

Now we need to change the Update.elm file:

-- src/Update.elm

import Http
import Ports
import Session.Request exposing (logout)

-- ..

updateRoute : Maybe Route -> Model -> ( Model, Cmd Msg )
updateRoute maybeRoute model =
    case maybeRoute of

        -- ..

        Just Route.Logout ->
            model => (Http.send LogoutCompleted <| logout model.session)

-- ..

updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    case ( msg, page ) of

        -- ..

        ( LogoutCompleted (Ok ()), _ ) ->
            { model | session = Nothing }
                => Cmd.batch
                    [ Ports.storeSession Nothing
                    , Route.modifyUrl Route.Home
                    ]

Pay attention that the logout action has two phases, the first one is when the route changes to the Route.Logout. This doesn't modify the model, but creates a command to send the logout request to the backend and tag the Result with the LogoutCompleted message. The Elm runtime will to the request and will build a message with the Ok or Err response. This messages will be passed to the update function with the current model. The second phase is when the update function receives the LogoutCompleted message. If the message is LogoutCompleted (Ok ()) we'll batch two commands: one to use the ports to store Nothing in the localStorage, and the other to move to the Home route.

We're done with the logout. Reload the app and verify that after loggin in the localStorage should have the correct serialized Session. When we click on the Logout button the request is made and then some time after that, the LogoutCompleted messages will be sent to continue with the logout. When this happens the localStorage will be set to Nothing and you should see a null as the value of the localStorage.session property in the browser developer tools. Also, you should end in the Home page and logged out.

After clicking on the logout menu, the localStorage is set to null

Adding an error page

As you probably noticed, we are only handling the success logout response. The failure will be ignored completely in the update function. I'll fix that in a moment, because first we are going to add an error page to show in case something goes wrong. Right now it only shows a generic error message.

Add a Error.elm page in src/Page

-- src/Page/Error.elm

module Page.Error exposing (PageError, pageError, view)

import Html exposing (Html, div, h1, main_, p, text)
import Page.Page exposing (ActivePage)


type PageError
    = PageError Model


type alias Model =
    { activePage : ActivePage
    , errorMessage : String
    }


pageError : ActivePage -> String -> PageError
pageError activePage errorMessage =
    PageError { activePage = activePage, errorMessage = errorMessage }


view : PageError -> Html msg
view (PageError model) =
    main_ []
        [ h1 [] [ text "Something wrong happened" ]
        , div []
            [ p [] [ text model.errorMessage ] ]
        ]

And add the new page to the Page type:

-- src/Model.elm

import Page.Error as Error exposing (PageError)

-- ..

type Page
    = Blank
    | NotFound
    | Error PageError
    -- ..

Add a helper function to generate Error pages:

-- src/Update.elm

import Page.Error as Error
import Page.Page as Page exposing (ActivePage)

-- ..

pageError : Model -> ActivePage -> String -> ( Model, Cmd msg )
pageError model activePage errorMessage =
    let
        error =
            Error.pageError activePage errorMessage
    in
        { model | pageState = Loaded (Error error) } => Cmd.none

And now handle the logout error in the updatePage function:

-- src/Update.elm
updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    case ( msg, page ) of

        -- ..

        ( LogoutCompleted (Err error), _ ) ->
            pageError model Page.Other "There was a problem while trying to logout"

We need to handle the Error page on the view file:

-- src/View.elm

import Page.Error as Error

-- ..

viewPage : Bool -> Maybe Session -> Page -> Html Msg
viewPage isLoading session page =

-- ..

            Error subModel ->
                Error.view subModel
                    |> frame Page.Other

Finally, handle the Error page on the Subscriptions.elm

-- src/Subscriptions.elm

pageSubscriptions : Page -> Sub Msg
pageSubscriptions page =

-- ..

        Error _ ->
            Sub.none

That's it. Now login to the app, stop the backend API and try to logout. You should see the Error page

Error page shown when there is an error

You can find the source code here, on the part-05 branch.

Ok, let's leave it there as this post is already too long. Next post we'll start with the CRUD part of the app.