Log on works with htmx

This commit is contained in:
Daniel J. Summers 2021-09-30 19:30:34 -04:00
parent 0f9b128c79
commit ef1553ff4e
6 changed files with 232 additions and 39 deletions

View File

@ -1,6 +1,9 @@
## LiteDB database file
*.db
## Auth0 settings
wwwroot/auth-config.json
## Web application compile output
wwwroot/favicon.ico
wwwroot/index.html

View File

@ -9,7 +9,7 @@ open Giraffe.Htmx
open MyPrayerJournal.Data.Extensions
/// Send a partial result if this is not a full page load
let partialIfNotRefresh content layout : HttpHandler =
let partialIfNotRefresh content : HttpHandler =
fun next ctx -> task {
let hdrs = Headers.fromRequest ctx
let isHtmx =
@ -24,7 +24,7 @@ let partialIfNotRefresh content layout : HttpHandler =
|> function Some (HistoryRestoreRequest hist) -> hist | _ -> false
match isHtmx && not isRefresh with
| true -> return! ctx.WriteHtmlViewAsync content
| false -> return! layout content |> ctx.WriteHtmlViewAsync
| false -> return! Views.Layout.view content |> ctx.WriteHtmlViewAsync
}
/// Handler to return Vue files
@ -33,7 +33,7 @@ module Vue =
/// The application index page
let app : HttpHandler =
Headers.toResponse (Trigger "menu-refresh")
>=> partialIfNotRefresh (ViewEngine.HtmlElements.str "It works") Views.Layout.wide
>=> partialIfNotRefresh (ViewEngine.HtmlElements.str "It works")
open System
@ -60,6 +60,8 @@ module Error =
open Cuid
open LiteDB
open System.Security.Claims
open Microsoft.Extensions.Logging
/// Handler helpers
[<AutoOpen>]
@ -67,7 +69,6 @@ module private Helpers =
open Microsoft.AspNetCore.Http
open System.Threading.Tasks
open System.Security.Claims
/// Get the LiteDB database
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
@ -108,6 +109,18 @@ module private Helpers =
let asJson<'T> next ctx (o : 'T) =
json o next ctx
/// Trigger a menu item refresh
let withMenuRefresh : HttpHandler =
// let trigger = //string ctx.Request.Path |> sprintf "{ \"menu-refresh\": \"%s\" }" :> obj |> TriggerAfterSwap
Headers.toResponse (TriggerAfterSettle "menu-refresh")
/// Render a component result
let renderComponent nodes : HttpHandler =
fun next ctx -> task {
return! ctx.WriteHtmlStringAsync (ViewEngine.RenderView.AsString.htmlNodes nodes)
}
/// Strongly-typed models for post requests
module Models =
@ -166,13 +179,29 @@ module Components =
Headers.fromRequest ctx
|> List.tryFind HtmxReqHeader.isCurrentUrl
|> function Some (CurrentUrl u) -> Some u | _ -> None
let view = Views.Navigation.currentNav false false url |> ViewEngine.RenderView.AsString.htmlNodes
return! ctx.WriteHtmlStringAsync view
let isAuthorized = ctx |> (user >> Option.isSome)
return! renderComponent (Views.Navigation.currentNav isAuthorized false url) next ctx
}
// GET /components/journal-items
let journalItems : HttpHandler =
authorize
>=> fun next ctx -> task {
let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
do! System.Threading.Tasks.Task.Delay (TimeSpan.FromSeconds 5.)
return! renderComponent [ Views.Journal.journalItems jrnl ] next ctx
}
/// / URL
module Home =
/// /api/journal URLs
// GET /
let home : HttpHandler =
withMenuRefresh >=> partialIfNotRefresh Views.Home.home
/// /api/journal and /journal URLs
module Journal =
/// GET /api/journal
@ -183,16 +212,25 @@ module Journal =
return! json jrnl next ctx
}
// GET /journal
let journalPage : HttpHandler =
authorize
>=> withMenuRefresh
>=> fun next ctx -> task {
let usr = ctx.Request.Headers.["X-Given-Name"].[0]
return! partialIfNotRefresh (Views.Journal.journal usr) next ctx
}
/// Legalese
module Legal =
// GET /legal/privacy-policy
let privacyPolicy : HttpHandler =
partialIfNotRefresh Views.Legal.privacyPolicy Views.Layout.standard
withMenuRefresh >=> partialIfNotRefresh Views.Legal.privacyPolicy
let termsOfService : HttpHandler =
partialIfNotRefresh Views.Legal.termsOfService Views.Layout.standard
withMenuRefresh >=> partialIfNotRefresh Views.Legal.termsOfService
/// /api/request URLs
@ -362,10 +400,12 @@ open Giraffe.EndpointRouting
/// The routes for myPrayerJournal
let routes =
[ route "/" Vue.app
[ route "/" Home.home
subRoute "/components/" [
route "journal-items" Components.journalItems
route "nav-items" Components.navItems
]
route "/journal" Journal.journalPage
subRoute "/legal/" [
route "privacy-policy" Legal.privacyPolicy
route "terms-of-service" Legal.termsOfService

View File

@ -76,7 +76,7 @@ module Configure =
fun opts ->
let jwtCfg = bldr.Configuration.GetSection "Auth0"
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"]
opts.Audience <- jwtCfg.["Id"])
opts.Audience <- jwtCfg.["Audience"])
|> ignore
let jsonOptions = JsonSerializerOptions ()
jsonOptions.Converters.Add (JsonFSharpConverter ())

View File

@ -4,13 +4,33 @@ open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx
open System
/// Target the `main` tag with boosted links
let toMain = _hxTarget "main"
/// View for home page
module Home =
/// The home page
let home = article [ _class "container mt-3" ] [
p [] [ rawText "&nbsp;" ]
p [] [
str "myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for "
str "them, update them as God moves in the situation, and record a final answer received on that request. It "
str "also allows individuals to review their answered prayers."
]
p [] [
str "This site is open and available to the general public. To get started, simply click the "
rawText "&ldquo;Log On&rdquo; link above, and log on with either a Microsoft or Google account. You can also "
rawText "learn more about the site at the &ldquo;Docs&rdquo; link, also above."
]
]
/// Views for legal pages
module Legal =
/// View for the "Privacy Policy" page
let privacyPolicy = article [] [
let privacyPolicy = article [ _class "container mt-3" ] [
div [ _class "card" ] [
h5 [ _class "card-header" ] [ str "Privacy Policy" ]
div [ _class "card-body" ] [
@ -92,7 +112,7 @@ module Legal =
]
/// View for the "Terms of Service" page
let termsOfService = article [ _class "container" ] [
let termsOfService = article [ _class "container mt-3" ] [
div [ _class "card" ] [
h5 [ _class "card-header" ] [ str "Terms of Service" ]
div [ _class "card-body" ] [
@ -144,14 +164,15 @@ module Legal =
]
]
/// Views for navigation support
module Navigation =
/// The default navigation bar, which will load the items on page load, and whenever a refresh event occurs
let navBar =
nav [ _class "navbar navbar-dark"; _hxBoost; toMain ] [
nav [ _class "navbar navbar-dark" ] [
div [ _class "container-fluid" ] [
a [ _href "/"; _class "navbar-brand" ] [
a [ _href "/"; _class "navbar-brand"; _hxBoost; toMain ] [
span [ _class "m" ] [ str "my" ]
span [ _class "p" ] [ str "Prayer" ]
span [ _class "j" ] [ str "Journal" ]
@ -171,23 +192,58 @@ module Navigation =
match isAuthenticated with
| true ->
let currUrl = match url with Some u -> (u.PathAndQuery.Split '?').[0] | None -> ""
let deriveClass (matchUrl : string) css =
let attrs (matchUrl : string) =
[ _href matchUrl
match currUrl.StartsWith matchUrl with
| true -> sprintf "%s is-active-route" css
| false -> css
|> _class
li [ deriveClass "/journal" "nav-item" ] [ a [ _href "/journal" ] [ str "Journal" ] ]
li [ deriveClass "/requests/active" "nav-item" ] [ a [ _href "/requests/active" ] [ str "Active" ] ]
if hasSnoozed then
li [ deriveClass "/requests/snoozed" "nav-item" ] [ a [ _href "/requests/snoozed" ] [ str "Snoozed" ] ]
li [ deriveClass "/requests/answered" "nav-item" ] [ a [ _href "/requests/answered" ] [ str "Answered" ] ]
li [ _class "nav-item" ] [ a [ _href "/user/log-off"; _onclick "logOff()" ] [ str "Log Off" ] ]
| false -> li [ _class "nav-item"] [ a [ _href "/user/log-on"; _onclick "logOn()"] [ str "Log On" ] ]
| true -> _class "is-active-route"
| false -> ()
_hxBoost; toMain
]
li [ _class "nav-item" ] [ a (attrs "/journal") [ str "Journal" ] ]
li [ _class "nav-item" ] [ a (attrs "/requests/active") [ str "Active" ] ]
if hasSnoozed then li [ _class "nav-item" ] [ a (attrs "/requests/snoozed") [ str "Snoozed" ] ]
li [ _class "nav-item" ] [ a (attrs "/requests/answered") [ str "Answered" ] ]
li [ _class "nav-item" ] [ a [ _href "/user/log-off"; _onclick "mpj.logOff(event)" ] [ str "Log Off" ] ]
| false -> li [ _class "nav-item"] [ a [ _href "/user/log-on"; _onclick "mpj.logOn(event)"] [ str "Log On" ] ]
li [ _class "nav-item" ] [ a [ _href "https://docs.prayerjournal.me"; _target "_blank" ] [ str "Docs" ] ]
}
|> List.ofSeq
/// Views for journal pages and components
module Journal =
/// The journal loading page
let journal user = article [ _class "container-fluid mt-3" ] [
h2 [ _class "pb-3" ] [ str user; rawText "&rsquo;s Prayer Journal" ]
p [
_hxGet "/components/journal-items"
_hxSwap HxSwap.OuterHtml
_hxTrigger HxTrigger.Load
] [ rawText "Loading your prayer journal&hellip;" ]
]
/// The journal items
let journalItems items =
match items |> List.isEmpty with
| true ->
div [ _class "card no-requests" ] [
h5 [ _class "card-header"] [ str "No Active Requests" ]
div [ _class "card-body text-center" ] [
p [ _class "card-text" ] [
rawText "You have no requests to be shown; see the &ldquo;Active&rdquo; link above for snoozed or "
rawText "deferred requests, and the &ldquo;Answered&rdquo; link for answered requests"
]
a [
_class "btn btn-primary"
_href "/request/new/edit"
_hxBoost; toMain
] [ str "Add a Request" ]
]
]
| false -> p [] [ str "There are requests" ]
/// Layout views
module Layout =
@ -199,7 +255,7 @@ module Layout =
_integrity "sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
_crossorigin "anonymous"
]
link [ _href "/css/style.css"; _rel "stylesheet" ]
link [ _href "/style/style.css"; _rel "stylesheet" ]
script [
_src "https://unpkg.com/htmx.org@1.5.0"
_integrity "sha384-oGA+prIp5Vchu6we2YkI51UtVzN9Jpx2Z7PnR1I78PnZlN8LkrCT4lqqqmDkyrvI"
@ -225,25 +281,23 @@ module Layout =
]
]
script [
_async
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
_integrity "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
_crossorigin "anonymous"
] []
script [ _src "https://cdn.auth0.com/js/auth0-spa-js/1.13/auth0-spa-js.production.js" ] []
script [ _src "/script/mpj.js" ] []
]
let full content =
/// Create the full view of the page
let view content =
html [ _lang "en" ] [
htmlHead
body [] [
body [ _hxHeaders "" ] [
Navigation.navBar
content
main [] [ content ]
htmlFoot
]
]
let standard content =
main [ _class "container" ] [ content ] |> full
let wide content =
main [ _class "container-fluid" ] [ content ] |> full

View File

@ -0,0 +1,59 @@
"use strict"
/** myPrayerJournal script */
const mpj = {
/** Auth0 configuration */
auth: {
/** The Auth0 client */
auth0: null,
/** Configure the Auth0 client */
configureClient: async () => {
const response = await fetch("/auth-config.json")
const config = await response.json()
mpj.auth.auth0 = await createAuth0Client({
domain: config.domain,
client_id: config.clientId,
audience: config.audience
})
}
},
/** Whether the user is currently authenticated */
isAuthenticated: false,
/** Process a log on request */
logOn: async (e) => {
e.preventDefault()
await mpj.auth.auth0.loginWithRedirect({ redirect_uri: window.location.origin })
},
/** Log the user off */
logOff: (e) => {
e.preventDefault()
mpj.auth.auth0.logout({ returnTo: window.location.origin })
}
}
window.onload = async () => {
/** If the user is authenticated, set the JWT on the `body` tag */
const establishAuth = async () => {
mpj.isAuthenticated = await mpj.auth.auth0.isAuthenticated()
if (mpj.isAuthenticated) {
const token = await mpj.auth.auth0.getTokenSilently()
const user = await mpj.auth.auth0.getUser()
document.querySelector("body")
.setAttribute("hx-headers", `{ "Authorization": "Bearer ${token}", "X-Given-Name": "${user.given_name}" }`)
htmx.trigger(htmx.find(".navbar-nav"), "menu-refresh")
}
}
// Set up Auth0
await mpj.auth.configureClient()
await establishAuth()
if (mpj.isAuthenticated) return
// Handle log on code, if present
const query = window.location.search
if (query.includes("code=") && query.includes("state=")) {
await mpj.auth.auth0.handleRedirectCallback()
await establishAuth()
window.history.replaceState({}, document.title, "/")
}
}

View File

@ -0,0 +1,37 @@
nav {
background-color: green;
}
nav .m {
font-weight: 100;
}
nav .p {
font-weight: 400;
}
nav .j {
font-weight: 700;
}
.nav-item a:link,
.nav-item a:visited {
padding: .5rem 1rem;
margin: 0 .5rem;
border-radius: .5rem;
color: white;
text-decoration: none;
}
.nav-item a:hover {
cursor: pointer;
background-color: rgba(255, 255, 255, .2);
}
.navbar-nav .is-active-route {
background-color: rgba(255, 255, 255, .2);
}
footer {
border-top: solid 1px lightgray;
margin: 1rem -1rem 0;
padding: 0 1rem;
}
footer p {
margin: 0;
}