From ef1553ff4e6ae1eb2406b5ff891687f384690ee6 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 30 Sep 2021 19:30:34 -0400 Subject: [PATCH] Log on works with htmx --- src/MyPrayerJournal/Server/.gitignore | 3 + src/MyPrayerJournal/Server/Handlers.fs | 64 +++++++++-- src/MyPrayerJournal/Server/Program.fs | 2 +- src/MyPrayerJournal/Server/Views.fs | 106 +++++++++++++----- .../Server/wwwroot/script/mpj.js | 59 ++++++++++ .../Server/wwwroot/style/style.css | 37 ++++++ 6 files changed, 232 insertions(+), 39 deletions(-) create mode 100644 src/MyPrayerJournal/Server/wwwroot/script/mpj.js create mode 100644 src/MyPrayerJournal/Server/wwwroot/style/style.css diff --git a/src/MyPrayerJournal/Server/.gitignore b/src/MyPrayerJournal/Server/.gitignore index 35a495a..22673b1 100644 --- a/src/MyPrayerJournal/Server/.gitignore +++ b/src/MyPrayerJournal/Server/.gitignore @@ -1,6 +1,9 @@ ## LiteDB database file *.db +## Auth0 settings +wwwroot/auth-config.json + ## Web application compile output wwwroot/favicon.ico wwwroot/index.html diff --git a/src/MyPrayerJournal/Server/Handlers.fs b/src/MyPrayerJournal/Server/Handlers.fs index d3ff378..c922ec1 100644 --- a/src/MyPrayerJournal/Server/Handlers.fs +++ b/src/MyPrayerJournal/Server/Handlers.fs @@ -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 [] @@ -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() @@ -107,6 +108,18 @@ module private Helpers = /// Flip JSON result so we can pipe into it 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 @@ -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 } - -/// /api/journal URLs +/// / URL +module Home = + + // GET / + let home : HttpHandler = + withMenuRefresh >=> partialIfNotRefresh Views.Home.home + + +/// /api/journal and /journal URLs module Journal = /// GET /api/journal @@ -182,6 +211,15 @@ module Journal = let! jrnl = Data.journalByUserId (userId ctx) (db ctx) 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 @@ -189,10 +227,10 @@ 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 "nav-items" Components.navItems + 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 diff --git a/src/MyPrayerJournal/Server/Program.fs b/src/MyPrayerJournal/Server/Program.fs index 09f17dd..396ab25 100644 --- a/src/MyPrayerJournal/Server/Program.fs +++ b/src/MyPrayerJournal/Server/Program.fs @@ -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 ()) diff --git a/src/MyPrayerJournal/Server/Views.fs b/src/MyPrayerJournal/Server/Views.fs index b8e11b8..2d986b8 100644 --- a/src/MyPrayerJournal/Server/Views.fs +++ b/src/MyPrayerJournal/Server/Views.fs @@ -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 " " ] + 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 "“Log On” link above, and log on with either a Microsoft or Google account. You can also " + rawText "learn more about the site at the “Docs” 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 = - 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" ] ] + let attrs (matchUrl : string) = + [ _href matchUrl + match currUrl.StartsWith matchUrl with + | 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 "’s Prayer Journal" ] + p [ + _hxGet "/components/journal-items" + _hxSwap HxSwap.OuterHtml + _hxTrigger HxTrigger.Load + ] [ rawText "Loading your prayer journal…" ] + ] + + /// 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 “Active” link above for snoozed or " + rawText "deferred requests, and the “Answered” 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 diff --git a/src/MyPrayerJournal/Server/wwwroot/script/mpj.js b/src/MyPrayerJournal/Server/wwwroot/script/mpj.js new file mode 100644 index 0000000..76747dd --- /dev/null +++ b/src/MyPrayerJournal/Server/wwwroot/script/mpj.js @@ -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, "/") + } +} diff --git a/src/MyPrayerJournal/Server/wwwroot/style/style.css b/src/MyPrayerJournal/Server/wwwroot/style/style.css new file mode 100644 index 0000000..75b4daf --- /dev/null +++ b/src/MyPrayerJournal/Server/wwwroot/style/style.css @@ -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; +}