Switch to server-side auth

This commit is contained in:
Daniel J. Summers 2021-10-07 19:23:00 -04:00
parent 31d5eeb986
commit c0c5709194
5 changed files with 171 additions and 207 deletions

View File

@ -6,18 +6,44 @@ module MyPrayerJournal.Handlers
open Giraffe open Giraffe
open Giraffe.Htmx open Giraffe.Htmx
open Microsoft.AspNetCore.Authentication
open Microsoft.AspNetCore.Http
open System open System
open System.Security.Claims
/// Helper function to be able to split out log on
[<AutoOpen>]
module private LogOnHelpers =
/// Log on, optionally specifying a redirected URL once authentication is complete
let logOn url : HttpHandler =
fun next ctx -> task {
match url with
| Some it ->
do! ctx.ChallengeAsync ("Auth0", AuthenticationProperties (RedirectUri = it))
return! next ctx
| None -> return! challenge "Auth0" next ctx
}
/// Handlers for error conditions /// Handlers for error conditions
module Error = module Error =
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open System.Threading.Tasks
/// Handle errors /// Handle errors
let error (ex : Exception) (log : ILogger) = let error (ex : Exception) (log : ILogger) =
log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.") log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.")
clearResponse >=> setStatusCode 500 >=> json ex.Message clearResponse >=> setStatusCode 500 >=> json ex.Message
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized reponse
let notAuthorized : HttpHandler =
fun next ctx ->
(next, ctx)
||> match ctx.Request.Method with
| "GET" -> logOn None
| _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
let notFound : HttpHandler = let notFound : HttpHandler =
setStatusCode 404 >=> text "Not found" setStatusCode 404 >=> text "Not found"
@ -28,36 +54,14 @@ module Error =
module private Helpers = module private Helpers =
open LiteDB open LiteDB
open Microsoft.AspNetCore.Authentication
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Http.Features.Authentication
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open Microsoft.Net.Http.Headers open Microsoft.Net.Http.Headers
open System.Security.Claims
open System.Threading.Tasks
let debug (ctx : HttpContext) message = let debug (ctx : HttpContext) message =
let fac = ctx.GetService<ILoggerFactory>() let fac = ctx.GetService<ILoggerFactory>()
let log = fac.CreateLogger "Debug" let log = fac.CreateLogger "Debug"
log.LogInformation message log.LogInformation message
/// This type is internal in ASP.NET Core. :(
type AuthFeatures (result : AuthenticateResult) =
let mutable _user : ClaimsPrincipal = match result with null -> null | r -> r.Principal
let mutable _result : AuthenticateResult = result
interface IAuthenticateResultFeature with
member __.AuthenticateResult
with get () = _result
and set v =
_result <- v
_user <- match _result with null -> null | rslt -> rslt.Principal
interface IHttpAuthenticationFeature with
member __.User
with get () = _user
and set v =
_user <- v
_result <- null
/// Get the LiteDB database /// Get the LiteDB database
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>() let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
@ -67,11 +71,12 @@ module private Helpers =
|> Option.ofObj |> Option.ofObj
|> Option.map (fun user -> user.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier)) |> Option.map (fun user -> user.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier))
|> Option.flatten |> Option.flatten
|> Option.map (fun claim -> claim.Value)
/// Get the current user's ID /// Get the current user's ID
// NOTE: this may raise if you don't run the request through the authorize handler first // NOTE: this may raise if you don't run the request through the requiresAuthentication handler first
let userId ctx = let userId ctx =
((user >> Option.get) ctx).Value |> UserId (user >> Option.get) ctx |> UserId
/// Return a 201 CREATED response /// Return a 201 CREATED response
let created = let created =
@ -87,54 +92,6 @@ module private Helpers =
let jsNow () = let jsNow () =
DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> (int64 >> ( * ) 1_000L >> Ticks) DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> (int64 >> ( * ) 1_000L >> Ticks)
/// Handler to return a 401 Not Authorized reponse
let notAuthorized : HttpHandler =
setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
/// Handler to require authorization
// NOTE: This is cribbed from ASP.NET Core's `AuthenticationMiddleware#Invoke`
// https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/Core/src/AuthenticationMiddleware.cs
let authorize : HttpHandler =
fun next ctx -> task {
let schemes = ctx.GetService<IAuthenticationSchemeProvider> ()
ctx.Features.Set<IAuthenticationFeature>
(AuthenticationFeature (OriginalPath = ctx.Request.Path, OriginalPathBase = ctx.Request.PathBase))
// Give any IAuthenticationRequestHandler schemes a chance to handle the request
let handlers = ctx.GetService<IAuthenticationHandlerProvider> ()
let! schms = schemes.GetRequestHandlerSchemesAsync ()
let mutable handled = false
for schm in schms do
match handled with
| true -> ()
| false ->
match! handlers.GetHandlerAsync (ctx, schm.Name) with
| null -> ()
| :? IAuthenticationRequestHandler as handler ->
match! handler.HandleRequestAsync () with true -> handled <- true | _ -> ()
| _ -> ()
match handled with
| true -> return None
| false ->
match! schemes.GetDefaultAuthenticateSchemeAsync () with
| null -> ()
| auth ->
match! ctx.AuthenticateAsync auth.Name with
| null -> ()
| result ->
match result.Principal with null -> () | _ -> ctx.User <- result.Principal
match result.Succeeded with
| true ->
let authFeatures = AuthFeatures result
ctx.Features.Set<IHttpAuthenticationFeature> authFeatures
ctx.Features.Set<IAuthenticateResultFeature> authFeatures
| false -> ()
return! match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx
}
/// Render a component result /// Render a component result
let renderComponent nodes : HttpHandler = let renderComponent nodes : HttpHandler =
fun next ctx -> task { fun next ctx -> task {
@ -212,7 +169,7 @@ module Components =
// GET /components/journal-items // GET /components/journal-items
let journalItems : HttpHandler = let journalItems : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let shouldShow now r = now > Ticks.toLong r.snoozedUntil && now > Ticks.toLong r.showAfter let shouldShow now r = now > Ticks.toLong r.snoozedUntil && now > Ticks.toLong r.showAfter
let! jrnl = Data.journalByUserId (userId ctx) (db ctx) let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
@ -222,7 +179,7 @@ module Components =
// GET /components/request/[req-id]/edit // GET /components/request/[req-id]/edit
let requestEdit requestId : HttpHandler = let requestEdit requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
match requestId with match requestId with
| "new" -> | "new" ->
@ -230,22 +187,17 @@ module Components =
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) false) next ctx (Views.Request.edit (JournalRequest.ofRequestLite Request.empty) false) next ctx
| _ -> | _ ->
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
| Some req -> | Some req -> return! partialIfNotRefresh "Edit Prayer Request" (Views.Request.edit req false) next ctx
return! partialIfNotRefresh "Edit Prayer Request" (Views.Request.edit req false) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET /components/request-item/[req-id] // GET /components/request-item/[req-id]
let requestItem reqId : HttpHandler = let requestItem reqId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
match! Data.tryJournalById (RequestId.ofString reqId) (userId ctx) (db ctx) with match! Data.tryJournalById (RequestId.ofString reqId) (userId ctx) (db ctx) with
| Some req -> | Some req -> return! renderComponent [ Views.Request.reqListItem req ] next ctx
debug ctx "Found the item" | None -> return! Error.notFound next ctx
return! renderComponent [ Views.Request.reqListItem req ] next ctx
| None ->
debug ctx "Did not find the item"
return! Error.notFound next ctx
} }
@ -256,20 +208,20 @@ module Home =
let home : HttpHandler = let home : HttpHandler =
partialIfNotRefresh "Welcome!" Views.Home.home partialIfNotRefresh "Welcome!" Views.Home.home
// GET /user/log-on
let logOn : HttpHandler =
partialIfNotRefresh "Logging on..." Views.Home.logOn
/// /journal URL /// /journal URL
module Journal = module Journal =
// GET /journal // GET /journal
let journal : HttpHandler = let journal : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let usr = ctx.Request.Headers.["X-Given-Name"].[0] let usr =
return! partialIfNotRefresh "Your Prayer Journal" (Views.Journal.journal usr) next ctx ctx.User.Claims
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.GivenName)
|> Option.map (fun c -> c.Value)
|> Option.defaultValue "Your"
return! partialIfNotRefresh (sprintf "%s Prayer Journal" usr) (Views.Journal.journal usr) next ctx
} }
@ -294,7 +246,7 @@ module Request =
// PATCH /request/[req-id]/prayed // PATCH /request/[req-id]/prayed
let prayed requestId : HttpHandler = let prayed requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let db = db ctx let db = db ctx
let usrId = userId ctx let usrId = userId ctx
@ -315,7 +267,7 @@ module Request =
/// POST /api/request/[req-id]/note /// POST /api/request/[req-id]/note
let addNote requestId : HttpHandler = let addNote requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let db = db ctx let db = db ctx
let usrId = userId ctx let usrId = userId ctx
@ -329,17 +281,17 @@ module Request =
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
/// GET /requests/active // GET /requests/active
let active : HttpHandler = let active : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let! reqs = Data.journalByUserId (userId ctx) (db ctx) let! reqs = Data.journalByUserId (userId ctx) (db ctx)
return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx
} }
/// GET /requests/snoozed // GET /requests/snoozed
let snoozed : HttpHandler = let snoozed : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let! reqs = Data.journalByUserId (userId ctx) (db ctx) let! reqs = Data.journalByUserId (userId ctx) (db ctx)
let now = (jsNow >> Ticks.toLong) () let now = (jsNow >> Ticks.toLong) ()
@ -347,9 +299,9 @@ module Request =
return! partialIfNotRefresh "Active Requests" (Views.Request.snoozed snoozed) next ctx return! partialIfNotRefresh "Active Requests" (Views.Request.snoozed snoozed) next ctx
} }
/// GET /requests/answered // GET /requests/answered
let answered : HttpHandler = let answered : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let! reqs = Data.answeredRequests (userId ctx) (db ctx) let! reqs = Data.answeredRequests (userId ctx) (db ctx)
return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx
@ -357,7 +309,7 @@ module Request =
/// GET /api/request/[req-id] /// GET /api/request/[req-id]
let get requestId : HttpHandler = let get requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
| Some req -> return! json req next ctx | Some req -> return! json req next ctx
@ -366,7 +318,7 @@ module Request =
// GET /request/[req-id]/full // GET /request/[req-id]/full
let getFull requestId : HttpHandler = let getFull requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
match! Data.tryFullRequestById (RequestId.ofString requestId) (userId ctx) (db ctx) with match! Data.tryFullRequestById (RequestId.ofString requestId) (userId ctx) (db ctx) with
| Some req -> return! partialIfNotRefresh "Prayer Request" (Views.Request.full req) next ctx | Some req -> return! partialIfNotRefresh "Prayer Request" (Views.Request.full req) next ctx
@ -375,7 +327,7 @@ module Request =
/// GET /api/request/[req-id]/notes /// GET /api/request/[req-id]/notes
let getNotes requestId : HttpHandler = let getNotes requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let! notes = Data.notesById (RequestId.ofString requestId) (userId ctx) (db ctx) let! notes = Data.notesById (RequestId.ofString requestId) (userId ctx) (db ctx)
return! json notes next ctx return! json notes next ctx
@ -383,7 +335,7 @@ module Request =
// PATCH /request/[req-id]/show // PATCH /request/[req-id]/show
let show requestId : HttpHandler = let show requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let db = db ctx let db = db ctx
let usrId = userId ctx let usrId = userId ctx
@ -398,7 +350,7 @@ module Request =
/// PATCH /api/request/[req-id]/snooze /// PATCH /api/request/[req-id]/snooze
let snooze requestId : HttpHandler = let snooze requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let db = db ctx let db = db ctx
let usrId = userId ctx let usrId = userId ctx
@ -414,7 +366,7 @@ module Request =
// PATCH /request/[req-id]/cancel-snooze // PATCH /request/[req-id]/cancel-snooze
let cancelSnooze requestId : HttpHandler = let cancelSnooze requestId : HttpHandler =
authorize requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task { >=> fun next ctx -> task {
let db = db ctx let db = db ctx
let usrId = userId ctx let usrId = userId ctx
@ -434,7 +386,8 @@ module Request =
// POST /request // POST /request
let add : HttpHandler = let add : HttpHandler =
fun next ctx -> task { requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task {
let! form = ctx.BindModelAsync<Models.Request> () let! form = ctx.BindModelAsync<Models.Request> ()
let db = db ctx let db = db ctx
let usrId = userId ctx let usrId = userId ctx
@ -462,7 +415,8 @@ module Request =
// PATCH /request // PATCH /request
let update : HttpHandler = let update : HttpHandler =
fun next ctx -> Ply.task { requiresAuthentication Error.notAuthorized
>=> fun next ctx -> Ply.task {
let! form = ctx.BindModelAsync<Models.Request> () let! form = ctx.BindModelAsync<Models.Request> ()
let db = db ctx let db = db ctx
let usrId = userId ctx let usrId = userId ctx
@ -489,6 +443,25 @@ module Request =
} }
/// Handlers for /user URLs
module User =
open Microsoft.AspNetCore.Authentication.Cookies
// GET /user/log-on
let logOn : HttpHandler =
logOn (Some "/journal")
// GET /user/log-off
let logOff : HttpHandler =
requiresAuthentication Error.notAuthorized
>=> fun next ctx -> task {
do! ctx.SignOutAsync ("Auth0", AuthenticationProperties (RedirectUri = "/"))
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
return! next ctx
}
open Giraffe.EndpointRouting open Giraffe.EndpointRouting
/// The routes for myPrayerJournal /// The routes for myPrayerJournal
@ -526,7 +499,12 @@ let routes =
route "" Request.add route "" Request.add
] ]
] ]
GET_HEAD [ route "/user/log-on" Home.logOn ] subRoute "/user/" [
GET_HEAD [
route "log-off" User.logOff
route "log-on" User.logOn
]
]
subRoute "/api/" [ subRoute "/api/" [
GET [ GET [
subRoute "request" [ subRoute "request" [

View File

@ -16,7 +16,7 @@
<PackageReference Include="FunctionalCuid" Version="1.0.0" /> <PackageReference Include="FunctionalCuid" Version="1.0.0" />
<PackageReference Include="Giraffe" Version="5.0.0" /> <PackageReference Include="Giraffe" Version="5.0.0" />
<PackageReference Include="LiteDB" Version="5.0.11" /> <PackageReference Include="LiteDB" Version="5.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.10" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../../../Giraffe.Htmx/src/Htmx/Giraffe.Htmx.fsproj" /> <ProjectReference Include="../../../../Giraffe.Htmx/src/Htmx/Giraffe.Htmx.fsproj" />

View File

@ -56,27 +56,75 @@ module Configure =
open Giraffe open Giraffe
open LiteDB open LiteDB
open Microsoft.AspNetCore.Authentication.JwtBearer open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Authentication.OpenIdConnect
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.DependencyInjection
open Microsoft.IdentityModel.Protocols.OpenIdConnect
open System
open System.Text.Json open System.Text.Json
open System.Text.Json.Serialization open System.Text.Json.Serialization
open System.Threading.Tasks
/// Configure dependency injection /// Configure dependency injection
let services (bldr : WebApplicationBuilder) = let services (bldr : WebApplicationBuilder) =
let sameSite (opts : CookieOptions) =
match opts.SameSite, opts.Secure with
| SameSiteMode.None, false -> opts.SameSite <- SameSiteMode.Unspecified
| _, _ -> ()
bldr.Services bldr.Services
.AddRouting() .AddRouting()
.AddGiraffe() .AddGiraffe()
.Configure<CookiePolicyOptions>(
fun (opts : CookiePolicyOptions) ->
opts.MinimumSameSitePolicy <- SameSiteMode.Unspecified
opts.OnAppendCookie <- fun ctx -> sameSite ctx.CookieOptions
opts.OnDeleteCookie <- fun ctx -> sameSite ctx.CookieOptions)
.AddAuthentication( .AddAuthentication(
/// Use HTTP "Bearer" authentication with JWTs /// Use HTTP "Bearer" authentication with JWTs
fun opts -> fun opts ->
opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme opts.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme) opts.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
.AddJwtBearer( opts.DefaultChallengeScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
/// Configure JWT options with Auth0 options from configuration .AddCookie()
.AddOpenIdConnect("Auth0",
/// Configure OIDC with Auth0 options from configuration
fun opts -> fun opts ->
let jwtCfg = bldr.Configuration.GetSection "Auth0" let cfg = bldr.Configuration.GetSection "Auth0"
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"] opts.Authority <- sprintf "https://%s/" cfg.["Domain"]
opts.Audience <- jwtCfg.["Audience"]) opts.ClientId <- cfg.["Id"]
opts.ClientSecret <- cfg.["Secret"]
opts.ResponseType <- OpenIdConnectResponseType.Code
opts.Scope.Clear ()
opts.Scope.Add "openid"
opts.Scope.Add "profile"
opts.CallbackPath <- PathString "/user/log-on/success"
opts.ClaimsIssuer <- "Auth0"
opts.SaveTokens <- true
opts.Events <- OpenIdConnectEvents ()
opts.Events.OnRedirectToIdentityProviderForSignOut <- fun ctx ->
let returnTo =
match ctx.Properties.RedirectUri with
| it when isNull it || it = "" -> ""
| redirUri ->
let finalRedirUri =
match redirUri.StartsWith "/" with
| true ->
// transform to absolute
let request = ctx.Request
sprintf "%s://%s%s%s" request.Scheme request.Host.Value request.PathBase.Value redirUri
| false -> redirUri
Uri.EscapeDataString finalRedirUri |> sprintf "&returnTo=%s"
sprintf "https://%s/v2/logout?client_id=%s%s" cfg.["Domain"] cfg.["Id"] returnTo
|> ctx.Response.Redirect
ctx.HandleResponse ()
Task.CompletedTask
)
|> ignore |> ignore
let jsonOptions = JsonSerializerOptions () let jsonOptions = JsonSerializerOptions ()
jsonOptions.Converters.Add (JsonFSharpConverter ()) jsonOptions.Converters.Add (JsonFSharpConverter ())
@ -97,11 +145,14 @@ module Configure =
| true -> app.UseDeveloperExceptionPage () | true -> app.UseDeveloperExceptionPage ()
| false -> app.UseGiraffeErrorHandler Handlers.Error.error | false -> app.UseGiraffeErrorHandler Handlers.Error.error
|> ignore |> ignore
app.UseAuthentication() app.UseStaticFiles()
.UseStaticFiles() .UseCookiePolicy()
.UseRouting() .UseRouting()
.UseAuthentication()
// .UseAuthorization()
.UseEndpoints (fun e -> .UseEndpoints (fun e ->
e.MapGiraffeEndpoints Handlers.routes e.MapGiraffeEndpoints Handlers.routes
// TODO: fallback to 404
e.MapFallbackToFile "index.html" |> ignore) e.MapFallbackToFile "index.html" |> ignore)
|> ignore |> ignore
app app

View File

@ -9,7 +9,10 @@ open System
module Helpers = module Helpers =
/// Create a link that targets the `main` element and pushes a URL to history /// Create a link that targets the `main` element and pushes a URL to history
let pageLink href attrs = a (attrs |> List.append [ _href href; _hxBoost; _hxTarget "main"; _hxPushUrl ]) let pageLink href attrs =
attrs
|> List.append [ _href href; _hxBoost; _hxTarget "main"; _hxPushUrl ]
|> a
/// Create a Material icon /// Create a Material icon
let icon name = span [ _class "material-icons" ] [ str name ] let icon name = span [ _class "material-icons" ] [ str name ]
@ -235,18 +238,18 @@ module Navigation =
/// Generate the navigation items based on the current state /// Generate the navigation items based on the current state
let currentNav isAuthenticated hasSnoozed (url : Uri option) = let currentNav isAuthenticated hasSnoozed (url : Uri option) =
seq { seq {
match isAuthenticated with
| true ->
let currUrl = match url with Some u -> (u.PathAndQuery.Split '?').[0] | None -> "" let currUrl = match url with Some u -> (u.PathAndQuery.Split '?').[0] | None -> ""
let navLink (matchUrl : string) = let navLink (matchUrl : string) =
match currUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> [] match currUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> []
|> pageLink matchUrl |> pageLink matchUrl
match isAuthenticated with
| true ->
li [ _class "nav-item" ] [ navLink "/journal" [ str "Journal" ] ] li [ _class "nav-item" ] [ navLink "/journal" [ str "Journal" ] ]
li [ _class "nav-item" ] [ navLink "/requests/active" [ str "Active" ] ] li [ _class "nav-item" ] [ navLink "/requests/active" [ str "Active" ] ]
if hasSnoozed then li [ _class "nav-item" ] [ navLink "/requests/snoozed" [ str "Snoozed" ] ] if hasSnoozed then li [ _class "nav-item" ] [ navLink "/requests/snoozed" [ str "Snoozed" ] ]
li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ] li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ]
li [ _class "nav-item" ] [ a [ _href "/user/log-off"; _onclick "mpj.logOff(event)" ] [ str "Log Off" ] ] li [ _class "nav-item" ] [ a [ _href "/user/log-off" ] [ str "Log Off" ] ]
| false -> li [ _class "nav-item"] [ a [ _href "/user/log-on"; _onclick "mpj.logOn(event)"] [ str "Log On" ] ] | false -> li [ _class "nav-item"] [ a [ _href "/user/log-on" ] [ str "Log On" ] ]
li [ _class "nav-item" ] [ a [ _href "https://docs.prayerjournal.me"; _target "_blank" ] [ str "Docs" ] ] li [ _class "nav-item" ] [ a [ _href "https://docs.prayerjournal.me"; _target "_blank" ] [ str "Docs" ] ]
} }
|> List.ofSeq |> List.ofSeq
@ -290,7 +293,13 @@ module Journal =
/// The journal loading page /// The journal loading page
let journal user = article [ _class "container-fluid mt-3" ] [ let journal user = article [ _class "container-fluid mt-3" ] [
h2 [ _class "pb-3" ] [ str user; rawText "&rsquo;s Prayer Journal" ] h2 [ _class "pb-3" ] [
str user
match user with
| "Your" -> ()
| _ -> rawText "&rsquo;s"
str " Prayer Journal"
]
p [ p [
_hxGet "/components/journal-items" _hxGet "/components/journal-items"
_hxSwap HxSwap.OuterHtml _hxSwap HxSwap.OuterHtml
@ -571,7 +580,8 @@ module Layout =
/// The HTML `head` element /// The HTML `head` element
let htmlHead pageTitle = let htmlHead pageTitle =
head [] [ head [ _lang "en" ] [
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
title [] [ str pageTitle; rawText " &#xab; myPrayerJournal" ] title [] [ str pageTitle; rawText " &#xab; myPrayerJournal" ]
link [ link [
_href "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" _href "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
@ -581,11 +591,6 @@ module Layout =
] ]
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ] link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
link [ _href "/style/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"
_crossorigin "anonymous"
] []
] ]
/// Element used to display toasts /// Element used to display toasts
@ -612,13 +617,17 @@ module Layout =
] ]
] ]
] ]
script [
_src "https://unpkg.com/htmx.org@1.5.0"
_integrity "sha384-oGA+prIp5Vchu6we2YkI51UtVzN9Jpx2Z7PnR1I78PnZlN8LkrCT4lqqqmDkyrvI"
_crossorigin "anonymous"
] []
script [ script [
_async _async
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" _src "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
_integrity "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" _integrity "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
_crossorigin "anonymous" _crossorigin "anonymous"
] [] ] []
script [ _src "https://cdn.auth0.com/js/auth0-spa-js/1.13/auth0-spa-js.production.js" ] []
script [ _src "/script/mpj.js" ] [] script [ _src "/script/mpj.js" ] []
] ]

View File

@ -2,41 +2,6 @@
/** myPrayerJournal script */ /** myPrayerJournal script */
const mpj = { const mpj = {
/** Auth0 configuration */
auth: {
/** The Auth0 client */
auth0: null,
/** Configure the Auth0 client */
async configureClient () {
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,
/** Whether we should redirect to the journal the next time the menu items are refreshed */
redirToJournal: false,
/**
* Process a log on request
* @param {Event} e The HTML event from the `onclick` event
*/
async logOn (e) {
e.preventDefault()
await mpj.auth.auth0.loginWithRedirect({ redirect_uri: `${window.location.origin}/user/log-on` })
},
/**
* Log the user off
* @param {Event} e The HTML event from the `onclick` event
*/
logOff (e) {
e.preventDefault()
mpj.auth.auth0.logout({ returnTo: window.location.origin })
},
/** /**
* Show a message via toast * Show a message via toast
* @param {string} message The message to show * @param {string} message The message to show
@ -89,37 +54,6 @@ const mpj = {
}, },
} }
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()
if (window.location.pathname === "/user/log-on") {
mpj.redirToJournal = true
} else {
window.history.replaceState({}, document.title, "/")
}
}
}
htmx.on("htmx:afterOnLoad", function (evt) { htmx.on("htmx:afterOnLoad", function (evt) {
const hdrs = evt.detail.xhr.getAllResponseHeaders() const hdrs = evt.detail.xhr.getAllResponseHeaders()
// Set the page title if a header was in the response // Set the page title if a header was in the response
@ -133,11 +67,3 @@ htmx.on("htmx:afterOnLoad", function (evt) {
mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast")) mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
} }
}) })
htmx.on("htmx:afterSettle", function (evt) {
// Redirect to the journal (once menu items load after log on)
if (mpj.redirToJournal
&& ([...evt.target.attributes].find(it => it.name === "hx-target")?.value ?? "") === ".navbar-nav") {
mpj.redirToJournal = false
document.querySelector(`a[href="/journal"]`).click()
}
})