Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
12 changed files with 264 additions and 161 deletions
Showing only changes of commit 3196de4003 - Show all commits

View File

@ -19,7 +19,8 @@ import {
PublicSearchResult, PublicSearchResult,
StoryEntry, StoryEntry,
StoryForm, StoryForm,
Success Success,
Valid
} from "./types" } from "./types"
/** /**
@ -110,6 +111,20 @@ export default {
register: async (form : CitizenRegistrationForm) : Promise<boolean | string> => register: async (form : CitizenRegistrationForm) : Promise<boolean | string> =>
apiSend(await fetch(apiUrl("citizen/register"), reqInit("POST", undefined, form)), "registering citizen"), apiSend(await fetch(apiUrl("citizen/register"), reqInit("POST", undefined, form)), "registering citizen"),
/**
* Confirm an account by verifying a token they received via e-mail
*
* @param token The token to be verified
* @return True if the token is value, false if it is not, or an error message if one is encountered
*/
confirmToken: async (token : string) : Promise<boolean | string> => {
const resp = await apiResult<Valid>(
await fetch(apiUrl("citizen/confirm"), reqInit("PATCH", undefined, { token })), "confirming account")
if (typeof resp === "string") return resp
if (typeof resp === "undefined") return false
return resp.valid
},
/** /**
* Log a citizen on * Log a citizen on
* *

View File

@ -312,3 +312,9 @@ export interface Success {
/** The success story */ /** The success story */
story : string | undefined story : string | undefined
} }
/** Whether a check is valid */
export interface Valid {
/** The validity */
valid : boolean
}

View File

@ -7,7 +7,6 @@ import {
} from "vue-router" } from "vue-router"
import store, { Mutations } from "@/store" import store, { Mutations } from "@/store"
import Home from "@/views/Home.vue" import Home from "@/views/Home.vue"
import LogOn from "@/views/citizen/LogOn.vue"
/** The URL to which the user should be pointed once they have authorized with Mastodon */ /** The URL to which the user should be pointed once they have authorized with Mastodon */
export const AFTER_LOG_ON_URL = "jjj-after-log-on-url" export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
@ -35,38 +34,44 @@ const routes: Array<RouteRecordRaw> = [
path: "/how-it-works", path: "/how-it-works",
name: "HowItWorks", name: "HowItWorks",
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"), component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
meta: { title: "How It Works" } meta: { auth: false, title: "How It Works" }
}, },
{ {
path: "/privacy-policy", path: "/privacy-policy",
name: "PrivacyPolicy", name: "PrivacyPolicy",
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"), component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"),
meta: { title: "Privacy Policy" } meta: { auth: false, title: "Privacy Policy" }
}, },
{ {
path: "/terms-of-service", path: "/terms-of-service",
name: "TermsOfService", name: "TermsOfService",
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"), component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"),
meta: { title: "Terms of Service" } meta: { auth: false, title: "Terms of Service" }
}, },
// Citizen URLs // Citizen URLs
{ {
path: "/citizen/register", path: "/citizen/register",
name: "CitizenRegistration", name: "CitizenRegistration",
component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Register.vue"), component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Register.vue"),
meta: { title: "Register" } meta: { auth: false, title: "Register" }
}, },
{ {
path: "/citizen/registered", path: "/citizen/registered",
name: "CitizenRegistered", name: "CitizenRegistered",
component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Registered.vue"), component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Registered.vue"),
meta: { title: "Registration Successful" } meta: { auth: false, title: "Registration Successful" }
},
{
path: "/citizen/confirm/:token",
name: "ConfirmRegistration",
component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/ConfirmRegistration.vue"),
meta: { auth: false, title: "Account Confirmation" }
}, },
{ {
path: "/citizen/log-on", path: "/citizen/log-on",
name: "LogOn", name: "LogOn",
component: LogOn, component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/LogOn.vue"),
meta: { title: "Log On" } meta: { auth: false, title: "Log On" }
}, },
{ {
path: "/citizen/:abbr/authorized", path: "/citizen/:abbr/authorized",
@ -150,7 +155,7 @@ const routes: Array<RouteRecordRaw> = [
meta: { auth: true, title: "Account Deletion Options" } meta: { auth: true, title: "Account Deletion Options" }
}, },
{ {
path: "/so-long/success/:abbr", path: "/so-long/success",
name: "DeletionSuccess", name: "DeletionSuccess",
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue"), component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue"),
meta: { auth: false, title: "Account Deletion Success" } meta: { auth: false, title: "Account Deletion Success" }
@ -187,7 +192,7 @@ const router = createRouter({
// eslint-disable-next-line // eslint-disable-next-line
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => { router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => {
if (store.state.user === undefined && (to.meta.auth || false)) { if (store.state.user === undefined && to.meta.auth) {
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath) window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
return "/citizen/log-on" return "/citizen/log-on"
} }

View File

@ -0,0 +1,38 @@
<template>
<article>
<h3 class="pb-3">Account Confirmation</h3>
<load-data :load="confirmToken">
<p v-if="isConfirmed">
Your account was confirmed successfully! You may <router-link to="/citizen/log-on">log on here</router-link>.
</p>
<p v-else>
The confirmation token did not match any pending accounts. Confirmation tokens are only valid for 3 days; if
the token expired, you will need to re-register,
which <router-link to="/citzen/register">you can do here</router-link>.
</p>
</load-data>
</article>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useRoute } from "vue-router"
import api from "@/api"
import LoadData from "@/components/LoadData.vue"
const route = useRoute()
/** Whether the account was confirmed */
const isConfirmed = ref(false)
/** Confirm the account via the token */
const confirmToken = async (errors: string[]) => {
const resp = await api.citizen.confirmToken(route.params.token as string)
if (typeof resp === "string") {
errors.push(resp)
} else {
isConfirmed.value = resp
}
}
</script>

View File

@ -1,29 +1,39 @@
<template lang="pug"> <template>
article <article>
page-title(:title="title") <h3 class="pb-3">{{title}}</h3>
load-data(:load="retrieveProfile") <load-data :load="retrieveProfile">
h2 <h2>
a(:href="it.citizen.profileUrl" target="_blank") {{citizenName(it.citizen)}} <a :href="it.citizen.profileUrl" target="_blank" rel="noopener">{{citizenName(it.citizen)}}</a>
.jjj-heading-label(v-if="it.profile.seekingEmployment") <span class="jjj-heading-label" v-if="it.profile.seekingEmployment">
| &nbsp; &nbsp;#[span.badge.bg-dark Currently Seeking Employment] &nbsp; &nbsp;<span class="badge bg-dark">Currently Seeking Employment</span>
h4.pb-3 {{it.continent.name}}, {{it.profile.region}} </span>
p(v-html="workTypes") </h2>
hr <h4 class="pb-3">{{it.continent.name}}, {{it.profile.region}}</h4>
div(v-html="bioHtml") <p v-html="workTypes" />
template(v-if="it.profile.skills.length > 0") <hr>
hr <div v-html="bioHtml" />
h4.pb-3 Skills <template v-if="it.profile.skills.length > 0">
ul <hr>
li(v-for="(skill, idx) in it.profile.skills" :key="idx"). <h4 class="pb-3">Skills</h4>
{{skill.description}}#[template(v-if="skill.notes") &nbsp;({{skill.notes}})] <ul>
template(v-if="it.profile.experience") <li v-for="(skill, idx) in it.profile.skills" :key="idx">
hr {{skill.description}}<template v-if="skill.notes"> &nbsp;({{skill.notes}})</template>
h4.pb-3 Experience / Employment History </li>
div(v-html="expHtml") </ul>
template(v-if="user.citizenId === it.citizen.id") </template>
br <template v-if="it.profile.experience">
br <hr>
router-link.btn.btn-primary(to="/citizen/profile") #[icon(:icon="mdiPencil")]&nbsp; Edit Your Profile <h4 class="pb-3">Experience / Employment History</h4>
<div v-html="expHtml" />
</template>
<template v-if="user.citizenId === it.citizen.id">
<br><br>
<router-link class="btn btn-primary" to="/citizen/profile">
<icon :icon="mdiPencil" />&nbsp; Edit Your Profile
</router-link>
</template>
</load-data>
</article>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -1,32 +1,34 @@
<template lang="pug"> <template>
article <article>
h3.pb-3 Account Deletion Options <h3 class="pb-3">Account Deletion Options</h3>
h4.pb-3 Option 1 &ndash; Delete Your Profile <h4 class="pb-3">Option 1 &ndash; Delete Your Profile</h4>
p. <p>
Utilizing this option will remove your current employment profile and skills. This will preserve any success stories Utilizing this option will remove your current employment profile and skills. This will preserve any job listings
you may have written, and preserves this application&rsquo;s knowledge of you. This is what you want to use if you you may have posted, or any success stories you may have written, and preserves this application&rsquo;s knowledge
want to clear out your profile and start again (and remove the current one from others&rsquo; view). of you. This is what you want to use if you want to clear out your profile and start again (and remove the current
p.text-center: button.btn.btn-danger(@click.prevent="deleteProfile") Delete Your Profile one from others&rsquo; view).
hr </p>
h4.pb-3 Option 2 &ndash; Delete Your Account <p class="text-center">
p. <button class="btn btn-danger" @click.prevent="deleteProfile">Delete Your Profile</button>
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and </p>
account. This is what you want to use if you want to disappear from this application. Clicking the button below <hr>
#[strong will not] affect your Mastodon account in any way; its effects are limited to Jobs, Jobs, Jobs. <h4 class="pb-3">Option 2 &ndash; Delete Your Account</h4>
p: em. <p>
(This will not revoke this application&rsquo;s permissions on Mastodon; you will have to remove this yourself. The This option will make it like you never visited this site. It will delete your profile, skills, job listings,
confirmation message has a link where you can do this; once the page loads, find the success stories, and account. This is what you want to use if you want to disappear from this application.
#[strong Jobs, Jobs, Jobs] entry, and click the #[strong &times; Revoke] link for that entry.) </p>
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account <p class="text-center">
<button class="btn btn-danger" @click.prevent="deleteAccount">Delete Your Entire Account</button>
</p>
</article>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import api, { LogOnSuccess } from "@/api" import api, { LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue" import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore, Actions, Mutations } from "@/store" import { useStore, Mutations } from "@/store"
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
@ -41,34 +43,19 @@ const deleteProfile = async () => {
toastError(resp, "Deleting Profile") toastError(resp, "Deleting Profile")
} else { } else {
toastSuccess("Profile Deleted Successfully") toastSuccess("Profile Deleted Successfully")
router.push("/citizen/dashboard") await router.push("/citizen/dashboard")
} }
} }
/** Delete everything pertaining to the user's account */ /** Delete everything pertaining to the user's account */
const deleteAccount = async () => { const deleteAccount = async () => {
const citizenResp = await api.citizen.retrieve(user.citizenId, user) const resp = await api.citizen.delete(user)
if (typeof citizenResp === "string") { if (typeof resp === "string") {
toastError(citizenResp, "retrieving citizen") toastError(resp, "Deleting Account")
} else if (typeof citizenResp === "undefined") {
toastError("Could not retrieve citizen record", undefined)
} else { } else {
const instance = store.state.instances.find(it => it.abbr === citizenResp.instance) store.commit(Mutations.ClearUser)
if (typeof instance === "undefined") { toastSuccess("Account Deleted Successfully")
toastError("Could not retrieve instance", undefined) await router.push("/so-long/success")
} else {
const resp = await api.citizen.delete(user)
if (typeof resp === "string") {
toastError(resp, "Deleting Account")
} else {
store.commit(Mutations.ClearUser)
toastSuccess("Account Deleted Successfully")
router.push(`/so-long/success/${instance.abbr}`)
}
}
} }
} }
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script> </script>

View File

@ -1,28 +1,9 @@
<template lang="pug"> <template>
article <article>
h3.pb-3 Account Deletion Success <h3 class="pb-3">Account Deletion Success</h3>
p. <p>&nbsp;</p>
Your account has been successfully deleted. To revoke the permissions you have previously granted to this <p>Your account has been successfully deleted.</p>
application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click <p>&nbsp;</p>
#[strong &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty <p>Thank you for participating, and thank you for your courage. #GitmoNation</p>
account without prompting you further. </article>
p Thank you for participating, and thank you for your courage. #GitmoNation
</template> </template>
<script setup lang="ts">
import { computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import { useStore, Actions } from "@/store"
const route = useRoute()
const store = useStore()
/** The abbreviation of the instance from which the deleted user had authorized access */
const abbr = route.params.abbr as string
/** The URL of that instance */
const url = computed(() => store.state.instances.find(it => it.abbr === abbr)?.url ?? "")
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script>

View File

@ -1,20 +1,31 @@
<template lang="pug"> <template>
article <article>
h3.pb-3 {{title}} <h3 class="pb-3">{{title}}</h3>
load-data(:load="retrieveStory") <load-data :load="retrieveStory">
p(v-if="isNew"). <p v-if="isNew">
Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came about; tell us Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came about; tell us
about it below! #[em (These will be visible to other users, but not to the general public.)] about it below! <em>(These will be visible to other users, but not to the general public.)</em>
form.row.g-3 </p>
.col-12: .form-check <form class="row g-3">
input.form-check-input(type="checkbox" id="fromHere" v-model="v$.fromHere.$model") <div class="col-12">
label.form-check-label(for="fromHere") I found my employment here <div class="form-check">
markdown-editor(id="story" label="The Success Story" v-model:text="v$.story.$model") <input type="checkbox" id="fromHere" class="form-check-input" v-model="v$.fromHere.$model">
.col-12 <label class="form-check-label" for="fromHere">I found my employment here</label>
button.btn.btn-primary(type="submit" @click.prevent="saveStory(true)"). </div>
#[icon(:icon="mdiContentSaveOutline")]&nbsp; Save </div>
p(v-if="isNew"): em (Saving this will set &ldquo;Seeking Employment&rdquo; to &ldquo;No&rdquo; on your profile.) <markdown-editor id="story" label="The Success Story" v-model:text="v$.story.$model" />
maybe-save(:saveAction="doSave" :validator="v$") <div class="col-12">
<button class="btn btn-primary" type="submit" @click.prevent="saveStory(true)">
<icon :icon="mdiContentSaveOutline" />&nbsp; Save
</button>
<p v-if="isNew">
<em>(Saving this will set &ldquo;Seeking Employment&rdquo; to &ldquo;No&rdquo; on your profile.)</em>
</p>
</div>
</form>
</load-data>
<maybe-save :saveAction="doSave" :validator="v$" />
</article>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -91,14 +102,14 @@ const saveStory = async (navigate : boolean) => {
toastSuccess("Success Story saved and Seeking Employment flag cleared successfully") toastSuccess("Success Story saved and Seeking Employment flag cleared successfully")
v$.value.$reset() v$.value.$reset()
if (navigate) { if (navigate) {
router.push("/success-story/list") await router.push("/success-story/list")
} }
} }
} else { } else {
toastSuccess("Success Story saved successfully") toastSuccess("Success Story saved successfully")
v$.value.$reset() v$.value.$reset()
if (navigate) { if (navigate) {
router.push("/success-story/list") await router.push("/success-story/list")
} }
} }
} }

View File

@ -1,25 +1,34 @@
<template lang="pug"> <template>
article <article>
h3.pb-3 Success Stories <h3 class="pb-3">Success Stories</h3>
load-data(:load="retrieveStories") <load-data :load="retrieveStories">
table.table.table-sm.table-hover(v-if="stories?.length > 0") <table class="table table-sm table-hover" v-if="stories?.length > 0">
thead: tr <thead>
th(scope="col") Story <tr>
th(scope="col") From <th scope="col">Story</th>
th(scope="col") Found Here? <th scope="col">From</th>
th(scope="col") Recorded On <th scope="col">Found Here?</th>
tbody: tr(v-for="story in stories" :key="story.id") <th scope="col">Recorded On</th>
td </tr>
router-link(v-if="story.hasStory" :to="`/success-story/${story.id}/view`") View </thead>
em(v-else) None <tbody>
template(v-if="story.citizenId === user.citizenId") <tr v-for="story in stories" :key="story.id">
| ~ #[router-link(:to="`/success-story/${story.id}/edit`") Edit] <td>
td {{story.citizenName}} <router-link v-if="story.hasStory" :to="`/success-story/${story.id}/view`">View</router-link>
td <em v-else>None</em>
strong(v-if="story.fromHere") Yes <template v-if="story.citizenId === user.citizenId">
template(v-else) No ~ <router-link :to="`/success-story/${story.id}/edit`">Edit</router-link>
td: full-date(:date="story.recordedOn") </template>
p(v-else) There are no success stories recorded #[em (yet)] </td>
<td>{{story.citizenName}}</td>
<td><strong v-if="story.fromHere">Yes</strong><template v-else>No</template></td>
<td><full-date :date="story.recordedOn" /></td>
</tr>
</tbody>
</table>
<p v-else>There are no success stories recorded <em>(yet)</em></p>
</load-data>
</article>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -1,12 +1,16 @@
<template lang="pug"> <template>
article <article>
load-data(:load="retrieveStory") <load-data :load="retrieveStory">
h3 <h3>
| {{citizenName}}&rsquo;s Success Story {{citizenName}}&rsquo;s Success Story
.jjj-heading-label(v-if="story.fromHere") <span class="jjj-heading-label" v-if="story.fromHere">
| &nbsp; &nbsp;#[span.badge.bg-success Via {{profileOrListing}} on Jobs, Jobs, Jobs] &nbsp; &nbsp;<span class="badge bg-success">Via {{profileOrListing}} on Jobs, Jobs, Jobs</span>
h4.pb-3.text-muted: full-date-time(:date="story.recordedOn") </span>
div(v-if="story.story" v-html="successStory") </h3>
<h4 class="pb-3 text-muted"><full-date-time :date="story.recordedOn" /></h4>
<div v-if="story.story" v-html="successStory" />
</load-data>
</article>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -138,8 +138,6 @@ open JobsJobsJobs.Domain
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Citizens = module Citizens =
open Npgsql
/// Delete a citizen by their ID /// Delete a citizen by their ID
let deleteById citizenId = backgroundTask { let deleteById citizenId = backgroundTask {
let! _ = let! _ =
@ -184,6 +182,37 @@ module Citizens =
do! txn.CommitAsync () do! txn.CommitAsync ()
} }
/// Purge expired tokens
let private purgeExpiredTokens now = backgroundTask {
let connProps = connection ()
let! info =
Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps
|> Sql.executeAsync toDocument<SecurityInfo>
for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do
do! saveSecurity { expired with Token = None; TokenUsage = None; TokenExpires = None } connProps
}
/// Confirm a citizen's account
let confirmAccount token now = backgroundTask {
do! purgeExpiredTokens now
let connProps = connection ()
let! tryInfo =
connProps
|> Sql.query $"
SELECT *
FROM {Table.SecurityInfo}
WHERE data ->> 'token' = @token
AND data ->> 'tokenUsage' = 'confirm'"
|> Sql.parameters [ "@token", Sql.string token ]
|> Sql.executeAsync toDocument<SecurityInfo>
match List.tryHead tryInfo with
| Some info ->
do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
connProps
return true
| None -> return false
}
/// Attempt a user log on /// Attempt a user log on
let tryLogOn email (pwCheck : string -> bool) now = backgroundTask { let tryLogOn email (pwCheck : string -> bool) now = backgroundTask {
let connProps = connection () let connProps = connection ()
@ -229,7 +258,7 @@ module Continents =
/// Retrieve all continents /// Retrieve all continents
let all () = let all () =
connection () connection ()
|> Sql.query $"SELECT * FROM {Table.Continent}" |> Sql.query $"SELECT * FROM {Table.Continent} ORDER BY data ->> 'name'"
|> Sql.executeAsync toDocument<Continent> |> Sql.executeAsync toDocument<Continent>
/// Retrieve a continent by its ID /// Retrieve a continent by its ID

View File

@ -136,6 +136,13 @@ module Citizen =
return! ok next ctx return! ok next ctx
} }
// PATCH: /api/citizen/confirm
let confirmToken : HttpHandler = fun next ctx -> task {
let! form = ctx.BindJsonAsync<{| token : string |}> ()
let! valid = Citizens.confirmAccount form.token (now ctx)
return! json {| valid = valid |} next ctx
}
// GET: /api/citizen/log-on/[code] // GET: /api/citizen/log-on/[code]
let logOn (abbr, authCode) : HttpHandler = fun next ctx -> task { let logOn (abbr, authCode) : HttpHandler = fun next ctx -> task {
match! Citizens.tryLogOn "to@do.com" (fun _ -> false) (now ctx) with match! Citizens.tryLogOn "to@do.com" (fun _ -> false) (now ctx) with
@ -497,6 +504,7 @@ let allEndpoints = [
routef "/log-on/%s/%s" Citizen.logOn routef "/log-on/%s/%s" Citizen.logOn
routef "/%O" Citizen.get routef "/%O" Citizen.get
] ]
PATCH [ route "/confirm" Citizen.confirmToken ]
POST [ route "/register" Citizen.register ] POST [ route "/register" Citizen.register ]
DELETE [ route "" Citizen.delete ] DELETE [ route "" Citizen.delete ]
] ]