WIP on log on
- Add anti-CSRF tokens to forms - WIP on toast notifications
This commit is contained in:
parent
663c37b642
commit
c7bda8eb28
|
@ -39,7 +39,7 @@ module DataConnection =
|
||||||
/// The data source for the document store
|
/// The data source for the document store
|
||||||
let mutable private dataSource : NpgsqlDataSource option = None
|
let mutable private dataSource : NpgsqlDataSource option = None
|
||||||
|
|
||||||
/// Get the connection string
|
/// Get a connection
|
||||||
let connection () =
|
let connection () =
|
||||||
match dataSource with
|
match dataSource with
|
||||||
| Some ds -> ds.OpenConnection () |> Sql.existingConnection
|
| Some ds -> ds.OpenConnection () |> Sql.existingConnection
|
||||||
|
@ -212,10 +212,9 @@ module Citizens =
|
||||||
|
|
||||||
/// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken
|
/// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken
|
||||||
let register citizen (security : SecurityInfo) = backgroundTask {
|
let register citizen (security : SecurityInfo) = backgroundTask {
|
||||||
let connProps = connection ()
|
let connProps = connection ()
|
||||||
use conn = Sql.createConnection connProps
|
use conn = Sql.createConnection connProps
|
||||||
do! conn.OpenAsync ()
|
use! txn = conn.BeginTransactionAsync ()
|
||||||
use! txn = conn.BeginTransactionAsync ()
|
|
||||||
try
|
try
|
||||||
do! saveCitizen citizen connProps
|
do! saveCitizen citizen connProps
|
||||||
do! saveSecurity security connProps
|
do! saveSecurity security connProps
|
||||||
|
|
|
@ -58,7 +58,9 @@ module Helpers =
|
||||||
|
|
||||||
open System.Security.Claims
|
open System.Security.Claims
|
||||||
open Giraffe.Htmx
|
open Giraffe.Htmx
|
||||||
|
open Microsoft.AspNetCore.Antiforgery
|
||||||
open Microsoft.Extensions.Configuration
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.DependencyInjection
|
||||||
open Microsoft.Extensions.Options
|
open Microsoft.Extensions.Options
|
||||||
|
|
||||||
/// Get the NodaTime clock from the request context
|
/// Get the NodaTime clock from the request context
|
||||||
|
@ -99,13 +101,20 @@ module Helpers =
|
||||||
|
|
||||||
// -- NEW --
|
// -- NEW --
|
||||||
|
|
||||||
|
let antiForgery (ctx : HttpContext) =
|
||||||
|
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||||
|
|
||||||
|
/// Obtain an anti-forgery token set
|
||||||
|
let csrf ctx =
|
||||||
|
(antiForgery ctx).GetAndStoreTokens ctx
|
||||||
|
|
||||||
/// Add a message to the response
|
/// Add a message to the response
|
||||||
let sendMessage (msg : string) : HttpHandler =
|
let sendMessage (msg : string) : HttpHandler =
|
||||||
setHttpHeader "X-Message" msg
|
setHttpHeader "X-Message" msg
|
||||||
|
|
||||||
/// Add an error message to the response
|
/// Add an error message to the response
|
||||||
let sendError (msg : string) : HttpHandler =
|
let sendError (msg : string) : HttpHandler =
|
||||||
sendMessage $"ERROR|||{msg}"
|
sendMessage $"error|||{msg}"
|
||||||
|
|
||||||
/// Render a page-level view
|
/// Render a page-level view
|
||||||
let render pageTitle content : HttpHandler = fun _ ctx -> task {
|
let render pageTitle content : HttpHandler = fun _ ctx -> task {
|
||||||
|
@ -119,6 +128,13 @@ module Helpers =
|
||||||
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
|
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate the anti cross-site request forgery token in the current request
|
||||||
|
let validateCsrf : HttpHandler = fun next ctx -> task {
|
||||||
|
match! (antiForgery ctx).IsRequestValidAsync ctx with
|
||||||
|
| true -> return! next ctx
|
||||||
|
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
open System
|
open System
|
||||||
open JobsJobsJobs.Data
|
open JobsJobsJobs.Data
|
||||||
|
@ -165,8 +181,24 @@ module Citizen =
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: /citizen/log-on
|
// GET: /citizen/log-on
|
||||||
let logOn : HttpHandler =
|
let logOn : HttpHandler = fun next ctx ->
|
||||||
render "Log On" (Citizen.logOn { ErrorMessage = None; Email = ""; Password = "" })
|
render "Log On" (Citizen.logOn { ErrorMessage = None; Email = ""; Password = "" } (csrf ctx)) next ctx
|
||||||
|
|
||||||
|
// POST: /citizen/log-on
|
||||||
|
// TODO: convert
|
||||||
|
let doLogOn = validateCsrf >=> fun next ctx -> task {
|
||||||
|
let! form = ctx.BindJsonAsync<LogOnForm> ()
|
||||||
|
|
||||||
|
match! Citizens.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with
|
||||||
|
| Ok citizen ->
|
||||||
|
return!
|
||||||
|
json
|
||||||
|
{ Jwt = Auth.createJwt citizen (authConfig ctx)
|
||||||
|
CitizenId = CitizenId.toString citizen.Id
|
||||||
|
Name = Citizen.name citizen
|
||||||
|
} next ctx
|
||||||
|
| Error msg -> return! RequestErrors.BAD_REQUEST msg next ctx
|
||||||
|
}
|
||||||
|
|
||||||
// GET: /citizen/register
|
// GET: /citizen/register
|
||||||
let register : HttpHandler = fun next ctx ->
|
let register : HttpHandler = fun next ctx ->
|
||||||
|
@ -178,11 +210,11 @@ module Citizen =
|
||||||
let qAndA = Support.questions ctx
|
let qAndA = Support.questions ctx
|
||||||
render "Register"
|
render "Register"
|
||||||
(Citizen.register (fst qAndA[q1Index]) (fst qAndA[q2Index])
|
(Citizen.register (fst qAndA[q1Index]) (fst qAndA[q2Index])
|
||||||
{ RegisterViewModel.empty with Question1Index = q1Index; Question2Index = q2Index }) next ctx
|
{ RegisterViewModel.empty with Question1Index = q1Index; Question2Index = q2Index } (csrf ctx)) next ctx
|
||||||
|
|
||||||
|
|
||||||
// POST: /citizen/register
|
// POST: /citizen/register
|
||||||
let doRegistration : HttpHandler = fun next ctx -> task {
|
let doRegistration = validateCsrf >=> fun next ctx -> task {
|
||||||
let! form = ctx.BindFormAsync<RegisterViewModel> ()
|
let! form = ctx.BindFormAsync<RegisterViewModel> ()
|
||||||
let qAndA = Support.questions ctx
|
let qAndA = Support.questions ctx
|
||||||
let mutable badForm = false
|
let mutable badForm = false
|
||||||
|
@ -202,7 +234,7 @@ module Citizen =
|
||||||
let refreshPage () =
|
let refreshPage () =
|
||||||
render "Register"
|
render "Register"
|
||||||
(Citizen.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index])
|
(Citizen.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index])
|
||||||
{ form with Password = "" })
|
{ form with Password = "" } (csrf ctx))
|
||||||
if badForm then
|
if badForm then
|
||||||
let handle = sendError "The form posted was invalid; please complete it again" >=> register
|
let handle = sendError "The form posted was invalid; please complete it again" >=> register
|
||||||
return! handle next ctx
|
return! handle next ctx
|
||||||
|
@ -247,71 +279,6 @@ module Citizen =
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module CitizenApi =
|
module CitizenApi =
|
||||||
|
|
||||||
// POST: /api/citizen/register
|
|
||||||
let register : HttpHandler = fun next ctx -> task {
|
|
||||||
let! form = ctx.BindJsonAsync<CitizenRegistrationForm> ()
|
|
||||||
if form.Password.Length < 8 || form.ConfirmPassword.Length < 8 || form.Password <> form.ConfirmPassword then
|
|
||||||
return! RequestErrors.BAD_REQUEST "Password out of range" next ctx
|
|
||||||
else
|
|
||||||
let now = now ctx
|
|
||||||
let noPass =
|
|
||||||
{ Citizen.empty with
|
|
||||||
Id = CitizenId.create ()
|
|
||||||
Email = form.Email
|
|
||||||
FirstName = form.FirstName
|
|
||||||
LastName = form.LastName
|
|
||||||
DisplayName = noneIfBlank form.DisplayName
|
|
||||||
JoinedOn = now
|
|
||||||
LastSeenOn = now
|
|
||||||
}
|
|
||||||
let citizen = { noPass with PasswordHash = Auth.Passwords.hash noPass form.Password }
|
|
||||||
let security =
|
|
||||||
{ SecurityInfo.empty with
|
|
||||||
Id = citizen.Id
|
|
||||||
AccountLocked = true
|
|
||||||
Token = Some (Auth.createToken citizen)
|
|
||||||
TokenUsage = Some "confirm"
|
|
||||||
TokenExpires = Some (now + (Duration.FromDays 3))
|
|
||||||
}
|
|
||||||
let! success = Citizens.register citizen security
|
|
||||||
if success then
|
|
||||||
let! emailResponse = Email.sendAccountConfirmation citizen security
|
|
||||||
let logFac = logger ctx
|
|
||||||
let log = logFac.CreateLogger "JobsJobsJobs.Api.Handlers.Citizen"
|
|
||||||
log.LogInformation $"Confirmation e-mail for {citizen.Email} received {emailResponse}"
|
|
||||||
return! ok next ctx
|
|
||||||
else
|
|
||||||
return! RequestErrors.CONFLICT "" next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH: /api/citizen/confirm
|
|
||||||
let confirmToken : HttpHandler = fun next ctx -> task {
|
|
||||||
let! form = ctx.BindJsonAsync<{| token : string |}> ()
|
|
||||||
let! valid = Citizens.confirmAccount form.token
|
|
||||||
return! json {| Valid = valid |} next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE: /api/citizen/deny
|
|
||||||
let denyToken : HttpHandler = fun next ctx -> task {
|
|
||||||
let! form = ctx.BindJsonAsync<{| token : string |}> ()
|
|
||||||
let! valid = Citizens.denyAccount form.token
|
|
||||||
return! json {| Valid = valid |} next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: /api/citizen/log-on
|
|
||||||
let logOn : HttpHandler = fun next ctx -> task {
|
|
||||||
let! form = ctx.BindJsonAsync<LogOnForm> ()
|
|
||||||
match! Citizens.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with
|
|
||||||
| Ok citizen ->
|
|
||||||
return!
|
|
||||||
json
|
|
||||||
{ Jwt = Auth.createJwt citizen (authConfig ctx)
|
|
||||||
CitizenId = CitizenId.toString citizen.Id
|
|
||||||
Name = Citizen.name citizen
|
|
||||||
} next ctx
|
|
||||||
| Error msg -> return! RequestErrors.BAD_REQUEST msg next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: /api/citizen/[id]
|
// GET: /api/citizen/[id]
|
||||||
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
|
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
match! Citizens.findById (CitizenId citizenId) with
|
match! Citizens.findById (CitizenId citizenId) with
|
||||||
|
@ -645,7 +612,10 @@ let allEndpoints = [
|
||||||
route "/log-on" Citizen.logOn
|
route "/log-on" Citizen.logOn
|
||||||
route "/register" Citizen.register
|
route "/register" Citizen.register
|
||||||
]
|
]
|
||||||
POST [ route "/register" Citizen.doRegistration ]
|
POST [
|
||||||
|
route "/log-on" Citizen.doLogOn
|
||||||
|
route "/register" Citizen.doRegistration
|
||||||
|
]
|
||||||
]
|
]
|
||||||
GET_HEAD [ route "/how-it-works" Home.howItWorks ]
|
GET_HEAD [ route "/how-it-works" Home.howItWorks ]
|
||||||
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
|
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
|
||||||
|
@ -655,15 +625,9 @@ let allEndpoints = [
|
||||||
GET_HEAD [ routef "/%O" CitizenApi.get ]
|
GET_HEAD [ routef "/%O" CitizenApi.get ]
|
||||||
PATCH [
|
PATCH [
|
||||||
route "/account" CitizenApi.account
|
route "/account" CitizenApi.account
|
||||||
route "/confirm" CitizenApi.confirmToken
|
|
||||||
]
|
|
||||||
POST [
|
|
||||||
route "/log-on" CitizenApi.logOn
|
|
||||||
route "/register" CitizenApi.register
|
|
||||||
]
|
]
|
||||||
DELETE [
|
DELETE [
|
||||||
route "" CitizenApi.delete
|
route "" CitizenApi.delete
|
||||||
route "/deny" CitizenApi.denyToken
|
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
GET_HEAD [ route "/continents" Continent.all ]
|
GET_HEAD [ route "/continents" Continent.all ]
|
||||||
|
|
|
@ -15,6 +15,7 @@ type LogOnViewModel =
|
||||||
|
|
||||||
|
|
||||||
/// View model for the registration page
|
/// View model for the registration page
|
||||||
|
[<CLIMutable>]
|
||||||
type RegisterViewModel =
|
type RegisterViewModel =
|
||||||
{ /// The user's first name
|
{ /// The user's first name
|
||||||
FirstName : string
|
FirstName : string
|
||||||
|
|
|
@ -4,6 +4,7 @@ module JobsJobsJobs.Views.Citizen
|
||||||
|
|
||||||
open Giraffe.ViewEngine
|
open Giraffe.ViewEngine
|
||||||
open Giraffe.ViewEngine.Htmx
|
open Giraffe.ViewEngine.Htmx
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
open JobsJobsJobs.ViewModels
|
open JobsJobsJobs.ViewModels
|
||||||
|
|
||||||
/// The account confirmation page
|
/// The account confirmation page
|
||||||
|
@ -21,6 +22,77 @@ let confirmAccount isConfirmed =
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// The citizen's dashboard page
|
||||||
|
let dashboard (citizen : Citizen) (profile : Profile option) profileCount =
|
||||||
|
article [ _class "container" ] [
|
||||||
|
h3 [ _class "pb-4" ] [ rawText "ITM, "; str citizen.FirstName; rawText "!" ]
|
||||||
|
div [ _class "row row-cols-1 row-cols-md-2" ] [
|
||||||
|
div [ _class "col" ] [
|
||||||
|
div [ _class "card h-100" ] [
|
||||||
|
h5 [ _class "card-header" ] [ rawText "Your Profile" ]
|
||||||
|
div [ _class "card-body" ] [
|
||||||
|
match profile with
|
||||||
|
| Some prfl ->
|
||||||
|
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [
|
||||||
|
rawText "Last updated "; (* full-date-time :date="profile.lastUpdatedOn" *)
|
||||||
|
]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
rawText "Your profile currently lists "; str $"{List.length prfl.Skills}"
|
||||||
|
rawText " skill"; rawText (if List.length prfl.Skills <> 1 then "s" else "")
|
||||||
|
rawText "."
|
||||||
|
if prfl.IsSeekingEmployment then
|
||||||
|
br []; br []
|
||||||
|
rawText "Your profile indicates that you are seeking employment. Once you find it, "
|
||||||
|
a [ _href "/success-story/add" ] [ rawText "tell your fellow citizens about it!" ]
|
||||||
|
]
|
||||||
|
| None ->
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
rawText "You do not have an employment profile established; click below (or "
|
||||||
|
rawText "“Edit Profile” in the menu) to get started!"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "card-footer" ] [
|
||||||
|
match profile with
|
||||||
|
| Some p ->
|
||||||
|
a [ _href $"/profile/{citizen.Id}/view"; _class "btn btn-outline-secondary" ] [
|
||||||
|
rawText "View Profile"
|
||||||
|
]; rawText " "
|
||||||
|
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ]
|
||||||
|
| None ->
|
||||||
|
a [ _href "/profile/edit"; _class "btn btn-primary" ] [ rawText "Create Profile" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "col" ] [
|
||||||
|
div [ _class "card h-100" ] [
|
||||||
|
h5 [ _class "card-header" ] [ rawText "Other Citizens" ]
|
||||||
|
div [ _class "card-body" ] [
|
||||||
|
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [
|
||||||
|
rawText (if profileCount = 0 then "No" else $"{profileCount} Total")
|
||||||
|
rawText " Employment Profile"; rawText (if profileCount <> 1 then "s" else "")
|
||||||
|
]
|
||||||
|
p [ _class "card-text" ] [
|
||||||
|
if profileCount = 1 && Option.isSome profile then
|
||||||
|
"It looks like, for now, it’s just you…"
|
||||||
|
else if profileCount > 0 then "Take a look around and see if you can help them find work!"
|
||||||
|
else "You can click below, but you will not find anything…"
|
||||||
|
|> rawText
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "card-footer" ] [
|
||||||
|
a [ _href "/profile/search"; _class "btn btn-outline-secondary" ] [ rawText "Search Profiles" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
p [] [ rawText " " ]
|
||||||
|
p [] [
|
||||||
|
rawText "To see how this application works, check out “How It Works” in the sidebar (last "
|
||||||
|
rawText "updated August 29<sup>th</sup>, 2021)."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
/// The account denial page
|
/// The account denial page
|
||||||
let denyAccount wasDeleted =
|
let denyAccount wasDeleted =
|
||||||
article [] [
|
article [] [
|
||||||
|
@ -35,7 +107,7 @@ let denyAccount wasDeleted =
|
||||||
]
|
]
|
||||||
|
|
||||||
/// The log on page
|
/// The log on page
|
||||||
let logOn (m : LogOnViewModel) =
|
let logOn (m : LogOnViewModel) csrf =
|
||||||
article [] [
|
article [] [
|
||||||
h3 [ _class "pb-3" ] [ rawText "Log On" ]
|
h3 [ _class "pb-3" ] [ rawText "Log On" ]
|
||||||
match m.ErrorMessage with
|
match m.ErrorMessage with
|
||||||
|
@ -50,6 +122,7 @@ let logOn (m : LogOnViewModel) =
|
||||||
]
|
]
|
||||||
| None -> ()
|
| None -> ()
|
||||||
form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [
|
form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [
|
||||||
|
antiForgery csrf
|
||||||
div [ _class "col-12 col-md-6" ] [
|
div [ _class "col-12 col-md-6" ] [
|
||||||
div [ _class "form-floating" ] [
|
div [ _class "form-floating" ] [
|
||||||
input [ _type "email"
|
input [ _type "email"
|
||||||
|
@ -89,10 +162,11 @@ let logOn (m : LogOnViewModel) =
|
||||||
]
|
]
|
||||||
|
|
||||||
/// The registration page
|
/// The registration page
|
||||||
let register q1 q2 (m : RegisterViewModel) =
|
let register q1 q2 (m : RegisterViewModel) csrf =
|
||||||
article [] [
|
article [] [
|
||||||
h3 [ _class "pb-3" ] [ rawText "Register" ]
|
h3 [ _class "pb-3" ] [ rawText "Register" ]
|
||||||
form [ _class "row g-3"; _hxPost "/citizen/register" ] [
|
form [ _class "row g-3"; _hxPost "/citizen/register" ] [
|
||||||
|
antiForgery csrf
|
||||||
div [ _class "col-6 col-xl-4" ] [
|
div [ _class "col-6 col-xl-4" ] [
|
||||||
div [ _class "form-floating" ] [
|
div [ _class "form-floating" ] [
|
||||||
input [ _type "text"; _class "form-control"; _id (nameof m.FirstName); _name (nameof m.FirstName)
|
input [ _type "text"; _class "form-control"; _id (nameof m.FirstName); _name (nameof m.FirstName)
|
||||||
|
|
|
@ -2,9 +2,14 @@
|
||||||
module JobsJobsJobs.Views.Common
|
module JobsJobsJobs.Views.Common
|
||||||
|
|
||||||
open Giraffe.ViewEngine
|
open Giraffe.ViewEngine
|
||||||
|
open Microsoft.AspNetCore.Antiforgery
|
||||||
|
|
||||||
/// Create an audio clip with the specified text node
|
/// Create an audio clip with the specified text node
|
||||||
let audioClip clip text =
|
let audioClip clip text =
|
||||||
span [ _class "jjj-audio-clip"; _onclick "jjj.playFile(this)" ] [
|
span [ _class "jjj-audio-clip"; _onclick "jjj.playFile(this)" ] [
|
||||||
text; audio [ _id clip ] [ source [ _src $"/audio/{clip}.mp3" ] ]
|
text; audio [ _id clip ] [ source [ _src $"/audio/{clip}.mp3" ] ]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// Create an anti-forgery hidden input
|
||||||
|
let antiForgery (csrf : AntiforgeryTokenSet) =
|
||||||
|
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
|
||||||
|
|
|
@ -15,5 +15,77 @@ this.jjj = {
|
||||||
/** @type {HTMLElement} */
|
/** @type {HTMLElement} */
|
||||||
const menu = document.querySelector(".jjj-mobile-menu")
|
const menu = document.querySelector(".jjj-mobile-menu")
|
||||||
if (menu.style.display !== "none") bootstrap.Offcanvas.getOrCreateInstance(menu).hide()
|
if (menu.style.display !== "none") bootstrap.Offcanvas.getOrCreateInstance(menu).hide()
|
||||||
}
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a message via toast
|
||||||
|
* @param {string} message The message to show
|
||||||
|
*/
|
||||||
|
showToast (message) {
|
||||||
|
const [level, msg] = message.split("|||")
|
||||||
|
|
||||||
|
let header
|
||||||
|
if (level !== "success") {
|
||||||
|
const heading = typ => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
|
||||||
|
|
||||||
|
header = document.createElement("div")
|
||||||
|
header.className = "toast-header"
|
||||||
|
header.innerHTML = heading(level === "warning" ? level : "error")
|
||||||
|
|
||||||
|
const close = document.createElement("button")
|
||||||
|
close.type = "button"
|
||||||
|
close.className = "btn-close"
|
||||||
|
close.setAttribute("data-bs-dismiss", "toast")
|
||||||
|
close.setAttribute("aria-label", "Close")
|
||||||
|
header.appendChild(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement("div")
|
||||||
|
body.className = "toast-body"
|
||||||
|
body.innerText = msg
|
||||||
|
|
||||||
|
const toastEl = document.createElement("div")
|
||||||
|
toastEl.className = `toast bg-${level === "error" ? "danger" : level} text-white`
|
||||||
|
toastEl.setAttribute("role", "alert")
|
||||||
|
toastEl.setAttribute("aria-live", "assertlive")
|
||||||
|
toastEl.setAttribute("aria-atomic", "true")
|
||||||
|
toastEl.addEventListener("hidden.bs.toast", e => e.target.remove())
|
||||||
|
if (header) toastEl.appendChild(header)
|
||||||
|
|
||||||
|
toastEl.appendChild(body)
|
||||||
|
document.getElementById("toasts").appendChild(toastEl)
|
||||||
|
new bootstrap.Toast(toastEl, { autohide: level === "success" }).show()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time zone of the current browser
|
||||||
|
* @type {string}
|
||||||
|
**/
|
||||||
|
timeZone: undefined,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the time zone from the current browser
|
||||||
|
*/
|
||||||
|
deriveTimeZone () {
|
||||||
|
try {
|
||||||
|
this.timeZone = (new Intl.DateTimeFormat()).resolvedOptions().timeZone
|
||||||
|
} catch (_) { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
htmx.on("htmx:afterOnLoad", function (evt) {
|
||||||
|
const hdrs = evt.detail.xhr.getAllResponseHeaders()
|
||||||
|
// Show a message if there was one in the response
|
||||||
|
if (hdrs.indexOf("x-toast") >= 0) {
|
||||||
|
jjj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
htmx.on("htmx:configRequest", function (evt) {
|
||||||
|
// Send the user's current time zone so that we can display local time
|
||||||
|
if (jjj.timeZone) {
|
||||||
|
evt.detail.headers["X-Time-Zone"] = jjj.timeZone
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
jjj.deriveTimeZone()
|
||||||
|
|
Loading…
Reference in New Issue
Block a user