Version 3 #40
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
AccountProfileForm,
|
||||||
Citizen,
|
Citizen,
|
||||||
CitizenRegistrationForm,
|
CitizenRegistrationForm,
|
||||||
Continent,
|
Continent,
|
||||||
|
@ -165,6 +166,16 @@ export default {
|
||||||
retrieve: async (id : string, user : LogOnSuccess) : Promise<Citizen | string | undefined> =>
|
retrieve: async (id : string, user : LogOnSuccess) : Promise<Citizen | string | undefined> =>
|
||||||
apiResult<Citizen>(await fetch(apiUrl(`citizen/${id}`), reqInit("GET", user)), `retrieving citizen ${id}`),
|
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
|
* Delete the current citizen's entire Jobs, Jobs, Jobs record
|
||||||
*
|
*
|
||||||
|
|
|
@ -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 */
|
/** A user of Jobs, Jobs, Jobs */
|
||||||
export interface Citizen {
|
export interface Citizen {
|
||||||
|
@ -15,8 +44,8 @@ export interface Citizen {
|
||||||
lastName : string
|
lastName : string
|
||||||
/** The citizen's display name */
|
/** The citizen's display name */
|
||||||
displayName : string | undefined
|
displayName : string | undefined
|
||||||
/** The user's real name */
|
/** The citizen's contact information */
|
||||||
otherContacts : any[]
|
otherContacts : OtherContact[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The data required to register as a user */
|
/** The data required to register as a user */
|
||||||
|
@ -28,7 +57,7 @@ export class CitizenRegistrationForm {
|
||||||
/** The citizen's e-mail address */
|
/** The citizen's e-mail address */
|
||||||
email = ""
|
email = ""
|
||||||
/** The name by which the citizen wishes to be known within the site */
|
/** The name by which the citizen wishes to be known within the site */
|
||||||
displayName = ""
|
displayName : string | undefined
|
||||||
/** The password for the citizen */
|
/** The password for the citizen */
|
||||||
password = ""
|
password = ""
|
||||||
/** The confirmed password for the citizen */
|
/** The confirmed password for the citizen */
|
||||||
|
|
74
src/JobsJobsJobs/App/src/components/citizen/ContactEdit.vue
Normal file
74
src/JobsJobsJobs/App/src/components/citizen/ContactEdit.vue
Normal 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')"> − </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>
|
|
@ -14,6 +14,9 @@
|
||||||
<icon :icon="mdiThumbUp" /> Success Stories
|
<icon :icon="mdiThumbUp" /> Success Stories
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="separator"></div>
|
<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">
|
<router-link to="/listings/mine" @click="hide">
|
||||||
<icon :icon="mdiSignText" /> My Job Listings
|
<icon :icon="mdiSignText" /> My Job Listings
|
||||||
</router-link>
|
</router-link>
|
||||||
|
@ -46,8 +49,8 @@
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { Offcanvas } from "bootstrap"
|
import { Offcanvas } from "bootstrap"
|
||||||
import { useStore } from "@/store"
|
|
||||||
import {
|
import {
|
||||||
|
mdiAccountEdit,
|
||||||
mdiHelpCircleOutline,
|
mdiHelpCircleOutline,
|
||||||
mdiHome,
|
mdiHome,
|
||||||
mdiLoginVariant,
|
mdiLoginVariant,
|
||||||
|
@ -60,6 +63,10 @@ import {
|
||||||
mdiViewListOutline
|
mdiViewListOutline
|
||||||
} from "@mdi/js"
|
} from "@mdi/js"
|
||||||
|
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import Icon from "@/components/Icon.vue"
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
|
@ -85,6 +85,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||||
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"),
|
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"),
|
||||||
meta: { auth: true, title: "Dashboard" }
|
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",
|
path: "/citizen/log-off",
|
||||||
name: "LogOff",
|
name: "LogOff",
|
||||||
|
|
181
src/JobsJobsJobs/App/src/views/citizen/AccountProfile.vue
Normal file
181
src/JobsJobsJobs/App/src/views/citizen/AccountProfile.vue
Normal 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 “Display Name” 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
|
||||||
|
<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" /> 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>
|
|
@ -13,7 +13,7 @@
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input type="email" id="email" :class="{ 'form-control': true, 'is-invalid': v$.email.$error }"
|
<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>
|
<div class="invalid-feedback">Please enter a valid e-mail address</div>
|
||||||
<label class="jjj-required" for="email">E-mail Address</label>
|
<label class="jjj-required" for="email">E-mail Address</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -46,7 +46,7 @@
|
||||||
<hr>
|
<hr>
|
||||||
<h4 class="pb-2">
|
<h4 class="pb-2">
|
||||||
Skills
|
Skills
|
||||||
<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>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
|
<profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
|
||||||
|
|
|
@ -4,7 +4,7 @@ open System.Text.Json
|
||||||
open System.Text.Json.Serialization
|
open System.Text.Json.Serialization
|
||||||
open JobsJobsJobs.Domain
|
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) =
|
type WrappedJsonConverter<'T> (wrap : string -> 'T, unwrap : 'T -> string) =
|
||||||
inherit JsonConverter<'T> ()
|
inherit JsonConverter<'T> ()
|
||||||
override _.Read(reader, _, _) =
|
override _.Read(reader, _, _) =
|
||||||
|
@ -18,12 +18,14 @@ open NodaTime.Serialization.SystemTextJson
|
||||||
/// JsonSerializer options that use the custom converters
|
/// JsonSerializer options that use the custom converters
|
||||||
let options =
|
let options =
|
||||||
let opts = JsonSerializerOptions ()
|
let opts = JsonSerializerOptions ()
|
||||||
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
|
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
|
||||||
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
|
WrappedJsonConverter (ContactType.parse, ContactType.toString)
|
||||||
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
|
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
|
||||||
WrappedJsonConverter (Text, MarkdownString.toString)
|
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
|
||||||
WrappedJsonConverter (SkillId.ofString, SkillId.toString)
|
WrappedJsonConverter (Text, MarkdownString.toString)
|
||||||
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
|
WrappedJsonConverter (OtherContactId.ofString, OtherContactId.toString)
|
||||||
|
WrappedJsonConverter (SkillId.ofString, SkillId.toString)
|
||||||
|
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
|
||||||
JsonFSharpConverter ()
|
JsonFSharpConverter ()
|
||||||
]
|
]
|
||||||
|> List.iter opts.Converters.Add
|
|> List.iter opts.Converters.Add
|
||||||
|
|
|
@ -5,6 +5,47 @@ open JobsJobsJobs.Domain
|
||||||
open Microsoft.Extensions.Options
|
open Microsoft.Extensions.Options
|
||||||
open NodaTime
|
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)
|
/// The data required to register a new citizen (user)
|
||||||
type CitizenRegistrationForm =
|
type CitizenRegistrationForm =
|
||||||
{ /// The first name of the new citizen
|
{ /// The first name of the new citizen
|
||||||
|
@ -14,7 +55,7 @@ type CitizenRegistrationForm =
|
||||||
LastName : string
|
LastName : string
|
||||||
|
|
||||||
/// The display name for the new citizen
|
/// The display name for the new citizen
|
||||||
DisplayName : string
|
DisplayName : string option
|
||||||
|
|
||||||
/// The citizen's e-mail address
|
/// The citizen's e-mail address
|
||||||
Email : string
|
Email : string
|
||||||
|
@ -26,6 +67,7 @@ type CitizenRegistrationForm =
|
||||||
ConfirmPassword : string
|
ConfirmPassword : string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The data required to add or edit a job listing
|
/// The data required to add or edit a job listing
|
||||||
type ListingForm =
|
type ListingForm =
|
||||||
{ /// The ID of the listing
|
{ /// The ID of the listing
|
||||||
|
|
|
@ -78,6 +78,22 @@ module MarkdownString =
|
||||||
let toString = function Text text -> text
|
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
|
/// Types of contacts supported by Jobs, Jobs, Jobs
|
||||||
type ContactType =
|
type ContactType =
|
||||||
/// E-mail addresses
|
/// E-mail addresses
|
||||||
|
@ -87,10 +103,31 @@ type ContactType =
|
||||||
/// Websites (personal, social, etc.)
|
/// Websites (personal, social, etc.)
|
||||||
| Website
|
| 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
|
/// Another way to contact a citizen from this site
|
||||||
type OtherContact =
|
type OtherContact =
|
||||||
{ /// The type of contact
|
{ /// The ID of the contact
|
||||||
|
Id : OtherContactId
|
||||||
|
|
||||||
|
/// The type of contact
|
||||||
ContactType : ContactType
|
ContactType : ContactType
|
||||||
|
|
||||||
/// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)
|
/// 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.)
|
/// The value for the contact (e-mail address, user name, URL, etc.)
|
||||||
Value : string
|
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
|
/// Parse a string into a skill ID
|
||||||
let ofString = ShortGuid.toGuid >> SkillId
|
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
|
/// The ID of a success report
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
module JobsJobsJobs.Api.Email
|
module JobsJobsJobs.Api.Email
|
||||||
|
|
||||||
|
open System.Net
|
||||||
open JobsJobsJobs.Domain
|
open JobsJobsJobs.Domain
|
||||||
open MailKit.Net.Smtp
|
open MailKit.Net.Smtp
|
||||||
open MailKit.Security
|
open MailKit.Security
|
||||||
|
@ -8,7 +9,7 @@ open MimeKit
|
||||||
/// Send an account confirmation e-mail
|
/// Send an account confirmation e-mail
|
||||||
let sendAccountConfirmation citizen security = backgroundTask {
|
let sendAccountConfirmation citizen security = backgroundTask {
|
||||||
let name = Citizen.name citizen
|
let name = Citizen.name citizen
|
||||||
let token = security.Token.Value
|
let token = WebUtility.UrlEncode security.Token.Value
|
||||||
use client = new SmtpClient ()
|
use client = new SmtpClient ()
|
||||||
do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None)
|
do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None)
|
||||||
|
|
||||||
|
|
|
@ -116,7 +116,7 @@ module Citizen =
|
||||||
Email = form.Email
|
Email = form.Email
|
||||||
FirstName = form.FirstName
|
FirstName = form.FirstName
|
||||||
LastName = form.LastName
|
LastName = form.LastName
|
||||||
DisplayName = noneIfEmpty form.DisplayName
|
DisplayName = noneIfBlank form.DisplayName
|
||||||
JoinedOn = now
|
JoinedOn = now
|
||||||
LastSeenOn = now
|
LastSeenOn = now
|
||||||
}
|
}
|
||||||
|
@ -171,10 +171,38 @@ module Citizen =
|
||||||
// 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
|
||||||
| Some citizen -> return! json citizen next ctx
|
| Some citizen -> return! json { citizen with PasswordHash = "" } next ctx
|
||||||
| None -> return! Error.notFound 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
|
// DELETE: /api/citizen
|
||||||
let delete : HttpHandler = authorize >=> fun next ctx -> task {
|
let delete : HttpHandler = authorize >=> fun next ctx -> task {
|
||||||
do! Citizens.deleteById (currentCitizenId ctx)
|
do! Citizens.deleteById (currentCitizenId ctx)
|
||||||
|
@ -447,7 +475,10 @@ let allEndpoints = [
|
||||||
subRoute "/api" [
|
subRoute "/api" [
|
||||||
subRoute "/citizen" [
|
subRoute "/citizen" [
|
||||||
GET_HEAD [ routef "/%O" Citizen.get ]
|
GET_HEAD [ routef "/%O" Citizen.get ]
|
||||||
PATCH [ route "/confirm" Citizen.confirmToken ]
|
PATCH [
|
||||||
|
route "/account" Citizen.account
|
||||||
|
route "/confirm" Citizen.confirmToken
|
||||||
|
]
|
||||||
POST [
|
POST [
|
||||||
route "/log-on" Citizen.logOn
|
route "/log-on" Citizen.logOn
|
||||||
route "/register" Citizen.register
|
route "/register" Citizen.register
|
||||||
|
|
Loading…
Reference in New Issue
Block a user