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
This commit is contained in:
parent
29a00dc571
commit
421c71c892
15 changed files with 1301 additions and 54 deletions
|
@ -11,6 +11,7 @@ in which you can develop:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ nix-shell
|
$ nix-shell
|
||||||
|
$ npx tailwindcss build index.css -o output.css
|
||||||
$ elm-live -- src/Main.elm --output=Main.min.js
|
$ elm-live -- src/Main.elm --output=Main.min.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
let
|
let
|
||||||
briefcase = import <briefcase> {};
|
briefcase = import /home/wpcarro/briefcase {};
|
||||||
in briefcase.utils.nixBufferFromShell ./shell.nix
|
in briefcase.utils.nixBufferFromShell ./shell.nix
|
||||||
|
|
|
@ -9,18 +9,26 @@
|
||||||
"elm/browser": "1.0.2",
|
"elm/browser": "1.0.2",
|
||||||
"elm/core": "1.0.5",
|
"elm/core": "1.0.5",
|
||||||
"elm/html": "1.0.0",
|
"elm/html": "1.0.0",
|
||||||
|
"elm/http": "2.0.0",
|
||||||
|
"elm/json": "1.1.3",
|
||||||
"elm/random": "1.0.0",
|
"elm/random": "1.0.0",
|
||||||
"elm/svg": "1.0.1",
|
"elm/svg": "1.0.1",
|
||||||
"elm/time": "1.0.0",
|
"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/list-extra": "8.2.3",
|
||||||
"elm-community/maybe-extra": "5.2.0",
|
"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": {
|
"indirect": {
|
||||||
"elm/json": "1.1.3",
|
"elm/bytes": "1.0.8",
|
||||||
"elm/url": "1.0.0",
|
"elm/file": "1.0.5",
|
||||||
|
"elm/parser": "1.1.0",
|
||||||
"elm/virtual-dom": "1.0.2",
|
"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": {
|
"test-dependencies": {
|
||||||
|
|
|
@ -2,6 +2,7 @@ let
|
||||||
pkgs = import <nixpkgs> {};
|
pkgs = import <nixpkgs> {};
|
||||||
in pkgs.mkShell {
|
in pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
|
nodejs
|
||||||
elmPackages.elm
|
elmPackages.elm
|
||||||
elmPackages.elm-format
|
elmPackages.elm-format
|
||||||
elmPackages.elm-live
|
elmPackages.elm-live
|
||||||
|
|
99
client/src/Admin.elm
Normal file
99
client/src/Admin.elm
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
|
@ -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!" ]
|
|
||||||
]
|
|
|
@ -4,13 +4,214 @@ import Html exposing (..)
|
||||||
import Html.Attributes exposing (..)
|
import Html.Attributes exposing (..)
|
||||||
import Html.Events exposing (..)
|
import Html.Events exposing (..)
|
||||||
import State
|
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 : State.Model -> Html State.Msg
|
||||||
render model =
|
render model =
|
||||||
div [ class "pt-10 pb-20 px-10" ]
|
case model.session of
|
||||||
[ googleSignIn
|
Nothing ->
|
||||||
]
|
login model
|
||||||
|
|
||||||
|
Just x ->
|
||||||
|
logout model
|
||||||
|
|
|
@ -1,31 +1,62 @@
|
||||||
module Main exposing (main)
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Admin
|
||||||
import Browser
|
import Browser
|
||||||
import Html exposing (..)
|
import Html exposing (..)
|
||||||
import Landing
|
|
||||||
import Login
|
import Login
|
||||||
|
import Manager
|
||||||
import State
|
import State
|
||||||
|
import Url
|
||||||
|
import User
|
||||||
|
|
||||||
|
|
||||||
subscriptions : State.Model -> Sub State.Msg
|
viewForRoute : State.Route -> (State.Model -> Html State.Msg)
|
||||||
subscriptions model =
|
viewForRoute route =
|
||||||
Sub.none
|
case route of
|
||||||
|
|
||||||
|
|
||||||
view : State.Model -> Html State.Msg
|
|
||||||
view model =
|
|
||||||
case model.view of
|
|
||||||
State.Landing ->
|
|
||||||
Landing.render model
|
|
||||||
|
|
||||||
State.Login ->
|
State.Login ->
|
||||||
Login.render model
|
Login.render
|
||||||
|
|
||||||
|
State.UserHome ->
|
||||||
|
User.render
|
||||||
|
|
||||||
|
State.ManagerHome ->
|
||||||
|
Manager.render
|
||||||
|
|
||||||
|
State.AdminHome ->
|
||||||
|
Admin.render
|
||||||
|
|
||||||
|
|
||||||
|
view : State.Model -> Browser.Document State.Msg
|
||||||
|
view 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
|
||||||
|
|
||||||
|
( 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 =
|
main =
|
||||||
Browser.element
|
Browser.application
|
||||||
{ init = \() -> ( State.init, Cmd.none )
|
{ init = State.init
|
||||||
, subscriptions = subscriptions
|
, onUrlChange = State.UrlChanged
|
||||||
|
, onUrlRequest = State.LinkClicked
|
||||||
|
, subscriptions = \_ -> Sub.none
|
||||||
, update = State.update
|
, update = State.update
|
||||||
, view = view
|
, view = view
|
||||||
}
|
}
|
||||||
|
|
46
client/src/Manager.elm
Normal file
46
client/src/Manager.elm
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
7
client/src/Shared.elm
Normal file
7
client/src/Shared.elm
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
module Shared exposing (..)
|
||||||
|
|
||||||
|
clientOrigin =
|
||||||
|
"http://localhost:8000"
|
||||||
|
|
||||||
|
serverOrigin =
|
||||||
|
"http://localhost:3000"
|
|
@ -1,29 +1,322 @@
|
||||||
module State exposing (..)
|
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
|
type Msg
|
||||||
= DoNothing
|
= 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 View
|
type Route
|
||||||
= Landing
|
= Login
|
||||||
| 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 AdminTab
|
||||||
|
= Users
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
type alias Model =
|
||||||
{ isLoading : Bool
|
{ route : Maybe Route
|
||||||
, view : View
|
, 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.
|
{-| The initial state for the application.
|
||||||
-}
|
-}
|
||||||
init : Model
|
init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||||
init =
|
init _ url key =
|
||||||
{ isLoading = False
|
( { route = Nothing
|
||||||
, view = Login
|
, 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.
|
{-| Now that we have state, we need a function to change the state.
|
||||||
|
@ -34,10 +327,171 @@ update msg model =
|
||||||
DoNothing ->
|
DoNothing ->
|
||||||
( model, Cmd.none )
|
( 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
|
( { model
|
||||||
| view = x
|
| loginError = Nothing
|
||||||
, isLoading = True
|
, logoutError = Nothing
|
||||||
|
, signUpError = Nothing
|
||||||
|
, deleteUserError = Nothing
|
||||||
}
|
}
|
||||||
, Cmd.none
|
, 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
|
||||||
|
)
|
||||||
|
|
29
client/src/Tailwind.elm
Normal file
29
client/src/Tailwind.elm
Normal file
|
@ -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
|
254
client/src/UI.elm
Normal file
254
client/src/UI.elm
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
39
client/src/User.elm
Normal file
39
client/src/User.elm
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
90
client/src/Utils.elm
Normal file
90
client/src/Utils.elm
Normal file
|
@ -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
|
Loading…
Reference in a new issue