WIP on account profile page

This commit is contained in:
Daniel J. Summers 2022-09-03 22:10:08 -04:00
parent 45b115418f
commit 5ad408fcfc
13 changed files with 444 additions and 23 deletions

View File

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

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

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 <icon :icon="mdiThumbUp" />&nbsp; 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" />&nbsp; My Job Listings <icon :icon="mdiSignText" />&nbsp; 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()

View File

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

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

View File

@ -46,7 +46,7 @@
<hr> <hr>
<h4 class="pb-2"> <h4 class="pb-2">
Skills &nbsp; 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> </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]"

View File

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

View File

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

View File

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

View File

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

View File

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