Multi instance #26

Merged
danieljsummers merged 4 commits from multi-instance into main 2021-09-07 01:20:51 +00:00
19 changed files with 126 additions and 116 deletions
Showing only changes of commit df6188dfd1 - Show all commits

View File

@ -360,10 +360,14 @@ module Citizen =
/// Find a citizen by their Mastodon username /// Find a citizen by their Mastodon username
let findByMastodonUser (instance : string) (mastodonUser : string) conn = let findByMastodonUser (instance : string) (mastodonUser : string) conn =
r.Table(Table.Citizen) fun c -> task {
.GetAll(r.Array (instance, mastodonUser)).OptArg("index", "instanceUser").Nth(0) let! u =
.RunResultAsync<Citizen> r.Table(Table.Citizen)
|> withReconnOption conn .GetAll(r.Array (instance, mastodonUser)).OptArg("index", "instanceUser").Limit(1)
.RunResultAsync<Citizen list> c
return u |> List.tryHead
}
|> withReconn conn
/// Add a citizen /// Add a citizen
let add (citizen : Citizen) conn = let add (citizen : Citizen) conn =

View File

@ -201,14 +201,6 @@ module Instances =
fun next ctx -> task { fun next ctx -> task {
return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx
} }
// GET: /api/instance/[abbr]
let byAbbr abbr : HttpHandler =
fun next ctx -> task {
match (authConfig ctx).Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with
| Some inst -> return! json (toInstance inst) next ctx
| None -> return! Error.notFound next ctx
}
/// Handlers for /api/listing[s] routes /// Handlers for /api/listing[s] routes
@ -530,12 +522,7 @@ let allEndpoints = [
DELETE [ route "" Citizen.delete ] DELETE [ route "" Citizen.delete ]
] ]
GET_HEAD [ route "/continents" Continent.all ] GET_HEAD [ route "/continents" Continent.all ]
subRoute "/instance" [ GET_HEAD [ route "/instances" Instances.all ]
GET_HEAD [
route "s" Instances.all
routef "/%s" Instances.byAbbr
]
]
subRoute "/listing" [ subRoute "/listing" [
GET_HEAD [ GET_HEAD [
routef "/%O" Listing.get routef "/%O" Listing.get

View File

@ -1,12 +1,13 @@
{ {
"name": "jobs-jobs-jobs", "name": "jobs-jobs-jobs",
"version": "2.0.0", "version": "2.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint",
"apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug" "apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug",
"publish": "vue-cli-service build --modern && cd ../Api && dotnet publish -c Release -r linux-x64 --self-contained false"
}, },
"dependencies": { "dependencies": {
"@mdi/js": "^5.9.55", "@mdi/js": "^5.9.55",

View File

@ -40,7 +40,7 @@ export function yesOrNo (cond : boolean) : string {
} }
/** /**
* Get the display name for a citizen (the first available among real, display, or NAS handle) * Get the display name for a citizen (the first available among real, display, or Mastodon handle)
* *
* @param cit The citizen * @param cit The citizen
* @returns The citizen's display name * @returns The citizen's display name

View File

@ -152,16 +152,7 @@ export default {
* @returns All instances, or an error * @returns All instances, or an error
*/ */
all: async () : Promise<Instance[] | string | undefined> => all: async () : Promise<Instance[] | string | undefined> =>
apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances"), apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances")
/**
* Retrieve a Mastodon instance by its abbreviation
*
* @param abbr The abbreviation of the Mastodon instance to retrieve
* @returns The Mastodon instance (if found), undefined (if not found), or an error string
*/
byAbbr: async (abbr : string) : Promise<Instance | string | undefined> =>
apiResult<Instance>(await fetch(apiUrl(`instance/${abbr}`), { method: "GET" }), "retrieving Mastodon instance")
}, },
/** API functions for job listings */ /** API functions for job listings */

View File

@ -10,7 +10,7 @@ import store from "@/store"
import Home from "@/views/Home.vue" import Home from "@/views/Home.vue"
import LogOn from "@/views/citizen/LogOn.vue" import LogOn from "@/views/citizen/LogOn.vue"
/** The URL to which the user should be pointed once they have authorized with NAS */ /** 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"
/** /**
@ -121,7 +121,7 @@ const routes: Array<RouteRecordRaw> = [
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue") component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue")
}, },
{ {
path: "/so-long/success/:url", path: "/so-long/success/:abbr",
name: "DeletionSuccess", name: "DeletionSuccess",
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue") component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue")
}, },

View File

@ -0,0 +1,8 @@
/** Logs a user on to Jobs, Jobs, Jobs */
export const LogOn = "logOn"
/** Ensures that the continent list in the state has been populated */
export const EnsureContinents = "ensureContinents"
/** Ensures that the Mastodon instance list in the state has been populated */
export const EnsureInstances = "ensureInstances"

View File

@ -1,6 +1,8 @@
import { InjectionKey } from "vue" import { InjectionKey } from "vue"
import { createStore, Store, useStore as baseUseStore } from "vuex" import { createStore, Store, useStore as baseUseStore } from "vuex"
import api, { Continent, LogOnSuccess } from "../api" import api, { Continent, Instance, LogOnSuccess } from "../api"
import * as Actions from "./actions"
import * as Mutations from "./mutations"
/** The state tracked by the application */ /** The state tracked by the application */
export interface State { export interface State {
@ -10,6 +12,8 @@ export interface State {
logOnState: string logOnState: string
/** All continents (use `ensureContinents` action) */ /** All continents (use `ensureContinents` action) */
continents: Continent[] continents: Continent[]
/** All instances (use `ensureInstances` action) */
instances: Instance[]
} }
/** An injection key to identify this state with Vue */ /** An injection key to identify this state with Vue */
@ -25,42 +29,50 @@ export default createStore({
return { return {
user: undefined, user: undefined,
logOnState: "<em>Welcome back!</em>", logOnState: "<em>Welcome back!</em>",
continents: [] continents: [],
instances: []
} }
}, },
mutations: { mutations: {
setUser (state, user : LogOnSuccess) { [Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user },
state.user = user [Mutations.ClearUser]: (state) => { state.user = undefined },
}, [Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message },
clearUser (state) { [Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents },
state.user = undefined [Mutations.SetInstances]: (state, instances : Instance[]) => { state.instances = instances }
},
setLogOnState (state, message : string) {
state.logOnState = message
},
setContinents (state, continents : Continent[]) {
state.continents = continents
}
}, },
actions: { actions: {
async logOn ({ commit }, { abbr, code }) { [Actions.LogOn]: async ({ commit }, { abbr, code }) => {
const logOnResult = await api.citizen.logOn(abbr, code) const logOnResult = await api.citizen.logOn(abbr, code)
if (typeof logOnResult === "string") { if (typeof logOnResult === "string") {
commit("setLogOnState", logOnResult) commit(Mutations.SetLogOnState, logOnResult)
} else { } else {
commit("setUser", logOnResult) commit(Mutations.SetUser, logOnResult)
} }
}, },
async ensureContinents ({ state, commit }) { [Actions.EnsureContinents]: async ({ state, commit }) => {
if (state.continents.length > 0) return if (state.continents.length > 0) return
const theSeven = await api.continent.all() const theSeven = await api.continent.all()
if (typeof theSeven === "string") { if (typeof theSeven === "string") {
console.error(theSeven) console.error(theSeven)
} else { } else {
commit("setContinents", theSeven) commit(Mutations.SetContinents, theSeven)
}
},
[Actions.EnsureInstances]: async ({ state, commit }) => {
if (state.instances.length > 0) return
const instResp = await api.instances.all()
if (typeof instResp === "string") {
console.error(instResp)
} else if (typeof instResp === "undefined") {
console.error("No instances were found; this should not happen")
} else {
commit(Mutations.SetInstances, instResp)
} }
} }
}, },
modules: { modules: {
} }
}) })
export * as Actions from "./actions"
export * as Mutations from "./mutations"

View File

@ -0,0 +1,14 @@
/** Set the logged-on user */
export const SetUser = "setUser"
/** Clear the logged-on user */
export const ClearUser = "clearUser"
/** Set the status of the current log on action */
export const SetLogOnState = "setLogOnState"
/** Set the list of continents */
export const SetContinents = "setContinents"
/** Set the list of Mastodon instances */
export const SetInstances = "setInstances"

View File

@ -13,14 +13,13 @@ article
h4 Description of Service and Registration h4 Description of Service and Registration
p p
| Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access | Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
| to the details of these profiles to other users of | to the details of these profiles to other users of No Agenda-afilliated Mastodon sites (currently
= " " = " "
template(v-for="(it, idx) in instances" :key="idx") template(v-for="(it, idx) in instances" :key="idx")
a(:href="it.url" target="_blank") {{it.name}} a(:href="it.url" target="_blank") {{it.name}}
template(v-if="idx + 2 < instances.length")= ", " template(v-if="idx + 2 < instances.length")= ", "
template(v-else-if="idx + 1 < instances.length")= ", and " template(v-else-if="idx + 1 < instances.length")= ", and "
template(v-else)= ". " | ). Registration is accomplished by allowing Jobs, Jobs, Jobs to read one&rsquo;s Mastodon profile. See our
| Registration is accomplished by allowing Jobs, Jobs, Jobs to read one&rsquo;s Mastodon profile. See our
= " " = " "
router-link(to="/privacy-policy") privacy policy router-link(to="/privacy-policy") privacy policy
= " " = " "
@ -51,21 +50,14 @@ article
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, Ref, ref } from "vue" import { computed, onMounted } from "vue"
import { useStore, Actions } from "@/store"
import api, { Instance } from "@/api" const store = useStore()
import { toastError } from "@/components/layout/AppToaster.vue"
const instances : Ref<Instance[]> = ref([]) /** All instances authorized to view Jobs, Jobs, Jobs */
const instances = computed(() => store.state.instances)
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
onMounted(async () => {
const apiResp = await api.instances.all()
if (typeof apiResp === "string") {
toastError(apiResp, "retrieving instances")
} else if (typeof apiResp === "undefined") {
toastError("No instances to display", undefined)
} else {
instances.value = apiResp
}
})
</script> </script>

View File

@ -8,8 +8,7 @@ article
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue" import { computed, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import api from "@/api" import { useStore, Actions, Mutations } from "@/store"
import { useStore } from "@/store"
import { AFTER_LOG_ON_URL } from "@/router" import { AFTER_LOG_ON_URL } from "@/router"
const store = useStore() const store = useStore()
@ -20,20 +19,19 @@ const router = useRouter()
const abbr = route.params.abbr as string const abbr = route.params.abbr as string
/** Set the message for this component */ /** Set the message for this component */
const setMessage = (msg : string) => store.commit("setLogOnState", msg) const setMessage = (msg : string) => store.commit(Mutations.SetLogOnState, msg)
/** Pass the code to the API and exchange it for a user and a JWT */ /** Pass the code to the API and exchange it for a user and a JWT */
const logOn = async () => { const logOn = async () => {
const instance = await api.instances.byAbbr(abbr) await store.dispatch(Actions.EnsureInstances)
if (typeof instance === "string") { const instance = store.state.instances.find(it => it.abbr === abbr)
setMessage(instance) if (typeof instance === "undefined") {
} else if (typeof instance === "undefined") {
setMessage(`Mastodon instance ${abbr} not found`) setMessage(`Mastodon instance ${abbr} not found`)
} else { } else {
setMessage(`<em>Welcome back! Verifying your ${instance.name} account&hellip;</em>`) setMessage(`<em>Welcome back! Verifying your ${instance.name} account&hellip;</em>`)
const code = route.query.code const code = route.query.code
if (code) { if (code) {
await store.dispatch("logOn", { abbr, code }) await store.dispatch(Actions.LogOn, { abbr, code })
if (store.state.user !== undefined) { if (store.state.user !== undefined) {
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL) const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
if (afterLogOnUrl) { if (afterLogOnUrl) {

View File

@ -6,7 +6,8 @@ article.container
.col: .card.h-100 .col: .card.h-100
h5.card-header Your Profile h5.card-header Your Profile
.card-body .card-body
h6.card-subtitle.mb-3.text-muted.fst-italic Last updated #[full-date-time(:date="profile.lastUpdatedOn")] h6.card-subtitle.mb-3.text-muted.fst-italic(v-if="profile").
Last updated #[full-date-time(:date="profile.lastUpdatedOn")]
p.card-text(v-if="profile") p.card-text(v-if="profile")
| Your profile currently lists {{profile.skills.length}} | Your profile currently lists {{profile.skills.length}}
| skill#[template(v-if="profile.skills.length !== 1") s]. | skill#[template(v-if="profile.skills.length !== 1") s].

View File

@ -6,9 +6,9 @@ article
.col-12.col-sm-10.col-md-8.col-lg-6 .col-12.col-sm-10.col-md-8.col-lg-6
.form-floating .form-floating
input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255" input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255"
placeholder="Leave blank to use your NAS display name") placeholder="Leave blank to use your Mastodon display name")
label(for="realName") Real Name label(for="realName") Real Name
.form-text Leave blank to use your NAS display name .form-text Leave blank to use your Mastodon display name
.col-12 .col-12
.form-check .form-check
input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model") input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model")

View File

@ -9,13 +9,13 @@ article
import { onMounted } from "vue" import { onMounted } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import { toastSuccess } from "@/components/layout/AppToaster.vue" import { toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store" import { useStore, Mutations } from "@/store"
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
onMounted(() => { onMounted(() => {
store.commit("clearUser") store.commit(Mutations.ClearUser)
toastSuccess("Log Off Successful &nbsp; | &nbsp; <strong>Have a Nice Day!</strong>") toastSuccess("Log Off Successful &nbsp; | &nbsp; <strong>Have a Nice Day!</strong>")
router.push("/") router.push("/")
}) })

View File

@ -1,22 +1,24 @@
<template lang="pug"> <template lang="pug">
article article
p &nbsp; p &nbsp;
load-data(:load="retrieveInstances") p.fst-italic(v-if="selected") Sending you over to {{selected.name}} to log on; see you back in just a second&hellip;
p.fst-italic(v-if="selected") Sending you over to {{selected.name}} to log on; see you back in just a second&hellip; template(v-else)
template(v-else) p.text-center Please select your No Agenda-affiliated Mastodon instance
p.text-center Please select your No Agenda-affiliated Mastodon instance p.text-center(v-for="it in instances" :key="it.abbr")
p.text-center(v-for="it in instances" :key="it.abbr") button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}}
button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}}
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, Ref, ref } from "vue" import { computed, onMounted, Ref, ref } from "vue"
import api, { Instance } from "@/api" import { Instance } from "@/api"
import { useStore, Actions } from "@/store"
import LoadData from "@/components/LoadData.vue" import LoadData from "@/components/LoadData.vue"
const store = useStore()
/** The instances configured for Jobs, Jobs, Jobs */ /** The instances configured for Jobs, Jobs, Jobs */
const instances : Ref<Instance[]> = ref([]) const instances = computed(() => store.state.instances)
/** Whether authorization is in progress */ /** Whether authorization is in progress */
const selected : Ref<Instance | undefined> = ref(undefined) const selected : Ref<Instance | undefined> = ref(undefined)
@ -43,15 +45,6 @@ const select = (abbr : string) => {
document.location.assign(authUrl.value) document.location.assign(authUrl.value)
} }
/** Load the instances we have configured */ onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
const retrieveInstances = async (errors : string[]) => {
const instancesResp = await api.instances.all()
if (typeof instancesResp === "string") {
errors.push(instancesResp)
} else if (typeof instancesResp === "undefined") {
errors.push("No instances found (this should not happen)")
} else {
instances.value = instancesResp
}
}
</script> </script>

View File

@ -65,7 +65,7 @@ const title = computed(() => it.value ? `${it.value.listing.title} | Job Listing
/** The HTML details of the job listing */ /** The HTML details of the job listing */
const details = computed(() => toHtml(it.value?.listing.text ?? "")) const details = computed(() => toHtml(it.value?.listing.text ?? ""))
/** The NAS profile URL for the citizen who posted this job listing */ /** The Mastodon profile URL for the citizen who posted this job listing */
const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "") const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "")
/** The needed by date, formatted in SHOUTING MODE */ /** The needed by date, formatted in SHOUTING MODE */

View File

@ -21,14 +21,14 @@ article
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
</template> </template>
<script 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 } from "@/store" import { useStore, Actions, Mutations } from "@/store"
</script>
<script setup lang="ts">
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
@ -54,21 +54,22 @@ const deleteAccount = async () => {
} else if (typeof citizenResp === "undefined") { } else if (typeof citizenResp === "undefined") {
toastError("Could not retrieve citizen record", undefined) toastError("Could not retrieve citizen record", undefined)
} else { } else {
const instResp = await api.instances.byAbbr(citizenResp.instance) const instance = store.state.instances.find(it => it.abbr === citizenResp.instance)
if (typeof instResp === "string") { if (typeof instance === "undefined") {
toastError(instResp, "retriving instance")
} else if (typeof instResp === "undefined") {
toastError("Could not retrieve instance", undefined) toastError("Could not retrieve instance", undefined)
} else { } else {
const resp = await api.citizen.delete(user) const resp = await api.citizen.delete(user)
if (typeof resp === "string") { if (typeof resp === "string") {
toastError(resp, "Deleting Account") toastError(resp, "Deleting Account")
} else { } else {
store.commit("clearUser") store.commit(Mutations.ClearUser)
toastSuccess("Account Deleted Successfully") toastSuccess("Account Deleted Successfully")
router.push(`/so-long/success/${instResp.url}`) router.push(`/so-long/success/${instance.abbr}`)
} }
} }
} }
} }
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script> </script>

View File

@ -11,11 +11,19 @@ article
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue"
import { useRoute } from "vue-router" import { useRoute } from "vue-router"
import { useStore, Actions } from "@/store"
const route = useRoute() const route = useRoute()
const store = useStore()
/** The URL of the instance from which the deleted user had authorized access */ /** The abbreviation of the instance from which the deleted user had authorized access */
const url = route.params.url as string 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> </script>

View File

@ -31,7 +31,7 @@ const user = store.state.user as LogOnSuccess
/** The story to be displayed */ /** The story to be displayed */
const story : Ref<Success | undefined> = ref(undefined) const story : Ref<Success | undefined> = ref(undefined)
/** The citizen's name (real, display, or NAS, whichever is found first) */ /** The citizen's name (real, display, or Mastodon, whichever is found first) */
const citizenName = ref("") const citizenName = ref("")
/** Retrieve the success story */ /** Retrieve the success story */