Support multiple Mastodon instances (#26)

The application handles multiple instances, and gets that information from configuration, making it much easier to bring in additional NA-affiliated instances in the future

Fixes #22
This commit is contained in:
Daniel J. Summers 2021-09-06 21:20:51 -04:00 committed by GitHub
parent 45861e06f0
commit a1d1b53ff4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 483 additions and 213 deletions

View File

@ -30,6 +30,7 @@ open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open Microsoft.IdentityModel.Tokens open Microsoft.IdentityModel.Tokens
open System.Text open System.Text
open JobsJobsJobs.Domain.SharedTypes
/// Configure dependency injection /// Configure dependency injection
let configureServices (svc : IServiceCollection) = let configureServices (svc : IServiceCollection) =
@ -57,9 +58,10 @@ let configureServices (svc : IServiceCollection) =
ValidAudience = "https://noagendacareers.com", ValidAudience = "https://noagendacareers.com",
ValidIssuer = "https://noagendacareers.com", ValidIssuer = "https://noagendacareers.com",
IssuerSigningKey = SymmetricSecurityKey ( IssuerSigningKey = SymmetricSecurityKey (
Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"])))) Encoding.UTF8.GetBytes (cfg.GetSection "Auth").["ServerSecret"])))
|> ignore |> ignore
svc.AddAuthorization () |> ignore svc.AddAuthorization () |> ignore
svc.Configure<AuthOptions> (cfg.GetSection "Auth") |> ignore
let dbCfg = cfg.GetSection "Rethink" let dbCfg = cfg.GetSection "Rethink"
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup) let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)

View File

@ -3,16 +3,16 @@ module JobsJobsJobs.Api.Auth
open System.Text.Json.Serialization open System.Text.Json.Serialization
/// The variables we need from the account information we get from No Agenda Social /// The variables we need from the account information we get from Mastodon
[<NoComparison; NoEquality; AllowNullLiteral>] [<NoComparison; NoEquality; AllowNullLiteral>]
type MastodonAccount () = type MastodonAccount () =
/// The user name (what we store as naUser) /// The user name (what we store as mastodonUser)
[<JsonPropertyName "username">] [<JsonPropertyName "username">]
member val Username = "" with get, set member val Username = "" with get, set
/// The account name; will be the same as username for local (non-federated) accounts /// The account name; will generally be the same as username for local accounts, which is all we can verify
[<JsonPropertyName "acct">] [<JsonPropertyName "acct">]
member val AccountName = "" with get, set member val AccountName = "" with get, set
/// The user's display name as it currently shows on No Agenda Social /// The user's display name as it currently shows on Mastodon
[<JsonPropertyName "display_name">] [<JsonPropertyName "display_name">]
member val DisplayName = "" with get, set member val DisplayName = "" with get, set
/// The user's profile URL /// The user's profile URL
@ -21,25 +21,29 @@ type MastodonAccount () =
open FSharp.Control.Tasks open FSharp.Control.Tasks
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open System open System
open System.Net.Http open System.Net.Http
open System.Net.Http.Headers open System.Net.Http.Headers
open System.Net.Http.Json open System.Net.Http.Json
open System.Text.Json open System.Text.Json
open JobsJobsJobs.Domain.SharedTypes
/// HTTP client to use to communication with Mastodon
let private http = new HttpClient()
/// Verify the authorization code with Mastodon and get the user's profile /// Verify the authorization code with Mastodon and get the user's profile
let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task { let verifyWithMastodon (authCode : string) (inst : MastodonInstance) rtnHost (log : ILogger) = task {
use http = new HttpClient() // Function to create a URL for the given instance
let apiUrl = sprintf "%s/api/v1/%s" inst.Url
// Use authorization code to get an access token from NAS // Use authorization code to get an access token from Mastodon
use! codeResult = use! codeResult =
http.PostAsJsonAsync("https://noagendasocial.com/oauth/token", http.PostAsJsonAsync($"{inst.Url}/oauth/token",
{| client_id = cfg.["ClientId"] {| client_id = inst.ClientId
client_secret = cfg.["Secret"] client_secret = inst.Secret
redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"] redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized"
grant_type = "authorization_code" grant_type = "authorization_code"
code = authCode code = authCode
scope = "read" scope = "read"
@ -49,11 +53,10 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync () let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes) use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
match tokenResponse with match tokenResponse with
| null -> | null -> return Error "Could not parse authorization code result"
return Error "Could not parse authorization code result"
| _ -> | _ ->
// Use access token to get profile from NAS // Use access token to get profile from NAS
use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"]) use req = new HttpRequestMessage (HttpMethod.Get, apiUrl "accounts/verify_credentials")
req.Headers.Authorization <- AuthenticationHeaderValue req.Headers.Authorization <- AuthenticationHeaderValue
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ()) ("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
use! profileResult = http.SendAsync req use! profileResult = http.SendAsync req
@ -62,19 +65,13 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
| true -> | true ->
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync () let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
| null -> | null -> return Error "Could not parse profile result"
return Error "Could not parse profile result" | profile -> return Ok profile
| x when x.Username <> x.AccountName -> | false -> return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}"
| profile ->
return Ok profile
| false ->
return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
| false -> | false ->
let! err = codeResult.Content.ReadAsStringAsync () let! err = codeResult.Content.ReadAsStringAsync ()
log.LogError $"Could not get token result from Mastodon:\n {err}" log.LogError $"Could not get token result from Mastodon:\n {err}"
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})" return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
} }
@ -86,7 +83,7 @@ open System.Security.Claims
open System.Text open System.Text
/// Create a JSON Web Token for this citizen to use for further requests to this API /// Create a JSON Web Token for this citizen to use for further requests to this API
let createJwt (citizen : Citizen) (cfg : IConfigurationSection) = let createJwt (citizen : Citizen) (cfg : AuthOptions) =
let tokenHandler = JwtSecurityTokenHandler () let tokenHandler = JwtSecurityTokenHandler ()
let token = let token =
@ -100,8 +97,7 @@ let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
Issuer = "https://noagendacareers.com", Issuer = "https://noagendacareers.com",
Audience = "https://noagendacareers.com", Audience = "https://noagendacareers.com",
SigningCredentials = SigningCredentials ( SigningCredentials = SigningCredentials (
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]), SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature)
SecurityAlgorithms.HmacSha256Signature)
) )
) )
tokenHandler.WriteToken token tokenHandler.WriteToken token

View File

@ -6,6 +6,7 @@ open JobsJobsJobs.Domain.Types
open Polly open Polly
open RethinkDb.Driver open RethinkDb.Driver
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
open RethinkDb.Driver.Ast
/// Shorthand for the RethinkDB R variable (how every command starts) /// Shorthand for the RethinkDB R variable (how every command starts)
let private r = RethinkDB.R let private r = RethinkDB.R
@ -166,10 +167,20 @@ module Startup =
log.LogInformation $"Creating \"{idx}\" index on {table}" log.LogInformation $"Creating \"{idx}\" index on {table}"
r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore) r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore)
} }
do! ensureIndexes Table.Citizen [ "naUser" ]
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ] do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ]
do! ensureIndexes Table.Profile [ "continentId" ] do! ensureIndexes Table.Profile [ "continentId" ]
do! ensureIndexes Table.Success [ "citizenId" ] do! ensureIndexes Table.Success [ "citizenId" ]
// The instance/user is a compound index
let! userIdx = r.Table(Table.Citizen).IndexList().RunResultAsync<string list> 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
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
open RethinkDb.Driver.Ast
/// Profile data access functions /// Profile data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
@ -287,7 +297,7 @@ module Profile =
.HashMap("displayName", .HashMap("displayName",
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName", r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName", it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "naUser")) it.G "mastodonUser"))
.With ("citizenId", it.G "id"))) .With ("citizenId", it.G "id")))
.Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn") .Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn")
.OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ())) .OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ()))
@ -348,12 +358,16 @@ module Citizen =
.RunResultAsync<Citizen> .RunResultAsync<Citizen>
|> withReconnOption conn |> withReconnOption conn
/// Find a citizen by their No Agenda Social username /// Find a citizen by their Mastodon username
let findByNaUser (naUser : string) conn = let findByMastodonUser (instance : string) (mastodonUser : string) conn =
r.Table(Table.Citizen) fun c -> task {
.GetAll(naUser).OptArg("index", "naUser").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 =
@ -546,7 +560,7 @@ module Success =
.HashMap("citizenName", .HashMap("citizenName",
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName", r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
it.G("displayName").Default_("").Ne "", it.G "displayName", it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "naUser")) it.G "mastodonUser"))
.With ("hasStory", it.G("story").Default_("").Gt ""))) .With ("hasStory", it.G("story").Default_("").Gt "")))
.Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory") .Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory")
.OrderBy(r.Desc "recordedOn") .OrderBy(r.Desc "recordedOn")

View File

@ -23,23 +23,23 @@ module Error =
/// URL prefixes for the Vue app /// URL prefixes for the Vue app
let vueUrls = [ let vueUrls = [
"/"; "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile" "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
"/so-long"; "/success-story" "/so-long"; "/success-story"
] ]
/// Handler that will return a status code 404 and the text "Not Found" /// Handler that will return a status code 404 and the text "Not Found"
let notFound : HttpHandler = let notFound : HttpHandler =
fun next ctx -> task { fun next ctx -> task {
let fac = ctx.GetService<ILoggerFactory>() let fac = ctx.GetService<ILoggerFactory> ()
let log = fac.CreateLogger("Handler") let log = fac.CreateLogger "Handler"
let path = string ctx.Request.Path
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
| true when vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) -> | true when path = "/" || vueUrls |> List.exists path.StartsWith ->
log.LogInformation "Returning Vue app" log.LogInformation "Returning Vue app"
return! Vue.app next ctx return! Vue.app next ctx
| _ -> | _ ->
log.LogInformation "Returning 404" log.LogInformation "Returning 404"
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
ctx
} }
/// Handler that returns a 403 NOT AUTHORIZED response /// Handler that returns a 403 NOT AUTHORIZED response
@ -58,6 +58,7 @@ module Helpers =
open NodaTime open NodaTime
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Options
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
open System.Security.Claims open System.Security.Claims
@ -67,6 +68,9 @@ module Helpers =
/// Get the application configuration from the request context /// Get the application configuration from the request context
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> () let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
/// Get the authorization configuration from the request context
let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value
/// Get the logger factory from the request context /// Get the logger factory from the request context
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> () let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
@ -104,46 +108,50 @@ module Helpers =
module Citizen = module Citizen =
// GET: /api/citizen/log-on/[code] // GET: /api/citizen/log-on/[code]
let logOn authCode : HttpHandler = let logOn (abbr, authCode) : HttpHandler =
fun next ctx -> task { fun next ctx -> task {
// Step 1 - Verify with Mastodon // Step 1 - Verify with Mastodon
let cfg = (config ctx).GetSection "Auth" let cfg = authConfig ctx
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
match! Auth.verifyWithMastodon authCode cfg log with match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with
| Ok account -> | Some instance ->
// Step 2 - Find / establish Jobs, Jobs, Jobs account let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
let now = (clock ctx).GetCurrentInstant ()
let dbConn = conn ctx
let! citizen = task {
match! Data.Citizen.findByNaUser 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
}
do! Data.Citizen.add it dbConn
return it
| Some citizen ->
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
do! Data.Citizen.logOnUpdate it dbConn
return it
}
// Step 3 - Generate JWT match! Auth.verifyWithMastodon authCode instance cfg.ReturnHost log with
return! | Ok account ->
json // Step 2 - Find / establish Jobs, Jobs, Jobs account
{ jwt = Auth.createJwt citizen cfg let now = (clock ctx).GetCurrentInstant ()
citizenId = CitizenId.toString citizen.id let dbConn = conn ctx
name = Citizen.name citizen let! citizen = task {
} next ctx match! Data.Citizen.findByMastodonUser instance.Abbr account.Username dbConn with
| Error err -> | None ->
return! RequestErrors.BAD_REQUEST err next ctx let it : Citizen =
{ 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
| Some citizen ->
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
do! Data.Citizen.logOnUpdate it dbConn
return it
}
// Step 3 - Generate JWT
return!
json
{ jwt = Auth.createJwt citizen cfg
citizenId = CitizenId.toString citizen.id
name = Citizen.name citizen
} next ctx
| Error err -> return! RequestErrors.BAD_REQUEST err next ctx
| None -> return! Error.notFound next ctx
} }
// GET: /api/citizen/[id] // GET: /api/citizen/[id]
@ -176,6 +184,25 @@ module Continent =
} }
/// Handlers for /api/instances routes
[<RequireQualifiedAccess>]
module Instances =
/// Convert a Masotodon instance to the one we use in the API
let private toInstance (inst : MastodonInstance) =
{ name = inst.Name
url = inst.Url
abbr = inst.Abbr
clientId = inst.ClientId
}
// GET: /api/instances
let all : HttpHandler =
fun next ctx -> task {
return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx
}
/// Handlers for /api/listing[s] routes /// Handlers for /api/listing[s] routes
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Listing = module Listing =
@ -489,12 +516,13 @@ let allEndpoints = [
subRoute "/api" [ subRoute "/api" [
subRoute "/citizen" [ subRoute "/citizen" [
GET_HEAD [ GET_HEAD [
routef "/log-on/%s" Citizen.logOn routef "/log-on/%s/%s" Citizen.logOn
routef "/%O" Citizen.get routef "/%O" Citizen.get
] ]
DELETE [ route "" Citizen.delete ] DELETE [ route "" Citizen.delete ]
] ]
GET_HEAD [ route "/continents" Continent.all ] GET_HEAD [ route "/continents" Continent.all ]
GET_HEAD [ route "/instances" Instances.all ]
subRoute "/listing" [ subRoute "/listing" [
GET_HEAD [ GET_HEAD [
routef "/%O" Listing.get routef "/%O" Listing.get

View File

@ -1,6 +1,22 @@
{ {
"Rethink": { "Auth": {
"Hostname": "localhost", "ReturnHost": "http://localhost:5000",
"Db": "jobsjobsjobs" "Instances": {
"0": {
"Name": "No Agenda Social",
"Url": "https://noagendasocial.com",
"Abbr": "nas"
},
"1": {
"Name": "ITM Slaves!",
"Url": "https://itmslaves.com",
"Abbr": "itm"
},
"2": {
"Name": "Liberty Woof",
"Url": "https://libertywoof.com",
"Abbr": "lw"
}
}
} }
} }

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,13 +40,13 @@ 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
*/ */
export function citizenName (cit : Citizen) : string { export function citizenName (cit : Citizen) : string {
return cit.realName ?? cit.displayName ?? cit.naUser return cit.realName ?? cit.displayName ?? cit.mastodonUser
} }
</script> </script>

View File

@ -2,6 +2,7 @@ import {
Citizen, Citizen,
Continent, Continent,
Count, Count,
Instance,
Listing, Listing,
ListingExpireForm, ListingExpireForm,
ListingForm, ListingForm,
@ -25,7 +26,7 @@ import {
* @param url The partial URL for the API * @param url The partial URL for the API
* @returns A full URL for the API * @returns A full URL for the API
*/ */
const apiUrl = (url : string) : string => `http://localhost:5000/api/${url}` const apiUrl = (url : string) : string => `/api/${url}`
/** /**
* Create request init parameters * Create request init parameters
@ -100,11 +101,12 @@ export default {
/** /**
* Log a citizen on * Log a citizen on
* *
* @param code The authorization code from No Agenda Social * @param abbr The abbreviation of the Mastodon instance that issued the code
* @param code The authorization code from Mastodon
* @returns The user result, or an error * @returns The user result, or an error
*/ */
logOn: async (code : string) : Promise<LogOnSuccess | string> => { logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" }) const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
if (resp.status === 200) return await resp.json() as LogOnSuccess if (resp.status === 200) return await resp.json() as LogOnSuccess
return `Error logging on - ${await resp.text()}` return `Error logging on - ${await resp.text()}`
}, },
@ -141,6 +143,18 @@ export default {
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents") apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
}, },
/** API functions for instances */
instances: {
/**
* Get all Mastodon instances we support
*
* @returns All instances, or an error
*/
all: async () : Promise<Instance[] | string | undefined> =>
apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances")
},
/** API functions for job listings */ /** API functions for job listings */
listings: { listings: {

View File

@ -3,8 +3,10 @@
export interface Citizen { export interface Citizen {
/** The ID of the user */ /** The ID of the user */
id : string id : string
/** The abbreviation of the instance where this citizen is based */
instance : string
/** The handle by which the user is known on Mastodon */ /** The handle by which the user is known on Mastodon */
naUser : string mastodonUser : string
/** The user's display name from Mastodon (updated every login) */ /** The user's display name from Mastodon (updated every login) */
displayName : string | undefined displayName : string | undefined
/** The user's real name */ /** The user's real name */
@ -31,6 +33,18 @@ export interface Count {
count : number count : number
} }
/** The Mastodon instance data provided via the Jobs, Jobs, Jobs API */
export interface Instance {
/** The name of the instance */
name : string
/** The URL for this instance */
url : string
/** The abbreviation used in the URL to distinguish this instance's return codes */
abbr : string
/** The client ID (assigned by the Mastodon server) */
clientId : string
}
/** A job listing */ /** A job listing */
export interface Listing { export interface Listing {
/** The ID of the job listing */ /** The ID of the job listing */

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"
/** /**
@ -53,7 +53,7 @@ const routes: Array<RouteRecordRaw> = [
component: LogOn component: LogOn
}, },
{ {
path: "/citizen/authorized", path: "/citizen/:abbr/authorized",
name: "CitizenAuthorized", name: "CitizenAuthorized",
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue") component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue")
}, },
@ -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", 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 */
@ -24,43 +28,51 @@ export default createStore({
state: () : State => { state: () : State => {
return { return {
user: undefined, user: undefined,
logOnState: "<em>Welcome back! Verifying your No Agenda Social account&hellip;</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 }, code: string) { [Actions.LogOn]: async ({ commit }, { abbr, code }) => {
const logOnResult = await api.citizen.logOn(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

@ -21,8 +21,8 @@ article
p. p.
Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all 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. of the information from the search results, along with the citizen who posted it, and the full details of the job.
The citizen&rsquo;s name is a link to their profile page at No Agenda Social; you can use that to get their handle, The citizen&rsquo;s name is a link to their profile page at their Mastodon instance; you can use that to get their
and use NAS&rsquo;s communication facilites to inquire about the position. handle, and use Mastodon&rsquo;s communication facilites to inquire about the position.
p: em.text-muted. 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; (If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
I&rsquo;ve searched and searched, and asked NAS, but have not yet determined how to do that.) I&rsquo;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 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 #[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 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 the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to
users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s access, but then they view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site&rsquo;s
will get there as well.) access, but then they will get there as well.)
h5 Expire a Job Listing h5 Expire a Job Listing
p. p.
@ -68,7 +68,7 @@ article
The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the
citizen&rsquo;s desire for remote work, a skill, or any text in their professional biography and experience. If you citizen&rsquo;s desire for remote work, a skill, or any text in their professional biography and experience. If you
find someone with whom you&rsquo;d like to discuss potential opportunities, the name at the top of the profile links find someone with whom you&rsquo;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 hr
@ -76,8 +76,8 @@ article
p. p.
The employment profile is your r&eacute;sum&eacute;, visible to other citizens here. It also allows you to specify The employment profile is your r&eacute;sum&eacute;, 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, 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 profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
display name each time you log on. current display name each time you log on.
h5 Completing Your Profile h5 Completing Your Profile
p. p.
@ -99,19 +99,19 @@ article
li. li.
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking 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 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 site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the
behind the curtain a bit, and hopefully inspire them to join us. curtain a bit, and hopefully inspire them to join us.
h5 Viewing and Sharing Your Profile h5 Viewing and Sharing Your Profile
p. p.
Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom 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 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 link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated
would like to share it there. Just as with job listings, existing users will go straight there, while other No Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight
Agenda Social users will get there once they authorize this application. there, while others will get there once they authorize this application.
p. p.
The name on employment profiles is a link to that user&rsquo;s profile on No Agenda Social; from there, others can The name on employment profiles is a link to that user&rsquo;s profile on their Mastodon instance; from there,
communicate further with you using the tools Mastodon provides. others can communicate further with you using the tools Mastodon provides.
h5 &ldquo;I Found a Job!&rdquo; h5 &ldquo;I Found a Job!&rdquo;
p. p.

View File

@ -2,7 +2,7 @@
article article
page-title(title="Privacy Policy") page-title(title="Privacy Policy")
h3 Privacy Policy h3 Privacy Policy
p: em (as of February 6#[sup th], 2021) p: em (as of September 6#[sup th], 2021)
p. p.
{{name}} (&ldquo;we,&rdquo; &ldquo;our,&rdquo; or &ldquo;us&rdquo;) is committed to protecting your privacy. This {{name}} (&ldquo;we,&rdquo; &ldquo;our,&rdquo; or &ldquo;us&rdquo;) is committed to protecting your privacy. This
@ -58,7 +58,7 @@ article
li Name / Username li Name / Username
li Coarse Geographic Location li Coarse Geographic Location
li Employment History li Employment History
li No Agenda Social Account Name / Profile li Mastodon Account Name / Profile
h4 How Do We Use The Information We Collect? 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: 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 {{name}} will collect End User Data necessary to provide the {{name}} services to our customers.
p. p.
End users may voluntarily provide us with information they have made available on social media websites 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 (specifically No Agenda-affiliated Mastodon instances). If you provide us with any such information, we may collect
information from the social media websites you have indicated. You can control how much of your information social publicly available information from the social media websites you have indicated. You can control how much of your
media websites make public by visiting these websites and changing your privacy settings. 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? 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. p We do not utilize third party information apart from the end-user data described above.
@ -223,10 +223,10 @@ article
h4 Tracking Technologies h4 Tracking Technologies
p. p.
{{name}} does not use any tracking technologies. When an authorization code is received from No Agenda Social, that {{name}} does not use any tracking technologies. When an authorization code is received from Mastodon, that token is
token is stored in the browser&rsquo;s memory, and the Service uses tokens on each request for data. If the page is stored in the browser&rsquo;s memory, and the Service uses tokens on each request for data. If the page is refreshed
refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the or the browser window/tab is closed, this token disappears, and a new one must be generated before the application
application can be used again. can be used again.
h4 Information about General Data Protection Regulation (GDPR) h4 Information about General Data Protection Regulation (GDPR)
p. p.
@ -335,6 +335,12 @@ article
h4 Contact Us h4 Contact Us
p Don&rsquo;t hesitate to contact us if you have any questions. p Don&rsquo;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] 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 &ndash; replaced &ldquo;No Agenda Social&rdquo; with generic terms for any
authorized Mastodon instance.
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -2,7 +2,7 @@
article article
page-title(title="Terms of Service") page-title(title="Terms of Service")
h3 Terms of Service h3 Terms of Service
p: em (as of February 6#[sup th], 2021) p: em (as of September 6#[sup th], 2021)
h4 Acceptance of Terms h4 Acceptance of Terms
p. p.
@ -11,12 +11,19 @@ article
acceptance of these terms. acceptance of these terms.
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
#[a(href="https://noagendasocial.com" target="_blank") No Agenda Social]. Registration is accomplished by allowing = " "
Jobs, Jobs, Jobs to read one&rsquo;s No Agenda Social profile. See our template(v-for="(it, idx) in instances" :key="idx")
#[router-link(to="/privacy-policy") privacy policy] for details on the personal (user) information we maintain. a(:href="it.url" target="_blank") {{it.name}}
template(v-if="idx + 2 < instances.length")= ", "
template(v-else-if="idx + 1 < instances.length")= ", and "
| ). Registration is accomplished by allowing Jobs, Jobs, Jobs to read one&rsquo;s Mastodon profile. See our
= " "
router-link(to="/privacy-policy") privacy policy
= " "
| for details on the personal (user) information we maintain.
h4 Liability h4 Liability
p. p.
@ -34,4 +41,23 @@ article
p. p.
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
data. data.
hr
p: em.
Change on September 6#[sup th], 2021 &ndash; replaced &ldquo;No Agenda Social&rdquo; with a list of all No
Agenda-affiliated Mastodon instances.
</template> </template>
<script setup lang="ts">
import { computed, onMounted } from "vue"
import { useStore, Actions } from "@/store"
const store = useStore()
/** All instances authorized to view Jobs, Jobs, Jobs */
const instances = computed(() => store.state.instances)
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script>

View File

@ -7,30 +7,43 @@ article
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue" import { computed, onMounted } from "vue"
import { useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { useStore } from "@/store" import { useStore, Actions, Mutations } from "@/store"
import { AFTER_LOG_ON_URL } from "@/router" import { AFTER_LOG_ON_URL } from "@/router"
const router = useRouter()
const store = useStore() const store = useStore()
const route = useRoute()
const router = useRouter()
/** The abbreviation of the instance from which we received the code */
const abbr = route.params.abbr as string
/** Set the message for this component */
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 code = router.currentRoute.value.query.code await store.dispatch(Actions.EnsureInstances)
if (code) { const instance = store.state.instances.find(it => it.abbr === abbr)
await store.dispatch("logOn", code) if (typeof instance === "undefined") {
if (store.state.user !== undefined) { setMessage(`Mastodon instance ${abbr} not found`)
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
if (afterLogOnUrl) {
window.localStorage.removeItem(AFTER_LOG_ON_URL)
router.push(afterLogOnUrl)
} else {
router.push("/citizen/dashboard")
}
}
} else { } else {
store.commit("setLogOnState", setMessage(`<em>Welcome back! Verifying your ${instance.name} account&hellip;</em>`)
"Did not receive a token from No Agenda Social (perhaps you clicked &ldquo;Cancel&rdquo;?)") const code = route.query.code
if (code) {
await store.dispatch(Actions.LogOn, { abbr, code })
if (store.state.user !== undefined) {
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
if (afterLogOnUrl) {
window.localStorage.removeItem(AFTER_LOG_ON_URL)
router.push(afterLogOnUrl)
} else {
router.push("/citizen/dashboard")
}
}
} else {
setMessage(`Did not receive a token from ${instance.name} (perhaps you clicked &ldquo;Cancel&rdquo;?)`)
}
} }
} }

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,24 +1,50 @@
<template lang="pug"> <template lang="pug">
article article
p &nbsp; p &nbsp;
p.fst-italic Sending you over to No Agenda Social 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)
p.text-center Please select your No Agenda-affiliated Mastodon instance
p.text-center(v-for="it in instances" :key="it.abbr")
button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}}
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/** import { computed, onMounted, Ref, ref } from "vue"
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it import { Instance } from "@/api"
* can be called from two different places, and allow the app to support direct links to authorized content. import { useStore, Actions } from "@/store"
*/
import LoadData from "@/components/LoadData.vue"
const store = useStore()
/** The instances configured for Jobs, Jobs, Jobs */
const instances = computed(() => store.state.instances)
/** Whether authorization is in progress */
const selected : Ref<Instance | undefined> = ref(undefined)
/** The authorization URL to which the user should be directed */ /** The authorization URL to which the user should be directed */
const authUrl = (() => { const authUrl = computed(() => {
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */ if (selected.value) {
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU" const client = `client_id=${selected.value.clientId}`
const client = `client_id=${id}` const scope = "scope=read:accounts"
const scope = "scope=read:accounts" const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized`
const redirect = `redirect_uri=${document.location.origin}/citizen/authorized` const respType = "response_type=code"
const respType = "response_type=code" return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}` }
})() return ""
document.location.assign(authUrl) })
/**
* Select a given Mastodon instance
*
* @param abbr The abbreviation of the instance being selected
*/
const select = (abbr : string) => {
selected.value = instances.value.find(it => it.abbr === abbr)
document.location.assign(authUrl.value)
}
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script> </script>

View File

@ -65,8 +65,8 @@ 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 ? `https://noagendasocial.com/@${citizen.value.naUser}` : "") const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "")
/** The needed by date, formatted in SHOUTING MODE */ /** The needed by date, formatted in SHOUTING MODE */
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase() const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()

View File

@ -13,28 +13,31 @@ article
p. p.
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and 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 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. p: em.
(This will not revoke this application&rsquo;s permissions on No Agenda Social; you will have to remove this (This will not revoke this application&rsquo;s permissions on Mastodon; you will have to remove this yourself. The
yourself. The confirmation message has a link where you can do this; once the page loads, find 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.) #[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 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()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** Delete the profile only; redirect to home page on success */ /** Delete the profile only; redirect to home page on success */
const deleteProfile = async () => { 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") { if (typeof resp === "string") {
toastError(resp, "Deleting Profile") toastError(resp, "Deleting Profile")
} else { } else {
@ -45,13 +48,28 @@ const deleteProfile = async () => {
/** Delete everything pertaining to the user's account */ /** Delete everything pertaining to the user's account */
const deleteAccount = async () => { const deleteAccount = async () => {
const resp = await api.citizen.delete(store.state.user as LogOnSuccess) const citizenResp = await api.citizen.retrieve(user.citizenId, user)
if (typeof resp === "string") { if (typeof citizenResp === "string") {
toastError(resp, "Deleting Account") toastError(citizenResp, "retrieving citizen")
} else if (typeof citizenResp === "undefined") {
toastError("Could not retrieve citizen record", undefined)
} else { } else {
store.commit("clearUser") const instance = store.state.instances.find(it => it.abbr === citizenResp.instance)
toastSuccess("Account Deleted Successfully") if (typeof instance === "undefined") {
router.push("/so-long/success") 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}`)
}
}
} }
} }
onMounted(async () => { await store.dispatch(Actions.EnsureInstances) })
</script> </script>

View File

@ -4,8 +4,26 @@ article
h3.pb-3 Account Deletion Success h3.pb-3 Account Deletion Success
p. p.
Your account has been successfully deleted. To revoke the permissions you have previously granted to this 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 &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty #[strong &times; Revoke]. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new, empty
account without prompting you further. account without prompting you further.
p Thank you for participating, and thank you for your courage. #GitmoNation 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

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

View File

@ -14,6 +14,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="0.25.0" /> <PackageReference Include="Markdig" Version="0.25.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
<PackageReference Include="NodaTime" Version="3.0.5" /> <PackageReference Include="NodaTime" Version="3.0.5" />
</ItemGroup> </ItemGroup>

View File

@ -33,7 +33,7 @@ module CitizenId =
module Citizen = module Citizen =
/// Get the name of the citizen (the first of real name, display name, or handle that is filled in) /// Get the name of the citizen (the first of real name, display name, or handle that is filled in)
let name x = let name x =
[ x.realName; x.displayName; Some x.naUser ] [ x.realName; x.displayName; Some x.mastodonUser ]
|> List.find Option.isSome |> List.find Option.isSome
|> Option.get |> Option.get

View File

@ -2,6 +2,7 @@
module JobsJobsJobs.Domain.SharedTypes module JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Domain.Types open JobsJobsJobs.Domain.Types
open Microsoft.Extensions.Options
open NodaTime open NodaTime
// fsharplint:disable FieldNames // fsharplint:disable FieldNames
@ -75,6 +76,45 @@ type Count = {
} }
/// An instance of a Mastodon server which is configured to work with Jobs, Jobs, Jobs
type MastodonInstance () =
/// The name of the instance
member val Name = "" with get, set
/// The URL for this instance
member val Url = "" with get, set
/// The abbreviation used in the URL to distinguish this instance's return codes
member val Abbr = "" with get, set
/// The client ID (assigned by the Mastodon server)
member val ClientId = "" with get, set
/// The cryptographic secret (provided by the Mastodon server)
member val Secret = "" with get, set
/// The authorization options for Jobs, Jobs, Jobs
type AuthOptions () =
/// 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
member val Instances = Array.empty<MastodonInstance> with get, set
interface IOptions<AuthOptions> with
override this.Value = this
/// The Mastodon instance data provided via the Jobs, Jobs, Jobs API
type Instance = {
/// The name of the instance
name : string
/// The URL for this instance
url : string
/// The abbreviation used in the URL to distinguish this instance's return codes
abbr : string
/// The client ID (assigned by the Mastodon server)
clientId : string
}
/// The fields required for a skill /// The fields required for a skill
type SkillForm = { type SkillForm = {
/// The ID of this skill /// The ID of this skill

View File

@ -13,19 +13,21 @@ type CitizenId = CitizenId of Guid
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Citizen = { type Citizen = {
/// The ID of the user /// 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 /// The handle by which the user is known on Mastodon
naUser : string mastodonUser : string
/// The user's display name from Mastodon (updated every login) /// The user's display name from Mastodon (updated every login)
displayName : string option displayName : string option
/// The user's real name /// The user's real name
realName : string option realName : string option
/// The URL for the user's Mastodon profile /// The URL for the user's Mastodon profile
profileUrl : string profileUrl : string
/// When the user joined Jobs, Jobs, Jobs /// When the user joined Jobs, Jobs, Jobs
joinedOn : Instant joinedOn : Instant
/// When the user last logged in /// When the user last logged in
lastSeenOn : Instant lastSeenOn : Instant
} }