diff --git a/src/JobsJobsJobs/Data/Json.fs b/src/JobsJobsJobs/Data/Json.fs index 4ef0a81..81c5e4d 100644 --- a/src/JobsJobsJobs/Data/Json.fs +++ b/src/JobsJobsJobs/Data/Json.fs @@ -18,13 +18,12 @@ open NodaTime.Serialization.SystemTextJson /// JsonSerializer options that use the custom converters let options = let opts = JsonSerializerOptions () - [ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter - WrappedJsonConverter (ContactType.parse, ContactType.toString) - WrappedJsonConverter (ContinentId.ofString, ContinentId.toString) - WrappedJsonConverter (ListingId.ofString, ListingId.toString) - WrappedJsonConverter (Text, MarkdownString.toString) - WrappedJsonConverter (OtherContactId.ofString, OtherContactId.toString) - WrappedJsonConverter (SuccessId.ofString, SuccessId.toString) + [ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter + WrappedJsonConverter (ContactType.parse, ContactType.toString) + WrappedJsonConverter (ContinentId.ofString, ContinentId.toString) + WrappedJsonConverter (ListingId.ofString, ListingId.toString) + WrappedJsonConverter (Text, MarkdownString.toString) + WrappedJsonConverter (SuccessId.ofString, SuccessId.toString) JsonFSharpConverter () ] |> List.iter opts.Converters.Add diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index 0ac70b3..befd0f3 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -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 [] type ListingForView = diff --git a/src/JobsJobsJobs/Domain/SupportTypes.fs b/src/JobsJobsJobs/Domain/SupportTypes.fs index a62e913..f824960 100644 --- a/src/JobsJobsJobs/Domain/SupportTypes.fs +++ b/src/JobsJobsJobs/Domain/SupportTypes.fs @@ -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.) diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 6f35a42..7c85f82 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -251,7 +251,16 @@ module Citizen = |> Array.ofSeq 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,44 +396,52 @@ module Citizen = return! refreshPage () next ctx } + // POST: /citizen/save-account + let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let! theForm = ctx.BindFormAsync () + 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 form.NewPassword = "" then citizen.PasswordHash + else Auth.Passwords.hash citizen form.NewPassword + do! Citizens.save + { citizen with + FirstName = form.FirstName + LastName = form.LastName + DisplayName = noneIfEmpty form.DisplayName + PasswordHash = password + OtherContacts = form.Contacts + |> Array.map (fun c -> + { OtherContact.Name = noneIfEmpty c.Name + ContactType = ContactType.parse c.ContactType + Value = c.Value + IsPublic = c.IsPublic + }) + |> List.ofArray + } + 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 /api/citizen routes -[] -module CitizenApi = - - // PATCH: /api/citizen/account - let account : HttpHandler = authorize >=> fun next ctx -> task { - let! form = ctx.BindJsonAsync () - 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 - do! Citizens.save - { citizen with - FirstName = form.FirstName - LastName = form.LastName - DisplayName = noneIfBlank 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 - ContactType = ContactType.parse c.ContactType - Name = noneIfBlank c.Name - Value = c.Value - IsPublic = c.IsPublic - }) - } - return! ok next ctx - | None -> return! Error.notFound next ctx - } - - /// Handlers for the home page, legal stuff, and help [] module Home = @@ -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 @@ -801,9 +819,10 @@ let allEndpoints = [ route "/so-long" Citizen.soLong ] POST [ - route "/delete" Citizen.delete - route "/log-on" Citizen.doLogOn - route "/register" Citizen.doRegistration + 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 ] ] ] diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index e595c75..437f569 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -3,6 +3,70 @@ module JobsJobsJobs.ViewModels open JobsJobsJobs.Domain +/// The data to add or update an other contact +[] +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 +[] +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 [] type SkillForm = diff --git a/src/JobsJobsJobs/Server/Views/Citizen.fs b/src/JobsJobsJobs/Server/Views/Citizen.fs index ca7b46c..19cfa0a 100644 --- a/src/JobsJobsJobs/Server/Views/Citizen.fs +++ b/src/JobsJobsJobs/Server/Views/Citizen.fs @@ -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 " − " + ] + ] + 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 “Display Name” 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   " + 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 "  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 "  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)" ] ] diff --git a/src/JobsJobsJobs/Server/Views/Common.fs b/src/JobsJobsJobs/Server/Views/Common.fs index ac5c006..58454eb 100644 --- a/src/JobsJobsJobs/Server/Views/Common.fs +++ b/src/JobsJobsJobs/Server/Views/Common.fs @@ -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 diff --git a/src/JobsJobsJobs/Server/Views/Listing.fs b/src/JobsJobsJobs/Server/Views/Listing.fs index a9ef157..1d7ddd3 100644 --- a/src/JobsJobsJobs/Server/Views/Listing.fs +++ b/src/JobsJobsJobs/Server/Views/Listing.fs @@ -72,9 +72,7 @@ let expire (m : ExpireListingForm) (listing : Listing) csrf = ] ] ] - script [] [ - rawText """document.addEventListener("DOMContentLoaded", function () { jjj.listing.toggleFromHere() })""" - ] + jsOnLoad "jjj.listing.toggleFromHere()" ] diff --git a/src/JobsJobsJobs/Server/Views/Profile.fs b/src/JobsJobsJobs/Server/Views/Profile.fs index 27d2f94..a5cf1fa 100644 --- a/src/JobsJobsJobs/Server/Views/Profile.fs +++ b/src/JobsJobsJobs/Server/Views/Profile.fs @@ -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}" ] diff --git a/src/JobsJobsJobs/Server/wwwroot/script.js b/src/JobsJobsJobs/Server/wwwroot/script.js index 2ca3b95..f2ad8aa 100644 --- a/src/JobsJobsJobs/Server/wwwroot/script.js +++ b/src/JobsJobsJobs/Server/wwwroot/script.js @@ -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() } } }