Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
13 changed files with 444 additions and 23 deletions
Showing only changes of commit 5ad408fcfc - Show all commits

View File

@ -1,4 +1,5 @@
import {
AccountProfileForm,
Citizen,
CitizenRegistrationForm,
Continent,
@ -165,6 +166,16 @@ export default {
retrieve: async (id : string, user : LogOnSuccess) : Promise<Citizen | string | undefined> =>
apiResult<Citizen>(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<boolean | string> =>
apiSend(await fetch(apiUrl("citizen/account"), reqInit("PATCH", user, form)), "saving account profile"),
/**
* Delete the current citizen's entire Jobs, Jobs, Jobs record
*

View File

@ -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 */

View File

@ -0,0 +1,74 @@
<template>
<div class="row pb-3">
<div class="col-2 col-md-1 align-self-center">
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete"
@click.prevent="$emit('remove')">&nbsp;&minus;&nbsp;</button>
</div>
<div class="col-10 col-md-6">
<div class="form-floating">
<select :id="`contactType${contact.id}`" class="form-control" :value="contact.contactType"
@input="updateValue('contactType', $event.target.value)">
<option value="Website">Website</option>
<option value="Email">E-mail Address</option>
<option value="Phone">Phone Number</option>
</select>
<label class="jjj-label" :for="`contactType${contact.id}`">Type</label>
</div>
</div>
<div class="col-12 col-md-5">
<div class="form-floating">
<input type="text" :id="`contactName${contact.id}`" class="form-control" maxlength="1000"
placeholder="The name of this contact" :value="contact.name"
@input="updateValue('name', $event.target.value)">
<label class="jjj-label" :for="`contactName${contact.id}`">Name</label>
</div>
<div class="form-text">Optional; will link sites and e-mail, qualify phone numbers</div>
</div>
<div class="col-12 col-md-5">
<div class="form-floating">
<input type="text" :id="`contactValue${contact.id}`" class="form-control" maxlength="1000"
placeholder="The value forthis contact" :value="contact.value"
@input="updateValue('value', $event.target.value)">
<label class="jjj-label" :for="`contactValue${contact.id}`">Contact</label>
</div>
<div class="form-text">The URL, e-mail address, or phone number</div>
</div>
<div class="col-12 col-offset-md-2 col-md-4">
<div class="form-check">
<input type="checkbox" :id="`contactIsPublic${contact.id}`" class="form-check-input" value="true"
:checked="contact.isPublic">
<label class="form-check-label" :for="`contactIsPublic${contact.id}`">Public</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { OtherContact } from "@/api"
import { ref, Ref } from "vue"
const props = defineProps<{
modelValue: OtherContact
}>()
const emit = defineEmits<{
(e: "input") : void
(e: "remove") : void
(e: "update:modelValue", value: OtherContact) : void
}>()
/** The contact being edited */
const contact : Ref<OtherContact> = ref({ ...props.modelValue as OtherContact })
/** Update a value in the model */
const updateValue = (key : string, value : string) => {
contact.value = { ...contact.value, [key]: value }
emit("update:modelValue", contact.value)
emit("input")
}
</script>
<style scoped>
</style>

View File

@ -14,6 +14,9 @@
<icon :icon="mdiThumbUp" />&nbsp; Success Stories
</router-link>
<div class="separator"></div>
<router-link to="/citizen/account" @click="hide">
<icon :icon="mdiAccountEdit" /> My Account
</router-link>
<router-link to="/listings/mine" @click="hide">
<icon :icon="mdiSignText" />&nbsp; My Job Listings
</router-link>
@ -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()

View File

@ -85,6 +85,12 @@ const routes: Array<RouteRecordRaw> = [
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",

View File

@ -0,0 +1,181 @@
<template>
<article>
<h3 class="pb-3">Account Profile</h3>
<p>
This information is visible to all fellow logged-on citizens. For publicly-visible employment profiles and job
listings, the &ldquo;Display Name&rdquo; fields and any public contacts will be displayed.
</p>
<load-data :load="retrieveData">
<form class="row g-3" novalidate>
<div class="col-6 col-xl-4">
<div class="form-floating has-validation">
<input type="text" id="firstName" :class="{ 'form-control': true, 'is-invalid': v$.firstName.$error }"
v-model="v$.firstName.$model" placeholder="First Name">
<div class="invalid-feedback">Please enter your first name</div>
<label class="jjj-required" for="firstName">First Name</label>
</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="text" id="lastName" :class="{ 'form-control': true, 'is-invalid': v$.lastName.$error }"
v-model="v$.lastName.$model" placeholder="Last Name">
<div class="invalid-feedback">Please enter your last name</div>
<label class="jjj-required" for="firstName">Last Name</label>
</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="text" id="displayName" class="form-control" v-model="v$.displayName.$model"
placeholder="Display Name">
<label for="displayName">Display Name</label>
<div class="form-text"><em>Optional; overrides first/last for display</em></div>
</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="password" id="newPassword"
:class="{ 'form-control': true, 'is-invalid': v$.newPassword.$error }"
v-model="v$.newPassword.$model" placeholder="Password">
<div class="invalid-feedback">Password must be at least 8 characters long</div>
<label for="newPassword">New Password</label>
</div>
<div class="form-text">Leave blank to keep your current password</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="password" id="newPasswordConfirm"
:class="{ 'form-control': true, 'is-invalid': v$.newPasswordConfirm.$error }"
v-model="v$.newPasswordConfirm.$model" placeholder="Confirm Password">
<div class="invalid-feedback">The passwords do not match</div>
<label for="newPasswordConfirm">Confirm New Password</label>
</div>
<div class="form-text">Leave blank to keep your current password</div>
</div>
<div class="col-12">
<hr>
<h4 class="pb-2">
Ways to Be Contacted &nbsp;
<button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addContact">
Add a Contact Method
</button>
</h4>
</div>
<contact-edit v-for="(contact, idx) in accountForm.contacts" :key="contact.id"
v-model="accountForm.contacts[idx]" @remove="removeContact(contact.id)"
@input="v$.contacts.$touch" />
<div class="col-12">
<p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
<button class="btn btn-primary" @click.prevent="saveAccount(false)">
<icon :icon="mdiContentSaveOutline" />&nbsp; Save
</button>
</div>
</form>
</load-data>
<hr>
<p class="text-muted fst-italic">
(If you want to delete your profile, or your entire account,
<router-link to="/so-long/options">see your deletion options here</router-link>.)
</p>
<maybe-save :saveAction="() => saveAccount(true)" :validator="v$" />
</article>
</template>
<script setup lang="ts">
import { computed, reactive } from "vue"
import { mdiContentSaveOutline } from "@mdi/js"
import useVuelidate from "@vuelidate/core"
import { minLength, required, sameAs } from "@vuelidate/validators"
import api, { AccountProfileForm, Citizen, LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
import MaybeSave from "@/components/MaybeSave.vue"
import ContactEdit from "@/components/citizen/ContactEdit.vue"
const store = useStore()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** The information available to update */
const accountForm = reactive(new AccountProfileForm())
/** The validation rules for the form */
const rules = computed(() => ({
firstName: { required },
lastName: { required },
displayName: { },
newPassword: { length: minLength(8) },
newPasswordConfirm: { matchPassword: sameAs(accountForm.newPassword) },
contacts: { }
}))
/** Initialize form validation */
const v$ = useVuelidate(rules, accountForm, { $lazy: true })
/** The ID for new contacts */
let newContactId = 0
/** Add a contact to the profile */
const addContact = () => {
accountForm.contacts.push({
id: `new${newContactId++}`,
contactType: "Website",
name: undefined,
value: "",
isPublic: false
})
v$.value.contacts.$touch()
}
/** Remove the given contact from the profile */
const removeContact = (contactId : string) => {
accountForm.contacts = accountForm.contacts.filter(c => c.id !== contactId)
v$.value.contacts.$touch()
}
/** Retrieve the account profile */
const retrieveData = async (errors : string[]) => {
const citizenResult = await api.citizen.retrieve(user.citizenId, user)
if (typeof citizenResult === "string") {
errors.push(citizenResult)
} else if (typeof citizenResult === "undefined") {
errors.push("Citizen not found")
} else {
// Update the empty form with appropriate values
const c = citizenResult as Citizen
accountForm.firstName = c.firstName
accountForm.lastName = c.lastName
accountForm.displayName = c.displayName
accountForm.contacts = c.otherContacts
}
}
/** Save the account profile */
const saveAccount = async (isNavigating: boolean) => {
v$.value.$touch()
if (v$.value.$error) return
// Remove any blank contacts before submitting
accountForm.contacts = accountForm.contacts.filter(c => !((c.name?.trim() ?? "") === "" && c.value.trim() === ""))
const saveResult = await api.citizen.save(accountForm, user)
if (typeof saveResult === "string") {
toastError(saveResult, "saving profile")
} else {
toastSuccess("Account Profile Saved Successfully")
if (!isNavigating) {
v$.value.$reset()
const errors: string[] = []
await retrieveData(errors)
if (errors.length > 0) {
toastError(errors[0], "retrieving updated profile")
}
}
}
}
</script>
<style scoped>
</style>

View File

@ -13,7 +13,7 @@
<div class="form-floating">
<div class="form-floating">
<input type="email" id="email" :class="{ 'form-control': true, 'is-invalid': v$.email.$error }"
v-model="v$.email.$model" placeholder="E-mail Address">
v-model="v$.email.$model" placeholder="E-mail Address" autofocus>
<div class="invalid-feedback">Please enter a valid e-mail address</div>
<label class="jjj-required" for="email">E-mail Address</label>
</div>

View File

@ -46,7 +46,7 @@
<hr>
<h4 class="pb-2">
Skills &nbsp;
<button class="btn btn-sm btn-outline-primary.rounded-pill" @click.prevent="addSkill">Add a Skill</button>
<button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addSkill">Add a Skill</button>
</h4>
</div>
<profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"

View File

@ -4,7 +4,7 @@ open System.Text.Json
open System.Text.Json.Serialization
open JobsJobsJobs.Domain
/// Convert a wrapped GUID to/from its string representation
/// Convert a wrapped DU to/from its string representation
type WrappedJsonConverter<'T> (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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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<AccountProfileForm> ()
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