From 5ad408fcfcbd973b375b280b45aa76c7044892d5 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 3 Sep 2022 22:10:08 -0400 Subject: [PATCH] WIP on account profile page --- src/JobsJobsJobs/App/src/api/index.ts | 11 ++ src/JobsJobsJobs/App/src/api/types.ts | 35 +++- .../src/components/citizen/ContactEdit.vue | 74 +++++++ .../App/src/components/layout/AppLinks.vue | 9 +- src/JobsJobsJobs/App/src/router/index.ts | 6 + .../App/src/views/citizen/AccountProfile.vue | 181 ++++++++++++++++++ .../App/src/views/citizen/LogOn.vue | 2 +- .../App/src/views/profile/EditProfile.vue | 2 +- src/JobsJobsJobs/Data/Json.fs | 16 +- src/JobsJobsJobs/Domain/SharedTypes.fs | 44 ++++- src/JobsJobsJobs/Domain/SupportTypes.fs | 45 ++++- src/JobsJobsJobs/Server/Email.fs | 3 +- src/JobsJobsJobs/Server/Handlers.fs | 39 +++- 13 files changed, 444 insertions(+), 23 deletions(-) create mode 100644 src/JobsJobsJobs/App/src/components/citizen/ContactEdit.vue create mode 100644 src/JobsJobsJobs/App/src/views/citizen/AccountProfile.vue diff --git a/src/JobsJobsJobs/App/src/api/index.ts b/src/JobsJobsJobs/App/src/api/index.ts index 4f59d2e..641a119 100644 --- a/src/JobsJobsJobs/App/src/api/index.ts +++ b/src/JobsJobsJobs/App/src/api/index.ts @@ -1,4 +1,5 @@ import { + AccountProfileForm, Citizen, CitizenRegistrationForm, Continent, @@ -165,6 +166,16 @@ export default { retrieve: async (id : string, user : LogOnSuccess) : Promise => apiResult(await fetch(apiUrl(`citizen/${id}`), reqInit("GET", user)), `retrieving citizen ${id}`), + /** + * Save a citizen's account profile + * + * @param form The data to be saved + * @param user The currently logged-on user + * @returns True if successful, an error message if not + */ + save: async (form : AccountProfileForm, user : LogOnSuccess) : Promise => + apiSend(await fetch(apiUrl("citizen/account"), reqInit("PATCH", user, form)), "saving account profile"), + /** * Delete the current citizen's entire Jobs, Jobs, Jobs record * diff --git a/src/JobsJobsJobs/App/src/api/types.ts b/src/JobsJobsJobs/App/src/api/types.ts index 45e0708..028dc01 100644 --- a/src/JobsJobsJobs/App/src/api/types.ts +++ b/src/JobsJobsJobs/App/src/api/types.ts @@ -1,3 +1,32 @@ +/** An "other contact" for a citizen */ +export interface OtherContact { + /** The ID of this contact */ + id : string + /** The contact type (uses server-side DU) */ + contactType : string + /** The name for this contact */ + name : string | undefined + /** The value of the contact (e-mail, phone number, URL, etc.) */ + value : string + /** Whether this contact is visible on public employment profiles and job listings */ + isPublic : boolean +} + +/** The data a logged-on citizen can update */ +export class AccountProfileForm { + /** The citizen's first name */ + firstName = "" + /** The citizen's last name */ + lastName = "" + /** The name by which the citizen wishes to be known within the site */ + displayName : string | undefined + /** The new password for the citizen */ + newPassword : string | undefined + /** The confirmed new password for the citizen */ + newPasswordConfirm : string | undefined + /** The other contacts for this citizen */ + contacts : OtherContact[] = [] +} /** A user of Jobs, Jobs, Jobs */ export interface Citizen { @@ -15,8 +44,8 @@ export interface Citizen { lastName : string /** The citizen's display name */ displayName : string | undefined - /** The user's real name */ - otherContacts : any[] + /** The citizen's contact information */ + otherContacts : OtherContact[] } /** The data required to register as a user */ @@ -28,7 +57,7 @@ export class CitizenRegistrationForm { /** The citizen's e-mail address */ email = "" /** The name by which the citizen wishes to be known within the site */ - displayName = "" + displayName : string | undefined /** The password for the citizen */ password = "" /** The confirmed password for the citizen */ diff --git a/src/JobsJobsJobs/App/src/components/citizen/ContactEdit.vue b/src/JobsJobsJobs/App/src/components/citizen/ContactEdit.vue new file mode 100644 index 0000000..8a277d6 --- /dev/null +++ b/src/JobsJobsJobs/App/src/components/citizen/ContactEdit.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/JobsJobsJobs/App/src/components/layout/AppLinks.vue b/src/JobsJobsJobs/App/src/components/layout/AppLinks.vue index c21c79f..b1448a5 100644 --- a/src/JobsJobsJobs/App/src/components/layout/AppLinks.vue +++ b/src/JobsJobsJobs/App/src/components/layout/AppLinks.vue @@ -14,6 +14,9 @@   Success Stories
+ + My Account +   My Job Listings @@ -46,8 +49,8 @@ import { computed } from "vue" import { useRouter } from "vue-router" import { Offcanvas } from "bootstrap" -import { useStore } from "@/store" import { + mdiAccountEdit, mdiHelpCircleOutline, mdiHome, mdiLoginVariant, @@ -60,6 +63,10 @@ import { mdiViewListOutline } from "@mdi/js" +import { useStore } from "@/store" + +import Icon from "@/components/Icon.vue" + const store = useStore() const router = useRouter() diff --git a/src/JobsJobsJobs/App/src/router/index.ts b/src/JobsJobsJobs/App/src/router/index.ts index 976cc34..4a6beae 100644 --- a/src/JobsJobsJobs/App/src/router/index.ts +++ b/src/JobsJobsJobs/App/src/router/index.ts @@ -85,6 +85,12 @@ const routes: Array = [ component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"), meta: { auth: true, title: "Dashboard" } }, + { + path: "/citizen/account", + name: "AccountProfile", + component: () => import(/* webpackChunkName: "account" */ "../views/citizen/AccountProfile.vue"), + meta: { auth: true, title: "Account Profile" } + }, { path: "/citizen/log-off", name: "LogOff", diff --git a/src/JobsJobsJobs/App/src/views/citizen/AccountProfile.vue b/src/JobsJobsJobs/App/src/views/citizen/AccountProfile.vue new file mode 100644 index 0000000..15290c3 --- /dev/null +++ b/src/JobsJobsJobs/App/src/views/citizen/AccountProfile.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue b/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue index 5c2b54a..85a2fcb 100644 --- a/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue +++ b/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue @@ -13,7 +13,7 @@
+ v-model="v$.email.$model" placeholder="E-mail Address" autofocus>
Please enter a valid e-mail address
diff --git a/src/JobsJobsJobs/App/src/views/profile/EditProfile.vue b/src/JobsJobsJobs/App/src/views/profile/EditProfile.vue index ab00ca9..05820a7 100644 --- a/src/JobsJobsJobs/App/src/views/profile/EditProfile.vue +++ b/src/JobsJobsJobs/App/src/views/profile/EditProfile.vue @@ -46,7 +46,7 @@

Skills   - +

(wrap : string -> 'T, unwrap : 'T -> string) = inherit JsonConverter<'T> () override _.Read(reader, _, _) = @@ -18,12 +18,14 @@ open NodaTime.Serialization.SystemTextJson /// JsonSerializer options that use the custom converters let options = let opts = JsonSerializerOptions () - [ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter - WrappedJsonConverter (ContinentId.ofString, ContinentId.toString) - WrappedJsonConverter (ListingId.ofString, ListingId.toString) - WrappedJsonConverter (Text, MarkdownString.toString) - WrappedJsonConverter (SkillId.ofString, SkillId.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 (OtherContactId.ofString, OtherContactId.toString) + WrappedJsonConverter (SkillId.ofString, SkillId.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 ac88840..e66415d 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -5,6 +5,47 @@ open JobsJobsJobs.Domain open Microsoft.Extensions.Options 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 required to register a new citizen (user) type CitizenRegistrationForm = { /// The first name of the new citizen @@ -14,7 +55,7 @@ type CitizenRegistrationForm = LastName : string /// The display name for the new citizen - DisplayName : string + DisplayName : string option /// The citizen's e-mail address Email : string @@ -26,6 +67,7 @@ type CitizenRegistrationForm = ConfirmPassword : string } + /// The data required to add or edit a job listing type ListingForm = { /// The ID of the listing diff --git a/src/JobsJobsJobs/Domain/SupportTypes.fs b/src/JobsJobsJobs/Domain/SupportTypes.fs index f039867..94076d9 100644 --- a/src/JobsJobsJobs/Domain/SupportTypes.fs +++ b/src/JobsJobsJobs/Domain/SupportTypes.fs @@ -78,6 +78,22 @@ 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 @@ -87,10 +103,31 @@ type ContactType = /// Websites (personal, social, etc.) | Website +/// Functions to support contact types +module ContactType = + + /// Parse a contact type from a string + let parse typ = + match typ with + | "Email" -> Email + | "Phone" -> Phone + | "Website" -> Website + | it -> invalidOp $"{it} is not a valid contact type" + + /// Convert a contact type to its string representation + let toString = + function + | Email -> "Email" + | Phone -> "Phone" + | Website -> "Website" + /// Another way to contact a citizen from this site type OtherContact = - { /// The type of contact + { /// The ID of the contact + Id : OtherContactId + + /// The type of contact ContactType : ContactType /// The name of the contact (Email, No Agenda Social, LinkedIn, etc.) @@ -98,6 +135,9 @@ type OtherContact = /// The value for the contact (e-mail address, user name, URL, etc.) Value : string + + /// Whether this contact is visible in public employment profiles and job listings + IsPublic : bool } @@ -115,9 +155,6 @@ module SkillId = /// Parse a string into a skill ID let ofString = ShortGuid.toGuid >> SkillId - - /// Get the GUID value of a skill ID - let value = function SkillId guid -> guid /// The ID of a success report diff --git a/src/JobsJobsJobs/Server/Email.fs b/src/JobsJobsJobs/Server/Email.fs index d664002..86aef47 100644 --- a/src/JobsJobsJobs/Server/Email.fs +++ b/src/JobsJobsJobs/Server/Email.fs @@ -1,5 +1,6 @@ module JobsJobsJobs.Api.Email +open System.Net open JobsJobsJobs.Domain open MailKit.Net.Smtp open MailKit.Security @@ -8,7 +9,7 @@ open MimeKit /// Send an account confirmation e-mail let sendAccountConfirmation citizen security = backgroundTask { let name = Citizen.name citizen - let token = security.Token.Value + let token = WebUtility.UrlEncode security.Token.Value use client = new SmtpClient () do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None) diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 4aa4547..d74f646 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -116,7 +116,7 @@ module Citizen = Email = form.Email FirstName = form.FirstName LastName = form.LastName - DisplayName = noneIfEmpty form.DisplayName + DisplayName = noneIfBlank form.DisplayName JoinedOn = now LastSeenOn = now } @@ -171,10 +171,38 @@ module Citizen = // GET: /api/citizen/[id] let get citizenId : HttpHandler = authorize >=> fun next ctx -> task { match! Citizens.findById (CitizenId citizenId) with - | Some citizen -> return! json citizen next ctx + | Some citizen -> return! json { citizen with PasswordHash = "" } next ctx | None -> return! Error.notFound next ctx } - + + // 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 + } + // DELETE: /api/citizen let delete : HttpHandler = authorize >=> fun next ctx -> task { do! Citizens.deleteById (currentCitizenId ctx) @@ -447,7 +475,10 @@ let allEndpoints = [ subRoute "/api" [ subRoute "/citizen" [ GET_HEAD [ routef "/%O" Citizen.get ] - PATCH [ route "/confirm" Citizen.confirmToken ] + PATCH [ + route "/account" Citizen.account + route "/confirm" Citizen.confirmToken + ] POST [ route "/log-on" Citizen.logOn route "/register" Citizen.register