DEV Community

Miguel Cobá
Miguel Cobá

Posted on • Edited on • Originally published at blog.miguelcoba.com

Elixir API and Elm SPA - Part 4

Part 4: Adding Login and Register pages

We are going to add a Login and Register pages to the app.

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

Add CORS to the backend api

Before we can start sending requests to our backend api, we need to enable CORS in order to our backend to allow the request going through. So go to the toltec-api web and add a new dependency to the mix.exs file:

# mix.exs

 defp deps do
    [
      {:phoenix, "~> 1.3.0"},
      {:phoenix_pubsub, "~> 1.0"},
      {:phoenix_ecto, "~> 3.2"},
      {:postgrex, ">= 0.0.0"},
      {:gettext, "~> 0.11"},
      {:cowboy, "~> 1.0"},
      {:comeonin, "~> 4.0"},
      {:argon2_elixir, "~> 1.2"},
      {:guardian, "~> 1.0"},
      {:cors_plug, "~> 1.4"}
    ]
  end
Enter fullscreen mode Exit fullscreen mode

Then add this to the endpoint.ex, just before the router plug

# lib/toltec_web/endpoint.ex

  plug(CORSPlug)

  plug(ToltecWeb.Router)
Enter fullscreen mode Exit fullscreen mode

Get the dependencies with mix deps.get and restart the toltec-api app. We should be ready to consume the API.

Add Elm dependencies

We are going to need some external packages to build our app. Add them:

elm-app install NoRedInk/elm-decode-pipeline
elm-app install elm-community/json-extra
elm-app install elm-lang/http
elm-app install rtfeldman/elm-validate
Enter fullscreen mode Exit fullscreen mode

Create the User model

We also need a User model. Add a new User/ directory and create a Model.elm

-- src/User/Model.elm

module User.Model exposing (User, decoder, encode)

import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (decode, required, optional)
import Json.Encode as Encode exposing (Value)
import Json.Encode.Extra as Extra exposing (maybe)
import Util exposing ((=>))


type alias User =
    { email : String
    , name : Maybe String
    }


decoder : Decoder User
decoder =
    decode User
        |> required "email" Decode.string
        |> required "name" (Decode.nullable Decode.string)


encode : User -> Value
encode user =
    Encode.object
        [ "email" => Encode.string user.email
        , "name" => (Extra.maybe Encode.string) user.name
        ]
Enter fullscreen mode Exit fullscreen mode

Nothing very strange here. Just a model a pair encode/decoder functions to serialize/deserialize this model.

Create the Session model

Create a Model.elm file inside the Session/ directory

-- src/Session/Model.elm

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

import Session.AuthToken as AuthToken exposing (AuthToken, decoder)
import User.Model as User exposing (User, decoder)
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (decode, required, optional)
import Json.Encode as Encode exposing (Value)
import Util exposing ((=>))


type alias Session =
    { user : User
    , token : AuthToken
    }


decoder : Decoder Session
decoder =
    decode Session
        |> required "user" User.decoder
        |> required "token" AuthToken.decoder


encode : Session -> Value
encode session =
    Encode.object
        [ "user" => User.encode session.user
        , "token" => AuthToken.encode session.token
        ]
Enter fullscreen mode Exit fullscreen mode

The session is a type with a user and a token, the token we get from our backend API and that we need to send each time we do a request to the REST API.
The AuthToken.elm is this:

-- src/Session/AuthToken.elm

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

import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode exposing (Value)


type AuthToken
    = AuthToken String


encode : AuthToken -> Value
encode (AuthToken token) =
    Encode.string token


decoder : Decoder AuthToken
decoder =
    Decode.string
        |> Decode.map AuthToken

Enter fullscreen mode Exit fullscreen mode

The AuthToken is very simple and it is a tagged type to hold the token we get on successful login.

Create the Login page

The Login is a direct copy of Richard's Login page. The idea is this: you have a self-contained module that does it own internal model-update-view cycle. This will be driven from the outer update function we already have. This internal cycle will communicate with the outer one by returning a tuple with a message indicating what happened. This message is ExternalMsg. Other than this, the inner cycle knows nothing about how is used.

Let's start by adding the model to the Login.elm file:

-- src/Session/Login.elm

-- MODEL --


type alias Model =
    { errors : List Error
    , email : String
    , password : String
    }


initialModel : Model
initialModel =
    { errors = []
    , email = ""
    , password = ""
    }
Enter fullscreen mode Exit fullscreen mode

It is very simple, we have an email and a password and a list of validation errors to show to the user.

Now replace our current view in the same file with this:

-- src/Session/Login.elm

-- VIEW --


view : Model -> Html Msg
view model =
    div [ class "mt4 mt6-l pa4" ]
        [ h1 [] [ text "Sign in" ]
        , div [ class "measure center" ]
            [ Form.viewErrors model.errors
            , viewForm
            ]
        ]


viewForm : Html Msg
viewForm =
    Html.form [ onSubmit SubmitForm ]
        [ Form.input "Email" [ onInput SetEmail ] []
        , Form.password "Password" [ onInput SetPassword ] []
        , button [ class "b ph3 pv2 input-reset ba b--black bg-transparent grow pointer f6" ] [ text "Sign in" ]
        ]
Enter fullscreen mode Exit fullscreen mode

Add the messages:

-- src/Session/Login.elm

-- MESSAGES --


type Msg
    = SubmitForm
    | SetEmail String
    | SetPassword String
    | LoginCompleted (Result Http.Error Session)


type ExternalMsg
    = NoOp
    | SetSession Session
Enter fullscreen mode Exit fullscreen mode

The main thing here is the ExternalMsg that will be used to communicate with the outer update function.

Add the Login's internal update function. Pay attention to the returning type that includes the ExternalMsg to signalling important things to the outer world.

-- src/Session/Login.elm

-- UPDATE --


update : Msg -> Model -> ( ( Model, Cmd Msg ), ExternalMsg )
update msg model =
    case msg of
        SubmitForm ->
            case validate modelValidator model of
                [] ->
                    { model | errors = [] }
                        => Http.send LoginCompleted (login model)
                        => NoOp

                errors ->
                    { model | errors = errors }
                        => Cmd.none
                        => NoOp

        SetEmail email ->
            { model | email = email }
                => Cmd.none
                => NoOp

        SetPassword password ->
            { model | password = password }
                => Cmd.none
                => NoOp

        LoginCompleted (Err error) ->
            let
                errorMessages =
                    case error of
                        Http.BadStatus response ->
                            response.body
                                |> decodeString (field "errors" errorsDecoder)
                                |> Result.withDefault []

                        _ ->
                            [ "unable to perform login" ]
            in
                { model | errors = List.map (\errorMessage -> Form => errorMessage) errorMessages }
                    => Cmd.none
                    => NoOp

        LoginCompleted (Ok session) ->
            model
                => Route.modifyUrl Route.Home
                => SetSession session
Enter fullscreen mode Exit fullscreen mode

We need to validate that the form is correct. Add it:

-- src/Session/Login.elm

-- VALIDATION --


type Field
    = Form
    | Email
    | Password


type alias Error =
    ( Field, String )


modelValidator : Validator Error Model
modelValidator =
    Validate.all
        [ ifBlank .email (Email => "email can't be blank.")
        , ifBlank .password (Password => "password can't be blank.")
        ]
Enter fullscreen mode Exit fullscreen mode

We are using a decoder to extract the errors from the JSON response that the backend sends to us.

-- src/Session/Login.elm

-- DECODERS --


errorsDecoder : Decoder (List String)
errorsDecoder =
    decode (\email password error -> error :: List.concat [ email, password ])
        |> optionalFieldError "email"
        |> optionalFieldError "password"
        |> optionalError "error"
Enter fullscreen mode Exit fullscreen mode

As you can see, it looks for errors under the email, password or a generic error attributes.

Your imports should be like this:

-- src/Session/Login.elm

module Session.Login exposing (ExternalMsg(..), Model, Msg, initialModel, update, view)

import Helpers.Decode exposing (optionalError, optionalFieldError)
import Helpers.Form as Form
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Http
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
import Json.Decode.Pipeline exposing (decode)
import Route exposing (Route)
import Session.Model exposing (Session)
import Session.Request exposing (login)
import Util exposing ((=>))
import Validate exposing (Validator, ifBlank, validate)
Enter fullscreen mode Exit fullscreen mode

We are using some helper functions here. Create a new Helpers/ directory and add these files:

-- src/Helpers/Decode.elm

module Helpers.Decode exposing (optionalError, optionalFieldError)

import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
import Json.Decode.Pipeline exposing (decode, optional)


optionalError : String -> Decoder (String -> a) -> Decoder a
optionalError fieldName =
    optional fieldName string ""


optionalFieldError : String -> Decoder (List String -> a) -> Decoder a
optionalFieldError fieldName =
    let
        errorToString errorMessage =
            String.join " " [ fieldName, errorMessage ]
    in
        optional fieldName (Decode.list (Decode.map errorToString string)) []
Enter fullscreen mode Exit fullscreen mode

These couple of functions simplifies the handling of missing or optional attributes in a JSON response.

-- src/Helpers/Form.elm

module Helpers.Form exposing (input, password, textarea, viewErrors)

import Html exposing (Attribute, Html, fieldset, li, text, ul, label)
import Html.Attributes as Attr exposing (class, type_, name)


password : String -> List (Attribute msg) -> List (Html msg) -> Html msg
password name attrs =
    control Html.input name ([ type_ "password" ] ++ attrs)


input : String -> List (Attribute msg) -> List (Html msg) -> Html msg
input name attrs =
    control Html.input name ([ type_ "text" ] ++ attrs)


textarea : String -> List (Attribute msg) -> List (Html msg) -> Html msg
textarea name =
    control Html.textarea name


viewErrors : List ( a, String ) -> Html msg
viewErrors errors =
    errors
        |> List.map (\( _, error ) -> li [ class "dib" ] [ text error ])
        |> ul [ class "ph2 tl f6 red measure" ]


control :
    (List (Attribute msg) -> List (Html msg) -> Html msg)
    -> String
    -> List (Attribute msg)
    -> List (Html msg)
    -> Html msg
control element name attributes children =
    fieldset [ class "ba b--transparent ph0 mh0 f6" ]
        [ label [ class "db fw6 lh-copy tl" ] [ text name ]
        , element
            ([ Attr.name name, class "pa2 input-reset ba bg-transparent w-100" ] ++ attributes)
            children
        ]
Enter fullscreen mode Exit fullscreen mode

This has some handy functions to create input fields for forms in our app.

-- src/Helpers/Request.elm

module Helpers.Request exposing (apiUrl)


apiUrl : String -> String
apiUrl str =
    "http://localhost:4000/api" ++ str
Enter fullscreen mode Exit fullscreen mode

Let's continue. There is something else missing here: the part where the request is done. On the SubmitForm branch inside the update function we're using Http.send to send a request to the backend and instruct it to tag the response with the LoginCompleted message. This is the definition of the request:

-- src/Session/Request.elm

module Session.Request exposing (login)

import Session.AuthToken as AuthToken exposing (AuthToken)
import User.Model as User exposing (User)
import Session.Model as Session exposing (Session)
import Http
import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode
import Helpers.Request exposing (apiUrl)
import Util exposing ((=>))


login : { r | email : String, password : String } -> Http.Request Session
login { email, password } =
    let
        user =
            Encode.object
                [ "email" => Encode.string email
                , "password" => Encode.string password
                ]

        body =
            user
                |> Http.jsonBody
    in
        decodeSessionResponse
            |> Http.post (apiUrl "/sessions") body


decodeSessionResponse : Decoder Session
decodeSessionResponse =
    Decode.map2 Session
        (Decode.field "data" User.decoder)
        (Decode.at [ "meta", "token" ] AuthToken.decoder)
Enter fullscreen mode Exit fullscreen mode

The login message receives an extensible record with email and password and builds a JSON body with them. This body is then POSTed to the "/sessions" path on the apiUrl.

We are also instructing the Http.post function to use the decodeSessionResponse to decode the response we get from the API and map it to a Session type, using the appropriate decoders for each subpart of the response. As we saw in the previous parts, the user data comes in the "data" property and the JWT in the "token" attribute below the "meta" attribute.

Summarizing, the Http.post will give us a Session type if the response of the API is successful. The Http.send will tag with LoginCompleted the result of the request. If it is ok, it will be the Session type, if it is not, it will be a error string.
We match for those two cases in the update function as you can see.

Modify the Model.elm to use the session we just created:

-- src/Model.elm

import Session.Login as Login
import Session.Model as Session exposing (Session)

-- ...

type Page
    = Blank
    | NotFound
    | Home
    | Login Login.Model
    | Register

-- ...

type alias Model =
    { session : Maybe Session
    , pageState : PageState
    }


initialModel : Value -> Model
initialModel val =
    { session = Nothing
    , pageState = Loaded Blank
    }
Enter fullscreen mode Exit fullscreen mode

Add the LoginMsg message to Messages.elm

-- src/Messages.elm

import Session.Login as Login

-- ...

type Msg
    = SetRoute (Maybe Route)
    | LoginMsg Login.Msg
Enter fullscreen mode Exit fullscreen mode

Change the updateRoute function in Update.elm:

-- src/Update.elm

-- ...

import Session.Login as Login

-- ...

-- From this
Just Route.Login ->
    { model | pageState = Loaded Login } => Cmd.none

-- To this
Just Route.Login ->
    { model | pageState = Loaded (Login Login.initialModel) } => Cmd.none
Enter fullscreen mode Exit fullscreen mode

Add the new branches to the updatePage function:

-- src/Update.elm

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

        ( LoginMsg subMsg, Login subModel ) ->
            let
                ( ( pageModel, cmd ), msgFromPage ) =
                    Login.update subMsg subModel

                newModel =
                    case msgFromPage of
                        Login.NoOp ->
                            model

                        Login.SetSession session ->
                            { model | session = Just session }
            in
                { newModel | pageState = Loaded (Login pageModel) }
                    => Cmd.map LoginMsg cmd

        ( _, NotFound ) ->
            -- Disregard incoming messages when we're on the
            -- NotFound page.
            model => Cmd.none

        ( _, _ ) ->
            -- Disregard incoming messages that arrived for the wrong page
            model => Cmd.none
Enter fullscreen mode Exit fullscreen mode

Now change the viewPage function in View.elm

-- src/View.elm
-- ...

import Session.Login as Login

-- ...

-- From this
Login ->
    Login.view
        |> frame Page.Login

-- To this
Login subModel ->
    Login.view subModel
        |> frame Page.Login
        |> Html.map LoginMsg
Enter fullscreen mode Exit fullscreen mode

And the pageSubscriptions function in Subscriptions.elm

-- src/Subscriptions.elm

-- From this
Login ->
    Sub.none

-- To this
Login _ ->
    Sub.none
Enter fullscreen mode Exit fullscreen mode

That's it, the Login page should work now. elm-app start your app and you should see no errors. If you go to the browser you should see something like this:

Toltec login page showing the email and password form

And after logging in with our seed user (email: user@toltec, password: user@toltec) you should see the home page:

Toltec home page after successful login

Create the Register page

Let's create the register page. This is almost identical to the login page so we're not going to enter into a lot of detail here.

Start by modifying the model

-- src/Model.elm

import Session.Register as Register

-- ...

type Page
    = Blank
    | NotFound
    | Home
    | Login Login.Model
    | Register Register.Model
Enter fullscreen mode Exit fullscreen mode

The messages

-- src/Messages.elm

-- ...
import Session.Register as Register


type Msg
    = SetRoute (Maybe Route)
    | LoginMsg Login.Msg
    | RegisterMsg Register.Msg
Enter fullscreen mode Exit fullscreen mode

And subscriptions

-- src/Subscriptions.elm

-- From this
Register ->
    Sub.none

-- To this
Register _ ->
    Sub.none
Enter fullscreen mode Exit fullscreen mode

The updateRoute function

-- src/Update.elm

-- ...

import Session.Register as Register

-- ...

-- From this
Just Route.Register ->
    { model | pageState = Loaded Register } => Cmd.none

-- To this
Just Route.Register ->
    { model | pageState = Loaded (Register Register.initialModel) } => Cmd.none
Enter fullscreen mode Exit fullscreen mode

And the updatePage function

-- src/Update.elm

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

        ( RegisterMsg subMsg, Register subModel ) ->
            let
                ( ( pageModel, cmd ), msgFromPage ) =
                    Register.update subMsg subModel

                newModel =
                    case msgFromPage of
                        Register.NoOp ->
                            model

                        Register.SetSession session ->
                            { model | session = Just session }
            in
                { newModel | pageState = Loaded (Register pageModel) }
                    => Cmd.map RegisterMsg cmd

        -- ..
Enter fullscreen mode Exit fullscreen mode

The view

-- src/View.elm

-- From this
Register ->
    Register.view
        |> frame Page.Register

-- To this
Register subModel ->
    Register.view subModel
        |> frame Page.Register
        |> Html.map RegisterMsg
Enter fullscreen mode Exit fullscreen mode

And we need a new request to point to the create user API endpoint

-- src/Session/Request.elm
module Session.Request exposing (login, register)

-- ..

register : { r | name : String, email : String, password : String } -> Http.Request Session
register { name, email, password } =
    let
        user =
            Encode.object
                [ "name" => Encode.string name
                , "email" => Encode.string email
                , "password" => Encode.string password
                ]

        body =
            user
                |> Http.jsonBody
    in
        decodeSessionResponse
            |> Http.post (apiUrl "/users") body
Enter fullscreen mode Exit fullscreen mode

Finally the whole Register.elm is this

-- src/Session/Register.elm
module Session.Register exposing (ExternalMsg(..), Model, Msg, initialModel, update, view)

import Helpers.Decode exposing (optionalError, optionalFieldError)
import Helpers.Form as Form
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Http
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
import Json.Decode.Pipeline exposing (decode)
import Route exposing (Route)
import Session.Model exposing (Session, storeSession)
import Session.Request exposing (register)
import Util exposing ((=>))
import Validate exposing (Validator, ifBlank, validate)


-- MESSAGES --


type Msg
    = SubmitForm
    | SetName String
    | SetEmail String
    | SetPassword String
    | RegisterCompleted (Result Http.Error Session)


type ExternalMsg
    = NoOp
    | SetSession Session



-- MODEL --


type alias Model =
    { errors : List Error
    , name : String
    , email : String
    , password : String
    }


initialModel : Model
initialModel =
    { errors = []
    , name = ""
    , email = ""
    , password = ""
    }



-- VIEW --


view : Model -> Html Msg
view model =
    div [ class "mt4 mt6-l pa4" ]
        [ h1 [] [ text "Sign up" ]
        , p [ class "f7" ]
            [ a [ Route.href Route.Login ]
                [ text "Have an account?" ]
            ]
        , div [ class "measure center" ]
            [ Form.viewErrors model.errors
            , viewForm
            ]
        ]


viewForm : Html Msg
viewForm =
    Html.form [ onSubmit SubmitForm ]
        [ Form.input "Name" [ onInput SetName ] []
        , Form.input "Email" [ onInput SetEmail ] []
        , Form.password "Password" [ onInput SetPassword ] []
        , button [ class "b ph3 pv2 input-reset ba b--black bg-transparent grow pointer f6" ] [ text "Sign up" ]
        ]



-- UPDATE --


update : Msg -> Model -> ( ( Model, Cmd Msg ), ExternalMsg )
update msg model =
    case msg of
        SubmitForm ->
            case validate modelValidator model of
                [] ->
                    { model | errors = [] }
                        => Http.send RegisterCompleted (register model)
                        => NoOp

                errors ->
                    { model | errors = errors }
                        => Cmd.none
                        => NoOp

        SetName name ->
            { model | name = name }
                => Cmd.none
                => NoOp

        SetEmail email ->
            { model | email = email }
                => Cmd.none
                => NoOp

        SetPassword password ->
            { model | password = password }
                => Cmd.none
                => NoOp

        RegisterCompleted (Err error) ->
            let
                errorMessages =
                    case error of
                        Http.BadStatus response ->
                            response.body
                                |> decodeString (field "errors" errorsDecoder)
                                |> Result.withDefault []

                        _ ->
                            [ "Unable to process registration" ]
            in
                { model | errors = List.map (\errorMessage -> Form => errorMessage) errorMessages }
                    => Cmd.none
                    => NoOp

        RegisterCompleted (Ok session) ->
            model
                => Route.modifyUrl Route.Home
                => SetSession session



-- VALIDATION --


type Field
    = Form
    | Name
    | Email
    | Password


type alias Error =
    ( Field, String )


modelValidator : Validator Error Model
modelValidator =
    Validate.all
        [ ifBlank .name (Name => "name can't be blank.")
        , ifBlank .email (Email => "email can't be blank.")
        , ifBlank .password (Password => "password can't be blank.")
        ]



-- DECODERS --


errorsDecoder : Decoder (List String)
errorsDecoder =
    decode (\name email password error -> error :: List.concat [ name, email, password ])
        |> optionalFieldError "name"
        |> optionalFieldError "email"
        |> optionalFieldError "password"
        |> optionalError "error"
Enter fullscreen mode Exit fullscreen mode

We are done with the register page. Go to the browser and navigate to the Register menu and you'll see something like this:

Toltec Register page with name, email and password fields

And if you enter the info for a new user, it will be created in the backend app and you should be logged in too

Toltec Home page after user creation and automatic loggin in

You can find the source code for the backend changes here. The changes for the frontend are here. In both cases, look for the part-04 branch.

Let's wrap it here for now. We have added the Login and Register pages to the Elm app.

In part 5 we're going to store the session in local storage and add some visual improvements.

Top comments (1)

Collapse
 
jesuisbonbon profile image
jesuis bonbon

Nice work! Looking forward to part 5 :)!