Migrate / finish account profile page

This commit is contained in:
Daniel J. Summers 2023-01-16 20:20:51 -05:00
parent ccd7311e74
commit fa03e361e1
10 changed files with 334 additions and 141 deletions

View File

@ -23,7 +23,6 @@ let options =
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
WrappedJsonConverter (Text, MarkdownString.toString)
WrappedJsonConverter (OtherContactId.ofString, OtherContactId.toString)
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
JsonFSharpConverter ()
]

View File

@ -4,47 +4,6 @@ module JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Domain
open NodaTime
/// The data to add or update an other contact
type OtherContactForm =
{ /// The ID of the contact
Id : string
/// The type of the contact
ContactType : string
/// The name of the contact
Name : string option
/// The value of the contact (URL, e-mail address, phone, etc.)
Value : string
/// Whether this contact is displayed for public employment profiles and job listings
IsPublic : bool
}
/// The data available to update an account profile
type AccountProfileForm =
{ /// The first name of the citizen
FirstName : string
/// The last name of the citizen
LastName : string
/// The display name for the citizen
DisplayName : string option
/// The citizen's new password
NewPassword : string option
/// Confirmation of the citizen's new password
NewPasswordConfirm : string option
/// The contacts for this profile
Contacts : OtherContactForm list
}
/// The data needed to display a listing
[<NoComparison; NoEquality>]
type ListingForView =

View File

@ -78,22 +78,6 @@ module MarkdownString =
let toString = function Text text -> text
/// The ID of an other contact
type OtherContactId = OtherContactId of Guid
/// Support functions for other contact IDs
module OtherContactId =
/// Create a new job listing ID
let create () = (Guid.NewGuid >> OtherContactId) ()
/// A string representation of a listing ID
let toString = function OtherContactId it -> ShortGuid.fromGuid it
/// Parse a string into a listing ID
let ofString = ShortGuid.toGuid >> OtherContactId
/// Types of contacts supported by Jobs, Jobs, Jobs
type ContactType =
/// E-mail addresses
@ -124,10 +108,7 @@ module ContactType =
/// Another way to contact a citizen from this site
type OtherContact =
{ /// The ID of the contact
Id : OtherContactId
/// The type of contact
{ /// The type of contact
ContactType : ContactType
/// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)

View File

@ -252,6 +252,15 @@ module Citizen =
challenges <- Some qAndA
qAndA
// GET: /citizen/account
let account : HttpHandler = fun next ctx -> task {
match! Citizens.findById (currentCitizenId ctx) with
| Some citizen ->
return!
Citizen.account (AccountProfileForm.fromCitizen citizen) (csrf ctx) |> render "Account Profile" next ctx
| None -> return! Error.notFound next ctx
}
// GET: /citizen/confirm/[token]
let confirm token next ctx = task {
let! isConfirmed = Citizens.confirmAccount token
@ -387,43 +396,51 @@ module Citizen =
return! refreshPage () next ctx
}
// GET: /citizen/so-long
let soLong : HttpHandler = requireUser >=> fun next ctx ->
Citizen.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
/// Handlers for /api/citizen routes
[<RequireQualifiedAccess>]
module CitizenApi =
// PATCH: /api/citizen/account
let account : HttpHandler = authorize >=> fun next ctx -> task {
let! form = ctx.BindJsonAsync<AccountProfileForm> ()
// POST: /citizen/save-account
let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let! theForm = ctx.BindFormAsync<AccountProfileForm> ()
let form = { theForm with Contacts = theForm.Contacts |> Array.filter (box >> isNull >> not) }
let errors = [
if form.FirstName = "" then "First Name is required"
if form.LastName = "" then "Last Name is required"
if form.NewPassword <> form.NewPassword then "New passwords do not match"
if form.Contacts |> Array.exists (fun c -> c.ContactType = "") then "All Contact Types are required"
if form.Contacts |> Array.exists (fun c -> c.Value = "") then "All Contacts are required"
]
if List.isEmpty errors then
match! Citizens.findById (currentCitizenId ctx) with
| Some citizen ->
let password =
if defaultArg form.NewPassword "" = "" then citizen.PasswordHash
else Auth.Passwords.hash citizen form.NewPassword.Value
if form.NewPassword = "" then citizen.PasswordHash
else Auth.Passwords.hash citizen form.NewPassword
do! Citizens.save
{ citizen with
FirstName = form.FirstName
LastName = form.LastName
DisplayName = noneIfBlank form.DisplayName
DisplayName = noneIfEmpty form.DisplayName
PasswordHash = password
OtherContacts = form.Contacts
|> List.map (fun c -> {
Id = if c.Id.StartsWith "new" then OtherContactId.create ()
else OtherContactId.ofString c.Id
|> Array.map (fun c ->
{ OtherContact.Name = noneIfEmpty c.Name
ContactType = ContactType.parse c.ContactType
Name = noneIfBlank c.Name
Value = c.Value
IsPublic = c.IsPublic
})
|> List.ofArray
}
return! ok next ctx
let extraMsg = if form.NewPassword = "" then "" else " and password changed"
do! addSuccess $"Account profile updated{extraMsg} successfully" ctx
return! redirectToGet "/citizen/account" next ctx
| None -> return! Error.notFound next ctx
else
do! addErrors errors ctx
return! Citizen.account form (csrf ctx) |> render "Account Profile" next ctx
}
// GET: /citizen/so-long
let soLong : HttpHandler = requireUser >=> fun next ctx ->
Citizen.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
/// Handlers for the home page, legal stuff, and help
[<RequireQualifiedAccess>]
@ -792,6 +809,7 @@ let allEndpoints = [
]
subRoute "/citizen" [
GET_HEAD [
route "/account" Citizen.account
routef "/confirm/%s" Citizen.confirm
route "/dashboard" Citizen.dashboard
routef "/deny/%s" Citizen.deny
@ -804,6 +822,7 @@ let allEndpoints = [
route "/delete" Citizen.delete
route "/log-on" Citizen.doLogOn
route "/register" Citizen.doRegistration
route "/save-account" Citizen.saveAccount
]
]
subRoute "/listing" [
@ -838,13 +857,7 @@ let allEndpoints = [
]
POST [ route "y/save" Success.save ]
]
subRoute "/api" [
subRoute "/citizen" [
PATCH [
route "/account" CitizenApi.account
]
]
POST [ route "/markdown-preview" Api.markdownPreview ]
]
]

View File

@ -3,6 +3,70 @@ module JobsJobsJobs.ViewModels
open JobsJobsJobs.Domain
/// The data to add or update an other contact
[<CLIMutable; NoComparison; NoEquality>]
type OtherContactForm =
{ /// The type of the contact
ContactType : string
/// The name of the contact
Name : string
/// The value of the contact (URL, e-mail address, phone, etc.)
Value : string
/// Whether this contact is displayed for public employment profiles and job listings
IsPublic : bool
}
/// Support functions for the contact form
module OtherContactForm =
/// Create a contact form from a contact
let fromContact (contact : OtherContact) =
{ ContactType = ContactType.toString contact.ContactType
Name = defaultArg contact.Name ""
Value = contact.Value
IsPublic = contact.IsPublic
}
/// The data available to update an account profile
[<CLIMutable; NoComparison; NoEquality>]
type AccountProfileForm =
{ /// The first name of the citizen
FirstName : string
/// The last name of the citizen
LastName : string
/// The display name for the citizen
DisplayName : string
/// The citizen's new password
NewPassword : string
/// Confirmation of the citizen's new password
NewPasswordConfirm : string
/// The contacts for this profile
Contacts : OtherContactForm array
}
/// Support functions for the account profile form
module AccountProfileForm =
/// Create an account profile form from a citizen
let fromCitizen (citizen : Citizen) =
{ FirstName = citizen.FirstName
LastName = citizen.LastName
DisplayName = defaultArg citizen.DisplayName ""
NewPassword = ""
NewPasswordConfirm = ""
Contacts = citizen.OtherContacts |> List.map OtherContactForm.fromContact |> Array.ofList
}
/// The fields required for a skill
[<CLIMutable; NoComparison; NoEquality>]
type SkillForm =

View File

@ -7,6 +7,116 @@ open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.Domain
open JobsJobsJobs.ViewModels
/// The form to add or edit a means of contact
let contactEdit (contacts : OtherContactForm array) =
let mapToInputs (idx : int) (contact : OtherContactForm) =
div [ _id $"contactRow{idx}"; _class "row pb-3" ] [
div [ _class "col-2 col-md-1" ] [
button [ _type "button"; _class "btn btn-sm btn-outline-danger rounded-pill mt-3"; _title "Delete"
_onclick $"jjj.citizen.removeContact({idx})" ] [
rawText " &minus; "
]
]
div [ _class "col-10 col-md-4 col-xl-3" ] [
div [ _class "form-floating" ] [
select [ _id $"contactType{idx}"; _name $"Contacts[{idx}].ContactType"; _class "form-control"
_value contact.ContactType; _placeholder "Type"; _required ] [
option [ _value "Website" ] [ rawText "Website" ]
option [ _value "Email" ] [ rawText "E-mail Address" ]
option [ _value "Phone" ] [ rawText "Phone Number" ]
]
label [ _class "jjj-required"; _for $"contactType{idx}" ] [ rawText "Type" ]
]
]
div [ _class "col-12 col-md-4 col-xl-3" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"contactName{idx}"; _name $"Contacts[{idx}].Name"; _class "form-control"
_maxlength "1000"; _value contact.Name; _placeholder "Name" ]
label [ _class "jjj-label"; _for $"contactName{idx}" ] [ rawText "Name" ]
]
if idx < 1 then
div [ _class "form-text" ] [ rawText "Optional; will link sites and e-mail, qualify phone numbers" ]
]
div [ _class "col-12 col-md-7 offset-md-1 col-xl-4 offset-xl-0" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"contactValue{idx}"; _name $"Contacts[{idx}].Value"
_class "form-control"; _maxlength "1000"; _value contact.Value; _placeholder "Contact"
_required ]
label [ _class "jjj-required"; _for "contactValue{idx}" ] [ rawText "Contact" ]
]
if idx < 1 then div [ _class "form-text"] [ rawText "The URL, e-mail address, or phone number" ]
]
div [ _class "col-12 col-md-3 offset-md-1 col-xl-1 offset-xl-0" ] [
div [ _class "form-check mt-3" ] [
input [ _type "checkbox"; _id $"contactIsPublic{idx}"; _name $"Contacts[{idx}].IsPublic";
_class "form-check-input"; _value "true"; if contact.IsPublic then _checked ]
label [ _class "form-check-label"; _for $"contactIsPublic{idx}" ] [ rawText "Public" ]
]
]
]
template [ _id "newContact" ] [
mapToInputs -1 { ContactType = "Website"; Name = ""; Value = ""; IsPublic = false }
]
:: (contacts |> Array.mapi mapToInputs |> List.ofArray)
/// The account edit page
let account (m : AccountProfileForm) csrf =
article [] [
h3 [ _class "pb-3" ] [ rawText "Account Profile" ]
p [] [
rawText "This information is visible to all fellow logged-on citizens. For publicly-visible employment "
rawText "profiles and job listings, the &ldquo;Display Name&rdquo; fields and any public contacts will be "
rawText "displayed."
]
form [ _class "row g-3"; _method "POST"; _action "/citizen/save-account" ] [
antiForgery csrf
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.DisplayName) m.DisplayName "Display Name" false
div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ]
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPassword) "" "New Password" false
div [ _class "form-text" ] [ rawText "Leave blank to keep your current password" ]
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPasswordConfirm) "" "Confirm New Password"
false
div [ _class "form-text" ] [ rawText "Leave blank to keep your current password" ]
]
div [ _class "col-12" ] [
hr []
h4 [ _class "pb-2" ] [
rawText "Ways to Be Contacted &nbsp; "
button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
_onclick "jjj.citizen.addContact()" ] [
rawText "Add a Contact Method"
]
]
]
yield! contactEdit m.Contacts
div [ _class "col-12" ] [
button [ _type "submit"; _class "btn btn-primary" ] [
i [ _class "mdi mdi-content-save-outline" ] [ rawText "&nbsp; Save" ]
]
]
]
hr []
p [ _class "text-muted fst-italic" ] [
rawText "(If you want to delete your profile, or your entire account, "
a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; rawText ".)"
]
jsOnLoad $"
jjj.citizen.nextIndex = {m.Contacts.Length}
jjj.citizen.validatePasswords('{nameof m.NewPassword}', '{nameof m.NewPasswordConfirm}', false)"
]
/// The account confirmation page
let confirmAccount isConfirmed =
article [] [
@ -231,19 +341,7 @@ let register q1 q2 (m : RegisterViewModel) csrf =
i [ _class "mdi mdi-content-save-outline" ] []; rawText "&nbsp; Save"
]
]
script [] [ rawText """
const pw = document.getElementById("Password")
const pwConfirm = document.getElementById("ConfirmPassword")
pwConfirm.addEventListener("input", () => {
if (!pw.validity.valid) {
pwConfirm.setCustomValidity("")
} else if (!pwConfirm.validity.valueMissing && pw.value !== pwConfirm.value) {
pwConfirm.setCustomValidity("Confirmation password does not match")
} else {
pwConfirm.setCustomValidity("")
}
})"""
]
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)"
]
]

View File

@ -49,6 +49,12 @@ let continentList attrs name (continents : Continent list) emptyLabel selectedVa
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ rawText "Continent" ]
]
/// Register JavaScript code to run in the DOMContentLoaded event on the page
let jsOnLoad js =
script [] [
rawText """document.addEventListener("DOMContentLoaded", function () { """; rawText js; rawText " })"
]
/// Create a Markdown editor
let markdownEditor attrs name value editorLabel =
div [ _class "col-12"; _id $"{name}EditRow" ] [
@ -71,11 +77,7 @@ let markdownEditor attrs name value editorLabel =
]
label [ _for name ] [ rawText editorLabel ]
]
script [] [
rawText """document.addEventListener("DOMContentLoaded", function () {"""
rawText $" jjj.markdownOnLoad('{name}') "
rawText "})"
]
jsOnLoad $"jjj.markdownOnLoad('{name}')"
]
/// Wrap content in a collapsing panel

View File

@ -72,9 +72,7 @@ let expire (m : ExpireListingForm) (listing : Listing) csrf =
]
]
]
script [] [
rawText """document.addEventListener("DOMContentLoaded", function () { jjj.listing.toggleFromHere() })"""
]
jsOnLoad "jjj.listing.toggleFromHere()"
]

View File

@ -119,11 +119,7 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
rawText "(If you want to delete your profile, or your entire account, "
a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; rawText ".)"
]
script [] [
rawText """addEventListener("DOMContentLoaded", function () {"""
rawText $" jjj.profile.nextIndex = {m.Skills.Length} "
rawText "})"
]
jsOnLoad $"jjj.profile.nextIndex = {m.Skills.Length}"
]

View File

@ -130,6 +130,89 @@ this.jjj = {
editDiv.classList.add("jjj-shown")
},
citizen: {
/**
* The next index for a newly-added contact
* @type {number}
*/
nextIndex: 0,
/**
* Add a contact to the account form
*/
addContact() {
const next = this.nextIndex
/** @type {HTMLTemplateElement} */
const newContactTemplate = document.getElementById("newContact")
/** @type {HTMLDivElement} */
const newContact = newContactTemplate.content.firstElementChild.cloneNode(true)
newContact.setAttribute("id", `contactRow${next}`)
const cols = newContact.children
// Button column
cols[0].querySelector("button").setAttribute("onclick", `jjj.citizen.removeContact(${next})`)
// Contact Type column
const typeField = cols[1].querySelector("select")
typeField.setAttribute("id", `contactType${next}`)
typeField.setAttribute("name", `Contacts[${this.nextIndex}].ContactType`)
cols[1].querySelector("label").setAttribute("for", `contactType${next}`)
// Name column
const nameField = cols[2].querySelector("input")
nameField.setAttribute("id", `contactName${next}`)
nameField.setAttribute("name", `Contacts[${this.nextIndex}].Name`)
cols[2].querySelector("label").setAttribute("for", `contactName${next}`)
if (next > 0) cols[2].querySelector("div.form-text").remove()
// Value column
const valueField = cols[3].querySelector("input")
valueField.setAttribute("id", `contactValue${next}`)
valueField.setAttribute("name", `Contacts[${this.nextIndex}].Value`)
cols[3].querySelector("label").setAttribute("for", `contactName${next}`)
if (next > 0) cols[3].querySelector("div.form-text").remove()
// Is Public column
const isPublicField = cols[4].querySelector("input")
isPublicField.setAttribute("id", `contactIsPublic${next}`)
isPublicField.setAttribute("name", `Contacts[${this.nextIndex}].IsPublic`)
cols[4].querySelector("label").setAttribute("for", `contactIsPublic${next}`)
// Add the row
const contacts = document.querySelectorAll("div[id^=contactRow]")
const sibling = contacts.length > 0 ? contacts[contacts.length - 1] : newContactTemplate
sibling.insertAdjacentElement('afterend', newContact)
this.nextIndex++
},
/**
* Remove a contact row from the profile form
* @param {number} idx The index of the contact row to remove
*/
removeContact(idx) {
document.getElementById(`contactRow${idx}`).remove()
},
/**
* Register a comparison validation between a password and a "confirm password" field
* @param {string} pwId The ID for the password field
* @param {string} confirmId The ID for the "confirm password" field
* @param {boolean} isRequired Whether these fields are required
*/
validatePasswords(pwId, confirmId, isRequired) {
const pw = document.getElementById(pwId)
const pwConfirm = document.getElementById(confirmId)
pwConfirm.addEventListener("input", () => {
if (!pw.validity.valid) {
pwConfirm.setCustomValidity("")
} else if ((!pwConfirm.validity.valueMissing || !isRequired) && pw.value !== pwConfirm.value) {
pwConfirm.setCustomValidity("Confirmation password does not match")
} else {
pwConfirm.setCustomValidity("")
}
})
}
},
/**
* Script for listing pages
*/
@ -172,7 +255,7 @@ this.jjj = {
const cols = newSkill.children
// Button column
cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill('${next}')`)
cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill(${next})`)
// Skill column
const skillField = cols[1].querySelector("input")
skillField.setAttribute("id", `skillDesc${next}`)
@ -196,10 +279,10 @@ this.jjj = {
/**
* Remove a skill row from the profile form
* @param {number} id The ID of the skill row to remove
* @param {number} idx The index of the skill row to remove
*/
removeSkill(id) {
document.getElementById(`skillRow${id}`).remove()
removeSkill(idx) {
document.getElementById(`skillRow${idx}`).remove()
}
}
}