Part 4: Adding Login and Register pages
We are going to add a Login and Register pages to the app.
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
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
Then add this to the endpoint.ex, just before the router plug
# lib/toltec_web/endpoint.ex
plug(CORSPlug)
plug(ToltecWeb.Router)
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
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
]
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
]
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
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 = ""
}
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" ]
]
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
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
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.")
]
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"
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)
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)) []
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
]
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
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)
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
}
Add the LoginMsg message to Messages.elm
-- src/Messages.elm
import Session.Login as Login
-- ...
type Msg
= SetRoute (Maybe Route)
| LoginMsg Login.Msg
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
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
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
And the pageSubscriptions function in Subscriptions.elm
-- src/Subscriptions.elm
-- From this
Login ->
Sub.none
-- To this
Login _ ->
Sub.none
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:
And after logging in with our seed user (email: user@toltec, password: user@toltec) you should see the home page:
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
The messages
-- src/Messages.elm
-- ...
import Session.Register as Register
type Msg
= SetRoute (Maybe Route)
| LoginMsg Login.Msg
| RegisterMsg Register.Msg
And subscriptions
-- src/Subscriptions.elm
-- From this
Register ->
Sub.none
-- To this
Register _ ->
Sub.none
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
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
-- ..
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
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
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"
We are done with the register page. Go to the browser and navigate to the Register menu and you'll see something like this:
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
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)
Nice work! Looking forward to part 5 :)!