This post belongs to the Elixir and Phoenix basic passwordless and databaseless authentication series.
- Project setup and the initial functionality for storing and verifying authentication tokens
- Sending authentication link emails and the user socket connection
- Setting up webpack as our asset bundler and the Elm single-page application
In the previous part of the series, we covered all the back-end logic regarding sending authentication emails and using the generated tokens to authenticate the connection of a Phoenix socket. In this part, we are going to focus on the front-end, building the Elm single page application skeleton, and using the websockect authentication to hide some parts of it to unauthenticated users. Let's get cracking!
Front-end setup
I've been using Elixir and Phoenix in the back-end, and Elm in the front-end, for almost three years already, and I'm delighted with the result. I tend to use Phoenix's default asset bundler, Brunch, but I've been having some issues lately with it, especially while using its Elm and SASS plugins. Therefore, for this particular experiment, I wanted to test out other alternatives, replacing Brunch with webpack and SASS with Tailwind CSS, which I'm starting to like a lot and seems like the first strong candidate to replace SASS in my front-end stack. Recalling the first part of the series, we created the project using the --no-brunch flag, which doesn't install Brunch so that we can add webpack directly. For that, I followed this great article from @_GazD, taking the parts that I needed and making some small changes to add Elm support.
Adding webpack to the project
This being said, let's start by adding a package.json
file to the passwordless_auth_web
application:
// apps/passwordless_auth_web/assets/package.json
{
"repository": {},
"license": "MIT",
"scripts": {
"deploy": "webpack --mode production",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"phoenix": "file:../../../deps/phoenix",
"phoenix_html": "file:../../../deps/phoenix_html"
},
"devDependencies": {
"autoprefixer": "^8.6.0",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.3",
"babel-preset-env": "^1.6.1",
"copy-webpack-plugin": "^4.5.0",
"css-loader": "^0.28.10",
"elm": "^0.18.0",
"elm-webpack-loader": "^4.5.0",
"mini-css-extract-plugin": "^0.4.0",
"optimize-css-assets-webpack-plugin": "^4.0.0",
"postcss-loader": "^2.1.5",
"style-loader": "^0.21.0",
"tailwindcss": "^0.5.3",
"uglifyjs-webpack-plugin": "^1.2.4",
"webpack": "^4.10.0",
"webpack-cli": "^3.0.0"
}
}
These are all the necessary dependencies that we need to add support for webpack, Elm and Tailwind CSS, although bear in mind that package versions might be outdated since I wrote this part. We can continue by adding the necessary configuration files:
$ curl https://raw.githubusercontent.com/phoenixframework/phoenix/master/installer/templates/phx_assets/webpack/webpack.config.js > apps/passwordless_auth_web/assets/webpack.config.js
$ curl https://raw.githubusercontent.com/phoenixframework/phoenix/master/installer/templates/phx_assets/webpack/babelrc > apps/passwordless_auth_web/assets/.babelrc
Let's continue by installing all the necessary dependencies:
$ cd apps/passwordless_auth_web/assets && npm install
We want Phoenix to update the changes we make while developing, so let's add the convenient watcher for this:
# apps/passwordless_auth_web/config/dev.exs
use Mix.Config
config :passwordless_auth_web, PasswordlessAuthWeb.Endpoint,
http: [port: 4000],
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: [
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch-stdin",
cd: Path.expand("../assets", __DIR__)
]
]
# ...
Configuring Tailwind CSS
To generate Tailwind's configuration file, we have to do the following:
$ cd apps/passwordless_auth_web/assets && ./node_modules/.bin/tailwind init
Next, we have to make webpack process CSS and Tailwind using postcss:
// apps/passwordless_auth_web/assets/webpack.config.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = (env, options) => ({
optimization: {
minimizer: [
new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }),
new OptimizeCSSAssetsPlugin({}),
],
},
entry: './js/app.js',
output: {
filename: 'app.js',
path: path.resolve(__dirname, '../priv/static/js'),
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
}
],
},
plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin([{ from: 'static/', to: '../' }]),
],
});
// apps/passwordless_auth_web/assets/postcss.config.js
module.exports = {
plugins: [
require('tailwindcss')('./tailwind.js'),
require('autoprefixer')
]
}
Finally, we need to import the main CSS file in the main javascript file and add all the necessary Tailwind hooks:
// apps/passwordless_auth_web/assets/js/app.js
import css from "../css/app.css";
/* apps/passwordless_auth_web/assets/css/app.css */
@tailwind preflight;
@tailwind components;
@tailwind utilities;
Adding Elm support
To make Elm work with webpack, we need to edit its configuration file:
// apps/passwordless_auth_web/assets/webpack.config.js
// ...
module.exports = (env, options) => ({
// ...
module: {
rules: [
// ...
{
test: /\.elm$/,
exclude: ['/elm-stuff/', '/node_modules'],
loader: 'elm-webpack-loader',
options: {
debug: true,
warn: true,
cwd: path.resolve(__dirname, 'elm'),
},
},
],
noParse: [/.elm$/],
},
plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin([{ from: 'static/', to: '../' }]),
],
});
To install all the necessary Elm packages let's add the following file:
// apps/passwordless_auth_web/assets/elm/elm-package.json
{
"version": "1.0.0",
"summary": "helpful summary of your project, less than 80 characters",
"repository": "https://github.com/user/project.git",
"license": "BSD3",
"source-directories": [
"./src",
"./vendor"
],
"exposed-modules": [],
"dependencies": {
"NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0",
"elm-lang/core": "5.1.1 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"elm-lang/navigation": "2.1.0 <= v < 3.0.0",
"elm-lang/websocket": "1.0.2 <= v < 2.0.0",
"evancz/url-parser": "2.0.1 <= v < 3.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}
And run the following command:
$ cd apps/passwordless_auth_web/assets/elm && elm package install
Now we can add a very basic Elm application to check that everything is working as expected:
-- apps/passwordless_auth_web/assets/elm/src/Main.elm
module Main exposing (..)
import Html exposing (Html)
type alias Model =
{}
init : ( Model, Cmd Msg )
init =
{} ! []
type Msg
= Noop
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Noop ->
model ! []
view : Model -> Html Msg
view model =
Html.text "Hello from Elm!"
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
main : Program Never Model Msg
main =
Html.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
We probably still have the default layout template from Phoenix, so let's replace its content with the following:
<!-- apps/passwordless_auth_web/lib/passwordless_auth_web/templates/layout/app.html.eex -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<link rel="stylesheet" href="<%= static_path(@conn, "/css/app.css") %>">
<title>Passwordless Auth</title>
</head>
<body>
<%= render @view_module, @view_template, assigns %>
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
We also need to add the container in which we are going to render the Elm application:
<!-- apps/passwordless_auth_web/lib/passwordless_auth_web/templates/page/index.html.eex -->
<div id="elm-main" class="flex items-center justify-center h-screen">
</div>
And finally, create the Elm application and render it in its container:
// apps/passwordless_auth_web/assets/js/app.js
import css from '../css/app.css';
import Elm from '../elm/src/Main.elm';
const elmDiv = document.getElementById('elm-main');
if (elmDiv) {
const app = Elm.Main.embed(elmDiv);
}
Now we are ready to start Phoenix's server, visit http://localhost:4000 and watch our basic Elm application render correctly, yay!
There is one thing left to do though. We are going to use the elm-phoenix library to handle Phoenix sockets communication, therefore we need to download the content of this folder and place it in the apps/passwordless_auth_web/assets/elm/vendor
folder.
Building the Elm application
Now that our basic Elm application is working let's make some changes to the Phoenix controller and template in charge of rendering it.
# apps/passwordless_auth_web/lib/passwordless_auth_web/controllers/page_controller.ex
defmodule PasswordlessAuthWeb.PageController do
use PasswordlessAuthWeb, :controller
def index(conn, params) do
conn
|> assign(:token, Map.get(params, "token", ""))
|> render("index.html")
end
end
The first change consists of assigning the received token
value from the authentication link to the connection so that we can pass it to the Elm application as a flag. We also need to pass the socket URL, so let's create a helper function for that:
# apps/passwordless_auth_web/lib/passwordless_auth_web/views/page_view.ex
defmodule PasswordlessAuthWeb.PageView do
use PasswordlessAuthWeb, :view
def socket_url do
PasswordlessAuthWeb.Endpoint.url()
|> String.replace("http", "ws")
|> Kernel.<>("/socket/websocket")
end
end
Now we can set these two values in the template:
# apps/passwordless_auth_web/lib/passwordless_auth_web/templates/page/index.html.eex
<div id="elm-main" class="flex items-center justify-center h-screen">
</div>
<script>
window.token = '<%= @token %>';
window.socketUrl = '<%= socket_url() %>';
</script>
And finally pass them as flags in the Elm application:
// apps/passwordless_auth_web/assets/js/app.js
import Elm from '../elm/src/Main.elm';
import css from '../css/app.css';
const elmDiv = document.getElementById('elm-main');
let token = window.token;
const socketUrl = window.socketUrl;
if (token === '' || token == null) token = window.localStorage.getItem('token');
if (elmDiv) {
const app = Elm.Main.embed(elmDiv, { token, socketUrl });
app.ports.saveToken.subscribe((token) => {
window.localStorage.setItem('token', token);
});
}
We have also added a port for saving the token into localStorage
, that we are going to use later on.
Before continuing any further, let's take a moment to think about what we are building, using the following mock image:
To test out our passwordless authentication experiment, we only need a couple of screens and routes:
/
which is only accessible by authenticated users, and can potentially have any private data we want to display./sign-in
which is public, renders the form to request the magic link, and is where we are redirecting any user that tries to navigate to/
without being properly authenticated.
Having this in mind, we can start by implementing the routing module, so we can add these two routes:
-- apps/passwordless_auth_web/assets/elm/src/Route.elm
module Route
exposing
( Route(..)
, fromLocation
, newUrl
)
import Navigation exposing (Location)
import UrlParser as Url exposing ((</>), Parser, oneOf, s, string, parsePath)
-- ROUTING --
type Route
= SignInRoute
| LobbyRoute
matchers : Parser (Route -> a) a
matchers =
oneOf
[ Url.map LobbyRoute <| s ""
, Url.map SignInRoute <| s "sign-in"
]
-- INTERNAL --
routeToString : Route -> String
routeToString page =
let
pieces =
case page of
LobbyRoute ->
[]
SignInRoute ->
[ "sign-in" ]
in
"/" ++ String.join "/" pieces
-- PUBLIC HELPERS --
newUrl : Route -> Cmd msg
newUrl =
routeToString >> Navigation.newUrl
fromLocation : Location -> Maybe Route
fromLocation location =
parsePath matchers location
Now that we have defined the routes that we need, let's move on to the main elm module and start coding the application. In many of my Elm projects and tutorials, I've been organizing all the Elm code in folders, creating one for each route. Inside of these folders, I tended to add four different files, one for each of the Model, Messages, Update and View modules of that route. This approach used to work fine for small projects, but it didn't scale that well for bigger ones. Then I read Richard Feldman's article Tour of an Open-Source Elm SPA, and I have started to organize my new Elm projects following his recommendations, feeling more comfortable now when they start to grow.
The Page modules
Although this is a very small project, we will stick to Richard's approach, therefore let's get started by defining the page modules that define the logic for each screen for the application, and which we will use from the main module depending on which is the current page set in the application.
Page.SignIn
The first page we are implementing is the sign-in page. The goal is to define everything we need as it was a standalone Elm program, except that it does not have a view function, and the entry point can be either an init function to load any necessary data from the backend or its initial model:
-- apps/passwordless_auth_web/assets/elm/src/Page/SignIn.elm
module Page.SignIn
exposing
( Model
, SignInForm(..)
, Msg(..)
, update
, initialModel
)
import Http
import Json.Encode as Encode
import Json.Decode as Decode
import Request.Token exposing (requestToken)
-- MODEL --
type SignInForm
= Editing String
| Sending String
| Success String
| Error String
type alias Model =
{ form : SignInForm }
initialModel : Model
initialModel =
{ form = Editing "" }
-- UPDATE --
type Msg
= HandleEmailInput String
| HandleFormSubmit
| FormSubmitResponse (Result Http.Error String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ form } as model) =
case ( msg, form ) of
( HandleEmailInput value, Editing _ ) ->
{ model | form = Editing value } ! []
( HandleFormSubmit, Editing email ) ->
{ model | form = Sending email } ! [ requestToken FormSubmitResponse email ]
( FormSubmitResponse payload, Sending _ ) ->
case payload of
Ok message ->
{ model | form = Success message } ! []
_ ->
{ model | form = Error "We couldn't sent you your magic link due to an error, please try again later." } ! []
_ ->
model ! []
requestToken : (Result Http.Error String -> msg) -> String -> Cmd msg
requestToken msg email =
let
body =
Encode.object [ ( "email", Encode.string email ) ]
request =
Http.request
{ method = "POST"
, headers = []
, url = "/api/auth"
, body = Http.jsonBody body
, expect = Http.expectJson requestTokenDecoder
, timeout = Nothing
, withCredentials = False
}
in
Http.send msg request
requestTokenDecoder : Decode.Decoder String
requestTokenDecoder =
Decode.at [ "message" ] Decode.string
The Model
for this page consists of a record with a form
custom type that represents the different states that we need in the sign-in form:
Editing String
when the user is typing so we can store the value typed.Sending String
when the user submits the form so we can, for instance, render a spinner in the submit button.Success String
which means a successful form submission, and theString
received from the server.Error String
if anything goes wrong, and we want to show an error message.
The update
function is very simple too, handling the following messages:
HandleEmailInput String
when the form email input changes its value, storing it in the model.HandleFormSubmit
sends therequestToken
HTTP request.FormSubmitResponse (Result Http.Error String)
which handles the response from the previous HTTP request.
Page.Lobby
The other page we need to implement is the one we want to display only to authenticated users:
-- apps/passwordless_auth_web/assets/elm/src/Page/Lobby.elm
module Page.Lobby exposing ( Model , Msg(..) , init , update)
import Json.Decode as Decode exposing (Value)
import Phoenix
import Phoenix.Push as Push exposing (Push)
-- MODEL --
type alias Model =
{ emails : List String }
initialModel : Model
initialModel =
{ emails = [] }
-- UPDATE --
type Msg
= HandleInitSuccess Decode.Value
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
HandleInitSuccess payload ->
case Decode.decodeValue usersDecoder payload of
Ok emails ->
{ model | emails = emails } ! []
Err _ ->
model ! []
usersDecoder : Decode.Decoder (List String)
usersDecoder =
Decode.at [ "data" ] <| Decode.list Decode.string
-- INIT --
init : String -> ( Model, Cmd Msg )
init socketUrl =
( initialModel
, Push.init "admin:lobby" "data"
|> Push.onOk HandleInitSuccess
|> Phoenix.push socketUrl
)
For simplicity's sake, the Model
consists of a list of strings representing the list of emails able to authenticate into the application. The init
function, called every time an authenticated user visits the /
route, receives the socketUrl
and pushes a data
message to the admin:lobby
channel through the socket (already connected, we'll get there in a minute). If the message result is successful, it receives the list of emails, handled by the HandleInitSuccess
message, decodes the list and sets it as the new Model
.
The Views modules
Having the main Page modules done, let's focus now on the view modules for them.
Taking a look at the Lobby
page, we can see that there is a navigation bar on top, with the signout link. This navigation bar will appear in all the future private screen that we might implement, so we need to share it somehow.
Views.Page
Let's add a basic module that wraps each particular view content in a container along with the navigation bar:
-- apps/passwordless_auth_web/assets/elm/src/Views/Page.elm
module Views.Page
exposing
( frameView
, headerView
, Msg(..)
)
import Data.Session exposing (Session(..))
import Html exposing (Html)
import Html.Attributes as Html
import Html.Events as Html
type Msg
= SignOut
frameView : Session -> Html msg -> Html msg -> Html msg
frameView session header content =
case session of
Anonymous ->
Html.text ""
Authenticated _ ->
Html.div
[ Html.class "main-section flex-1 flex-col flex h-screen" ]
[ header
, Html.div
[ Html.class "main-content bg-grey-lightest flex-1 flex items-center justify-center" ]
[ content ]
]
headerView : Html Msg
headerView =
Html.header
[ Html.class "main-header" ]
[ Html.nav
[ Html.class "flex justify-between" ]
[ Html.span
[ Html.class "flex-1 p-4 text-white text-left" ]
[ Html.text "Admin panel" ]
, Html.a
[ Html.class "p-4"
, Html.onClick SignOut
]
[ Html.text "Sign out" ]
]
]
It exposes two functions:
frameView
which defines the structure of all private screens.headerView
which renders the top navigation bar, with the sign-out link.
frameView
takes a Session
, so we can render the contents only when the user is authenticated, and both the header
and the content
for the particular page it is rendering.
Views.SignIn
Let's add the view module for the SignIn
page.
-- apps/passwordless_auth_web/assets/elm/src/Views/SignIn.elm
module Views.SignIn exposing (view)
import Html exposing (Html, form)
import Html.Attributes as Html
import Html.Events as Html
import Page.SignIn exposing (Model, SignInForm(..), Msg(..))
view : Model -> Html Msg
view { form } =
let
content =
case form of
Editing email ->
formView email
Sending email ->
formView email
Success text ->
successMessageView text
Error text ->
errorMessageView text
in
Html.section
[ Html.class "bg-purple-darker p-8 flex flex-1 items-center justify-center h-screen" ]
[ content ]
formView : String -> Html Msg
formView email =
Html.div
[]
[ Html.img
[ Html.src "/images/icons8-mailbox-128.png"
, Html.class "mb-4 slide-in-blurred-top"
]
[]
, Html.h3
[]
[ Html.text "Password long? Hard to type?" ]
, Html.p
[]
[ Html.text "Get a magic link sent to your email that'll sign you instantly!" ]
, form
[ Html.class "w-full max-w-md"
, Html.onSubmit HandleFormSubmit
]
[ Html.input
[ Html.class "appearance-none block w-full bg-grey-lighter text-grey-darker rounded py-3 px-4 mb-3"
, Html.placeholder "foo@email.com"
, Html.type_ "email"
, Html.onInput HandleEmailInput
, Html.value email
]
[]
, Html.button
[ Html.class "bg-blue hover:bg-blue-dark text-white py-3 px-4 mb-3 rounded w-full" ]
[ Html.text "Send Magic Link" ]
]
]
successMessageView : String -> Html Msg
successMessageView message =
Html.div
[]
[ Html.img
[ Html.src "/images/icons8-postal-128.png"
, Html.class "mb-4 jello-horizontal"
]
[]
, Html.h3
[]
[ Html.text "Check your email" ]
, Html.p
[]
[ Html.text message ]
]
errorMessageView : String -> Html Msg
errorMessageView message =
Html.div
[]
[ Html.h3
[]
[ Html.text "Whoops!" ]
, Html.p
[]
[ Html.text message ]
]
The view
function returns the content to render, depending on the value of the custom type we created previously for the model's form.
View.Lobby
The next view we are going to implement is the view for the Lobby
page, so let's go ahead and add it:
-- apps/passwordless_auth_web/assets/elm/src/Views/Lobby.elm
module Views.Lobby exposing (view)
import Html exposing (Html, form)
import Html.Keyed
import Html.Attributes as Html
import Data.Session exposing (User)
import Page.Lobby exposing (Model, Msg(..))
view : User -> Model -> Html Msg
view { email } model =
Html.div
[]
[ Html.img
[ Html.src "/images/icons8-confetti-128.png"
, Html.class "mb-4 jello-horizontal"
]
[]
, Html.h3
[]
[ Html.text <| "Welcome " ++ email ++ "!" ]
, Html.p
[]
[ Html.text "You signed in successfully." ]
, Html.p
[]
[ Html.text "List of authorized users:" ]
, model
|> List.map emailView
|> Html.Keyed.ul [ Html.class "inline-block m-0 p-0" ]
]
emailView : String -> ( String, Html Msg )
emailView email =
( email
, Html.li
[]
[ Html.text email ]
)
Its view
function receives the current User
and its model, which consists of a list of emails, and renders them in the screen.
View.NotFound
Finally, let's add the view to render unexisting pages, in case the user visits a wrong route:
-- apps/passwordless_auth_web/assets/elm/src/Views/NotFound.elm
module Views.NotFound exposing (view)
import Html exposing (Html, form)
view : Html msg
view =
Html.div
[]
[ Html.h1
[]
[ Html.text "404" ]
, Html.p
[]
[ Html.text "Page not found" ]
]
The Main module
With our page specific logic and views, we can start editing the Main
module. As this module is quite big, let's split it into different chunks of code.
The model section
module Main exposing (main)
import Data.Session exposing (Session(..))
import Html exposing (Html, form, map)
import Json.Decode as Decode exposing (Value)
import Navigation exposing (Location)
import Page.Lobby
import Page.SignIn
import Phoenix
import Phoenix.Channel as Channel exposing (Channel)
import Phoenix.Push as Push exposing (Push)
import Phoenix.Socket as Socket exposing (AbnormalClose, Socket)
import Ports
import Route exposing (Route(..))
import Views.Lobby
import Views.Page
import Views.SignIn
-- MODEL --
type ConnectionStatus
= Connecting
| Connected ChannelState
| Disconnected
type ChannelState
= Joining
| Joined
| Left
type Page
= BlankPage
| NotFoundPage
| SignInPage Page.SignIn.Model
| LobbyPage Page.Lobby.Model
type alias Flags =
{ token : Maybe String
, socketUrl : String
}
type alias Model =
{ page : Page
, session : Session
, connectionStatus : ConnectionStatus
, flags : Flags
}
-- ...
The main Model
consists of:
page
which is a custom type representing the currentPage
state.session
which holds information about the authenticated user.connectionStatus
which is another custom type representing the status of the socket connection and the channel state.flags
which stores the configuration data passed from the outside while initializing the application.
The update section
-- UPDATE --
type Msg
= SetRoute (Maybe Route)
| ConnectionStatusChanged ConnectionStatus
| HandleAdminChannelJoin Decode.Value
| ViewsPageMsg Views.Page.Msg
| HandleSignOutSuccess Decode.Value
| PageSignInMsg Page.SignIn.Msg
| PageLobbyMsg Page.Lobby.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ flags, page } as model) =
let
{ socketUrl } =
flags
toPage toModel toMsg subUpdate subMsg subModel =
let
( newModel, newCmd ) =
subUpdate subMsg subModel
in
( { model | page = toModel newModel }, Cmd.map toMsg newCmd )
in
case ( msg, page ) of
( SetRoute route, _ ) ->
setRoute route model
( ConnectionStatusChanged connectionStatus, _ ) ->
let
cmds =
case connectionStatus of
Disconnected ->
[ Route.newUrl SignInRoute
, Ports.saveToken Nothing
]
_ ->
[]
in
{ model | connectionStatus = connectionStatus } ! cmds
( HandleAdminChannelJoin payload, _ ) ->
case Decode.decodeValue Data.Session.decoder payload of
Ok user ->
{ model
| session =
Authenticated user
, connectionStatus = Connected Joined
}
! [ Ports.saveToken model.flags.token ]
Err error ->
let
_ =
Debug.log "Error" error
in
model ! []
( HandleSignOutSuccess _, _ ) ->
{ model
| connectionStatus = Disconnected
, session = Anonymous
}
! [ Route.newUrl SignInRoute, Ports.saveToken Nothing ]
( ViewsPageMsg subMsg, _ ) ->
case subMsg of
Views.Page.SignOut ->
model
! [ Push.init "admin:lobby" "sign_out"
|> Push.onOk HandleSignOutSuccess
|> Phoenix.push socketUrl
]
( ViewsPageMsg subMsg, _ ) ->
case subMsg of
Views.Page.SignOut ->
model
! [ Push.init "admin:lobby" "sign_out"
|> Push.onOk HandleSignOutSuccess
|> Phoenix.push socketUrl
]
( PageSignInMsg subMsg, SignInPage subModel ) ->
toPage SignInPage PageSignInMsg Page.SignIn.update subMsg subModel
( PageLobbyMsg subMsg, LobbyPage subModel ) ->
toPage LobbyPage PageLobbyMsg Page.Lobby.update subMsg subModel
( _, _ ) ->
model ! []
setRoute : Maybe Route -> Model -> ( Model, Cmd Msg )
setRoute maybeRoute ({ connectionStatus, session, flags } as model) =
let
transition init page toMsg =
let
( subModel, subCmd ) =
init
in
{ model | page = page subModel } ! [ Cmd.map toMsg subCmd ]
in
case maybeRoute of
Nothing ->
{ model | page = NotFoundPage } ! [ Cmd.none ]
Just SignInRoute ->
{ model | page = SignInPage Page.SignIn.initialModel } ! [ Cmd.none ]
Just LobbyRoute ->
case connectionStatus of
Disconnected ->
model ! [ Route.newUrl SignInRoute ]
_ ->
transition (Page.Lobby.init flags.socketUrl) LobbyPage PageLobbyMsg
The update
function evaluates the current msg
and page
, and depending on the combination takes one of the following actions:
( SetRoute route, _ )
means a route change, so it calls thesetRoute
function to initialize the page state depending on the new route.( ConnectionStatusChanged connectionStatus, _ )
handles changes in the socket connection, and calls a port to delete the stored authentication token fromlocalStorage
and redirects to theSignInRoute
route, and sets the new connection status in the model.( HandleAdminChannelJoin payload, _ )
handles the success of joining the admin channel, setting the user's data into thesession
key of the model, and changing the connection state to the proper one.( HandleSignOutSuccess _, _ )
setsconnectionStatus
to disconnected, resets the session user toNothing
, calls the port to delete the token and redirect to theSignInRoute
route.( ViewsPageMsg subMsg, _ )
checks forViews.Page.SignOut
messages, sent by clicking on the sign-out link of the header and sends asign_out
message to the admin channel through the socket.( PageSignInMsg subMsg, SignInPage subModel )
and( PageLobbyMsg subMsg, LobbyPage subModel )
update the current page state using their corresponding update function.
The setRoute
function takes care of two different things:
- It initializes the current route page model.
- Redirects to the
SignInRoute
route if the user is not connected to the socket, which means that is not authenticated.
The view section
-- VIEW --
view : Model -> Html Msg
view { session, page } =
let
header =
Html.map ViewsPageMsg Views.Page.headerView
frame =
Views.Page.frameView session header
in
case ( page, session ) of
( SignInPage subModel, _ ) ->
map PageSignInMsg (Views.SignIn.view subModel)
( LobbyPage subModel, Authenticated user ) ->
frame <| map PageLobbyMsg (Views.Lobby.view user subModel)
( NotFoundPage, Authenticated _ ) ->
frame Views.NotFound.view
_ ->
frame <| Html.text "View not implemented yet"
The view
function takes the current model, and depending on the current page
and session
, renders the necessary view wrapping it with the frame
or not.
The subscriptions section
-- SUBSCRIPTIONS --
socket : Flags -> Socket Msg
socket { socketUrl, token } =
Socket.init socketUrl
|> Socket.withParams [ ( "token", Maybe.withDefault "" token ) ]
|> Socket.onOpen (ConnectionStatusChanged (Connected Joining))
|> Socket.onClose (\_ -> ConnectionStatusChanged Disconnected)
|> Socket.withDebug
subscriptions : Model -> Sub Msg
subscriptions { connectionStatus, flags } =
let
{ token } =
flags
adminChannel =
Channel.init "admin:lobby"
|> Channel.onJoin HandleAdminChannelJoin
|> Channel.withDebug
in
case connectionStatus of
Disconnected ->
Sub.none
_ ->
Phoenix.connect (socket flags) [ adminChannel ]
Depending on the connectionStatus
it tries to connect to the Phoenix Socket
, changing the status to Connected Joining
if the server-side authentication is successful. On the contrary, it sets it to Disconnected
which redirects to the sign-in page as we have previously seen.
The main and init section
-- MAIN AND INIT --
init : Flags -> Location -> ( Model, Cmd Msg )
init flags location =
let
( model, cmd ) =
setRoute (Route.fromLocation location)
{ page = BlankPage
, session = Anonymous
, connectionStatus = Connecting
, flags = flags
}
in
model ! [ cmd ]
main : Program Flags Model Msg
main =
Navigation.programWithFlags (Route.fromLocation >> SetRoute)
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
As main
returns a Navigation.programWithFlags
application, the init
function takes the received Flags
and a Location
, calling setRoute
to initialize the application's state depending on the current route.
Additional stuff
We have almost covered everything excepting two missing modules:
The Ports module
-- apps/passwordless_auth_web/assets/elm/src/Ports.elm
port module Ports exposing (saveToken)
port saveToken : Maybe String -> Cmd msg
saveToken
is a port, which sends the valid authentication token, or an empty one, to the main app.js
script where it is stored using localStorage
. The reason for this behaviour is that we want to reuse the valid token to reconnect if the user refreshes the browser, or delete it in case the user signs out.
The Data.Session module
As we are using Session
all over the place, and to prevent circular dependencies, it makes sense having everything related to Session
in its own module.
-- apps/passwordless_auth_web/assets/elm/src/Data/Session.elm
module Data.Session exposing
( Session(..)
, User
, decoder
, encode
)
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (decode, required)
import Json.Encode as Encode exposing (Value)
type alias User =
{ email : String }
type Session
= Anonymous
| Authenticated User
-- SERIALIZATION --
decoder : Decoder User
decoder =
decode User
|> required "email" Decode.string
encode : User -> Decode.Value
encode user =
Encode.object
[ ( "email", Encode.string user.email )
]
Once again we rely on a custom type to define Session
which can be either Anonymous
or Authenticated User
.
The Phoenix AdminChannel
If we start the application, we can see errors related to socket connection. This is because we haven't created the AdminChannel
yet, so let's go ahead and add its definition to the UserSocket
module:
# apps/passwordless_auth_web/lib/passwordless_auth_web/channels/user_socket.ex
defmodule PasswordlessAuthWeb.UserSocket do
# ...
channel("admin:*", PasswordlessAuthWeb.AdminChannel)
# ...
end
And finally, the channel module:
# apps/passwordless_auth_web/lib/passwordless_auth_web/channels/admin_channel.ex
defmodule PasswordlessAuthWeb.AdminChannel do
use PasswordlessAuthWeb, :channel
require Logger
alias PasswordlessAuth.Repo
def join("admin:lobby", _payload, socket) do
{:ok, socket.assigns.user, socket}
end
def handle_in("data", _, socket) do
admin_emails = Application.get_env(:passwordless_auth, :repo)[:emails]
{:reply, {:ok, %{data: admin_emails}}, socket}
end
def handle_in("sign_out", _, socket) do
email = socket.assigns.user.email
:ok = Repo.invalidate(email)
{:reply, {:ok, %{success: true}}, socket}
end
end
The logic for this module is very simple:
join
always returns success and the authenticated user data assigned to the connection.handle_in("data", _, _)
returns the existing list of valid emails so that we can render it in the lobby view.handle_in("sign_out", _, _)
takes the assigned email and invalidates its token in the repository, so cannot use it again.
So that's pretty much it. We finally have our basic Elm SPA admin panel, backed with a Phoenix passwordless and databaseless system. While I was writing this post, I have refactored many parts of the application so I probably might have missed including some of the changes. Therefore, don't forget to check out the source code for the missing pieces, and sorry for any inconvenience. Elm 0.19.0 just came out, so I'm planning to write a new part about upgrading it, once it has proper support for Websockets.
Happy coding!