about summary refs log tree commit diff
diff options
context:
space:
mode:
authorWilliam Carroll <wpcarro@gmail.com>2020-07-31T17·32+0100
committerWilliam Carroll <wpcarro@gmail.com>2020-07-31T17·57+0100
commit421c71c8922731563771ed75be7f28c9a559c068 (patch)
treef7723ade399bb02a638fa3df9371cdb072262eb5
parent29a00dc571b53b08064915c34e0d951467b6f1e4 (diff)
Support a basic client-side login flow
I will need to remove some of the baggage like:

- Scrub any copy about restaurants
- delete Restaurant.elm
- Change Owner.elm -> Manager.elm
-rw-r--r--client/README.md1
-rw-r--r--client/dir-locals.nix2
-rw-r--r--client/elm.json16
-rw-r--r--client/shell.nix1
-rw-r--r--client/src/Admin.elm99
-rw-r--r--client/src/Landing.elm13
-rw-r--r--client/src/Login.elm213
-rw-r--r--client/src/Main.elm57
-rw-r--r--client/src/Manager.elm46
-rw-r--r--client/src/Shared.elm7
-rw-r--r--client/src/State.elm482
-rw-r--r--client/src/Tailwind.elm29
-rw-r--r--client/src/UI.elm254
-rw-r--r--client/src/User.elm39
-rw-r--r--client/src/Utils.elm90
15 files changed, 1298 insertions, 51 deletions
diff --git a/client/README.md b/client/README.md
index 425d5163ea35..04804ad94fac 100644
--- a/client/README.md
+++ b/client/README.md
@@ -11,6 +11,7 @@ in which you can develop:
 
 ```shell
 $ nix-shell
+$ npx tailwindcss build index.css -o output.css
 $ elm-live -- src/Main.elm --output=Main.min.js
 ```
 
diff --git a/client/dir-locals.nix b/client/dir-locals.nix
index 498f4b5055f8..5c3ae08870b0 100644
--- a/client/dir-locals.nix
+++ b/client/dir-locals.nix
@@ -1,3 +1,3 @@
 let
-  briefcase = import <briefcase> {};
+  briefcase = import /home/wpcarro/briefcase {};
 in briefcase.utils.nixBufferFromShell ./shell.nix
diff --git a/client/elm.json b/client/elm.json
index a95f80408ec4..76664b62b8ed 100644
--- a/client/elm.json
+++ b/client/elm.json
@@ -9,18 +9,26 @@
             "elm/browser": "1.0.2",
             "elm/core": "1.0.5",
             "elm/html": "1.0.0",
+            "elm/http": "2.0.0",
+            "elm/json": "1.1.3",
             "elm/random": "1.0.0",
             "elm/svg": "1.0.1",
             "elm/time": "1.0.0",
+            "elm/url": "1.0.0",
+            "elm-community/json-extra": "4.2.0",
             "elm-community/list-extra": "8.2.3",
             "elm-community/maybe-extra": "5.2.0",
-            "elm-community/random-extra": "3.1.0"
+            "elm-community/random-extra": "3.1.0",
+            "krisajenkins/remotedata": "6.0.1",
+            "ryannhg/date-format": "2.3.0"
         },
         "indirect": {
-            "elm/json": "1.1.3",
-            "elm/url": "1.0.0",
+            "elm/bytes": "1.0.8",
+            "elm/file": "1.0.5",
+            "elm/parser": "1.1.0",
             "elm/virtual-dom": "1.0.2",
-            "owanturist/elm-union-find": "1.0.0"
+            "owanturist/elm-union-find": "1.0.0",
+            "rtfeldman/elm-iso8601-date-strings": "1.1.3"
         }
     },
     "test-dependencies": {
diff --git a/client/shell.nix b/client/shell.nix
index 6f1c8ee23b30..15ac040b9462 100644
--- a/client/shell.nix
+++ b/client/shell.nix
@@ -2,6 +2,7 @@ let
   pkgs = import <nixpkgs> {};
 in pkgs.mkShell {
   buildInputs = with pkgs; [
+    nodejs
     elmPackages.elm
     elmPackages.elm-format
     elmPackages.elm-live
diff --git a/client/src/Admin.elm b/client/src/Admin.elm
new file mode 100644
index 000000000000..3c0f221d93ed
--- /dev/null
+++ b/client/src/Admin.elm
@@ -0,0 +1,99 @@
+module Admin exposing (render)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import RemoteData
+import State
+import Tailwind
+import UI
+import Utils
+
+
+allUsers : State.Model -> Html State.Msg
+allUsers model =
+    case model.users of
+        RemoteData.NotAsked ->
+            UI.absentData { handleFetch = State.AttemptGetUsers }
+
+        RemoteData.Loading ->
+            UI.paragraph "Loading..."
+
+        RemoteData.Failure e ->
+            UI.paragraph ("Error: " ++ Utils.explainHttpError e)
+
+        RemoteData.Success xs ->
+            div []
+                [ UI.header 3 "Admins"
+                , users xs.admin
+                , UI.header 3 "Managers"
+                , users xs.manager
+                , UI.header 3 "Users"
+                , users xs.user
+                ]
+
+
+users : List String -> Html State.Msg
+users xs =
+    ul []
+        (xs
+            |> List.map
+                (\x ->
+                    li [ [ "py-4", "flex" ] |> Tailwind.use |> class ]
+                        [ p [ [ "flex-1" ] |> Tailwind.use |> class ] [ text x ]
+                        , div [ [ "flex-1" ] |> Tailwind.use |> class ]
+                            [ UI.simpleButton
+                                { label = "Delete"
+                                , handleClick = State.AttemptDeleteUser x
+                                }
+                            ]
+                        ]
+                )
+        )
+
+
+render : State.Model -> Html State.Msg
+render model =
+    div
+        [ [ "container"
+          , "mx-auto"
+          , "text-center"
+          ]
+            |> Tailwind.use
+            |> class
+        ]
+        [ UI.header 2 "Welcome back!"
+        , UI.simpleButton
+            { label = "Logout"
+            , handleClick = State.AttemptLogout
+            }
+        , div []
+            [ UI.baseButton
+                { label = "Switch to users"
+                , handleClick = State.UpdateAdminTab State.Users
+                , enabled = not (model.adminTab == State.Users)
+                , extraClasses = []
+                }
+            ]
+        , case model.adminTab of
+            State.Users ->
+                allUsers model
+        , case model.logoutError of
+            Nothing ->
+                text ""
+
+            Just e ->
+                UI.errorBanner
+                    { title = "Error logging out"
+                    , body = Utils.explainHttpError e
+                    }
+        , case model.deleteUserError of
+            Nothing ->
+                text ""
+
+            Just e ->
+                UI.errorBanner
+                    { title = "Error attempting to delete user"
+                    , body = Utils.explainHttpError e
+                    }
+        ]
diff --git a/client/src/Landing.elm b/client/src/Landing.elm
deleted file mode 100644
index 00bb9e281af4..000000000000
--- a/client/src/Landing.elm
+++ /dev/null
@@ -1,13 +0,0 @@
-module Landing exposing (render)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import State
-
-
-render : State.Model -> Html State.Msg
-render model =
-    div [ class "pt-10 pb-20 px-10" ]
-        [ p [] [ text "Welcome to the landing page!" ]
-        ]
diff --git a/client/src/Login.elm b/client/src/Login.elm
index 968325d48cfe..046ee8583ca7 100644
--- a/client/src/Login.elm
+++ b/client/src/Login.elm
@@ -4,13 +4,214 @@ import Html exposing (..)
 import Html.Attributes exposing (..)
 import Html.Events exposing (..)
 import State
+import Tailwind
+import UI
+import Utils
+
+
+loginForm : State.Model -> Html State.Msg
+loginForm model =
+    div
+        [ [ "w-full"
+          , "max-w-xs"
+          , "mx-auto"
+          ]
+            |> Tailwind.use
+            |> class
+        ]
+        [ div
+            [ [ "bg-white"
+              , "shadow-md"
+              , "rounded"
+              , "px-8"
+              , "pt-6"
+              , "pb-8"
+              , "mb-4"
+              , "text-left"
+              ]
+                |> Tailwind.use
+                |> class
+            ]
+            [ div
+                [ [ "mb-4" ] |> Tailwind.use |> class ]
+                [ UI.label_ { for_ = "username", text_ = "Username" }
+                , UI.textField
+                    { inputId = "Username"
+                    , pholder = "Username"
+                    , handleInput = State.UpdateUsername
+                    , inputValue = model.username
+                    }
+                ]
+            , div []
+                [ UI.label_ { for_ = "role", text_ = "Role" }
+                , select
+                    [ [ "mb-4"
+                      , "w-full"
+                      , "py-2"
+                      , "px-2"
+                      , "rounded"
+                      , "shadow"
+                      , "border"
+                      ]
+                        |> Tailwind.use
+                        |> class
+                    , id "role"
+                    , onInput State.UpdateRole
+                    ]
+                    [ option [] [ text "" ]
+                    , option [ value "user" ] [ text "User" ]
+                    , option [ value "manager" ] [ text "Manager" ]
+                    , option [ value "admin" ] [ text "Admin" ]
+                    ]
+                ]
+            , div
+                [ [ "mb-4" ] |> Tailwind.use |> class ]
+                [ UI.label_ { for_ = "password", text_ = "Password" }
+                , input
+                    [ [ "shadow"
+                      , "appearance-none"
+                      , "border"
+                      , "rounded"
+                      , "w-full"
+                      , "py-2"
+                      , "px-3"
+                      , "text-gray-700"
+                      , "leading-tight"
+                      , "focus:outline-none"
+                      , "focus:shadow-outline"
+                      ]
+                        |> Tailwind.use
+                        |> class
+                    , id "password"
+                    , type_ "password"
+                    , placeholder "******************"
+                    , onInput State.UpdatePassword
+                    ]
+                    []
+                ]
+            , div
+                []
+                [ UI.baseButton
+                    { label = "Sign In"
+                    , handleClick = State.AttemptLogin
+                    , extraClasses = []
+                    , enabled =
+                        case ( model.username, model.password ) of
+                            ( "", "" ) ->
+                                False
+
+                            ( "", _ ) ->
+                                False
+
+                            ( _, "" ) ->
+                                False
+
+                            _ ->
+                                True
+                    }
+                , div [ [ "inline", "pl-2" ] |> Tailwind.use |> class ]
+                    [ UI.baseButton
+                        { label = "Sign Up"
+                        , extraClasses = []
+                        , enabled =
+                            case ( model.username, model.password, model.role ) of
+                                ( "", "", _ ) ->
+                                    False
+
+                                ( _, "", _ ) ->
+                                    False
+
+                                ( "", _, _ ) ->
+                                    False
+
+                                ( _, _, Nothing ) ->
+                                    False
+
+                                _ ->
+                                    True
+                        , handleClick =
+                            case model.role of
+                                Just role ->
+                                    State.AttemptSignUp role
+
+                                Nothing ->
+                                    State.DoNothing
+                        }
+                    ]
+                ]
+            ]
+        ]
+
+
+login :
+    State.Model
+    -> Html State.Msg
+login model =
+    div
+        [ [ "text-center"
+          , "py-20"
+          , "bg-gray-200"
+          , "h-screen"
+          ]
+            |> Tailwind.use
+            |> class
+        ]
+        [ UI.header 3 "Welcome to Trip Planner"
+        , loginForm model
+        , case model.loginError of
+            Nothing ->
+                text ""
+
+            Just e ->
+                UI.errorBanner
+                    { title = "Error logging in"
+                    , body = Utils.explainHttpError e
+                    }
+        , case model.signUpError of
+            Nothing ->
+                text ""
+
+            Just e ->
+                UI.errorBanner
+                    { title = "Error creating account"
+                    , body = Utils.explainHttpError e
+                    }
+        ]
+
+
+logout : State.Model -> Html State.Msg
+logout model =
+    div
+        [ [ "text-center"
+          , "py-20"
+          , "bg-gray-200"
+          , "h-screen"
+          ]
+            |> Tailwind.use
+            |> class
+        ]
+        [ UI.header 3 "Looks like you're already signed in..."
+        , UI.simpleButton
+            { label = "Logout"
+            , handleClick = State.AttemptLogout
+            }
+        , case model.logoutError of
+            Nothing ->
+                text ""
+
+            Just e ->
+                UI.errorBanner
+                    { title = "Error logging out"
+                    , body = Utils.explainHttpError e
+                    }
+        ]
 
-googleSignIn : Html State.Msg
-googleSignIn =
-    div [ class "g-signin2", attribute "onsuccess" "onSignIn" ] []
 
 render : State.Model -> Html State.Msg
 render model =
-    div [ class "pt-10 pb-20 px-10" ]
-        [ googleSignIn
-        ]
+    case model.session of
+        Nothing ->
+            login model
+
+        Just x ->
+            logout model
diff --git a/client/src/Main.elm b/client/src/Main.elm
index 30006460cde9..de71a72db0df 100644
--- a/client/src/Main.elm
+++ b/client/src/Main.elm
@@ -1,31 +1,62 @@
 module Main exposing (main)
 
+import Admin
 import Browser
 import Html exposing (..)
-import Landing
 import Login
+import Manager
 import State
+import Url
+import User
 
 
-subscriptions : State.Model -> Sub State.Msg
-subscriptions model =
-    Sub.none
+viewForRoute : State.Route -> (State.Model -> Html State.Msg)
+viewForRoute route =
+    case route of
+        State.Login ->
+            Login.render
+
+        State.UserHome ->
+            User.render
+
+        State.ManagerHome ->
+            Manager.render
+
+        State.AdminHome ->
+            Admin.render
 
 
-view : State.Model -> Html State.Msg
+view : State.Model -> Browser.Document State.Msg
 view model =
-    case model.view of
-        State.Landing ->
-            Landing.render model
+    { title = "TripPlanner"
+    , body =
+        [ case ( model.session, model.route ) of
+            -- Redirect to /login when someone is not authenticated.
+            -- TODO(wpcarro): We should ensure that /login shows in the URL
+            -- bar.
+            ( Nothing, _ ) ->
+                Login.render model
 
-        State.Login ->
-            Login.render model
+            ( Just session, Nothing ) ->
+                Login.render model
+
+            -- Authenticated
+            ( Just session, Just route ) ->
+                if State.isAuthorized session.role route then
+                    viewForRoute route model
+
+                else
+                    text "Access denied. You are not authorized to be here. Evacuate the area immediately"
+        ]
+    }
 
 
 main =
-    Browser.element
-        { init = \() -> ( State.init, Cmd.none )
-        , subscriptions = subscriptions
+    Browser.application
+        { init = State.init
+        , onUrlChange = State.UrlChanged
+        , onUrlRequest = State.LinkClicked
+        , subscriptions = \_ -> Sub.none
         , update = State.update
         , view = view
         }
diff --git a/client/src/Manager.elm b/client/src/Manager.elm
new file mode 100644
index 000000000000..b7f36cfd46bb
--- /dev/null
+++ b/client/src/Manager.elm
@@ -0,0 +1,46 @@
+module Manager exposing (render)
+
+import Array
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import RemoteData
+import State
+import Tailwind
+import UI
+import Utils
+
+
+render : State.Model -> Html State.Msg
+render model =
+    case model.session of
+        Nothing ->
+            text "You are unauthorized to view this page."
+
+        Just session ->
+            div
+                [ class
+                    ([ "container"
+                     , "mx-auto"
+                     , "text-center"
+                     ]
+                        |> Tailwind.use
+                    )
+                ]
+                [ h1 []
+                    [ UI.header 2 ("Welcome back, " ++ session.username ++ "!")
+                    , UI.simpleButton
+                        { label = "Logout"
+                        , handleClick = State.AttemptLogout
+                        }
+                    , case model.logoutError of
+                        Nothing ->
+                            text ""
+
+                        Just e ->
+                            UI.errorBanner
+                                { title = "Error logging out"
+                                , body = Utils.explainHttpError e
+                                }
+                    ]
+                ]
diff --git a/client/src/Shared.elm b/client/src/Shared.elm
new file mode 100644
index 000000000000..addb0a4ffd12
--- /dev/null
+++ b/client/src/Shared.elm
@@ -0,0 +1,7 @@
+module Shared exposing (..)
+
+clientOrigin =
+    "http://localhost:8000"
+
+serverOrigin =
+    "http://localhost:3000"
diff --git a/client/src/State.elm b/client/src/State.elm
index 8c56a7ecce1d..8595ee4dd3e8 100644
--- a/client/src/State.elm
+++ b/client/src/State.elm
@@ -1,29 +1,322 @@
 module State exposing (..)
 
+import Array exposing (Array)
+import Browser
+import Browser.Navigation as Nav
+import Http
+import Json.Decode as JD
+import Json.Decode.Extra as JDE
+import Json.Encode as JE
+import Process
+import RemoteData exposing (WebData)
+import Shared
+import Task
+import Time
+import Url
+import Url.Builder as UrlBuilder
+import Url.Parser exposing ((</>), Parser, int, map, oneOf, s, string)
+import Utils
+
+
+
+--------------------------------------------------------------------------------
+-- Types
+--------------------------------------------------------------------------------
+
 
 type Msg
     = DoNothing
-    | SetView View
+    | UpdateUsername String
+    | UpdatePassword String
+    | UpdateRole String
+    | UpdateAdminTab AdminTab
+    | ClearErrors
+      -- SPA
+    | LinkClicked Browser.UrlRequest
+    | UrlChanged Url.Url
+      -- Outbound network
+    | AttemptGetUsers
+    | AttemptSignUp Role
+    | AttemptLogin
+    | AttemptLogout
+    | AttemptDeleteUser String
+      -- Inbound network
+    | GotUsers (WebData AllUsers)
+    | GotSignUp (Result Http.Error Session)
+    | GotLogin (Result Http.Error Session)
+    | GotLogout (Result Http.Error String)
+    | GotDeleteUser (Result Http.Error String)
+
+
+type Route
+    = Login
+    | UserHome
+    | ManagerHome
+    | AdminHome
+
+
+type Role
+    = User
+    | Manager
+    | Admin
+
+
+type alias AllUsers =
+    { user : List String
+    , manager : List String
+    , admin : List String
+    }
+
+
+type alias Session =
+    { role : Role
+    , username : String
+    }
+
+
+type alias Review =
+    { rowid : Int
+    , content : String
+    , rating : Int
+    , user : String
+    , dateOfVisit : String
+    }
+
+
+type alias Reviews =
+    { hi : Maybe Review
+    , lo : Maybe Review
+    , all : List Review
+    }
 
 
-type View
-    = Landing
-    | Login
+type AdminTab
+    = Users
 
 
 type alias Model =
-    { isLoading : Bool
-    , view : View
+    { route : Maybe Route
+    , url : Url.Url
+    , key : Nav.Key
+    , session : Maybe Session
+    , username : String
+    , password : String
+    , role : Maybe Role
+    , users : WebData AllUsers
+    , adminTab : AdminTab
+    , loginError : Maybe Http.Error
+    , logoutError : Maybe Http.Error
+    , signUpError : Maybe Http.Error
+    , deleteUserError : Maybe Http.Error
+    }
+
+
+
+--------------------------------------------------------------------------------
+-- Functions
+--------------------------------------------------------------------------------
+
+
+roleToString : Role -> String
+roleToString role =
+    case role of
+        User ->
+            "user"
+
+        Manager ->
+            "manager"
+
+        Admin ->
+            "admin"
+
+
+endpoint : List String -> List UrlBuilder.QueryParameter -> String
+endpoint =
+    UrlBuilder.crossOrigin Shared.serverOrigin
+
+
+decodeRole : JD.Decoder Role
+decodeRole =
+    let
+        toRole : String -> JD.Decoder Role
+        toRole s =
+            case s of
+                "user" ->
+                    JD.succeed User
+
+                "manager" ->
+                    JD.succeed Manager
+
+                "admin" ->
+                    JD.succeed Admin
+
+                _ ->
+                    JD.succeed User
+    in
+    JD.string |> JD.andThen toRole
+
+
+decodeSession : JD.Decoder Session
+decodeSession =
+    JD.map2
+        Session
+        (JD.field "role" decodeRole)
+        (JD.field "username" JD.string)
+
+
+encodeLoginRequest : String -> String -> JE.Value
+encodeLoginRequest username password =
+    JE.object
+        [ ( "username", JE.string username )
+        , ( "password", JE.string password )
+        ]
+
+
+login : String -> String -> Cmd Msg
+login username password =
+    Utils.postWithCredentials
+        { url = endpoint [ "login" ] []
+        , body = Http.jsonBody (encodeLoginRequest username password)
+        , expect = Http.expectJson GotLogin decodeSession
+        }
+
+
+logout : Cmd Msg
+logout =
+    Utils.getWithCredentials
+        { url = endpoint [ "logout" ] []
+        , expect = Http.expectString GotLogout
+        }
+
+
+signUp :
+    { username : String
+    , password : String
+    , role : Role
     }
+    -> Cmd Msg
+signUp { username, password, role } =
+    Utils.postWithCredentials
+        { url = endpoint [ "create-account" ] []
+        , body =
+            Http.jsonBody
+                (JE.object
+                    [ ( "username", JE.string username )
+                    , ( "password", JE.string password )
+                    , ( "role"
+                      , case role of
+                            User ->
+                                JE.string "user"
+
+                            Manager ->
+                                JE.string "manager"
+
+                            Admin ->
+                                JE.string "admin"
+                      )
+                    ]
+                )
+        , expect = Http.expectJson GotSignUp decodeSession
+        }
+
+
+deleteUser : String -> Cmd Msg
+deleteUser username =
+    Utils.deleteWithCredentials
+        { url = endpoint [ "user", username ] []
+        , expect = Http.expectString GotDeleteUser
+        }
+
+
+decodeReview : JD.Decoder Review
+decodeReview =
+    JD.map5
+        Review
+        (JD.field "rowid" JD.int)
+        (JD.field "content" JD.string)
+        (JD.field "rating" JD.int)
+        (JD.field "user" JD.string)
+        (JD.field "timestamp" JD.string)
+
+
+fetchUsers : Cmd Msg
+fetchUsers =
+    Utils.getWithCredentials
+        { url = endpoint [ "all-usernames" ] []
+        , expect =
+            Http.expectJson
+                (RemoteData.fromResult >> GotUsers)
+                (JD.map3
+                    AllUsers
+                    (JD.field "user" (JD.list JD.string))
+                    (JD.field "manager" (JD.list JD.string))
+                    (JD.field "admin" (JD.list JD.string))
+                )
+        }
+
+
+sleepAndClearErrors : Cmd Msg
+sleepAndClearErrors =
+    Process.sleep 4000
+        |> Task.perform (\_ -> ClearErrors)
+
+
+isAuthorized : Role -> Route -> Bool
+isAuthorized role route =
+    case ( role, route ) of
+        ( User, _ ) ->
+            True
+
+        ( Manager, _ ) ->
+            True
+
+        ( Admin, _ ) ->
+            True
+
+
+homeRouteForRole : Role -> String
+homeRouteForRole role =
+    case role of
+        User ->
+            "/user"
+
+        Manager ->
+            "/manager"
+
+        Admin ->
+            "/admin"
+
+
+routeParser : Parser (Route -> a) a
+routeParser =
+    oneOf
+        [ map Login (s "topic")
+        , map UserHome (s "user")
+        , map ManagerHome (s "manager")
+        , map AdminHome (s "admin")
+        ]
 
 
 {-| The initial state for the application.
 -}
-init : Model
-init =
-    { isLoading = False
-    , view = Login
-    }
+init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
+init _ url key =
+    ( { route = Nothing
+      , url = url
+      , key = key
+      , session = Nothing
+      , username = ""
+      , password = ""
+      , role = Nothing
+      , users = RemoteData.NotAsked
+      , adminTab = Users
+      , loginError = Nothing
+      , logoutError = Nothing
+      , signUpError = Nothing
+      , deleteUserError = Nothing
+      }
+    , Cmd.none
+    )
 
 
 {-| Now that we have state, we need a function to change the state.
@@ -34,10 +327,171 @@ update msg model =
         DoNothing ->
             ( model, Cmd.none )
 
-        SetView x ->
+        UpdateUsername x ->
+            ( { model | username = x }, Cmd.none )
+
+        UpdatePassword x ->
+            ( { model | password = x }, Cmd.none )
+
+        UpdateAdminTab x ->
+            ( { model | adminTab = x }, Cmd.none )
+
+        UpdateRole x ->
+            let
+                maybeRole =
+                    case x of
+                        "user" ->
+                            Just User
+
+                        "owner" ->
+                            Just Manager
+
+                        "admin" ->
+                            Just Admin
+
+                        _ ->
+                            Nothing
+            in
+            ( { model | role = maybeRole }, Cmd.none )
+
+        ClearErrors ->
             ( { model
-                | view = x
-                , isLoading = True
+                | loginError = Nothing
+                , logoutError = Nothing
+                , signUpError = Nothing
+                , deleteUserError = Nothing
               }
             , Cmd.none
             )
+
+        LinkClicked urlRequest ->
+            case urlRequest of
+                Browser.Internal url ->
+                    ( model, Nav.pushUrl model.key (Url.toString url) )
+
+                Browser.External href ->
+                    ( model, Nav.load href )
+
+        UrlChanged url ->
+            let
+                route =
+                    Url.Parser.parse routeParser url
+            in
+            case route of
+                Just UserHome ->
+                    ( { model
+                        | url = url
+                        , route = route
+                      }
+                    , Cmd.none
+                    )
+
+                Just ManagerHome ->
+                    case model.session of
+                        Nothing ->
+                            ( { model
+                                | url = url
+                                , route = route
+                              }
+                            , Cmd.none
+                            )
+
+                        Just session ->
+                            ( { model
+                                | url = url
+                                , route = route
+                              }
+                            , Cmd.none
+                            )
+
+                Just AdminHome ->
+                    ( { model
+                        | url = url
+                        , route = route
+                        , users = RemoteData.Loading
+                      }
+                    , Cmd.none
+                    )
+
+                _ ->
+                    ( { model
+                        | url = url
+                        , route = route
+                      }
+                    , Cmd.none
+                    )
+
+        -- GET /all-usernames
+        AttemptGetUsers ->
+            ( { model | users = RemoteData.Loading }, fetchUsers )
+
+        GotUsers xs ->
+            ( { model | users = xs }, Cmd.none )
+
+        -- DELETE /user/:username
+        AttemptDeleteUser username ->
+            ( model, deleteUser username )
+
+        GotDeleteUser result ->
+            case result of
+                Ok _ ->
+                    ( model, fetchUsers )
+
+                Err e ->
+                    ( { model | deleteUserError = Just e }
+                    , sleepAndClearErrors
+                    )
+
+        -- /create-account
+        AttemptSignUp role ->
+            ( model
+            , signUp
+                { username = model.username
+                , password = model.password
+                , role = role
+                }
+            )
+
+        GotSignUp result ->
+            case result of
+                Ok session ->
+                    ( { model | session = Just session }
+                    , Nav.pushUrl model.key (homeRouteForRole session.role)
+                    )
+
+                Err x ->
+                    ( { model | signUpError = Just x }
+                    , sleepAndClearErrors
+                    )
+
+        -- /login
+        AttemptLogin ->
+            ( model, login model.username model.password )
+
+        GotLogin result ->
+            case result of
+                Ok session ->
+                    ( { model | session = Just session }
+                    , Nav.pushUrl model.key (homeRouteForRole session.role)
+                    )
+
+                Err x ->
+                    ( { model | loginError = Just x }
+                    , sleepAndClearErrors
+                    )
+
+        -- / logout
+        AttemptLogout ->
+            ( model, logout )
+
+        GotLogout result ->
+            case result of
+                Ok _ ->
+                    ( { model | session = Nothing }
+                    , Nav.pushUrl model.key "/login"
+                    )
+
+                Err e ->
+                    ( { model | logoutError = Just e }
+                    , sleepAndClearErrors
+                    )
diff --git a/client/src/Tailwind.elm b/client/src/Tailwind.elm
new file mode 100644
index 000000000000..57d419db5a82
--- /dev/null
+++ b/client/src/Tailwind.elm
@@ -0,0 +1,29 @@
+module Tailwind exposing (..)
+
+{-| Functions to make Tailwind development in Elm even more pleasant.
+-}
+
+
+{-| Conditionally use `class` selection when `condition` is true.
+-}
+when : Bool -> String -> String
+when condition class =
+    if condition then
+        class
+
+    else
+        ""
+
+
+if_ : Bool -> String -> String -> String
+if_ condition whenTrue whenFalse =
+    if condition then
+        whenTrue
+
+    else
+        whenFalse
+
+
+use : List String -> String
+use styles =
+    String.join " " styles
diff --git a/client/src/UI.elm b/client/src/UI.elm
new file mode 100644
index 000000000000..565771e2120d
--- /dev/null
+++ b/client/src/UI.elm
@@ -0,0 +1,254 @@
+module UI exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import State
+import Tailwind
+
+
+label_ : { for_ : String, text_ : String } -> Html msg
+label_ { for_, text_ } =
+    label
+        [ [ "block"
+          , "text-gray-700"
+          , "text-sm"
+          , "font-bold"
+          , "mb-2"
+          ]
+            |> Tailwind.use
+            |> class
+        , for for_
+        ]
+        [ text text_ ]
+
+
+errorBanner : { title : String, body : String } -> Html msg
+errorBanner { title, body } =
+    div
+        [ [ "text-left"
+          , "fixed"
+          , "container"
+          , "top-0"
+          , "mt-6"
+          ]
+            |> Tailwind.use
+            |> class
+        , style "left" "50%"
+
+        -- TODO(wpcarro): Consider supporting breakpoints, but for now
+        -- don't.
+        , style "margin-left" "-512px"
+        ]
+        [ div
+            [ [ "bg-red-500"
+              , "text-white"
+              , "font-bold"
+              , "rounded-t"
+              , "px-4"
+              , "py-2"
+              ]
+                |> Tailwind.use
+                |> class
+            ]
+            [ text title ]
+        , div
+            [ [ "border"
+              , "border-t-0"
+              , "border-red-400"
+              , "rounded-b"
+              , "bg-red-100"
+              , "px-4"
+              , "py-3"
+              , "text-red-700"
+              ]
+                |> Tailwind.use
+                |> class
+            ]
+            [ p [] [ text body ] ]
+        ]
+
+
+baseButton :
+    { label : String
+    , enabled : Bool
+    , handleClick : msg
+    , extraClasses : List String
+    }
+    -> Html msg
+baseButton { label, enabled, handleClick, extraClasses } =
+    button
+        [ [ if enabled then
+                "bg-blue-500"
+
+            else
+                "bg-gray-500"
+          , if enabled then
+                "hover:bg-blue-700"
+
+            else
+                ""
+          , if enabled then
+                ""
+
+            else
+                "cursor-not-allowed"
+          , "text-white"
+          , "font-bold"
+          , "py-2"
+          , "px-4"
+          , "rounded"
+          , "focus:outline-none"
+          , "focus:shadow-outline"
+          ]
+            ++ extraClasses
+            |> Tailwind.use
+            |> class
+        , onClick handleClick
+        , disabled (not enabled)
+        ]
+        [ text label ]
+
+
+simpleButton :
+    { label : String
+    , handleClick : msg
+    }
+    -> Html msg
+simpleButton { label, handleClick } =
+    baseButton
+        { label = label
+        , enabled = True
+        , handleClick = handleClick
+        , extraClasses = []
+        }
+
+
+textField :
+    { pholder : String
+    , inputId : String
+    , handleInput : String -> msg
+    , inputValue : String
+    }
+    -> Html msg
+textField { pholder, inputId, handleInput, inputValue } =
+    input
+        [ [ "shadow"
+          , "appearance-none"
+          , "border"
+          , "rounded"
+          , "w-full"
+          , "py-2"
+          , "px-3"
+          , "text-gray-700"
+          , "leading-tight"
+          , "focus:outline-none"
+          , "focus:shadow-outline"
+          ]
+            |> Tailwind.use
+            |> class
+        , id inputId
+        , value inputValue
+        , placeholder pholder
+        , onInput handleInput
+        ]
+        []
+
+
+toggleButton :
+    { toggled : Bool
+    , label : String
+    , handleEnable : msg
+    , handleDisable : msg
+    }
+    -> Html msg
+toggleButton { toggled, label, handleEnable, handleDisable } =
+    button
+        [ [ if toggled then
+                "bg-blue-700"
+
+            else
+                "bg-blue-500"
+          , "hover:bg-blue-700"
+          , "text-white"
+          , "font-bold"
+          , "py-2"
+          , "px-4"
+          , "rounded"
+          , "focus:outline-none"
+          , "focus:shadow-outline"
+          ]
+            |> Tailwind.use
+            |> class
+        , onClick
+            (if toggled then
+                handleDisable
+
+             else
+                handleEnable
+            )
+        ]
+        [ text label ]
+
+
+paragraph : String -> Html msg
+paragraph x =
+    p [ [ "text-xl" ] |> Tailwind.use |> class ] [ text x ]
+
+
+header : Int -> String -> Html msg
+header which x =
+    let
+        hStyles =
+            case which of
+                1 ->
+                    [ "text-6xl"
+                    , "py-12"
+                    ]
+
+                2 ->
+                    [ "text-3xl"
+                    , "py-6"
+                    ]
+
+                _ ->
+                    [ "text-2xl"
+                    , "py-2"
+                    ]
+    in
+    h1
+        [ hStyles
+            ++ [ "font-bold"
+               , "text-gray-700"
+               ]
+            |> Tailwind.use
+            |> class
+        ]
+        [ text x ]
+
+
+link : String -> String -> Html msg
+link path label =
+    a
+        [ href path
+        , [ "underline"
+          , "text-blue-600"
+          , "text-xl"
+          ]
+            |> Tailwind.use
+            |> class
+        ]
+        [ text label ]
+
+
+absentData : { handleFetch : msg } -> Html msg
+absentData { handleFetch } =
+    div []
+        [ paragraph "Welp... it looks like you've caught us in a state that we considered impossible: we did not fetch the data upon which this page depends. Maybe you can help us out by clicking the super secret, highly privileged \"Fetch data\" button below (we don't normally show people this)."
+        , div [ [ "py-4" ] |> Tailwind.use |> class ]
+            [ simpleButton
+                { label = "Fetch data"
+                , handleClick = handleFetch
+                }
+            ]
+        ]
diff --git a/client/src/User.elm b/client/src/User.elm
new file mode 100644
index 000000000000..7139d2028368
--- /dev/null
+++ b/client/src/User.elm
@@ -0,0 +1,39 @@
+module User exposing (render)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Maybe.Extra
+import RemoteData
+import State
+import Tailwind
+import UI
+import Utils
+
+
+render : State.Model -> Html State.Msg
+render model =
+    div
+        [ class
+            ([ "container"
+             , "mx-auto"
+             , "text-center"
+             ]
+                |> Tailwind.use
+            )
+        ]
+        [ UI.header 2 ("Welcome, " ++ model.username ++ "!")
+        , UI.simpleButton
+            { label = "Logout"
+            , handleClick = State.AttemptLogout
+            }
+        , case model.logoutError of
+            Nothing ->
+                text ""
+
+            Just e ->
+                UI.errorBanner
+                    { title = "Error logging out"
+                    , body = Utils.explainHttpError e
+                    }
+        ]
diff --git a/client/src/Utils.elm b/client/src/Utils.elm
new file mode 100644
index 000000000000..0f6c61ed286f
--- /dev/null
+++ b/client/src/Utils.elm
@@ -0,0 +1,90 @@
+module Utils exposing (..)
+
+import DateFormat
+import Http
+import Time
+import Shared
+
+
+explainHttpError : Http.Error -> String
+explainHttpError e =
+    case e of
+        Http.BadUrl _ ->
+            "Bad URL: you may have supplied an improperly formatted URL"
+
+        Http.Timeout ->
+            "Timeout: the resource you requested did not arrive within the interval of time that you claimed it should"
+
+        Http.BadStatus s ->
+            "Bad Status: the server returned a bad status code: " ++ String.fromInt s
+
+        Http.BadBody b ->
+            "Bad Body: our application had trouble decoding the body of the response from the server: " ++ b
+
+        Http.NetworkError ->
+            "Network Error: something went awry in the network stack. I recommend checking the server logs if you can."
+
+
+getWithCredentials :
+    { url : String
+    , expect : Http.Expect msg
+    }
+    -> Cmd msg
+getWithCredentials { url, expect } =
+    Http.riskyRequest
+        { url = url
+        , headers = [ Http.header "Origin" Shared.clientOrigin ]
+        , method = "GET"
+        , timeout = Nothing
+        , tracker = Nothing
+        , body = Http.emptyBody
+        , expect = expect
+        }
+
+
+postWithCredentials :
+    { url : String
+    , body : Http.Body
+    , expect : Http.Expect msg
+    }
+    -> Cmd msg
+postWithCredentials { url, body, expect } =
+    Http.riskyRequest
+        { url = url
+        , headers = [ Http.header "Origin" Shared.clientOrigin ]
+        , method = "POST"
+        , timeout = Nothing
+        , tracker = Nothing
+        , body = body
+        , expect = expect
+        }
+
+
+deleteWithCredentials :
+    { url : String
+    , expect : Http.Expect msg
+    }
+    -> Cmd msg
+deleteWithCredentials { url, expect } =
+    Http.riskyRequest
+        { url = url
+        , headers = [ Http.header "Origin" Shared.clientOrigin ]
+        , method = "DELETE"
+        , timeout = Nothing
+        , tracker = Nothing
+        , body = Http.emptyBody
+        , expect = expect
+        }
+
+
+formatTime : Time.Posix -> String
+formatTime ts =
+    DateFormat.format
+        [ DateFormat.monthNameFull
+        , DateFormat.text " "
+        , DateFormat.dayOfMonthSuffix
+        , DateFormat.text ", "
+        , DateFormat.yearNumber
+        ]
+        Time.utc
+        ts