Account confirmation works (no e-mail yet)

This commit is contained in:
Daniel J. Summers 2022-08-28 22:28:59 -04:00
parent 4ca84c1bc1
commit 3196de4003
12 changed files with 264 additions and 161 deletions

View File

@ -19,7 +19,8 @@ import {
PublicSearchResult,
StoryEntry,
StoryForm,
Success
Success,
Valid
} from "./types"
/**
@ -110,6 +111,20 @@ export default {
register: async (form : CitizenRegistrationForm) : Promise<boolean | string> =>
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
*

View File

@ -312,3 +312,9 @@ export interface Success {
/** The success story */
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"
import store, { Mutations } from "@/store"
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 */
export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
@ -35,38 +34,44 @@ const routes: Array<RouteRecordRaw> = [
path: "/how-it-works",
name: "HowItWorks",
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
meta: { title: "How It Works" }
meta: { auth: false, title: "How It Works" }
},
{
path: "/privacy-policy",
name: "PrivacyPolicy",
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue"),
meta: { title: "Privacy Policy" }
meta: { auth: false, title: "Privacy Policy" }
},
{
path: "/terms-of-service",
name: "TermsOfService",
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue"),
meta: { title: "Terms of Service" }
meta: { auth: false, title: "Terms of Service" }
},
// Citizen URLs
{
path: "/citizen/register",
name: "CitizenRegistration",
component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Register.vue"),
meta: { title: "Register" }
meta: { auth: false, title: "Register" }
},
{
path: "/citizen/registered",
name: "CitizenRegistered",
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",
name: "LogOn",
component: LogOn,
meta: { title: "Log On" }
component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/LogOn.vue"),
meta: { auth: false, title: "Log On" }
},
{
path: "/citizen/:abbr/authorized",
@ -150,7 +155,7 @@ const routes: Array<RouteRecordRaw> = [
meta: { auth: true, title: "Account Deletion Options" }
},
{
path: "/so-long/success/:abbr",
path: "/so-long/success",
name: "DeletionSuccess",
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue"),
meta: { auth: false, title: "Account Deletion Success" }
@ -187,7 +192,7 @@ const router = createRouter({
// eslint-disable-next-line
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)
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">
article
page-title(:title="title")
load-data(:load="retrieveProfile")
h2
a(:href="it.citizen.profileUrl" target="_blank") {{citizenName(it.citizen)}}
.jjj-heading-label(v-if="it.profile.seekingEmployment")
| &nbsp; &nbsp;#[span.badge.bg-dark Currently Seeking Employment]
h4.pb-3 {{it.continent.name}}, {{it.profile.region}}
p(v-html="workTypes")
hr
div(v-html="bioHtml")
template(v-if="it.profile.skills.length > 0")
hr
h4.pb-3 Skills
ul
li(v-for="(skill, idx) in it.profile.skills" :key="idx").
{{skill.description}}#[template(v-if="skill.notes") &nbsp;({{skill.notes}})]
template(v-if="it.profile.experience")
hr
h4.pb-3 Experience / Employment History
div(v-html="expHtml")
template(v-if="user.citizenId === it.citizen.id")
br
br
router-link.btn.btn-primary(to="/citizen/profile") #[icon(:icon="mdiPencil")]&nbsp; Edit Your Profile
<template>
<article>
<h3 class="pb-3">{{title}}</h3>
<load-data :load="retrieveProfile">
<h2>
<a :href="it.citizen.profileUrl" target="_blank" rel="noopener">{{citizenName(it.citizen)}}</a>
<span class="jjj-heading-label" v-if="it.profile.seekingEmployment">
&nbsp; &nbsp;<span class="badge bg-dark">Currently Seeking Employment</span>
</span>
</h2>
<h4 class="pb-3">{{it.continent.name}}, {{it.profile.region}}</h4>
<p v-html="workTypes" />
<hr>
<div v-html="bioHtml" />
<template v-if="it.profile.skills.length > 0">
<hr>
<h4 class="pb-3">Skills</h4>
<ul>
<li v-for="(skill, idx) in it.profile.skills" :key="idx">
{{skill.description}}<template v-if="skill.notes"> &nbsp;({{skill.notes}})</template>
</li>
</ul>
</template>
<template v-if="it.profile.experience">
<hr>
<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>
<script setup lang="ts">

View File

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

View File

@ -1,28 +1,9 @@
<template lang="pug">
article
h3.pb-3 Account Deletion Success
p.
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click
#[strong &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty
account without prompting you further.
p Thank you for participating, and thank you for your courage. #GitmoNation
<template>
<article>
<h3 class="pb-3">Account Deletion Success</h3>
<p>&nbsp;</p>
<p>Your account has been successfully deleted.</p>
<p>&nbsp;</p>
<p>Thank you for participating, and thank you for your courage. #GitmoNation</p>
</article>
</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">
article
h3.pb-3 {{title}}
load-data(:load="retrieveStory")
p(v-if="isNew").
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.)]
form.row.g-3
.col-12: .form-check
input.form-check-input(type="checkbox" id="fromHere" v-model="v$.fromHere.$model")
label.form-check-label(for="fromHere") I found my employment here
markdown-editor(id="story" label="The Success Story" v-model:text="v$.story.$model")
.col-12
button.btn.btn-primary(type="submit" @click.prevent="saveStory(true)").
#[icon(:icon="mdiContentSaveOutline")]&nbsp; Save
p(v-if="isNew"): em (Saving this will set &ldquo;Seeking Employment&rdquo; to &ldquo;No&rdquo; on your profile.)
maybe-save(:saveAction="doSave" :validator="v$")
<template>
<article>
<h3 class="pb-3">{{title}}</h3>
<load-data :load="retrieveStory">
<p v-if="isNew">
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.)</em>
</p>
<form class="row g-3">
<div class="col-12">
<div class="form-check">
<input type="checkbox" id="fromHere" class="form-check-input" v-model="v$.fromHere.$model">
<label class="form-check-label" for="fromHere">I found my employment here</label>
</div>
</div>
<markdown-editor id="story" label="The Success Story" v-model:text="v$.story.$model" />
<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>
<script setup lang="ts">
@ -91,14 +102,14 @@ const saveStory = async (navigate : boolean) => {
toastSuccess("Success Story saved and Seeking Employment flag cleared successfully")
v$.value.$reset()
if (navigate) {
router.push("/success-story/list")
await router.push("/success-story/list")
}
}
} else {
toastSuccess("Success Story saved successfully")
v$.value.$reset()
if (navigate) {
router.push("/success-story/list")
await router.push("/success-story/list")
}
}
}

View File

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

View File

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

View File

@ -138,8 +138,6 @@ open JobsJobsJobs.Domain
[<RequireQualifiedAccess>]
module Citizens =
open Npgsql
/// Delete a citizen by their ID
let deleteById citizenId = backgroundTask {
let! _ =
@ -184,6 +182,37 @@ module Citizens =
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
let tryLogOn email (pwCheck : string -> bool) now = backgroundTask {
let connProps = connection ()
@ -229,7 +258,7 @@ module Continents =
/// Retrieve all continents
let all () =
connection ()
|> Sql.query $"SELECT * FROM {Table.Continent}"
|> Sql.query $"SELECT * FROM {Table.Continent} ORDER BY data ->> 'name'"
|> Sql.executeAsync toDocument<Continent>
/// Retrieve a continent by its ID

View File

@ -136,6 +136,13 @@ module Citizen =
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]
let logOn (abbr, authCode) : HttpHandler = fun next ctx -> task {
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 "/%O" Citizen.get
]
PATCH [ route "/confirm" Citizen.confirmToken ]
POST [ route "/register" Citizen.register ]
DELETE [ route "" Citizen.delete ]
]