From 989beb06593e062eb5817c59fd6a5d3abc60df2d Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 6 Sep 2021 15:42:15 -0400 Subject: [PATCH] Complete log on; genericize docs (#22) Still a WIP, but closing in... --- src/JobsJobsJobs/Api/Auth.fs | 2 +- src/JobsJobsJobs/Api/Data.fs | 32 ++++++++++--- src/JobsJobsJobs/Api/Handlers.fs | 19 ++++---- src/JobsJobsJobs/Api/appsettings.json | 2 + src/JobsJobsJobs/App/src/App.vue | 2 +- src/JobsJobsJobs/App/src/api/types.ts | 4 +- src/JobsJobsJobs/App/src/router/index.ts | 2 +- src/JobsJobsJobs/App/src/store/index.ts | 2 +- src/JobsJobsJobs/App/src/views/HowItWorks.vue | 30 ++++++------ .../App/src/views/PrivacyPolicy.vue | 24 ++++++---- .../App/src/views/TermsOfService.vue | 48 ++++++++++++++++--- .../App/src/views/citizen/Authorized.vue | 1 + .../App/src/views/citizen/LogOn.vue | 3 +- .../App/src/views/listing/ListingView.vue | 2 +- .../App/src/views/so-long/DeletionOptions.vue | 37 ++++++++++---- .../App/src/views/so-long/DeletionSuccess.vue | 12 ++++- src/JobsJobsJobs/Domain/Modules.fs | 2 +- src/JobsJobsJobs/Domain/SharedTypes.fs | 4 +- src/JobsJobsJobs/Domain/Types.fs | 16 ++++--- 19 files changed, 168 insertions(+), 76 deletions(-) diff --git a/src/JobsJobsJobs/Api/Auth.fs b/src/JobsJobsJobs/Api/Auth.fs index d1a0a74..772e041 100644 --- a/src/JobsJobsJobs/Api/Auth.fs +++ b/src/JobsJobsJobs/Api/Auth.fs @@ -6,7 +6,7 @@ open System.Text.Json.Serialization /// The variables we need from the account information we get from Mastodon [] type MastodonAccount () = - /// The user name (what we store as naUser) + /// The user name (what we store as mastodonUser) [] member val Username = "" with get, set /// The account name; will generally be the same as username for local accounts, which is all we can verify diff --git a/src/JobsJobsJobs/Api/Data.fs b/src/JobsJobsJobs/Api/Data.fs index 5d5f2c9..a46793a 100644 --- a/src/JobsJobsJobs/Api/Data.fs +++ b/src/JobsJobsJobs/Api/Data.fs @@ -6,6 +6,7 @@ open JobsJobsJobs.Domain.Types open Polly open RethinkDb.Driver open RethinkDb.Driver.Net +open RethinkDb.Driver.Ast /// Shorthand for the RethinkDB R variable (how every command starts) let private r = RethinkDB.R @@ -166,10 +167,20 @@ module Startup = log.LogInformation $"Creating \"{idx}\" index on {table}" r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore) } - do! ensureIndexes Table.Citizen [ "naUser" ] do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ] do! ensureIndexes Table.Profile [ "continentId" ] do! ensureIndexes Table.Success [ "citizenId" ] + // The instance/user is a compound index + let! userIdx = r.Table(Table.Citizen).IndexList().RunResultAsync conn + match userIdx |> List.contains "instanceUser" with + | true -> () + | false -> + let! _ = + r.Table(Table.Citizen) + .IndexCreate("instanceUser", + ReqlFunction1 (fun row -> upcast r.Array (row.G "instance", row.G "mastodonUser"))) + .RunWriteAsync conn + () } @@ -215,7 +226,6 @@ let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i) open JobsJobsJobs.Domain open JobsJobsJobs.Domain.SharedTypes -open RethinkDb.Driver.Ast /// Profile data access functions [] @@ -287,7 +297,7 @@ module Profile = .HashMap("displayName", r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName", it.G("displayName").Default_("").Ne "", it.G "displayName", - it.G "naUser")) + it.G "mastodonUser")) .With ("citizenId", it.G "id"))) .Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn") .OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ())) @@ -348,10 +358,10 @@ module Citizen = .RunResultAsync |> withReconnOption conn - /// Find a citizen by their No Agenda Social username - let findByNaUser (naUser : string) conn = + /// Find a citizen by their Mastodon username + let findByMastodonUser (instance : string) (mastodonUser : string) conn = r.Table(Table.Citizen) - .GetAll(naUser).OptArg("index", "naUser").Nth(0) + .GetAll(r.Array (instance, mastodonUser)).OptArg("index", "instanceUser").Nth(0) .RunResultAsync |> withReconnOption conn @@ -546,9 +556,17 @@ module Success = .HashMap("citizenName", r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName", it.G("displayName").Default_("").Ne "", it.G "displayName", - it.G "naUser")) + it.G "mastodonUser")) .With ("hasStory", it.G("story").Default_("").Gt ""))) .Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory") .OrderBy(r.Desc "recordedOn") .RunResultAsync |> withReconn conn + +(* +-- To replace naUser with mastodonUser for NAS -- +r.db('jobsjobsjobs').table('citizen').replace(function(it) { + return it.merge({ mastodonUser: it('naUser'), instance: 'nas' }).without('naUser'); +}) + +*) diff --git a/src/JobsJobsJobs/Api/Handlers.fs b/src/JobsJobsJobs/Api/Handlers.fs index c2f7de3..f32a847 100644 --- a/src/JobsJobsJobs/Api/Handlers.fs +++ b/src/JobsJobsJobs/Api/Handlers.fs @@ -117,22 +117,23 @@ module Citizen = | Some instance -> let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) - match! Auth.verifyWithMastodon authCode instance cfg.ReturnUrl log with + match! Auth.verifyWithMastodon authCode instance cfg.ReturnHost log with | Ok account -> // Step 2 - Find / establish Jobs, Jobs, Jobs account let now = (clock ctx).GetCurrentInstant () let dbConn = conn ctx let! citizen = task { - match! Data.Citizen.findByNaUser account.Username dbConn with + match! Data.Citizen.findByMastodonUser instance.Abbr account.Username dbConn with | None -> let it : Citizen = - { id = CitizenId.create () - naUser = account.Username - displayName = noneIfEmpty account.DisplayName - realName = None - profileUrl = account.Url - joinedOn = now - lastSeenOn = now + { id = CitizenId.create () + instance = instance.Abbr + mastodonUser = account.Username + displayName = noneIfEmpty account.DisplayName + realName = None + profileUrl = account.Url + joinedOn = now + lastSeenOn = now } do! Data.Citizen.add it dbConn return it diff --git a/src/JobsJobsJobs/Api/appsettings.json b/src/JobsJobsJobs/Api/appsettings.json index cb388fe..088157b 100644 --- a/src/JobsJobsJobs/Api/appsettings.json +++ b/src/JobsJobsJobs/Api/appsettings.json @@ -1,5 +1,7 @@ { "Rethink": { + "Hostname": "data02.bitbadger.solutions", + "Db": "jobsjobsjobs-dev" }, "Auth": { "ReturnHost": "http://localhost:5000", diff --git a/src/JobsJobsJobs/App/src/App.vue b/src/JobsJobsJobs/App/src/App.vue index b4cac8a..e112e60 100644 --- a/src/JobsJobsJobs/App/src/App.vue +++ b/src/JobsJobsJobs/App/src/App.vue @@ -46,7 +46,7 @@ export function yesOrNo (cond : boolean) : string { * @returns The citizen's display name */ export function citizenName (cit : Citizen) : string { - return cit.realName ?? cit.displayName ?? cit.naUser + return cit.realName ?? cit.displayName ?? cit.mastodonUser } diff --git a/src/JobsJobsJobs/App/src/api/types.ts b/src/JobsJobsJobs/App/src/api/types.ts index ea2ca27..2c98c89 100644 --- a/src/JobsJobsJobs/App/src/api/types.ts +++ b/src/JobsJobsJobs/App/src/api/types.ts @@ -3,8 +3,10 @@ export interface Citizen { /** The ID of the user */ id : string + /** The abbreviation of the instance where this citizen is based */ + instance : string /** The handle by which the user is known on Mastodon */ - naUser : string + mastodonUser : string /** The user's display name from Mastodon (updated every login) */ displayName : string | undefined /** The user's real name */ diff --git a/src/JobsJobsJobs/App/src/router/index.ts b/src/JobsJobsJobs/App/src/router/index.ts index 31c6418..e830ebb 100644 --- a/src/JobsJobsJobs/App/src/router/index.ts +++ b/src/JobsJobsJobs/App/src/router/index.ts @@ -121,7 +121,7 @@ const routes: Array = [ component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue") }, { - path: "/so-long/success", + path: "/so-long/success/:url", name: "DeletionSuccess", component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue") }, diff --git a/src/JobsJobsJobs/App/src/store/index.ts b/src/JobsJobsJobs/App/src/store/index.ts index 379110a..f3e2bd5 100644 --- a/src/JobsJobsJobs/App/src/store/index.ts +++ b/src/JobsJobsJobs/App/src/store/index.ts @@ -24,7 +24,7 @@ export default createStore({ state: () : State => { return { user: undefined, - logOnState: "Welcome back! Verifying your No Agenda Social account…", + logOnState: "Welcome back!", continents: [] } }, diff --git a/src/JobsJobsJobs/App/src/views/HowItWorks.vue b/src/JobsJobsJobs/App/src/views/HowItWorks.vue index f134a2f..4d3810f 100644 --- a/src/JobsJobsJobs/App/src/views/HowItWorks.vue +++ b/src/JobsJobsJobs/App/src/views/HowItWorks.vue @@ -21,8 +21,8 @@ article p. Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all of the information from the search results, along with the citizen who posted it, and the full details of the job. - The citizen’s name is a link to their profile page at No Agenda Social; you can use that to get their handle, - and use NAS’s communication facilites to inquire about the position. + The citizen’s name is a link to their profile page at their Mastodon instance; you can use that to get their + handle, and use Mastodon’s communication facilites to inquire about the position. p: em.text-muted. (If you know of a way to construct a link to Mastodon that would start a direct message, please reach out; I’ve searched and searched, and asked NAS, but have not yet determined how to do that.) @@ -43,9 +43,9 @@ article The #[span.link My Job Listings] page will show you all of your active job listings just below the #[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share - the link from your browser over on No Agenda Social, and those who click on it will be able to view it. (Existing - users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s access, but then they - will get there as well.) + the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to + view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s + access, but then they will get there as well.) h5 Expire a Job Listing p. @@ -68,7 +68,7 @@ article The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the citizen’s desire for remote work, a skill, or any text in their professional biography and experience. If you find someone with whom you’d like to discuss potential opportunities, the name at the top of the profile links - to their No Agenda Social account, where you can use its features to get in touch. + to their Mastodon profile, where you can use its features to get in touch. hr @@ -76,8 +76,8 @@ article p. The employment profile is your résumé, visible to other citizens here. It also allows you to specify your real name, if you so desire; if that is filled in, that is how you will be identified in search results, - profile views, etc. If not, you will be identified as you are on No Agenda Social; this system updates your current - display name each time you log on. + profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your + current display name each time you log on. h5 Completing Your Profile p. @@ -99,19 +99,19 @@ article li. If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking employment, your continent, region, and skills fields will be searchable and displayed to public users of the - site. They will not be tied to your No Agenda Social handle or real name; they are there to let people peek - behind the curtain a bit, and hopefully inspire them to join us. + site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the + curtain a bit, and hopefully inspire them to join us. h5 Viewing and Sharing Your Profile p. Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom that will let you view your profile the way all other validated users will be able to see it. (There will also be a - link to this page from the #[span.link Dashboard].) The URL of this page can be shared on No Agenda Social, if you - would like to share it there. Just as with job listings, existing users will go straight there, while other No - Agenda Social users will get there once they authorize this application. + link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated + Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight + there, while others will get there once they authorize this application. p. - The name on employment profiles is a link to that user’s profile on No Agenda Social; from there, others can - communicate further with you using the tools Mastodon provides. + The name on employment profiles is a link to that user’s profile on their Mastodon instance; from there, + others can communicate further with you using the tools Mastodon provides. h5 “I Found a Job!” p. diff --git a/src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue b/src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue index f917169..e77ca25 100644 --- a/src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue +++ b/src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue @@ -2,7 +2,7 @@ article page-title(title="Privacy Policy") h3 Privacy Policy - p: em (as of February 6#[sup th], 2021) + p: em (as of September 6#[sup th], 2021) p. {{name}} (“we,” “our,” or “us”) is committed to protecting your privacy. This @@ -58,7 +58,7 @@ article li Name / Username li Coarse Geographic Location li Employment History - li No Agenda Social Account Name / Profile + li Mastodon Account Name / Profile h4 How Do We Use The Information We Collect? p Any of the information we collect from you may be used in one of the following ways: @@ -75,9 +75,9 @@ article p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers. p. End users may voluntarily provide us with information they have made available on social media websites - (specifically No Agenda Social). If you provide us with any such information, we may collect publicly available - information from the social media websites you have indicated. You can control how much of your information social - media websites make public by visiting these websites and changing your privacy settings. + (specifically No Agenda-affiliated Mastodon instances). If you provide us with any such information, we may collect + publicly available information from the social media websites you have indicated. You can control how much of your + information social media websites make public by visiting these websites and changing your privacy settings. h4 When does {{name}} use customer information from third parties? p We do not utilize third party information apart from the end-user data described above. @@ -223,10 +223,10 @@ article h4 Tracking Technologies p. - {{name}} does not use any tracking technologies. When an authorization code is received from No Agenda Social, that - token is stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is - refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the - application can be used again. + {{name}} does not use any tracking technologies. When an authorization code is received from Mastodon, that token is + stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is refreshed + or the browser window/tab is closed, this token disappears, and a new one must be generated before the application + can be used again. h4 Information about General Data Protection Regulation (GDPR) p. @@ -335,6 +335,12 @@ article h4 Contact Us p Don’t hesitate to contact us if you have any questions. ul: li Via this Link: #[router-link(to="/how-it-works") https://noagendacareers.com/how-it-works] + + hr + + p: em. + Change on September 6#[sup th], 2021 – replaced “No Agenda Social” with generic terms for any + authorized Mastodon instance. diff --git a/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue b/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue index 9bd05f0..5ad832e 100644 --- a/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue +++ b/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue @@ -30,6 +30,7 @@ const logOn = async () => { } else if (typeof instance === "undefined") { setMessage(`Mastodon instance ${abbr} not found`) } else { + setMessage(`Welcome back! Verifying your ${instance.name} account…`) const code = route.query.code if (code) { await store.dispatch("logOn", { abbr, code }) diff --git a/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue b/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue index 38b43c3..f810a22 100644 --- a/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue +++ b/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue @@ -24,7 +24,6 @@ const selected : Ref = ref(undefined) /** The authorization URL to which the user should be directed */ const authUrl = computed(() => { if (selected.value) { - /** The client ID for Jobs, Jobs, Jobs at No Agenda Social */ const client = `client_id=${selected.value.clientId}` const scope = "scope=read:accounts" const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized` @@ -35,7 +34,7 @@ const authUrl = computed(() => { }) /** - * Select a given Mastadon instance + * Select a given Mastodon instance * * @param abbr The abbreviation of the instance being selected */ diff --git a/src/JobsJobsJobs/App/src/views/listing/ListingView.vue b/src/JobsJobsJobs/App/src/views/listing/ListingView.vue index 94ea1c0..278b8bf 100644 --- a/src/JobsJobsJobs/App/src/views/listing/ListingView.vue +++ b/src/JobsJobsJobs/App/src/views/listing/ListingView.vue @@ -66,7 +66,7 @@ const title = computed(() => it.value ? `${it.value.listing.title} | Job Listing const details = computed(() => toHtml(it.value?.listing.text ?? "")) /** The NAS profile URL for the citizen who posted this job listing */ -const profileUrl = computed(() => citizen.value ? `https://noagendasocial.com/@${citizen.value.naUser}` : "") +const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "") /** The needed by date, formatted in SHOUTING MODE */ const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase() diff --git a/src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue b/src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue index 0b59fa7..5f278ff 100644 --- a/src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue +++ b/src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue @@ -13,10 +13,10 @@ article 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 No Agenda Social account in any way; its effects are limited to Jobs, Jobs, Jobs. + #[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’s permissions on No Agenda Social; you will have to remove this - yourself. The confirmation message has a link where you can do this; once the page loads, find the + (This will not revoke this application’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 × Revoke] link for that entry.) p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account @@ -32,9 +32,12 @@ import { useStore } from "@/store" const store = useStore() const router = useRouter() +/** The currently logged-on user */ +const user = store.state.user as LogOnSuccess + /** Delete the profile only; redirect to home page on success */ const deleteProfile = async () => { - const resp = await api.profile.delete(store.state.user as LogOnSuccess) + const resp = await api.profile.delete(user) if (typeof resp === "string") { toastError(resp, "Deleting Profile") } else { @@ -45,13 +48,27 @@ const deleteProfile = async () => { /** Delete everything pertaining to the user's account */ const deleteAccount = async () => { - const resp = await api.citizen.delete(store.state.user as LogOnSuccess) - if (typeof resp === "string") { - toastError(resp, "Deleting Account") + 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) } else { - store.commit("clearUser") - toastSuccess("Account Deleted Successfully") - router.push("/so-long/success") + const instResp = await api.instances.byAbbr(citizenResp.instance) + if (typeof instResp === "string") { + toastError(instResp, "retriving instance") + } else if (typeof instResp === "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("clearUser") + toastSuccess("Account Deleted Successfully") + router.push(`/so-long/success/${instResp.url}`) + } + } } } diff --git a/src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue b/src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue index 83d9b38..3416af1 100644 --- a/src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue +++ b/src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue @@ -4,8 +4,18 @@ 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="https://noagendasocial.com/oauth/authorized_applications") this list] and click + application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click #[strong × Revoke]. Otherwise, clicking “Log On” 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 + + diff --git a/src/JobsJobsJobs/Domain/Modules.fs b/src/JobsJobsJobs/Domain/Modules.fs index d65bade..f6f9df5 100644 --- a/src/JobsJobsJobs/Domain/Modules.fs +++ b/src/JobsJobsJobs/Domain/Modules.fs @@ -33,7 +33,7 @@ module CitizenId = module Citizen = /// Get the name of the citizen (the first of real name, display name, or handle that is filled in) let name x = - [ x.realName; x.displayName; Some x.naUser ] + [ x.realName; x.displayName; Some x.mastodonUser ] |> List.find Option.isSome |> Option.get diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index cf485f6..dc0dcb3 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -92,8 +92,8 @@ type MastodonInstance () = /// The authorization options for Jobs, Jobs, Jobs type AuthOptions () = - /// The return URL for Mastodoon verification - member val ReturnUrl = "" with get, set + /// The host for the return URL for Mastodoon verification + member val ReturnHost = "" with get, set /// The secret with which the server signs the JWTs for auth once we've verified with Mastodon member val ServerSecret = "" with get, set /// The instances configured for use diff --git a/src/JobsJobsJobs/Domain/Types.fs b/src/JobsJobsJobs/Domain/Types.fs index 9659e10..244286e 100644 --- a/src/JobsJobsJobs/Domain/Types.fs +++ b/src/JobsJobsJobs/Domain/Types.fs @@ -13,19 +13,21 @@ type CitizenId = CitizenId of Guid [] type Citizen = { /// The ID of the user - id : CitizenId + id : CitizenId + /// The Mastodon instance abbreviation from which this citizen is authorized + instance : string /// The handle by which the user is known on Mastodon - naUser : string + mastodonUser : string /// The user's display name from Mastodon (updated every login) - displayName : string option + displayName : string option /// The user's real name - realName : string option + realName : string option /// The URL for the user's Mastodon profile - profileUrl : string + profileUrl : string /// When the user joined Jobs, Jobs, Jobs - joinedOn : Instant + joinedOn : Instant /// When the user last logged in - lastSeenOn : Instant + lastSeenOn : Instant }