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:
William Carroll 2020-07-31 18:32:00 +01:00
parent 29a00dc571
commit 421c71c892
15 changed files with 1301 additions and 54 deletions

View file

@ -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
```

View file

@ -1,3 +1,3 @@
let
briefcase = import <briefcase> {};
briefcase = import /home/wpcarro/briefcase {};
in briefcase.utils.nixBufferFromShell ./shell.nix

View file

@ -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": {

View file

@ -2,6 +2,7 @@ let
pkgs = import <nixpkgs> {};
in pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
elmPackages.elm
elmPackages.elm-format
elmPackages.elm-live

99
client/src/Admin.elm Normal file
View 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
}
]

View file

@ -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!" ]
]

View file

@ -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

View file

@ -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
view : State.Model -> Html State.Msg
view model =
case model.view of
State.Landing ->
Landing.render model
viewForRoute : State.Route -> (State.Model -> Html State.Msg)
viewForRoute route =
case route of
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 =
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
}

46
client/src/Manager.elm Normal file
View 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
View file

@ -0,0 +1,7 @@
module Shared exposing (..)
clientOrigin =
"http://localhost:8000"
serverOrigin =
"http://localhost:3000"

View file

@ -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 View
= Landing
| Login
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 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
)

29
client/src/Tailwind.elm Normal file
View 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
View 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
View 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
View 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