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
- Part 1 - Elixir App creation
- Part 2 - Adds Guardian Authentication
- Part 3 - Elm App creation and Routing setup
- Part 4 - Adding Login and Register pages
- 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:
After logging successfully in, it should have the session data in it:
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
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.