Convert to Blazor #6
3
src/JobsJobsJobs.Api/.gitignore
vendored
3
src/JobsJobsJobs.Api/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
||||||
obj/
|
|
||||||
bin/
|
|
||||||
appsettings.*.json
|
|
|
@ -1,59 +0,0 @@
|
||||||
module JobsJobsJobs.Api.Auth
|
|
||||||
|
|
||||||
open Data
|
|
||||||
open Domain
|
|
||||||
open FSharp.Json
|
|
||||||
open JWT.Algorithms
|
|
||||||
open JWT.Builder
|
|
||||||
open JWT.Exceptions
|
|
||||||
open System
|
|
||||||
open System.Net.Http
|
|
||||||
open System.Net.Http.Headers
|
|
||||||
|
|
||||||
/// Verify a user's credentials with No Agenda Social
|
|
||||||
let verifyWithMastodon accessToken = async {
|
|
||||||
use client = new HttpClient ()
|
|
||||||
use req = new HttpRequestMessage (HttpMethod.Get, $"{config.auth.apiUrl}accounts/verify_credentials")
|
|
||||||
req.Headers.Authorization <- AuthenticationHeaderValue ("Bearer", accessToken)
|
|
||||||
match! client.SendAsync req |> Async.AwaitTask with
|
|
||||||
| res when res.IsSuccessStatusCode ->
|
|
||||||
let! body = res.Content.ReadAsStringAsync ()
|
|
||||||
return
|
|
||||||
match Json.deserialize<ViewModels.Citizen.MastodonAccount> body with
|
|
||||||
| profile when profile.username = profile.acct -> Ok profile
|
|
||||||
| profile -> Error $"Profiles must be from noagendasocial.com; yours is {profile.acct}"
|
|
||||||
| res -> return Error $"Could not retrieve credentials: %d{int res.StatusCode} ~ {res.ReasonPhrase}"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a JWT for the given user
|
|
||||||
let createJwt citizenId = async {
|
|
||||||
match! Citizens.tryFind citizenId with
|
|
||||||
| Ok (Some citizen) ->
|
|
||||||
return
|
|
||||||
JwtBuilder()
|
|
||||||
.WithAlgorithm(HMACSHA256Algorithm ())
|
|
||||||
// TODO: generate separate secret for server
|
|
||||||
.WithSecret(config.auth.secret)
|
|
||||||
.AddClaim("sub", CitizenId.toString citizen.id)
|
|
||||||
.AddClaim("exp", DateTimeOffset.UtcNow.AddHours(1.).ToUnixTimeSeconds ())
|
|
||||||
.AddClaim("nam", citizen.displayName)
|
|
||||||
.Encode ()
|
|
||||||
|> Ok
|
|
||||||
| Ok None -> return Error (exn "Citizen record not found")
|
|
||||||
| Error exn -> return Error exn
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the given token
|
|
||||||
let validateJwt token =
|
|
||||||
try
|
|
||||||
let paylod =
|
|
||||||
JwtBuilder()
|
|
||||||
.WithAlgorithm(HMACSHA256Algorithm ())
|
|
||||||
// TODO: generate separate secret for server
|
|
||||||
.WithSecret(config.auth.secret)
|
|
||||||
.MustVerifySignature()
|
|
||||||
.Decode<Map<string, obj>> token
|
|
||||||
CitizenId.tryParse (paylod.["sub"] :?> string)
|
|
||||||
with
|
|
||||||
| :? TokenExpiredException -> Error "Token is expired"
|
|
||||||
| :? SignatureVerificationException -> Error "Invalid token signature"
|
|
|
@ -1,134 +0,0 @@
|
||||||
module JobsJobsJobs.Api.Data
|
|
||||||
|
|
||||||
open JobsJobsJobs.Api.Domain
|
|
||||||
open Npgsql.FSharp
|
|
||||||
open System
|
|
||||||
|
|
||||||
/// The connection URI for the database
|
|
||||||
let connectUri = Uri config.dbUri
|
|
||||||
|
|
||||||
|
|
||||||
/// Connect to the database
|
|
||||||
let db () =
|
|
||||||
(Sql.fromUri >> Sql.connect) connectUri
|
|
||||||
|
|
||||||
|
|
||||||
/// Return None if the error is that a single row was expected, but no rows matched
|
|
||||||
let private noneIfNotFound (it : Async<Result<'T, exn>>) = async {
|
|
||||||
match! it with
|
|
||||||
| Ok x ->
|
|
||||||
return (Some >> Ok) x
|
|
||||||
| Error err ->
|
|
||||||
return match err.Message with msg when msg.Contains "at least one" -> Ok None | _ -> Error err
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Get the item count from a single-row result
|
|
||||||
let private itemCount (read: RowReader) = read.int64 "item_count"
|
|
||||||
|
|
||||||
|
|
||||||
/// Functions for manipulating citizens
|
|
||||||
// (SHUT UP, SLAVE!)
|
|
||||||
module Citizens =
|
|
||||||
|
|
||||||
/// Create a Citizen from a row of data
|
|
||||||
let private fromReader (read: RowReader) =
|
|
||||||
match (read.string >> CitizenId.tryParse) "id" with
|
|
||||||
| Ok citizenId -> {
|
|
||||||
id = citizenId
|
|
||||||
naUser = read.string "na_user"
|
|
||||||
displayName = read.string "display_name"
|
|
||||||
profileUrl = read.string "profile_url"
|
|
||||||
joinedOn = (read.int64 >> Millis) "joined_on"
|
|
||||||
lastSeenOn = (read.int64 >> Millis) "last_seen_on"
|
|
||||||
}
|
|
||||||
| Error err -> failwith err
|
|
||||||
|
|
||||||
/// Determine if we already know about this user from No Agenda Social
|
|
||||||
let findIdByNaUser naUser =
|
|
||||||
db ()
|
|
||||||
|> Sql.query "SELECT id FROM citizen WHERE na_user = @na_user"
|
|
||||||
|> Sql.parameters [ "@na_user", Sql.string naUser ]
|
|
||||||
|> Sql.executeRowAsync (fun read ->
|
|
||||||
match (read.string >> CitizenId.tryParse) "id" with
|
|
||||||
| Ok citizenId -> citizenId
|
|
||||||
| Error err -> failwith err)
|
|
||||||
|> noneIfNotFound
|
|
||||||
|
|
||||||
/// Add a citizen
|
|
||||||
let add citizen =
|
|
||||||
db ()
|
|
||||||
|> Sql.query
|
|
||||||
"""INSERT INTO citizen (
|
|
||||||
na_user, display_name, profile_url, joined_on, last_seen_on, id
|
|
||||||
) VALUES (
|
|
||||||
@na_user, @display_name, @profile_url, @joined_on, @last_seen_on, @id
|
|
||||||
)"""
|
|
||||||
|> Sql.parameters [
|
|
||||||
"@na_user", Sql.string citizen.naUser
|
|
||||||
"@display_name", Sql.string citizen.displayName
|
|
||||||
"@profile_url", Sql.string citizen.profileUrl
|
|
||||||
"@joined_on", (Millis.toLong >> Sql.int64) citizen.joinedOn
|
|
||||||
"@last_seen_on", (Millis.toLong >> Sql.int64) citizen.lastSeenOn
|
|
||||||
"@id", (CitizenId.toString >> Sql.string) citizen.id
|
|
||||||
]
|
|
||||||
|> Sql.executeNonQueryAsync
|
|
||||||
|
|
||||||
/// Update a citizen record when they log on
|
|
||||||
let update citizenId displayName =
|
|
||||||
db ()
|
|
||||||
|> Sql.query
|
|
||||||
"""UPDATE citizen
|
|
||||||
SET display_name = @display_name,
|
|
||||||
last_seen_on = @last_seen_on
|
|
||||||
WHERE id = @id"""
|
|
||||||
|> Sql.parameters [
|
|
||||||
"@display_name", Sql.string displayName
|
|
||||||
"@last_seen_on", (DateTime.Now.toMillis >> Millis.toLong >> Sql.int64) ()
|
|
||||||
"@id", (CitizenId.toString >> Sql.string) citizenId
|
|
||||||
]
|
|
||||||
|> Sql.executeNonQueryAsync
|
|
||||||
|
|
||||||
/// Try to find a citizen with the given ID
|
|
||||||
let tryFind citizenId =
|
|
||||||
db ()
|
|
||||||
|> Sql.query "SELECT * FROM citizen WHERE id = @id"
|
|
||||||
|> Sql.parameters [ "@id", (CitizenId.toString >> Sql.string) citizenId ]
|
|
||||||
|> Sql.executeRowAsync fromReader
|
|
||||||
|> noneIfNotFound
|
|
||||||
|
|
||||||
|
|
||||||
/// Functions for manipulating employment profiles
|
|
||||||
module Profiles =
|
|
||||||
|
|
||||||
/// Create a Profile from a row of data
|
|
||||||
let private fromReader (read: RowReader) =
|
|
||||||
match (read.string >> CitizenId.tryParse) "citizen_id" with
|
|
||||||
| Ok citizenId ->
|
|
||||||
match (read.string >> ContinentId.tryParse) "continent_id" with
|
|
||||||
| Ok continentId -> {
|
|
||||||
citizenId = citizenId
|
|
||||||
seekingEmployment = read.bool "seeking_employment"
|
|
||||||
isPublic = read.bool "is_public"
|
|
||||||
continent = { id = continentId; name = read.string "continent_name" }
|
|
||||||
region = read.string "region"
|
|
||||||
remoteWork = read.bool "remote_work"
|
|
||||||
fullTime = read.bool "full_time"
|
|
||||||
biography = (read.string >> MarkdownString) "biography"
|
|
||||||
lastUpdatedOn = (read.int64 >> Millis) "last_updated_on"
|
|
||||||
experience = (read.stringOrNone >> Option.map MarkdownString) "experience"
|
|
||||||
}
|
|
||||||
| Error err -> failwith err
|
|
||||||
| Error err -> failwith err
|
|
||||||
|
|
||||||
/// Try to find an employment profile for the given citizen ID
|
|
||||||
let tryFind citizenId =
|
|
||||||
db ()
|
|
||||||
|> Sql.query
|
|
||||||
"""SELECT p.*, c.name AS continent_name
|
|
||||||
FROM profile p
|
|
||||||
INNER JOIN continent c ON p.continent_id = c.id
|
|
||||||
WHERE citizen_id = @id"""
|
|
||||||
|> Sql.parameters [ "@id", (CitizenId.toString >> Sql.string) citizenId ]
|
|
||||||
|> Sql.executeRowAsync fromReader
|
|
||||||
|> noneIfNotFound
|
|
|
@ -1,272 +0,0 @@
|
||||||
module JobsJobsJobs.Api.Domain
|
|
||||||
|
|
||||||
// fsharplint:disable RecordFieldNames MemberNames
|
|
||||||
|
|
||||||
/// A short ID (12 characters of a Nano ID)
|
|
||||||
type ShortId =
|
|
||||||
| ShortId of string
|
|
||||||
|
|
||||||
/// Functions to maniuplate short IDs
|
|
||||||
module ShortId =
|
|
||||||
|
|
||||||
open Nanoid
|
|
||||||
open System.Text.RegularExpressions
|
|
||||||
|
|
||||||
/// Regular expression to validate a string's format as a short ID
|
|
||||||
let validShortId = Regex ("^[a-z0-9_-]{12}", RegexOptions.Compiled ||| RegexOptions.IgnoreCase)
|
|
||||||
|
|
||||||
/// Convert a short ID to its string representation
|
|
||||||
let toString = function ShortId text -> text
|
|
||||||
|
|
||||||
/// Create a new short ID
|
|
||||||
let create () = async {
|
|
||||||
let! text = Nanoid.GenerateAsync (size = 12)
|
|
||||||
return ShortId text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to parse a string into a short ID
|
|
||||||
let tryParse (text : string) =
|
|
||||||
match text.Length with
|
|
||||||
| 12 when validShortId.IsMatch text -> (ShortId >> Ok) text
|
|
||||||
| 12 -> Error "ShortId must be 12 characters [a-z,0-9,-, or _]"
|
|
||||||
| x -> Error $"ShortId must be 12 characters; %d{x} provided"
|
|
||||||
|
|
||||||
|
|
||||||
/// The ID for a citizen (user) record
|
|
||||||
type CitizenId =
|
|
||||||
| CitizenId of ShortId
|
|
||||||
|
|
||||||
/// Functions for manipulating citizen (user) IDs
|
|
||||||
module CitizenId =
|
|
||||||
/// Convert a citizen ID to its string representation
|
|
||||||
let toString = function CitizenId shortId -> ShortId.toString shortId
|
|
||||||
|
|
||||||
/// Create a new citizen ID
|
|
||||||
let create () = async {
|
|
||||||
let! shortId = ShortId.create ()
|
|
||||||
return CitizenId shortId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to parse a string into a CitizenId
|
|
||||||
let tryParse text =
|
|
||||||
match ShortId.tryParse text with
|
|
||||||
| Ok shortId -> (CitizenId >> Ok) shortId
|
|
||||||
| Error err -> Error err
|
|
||||||
|
|
||||||
|
|
||||||
/// The ID for a continent record
|
|
||||||
type ContinentId =
|
|
||||||
| ContinentId of ShortId
|
|
||||||
|
|
||||||
/// Functions for manipulating continent IDs
|
|
||||||
module ContinentId =
|
|
||||||
/// Convert a continent ID to its string representation
|
|
||||||
let toString = function ContinentId shortId -> ShortId.toString shortId
|
|
||||||
|
|
||||||
/// Create a new continent ID
|
|
||||||
let create () = async {
|
|
||||||
let! shortId = ShortId.create ()
|
|
||||||
return ContinentId shortId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to parse a string into a ContinentId
|
|
||||||
let tryParse text =
|
|
||||||
match ShortId.tryParse text with
|
|
||||||
| Ok shortId -> (ContinentId >> Ok) shortId
|
|
||||||
| Error err -> Error err
|
|
||||||
|
|
||||||
|
|
||||||
/// The ID for a skill record
|
|
||||||
type SkillId =
|
|
||||||
| SkillId of ShortId
|
|
||||||
|
|
||||||
/// Functions for manipulating skill IDs
|
|
||||||
module SkillId =
|
|
||||||
/// Convert a skill ID to its string representation
|
|
||||||
let toString = function SkillId shortId -> ShortId.toString shortId
|
|
||||||
|
|
||||||
/// Create a new skill ID
|
|
||||||
let create () = async {
|
|
||||||
let! shortId = ShortId.create ()
|
|
||||||
return SkillId shortId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to parse a string into a CitizenId
|
|
||||||
let tryParse text =
|
|
||||||
match ShortId.tryParse text with
|
|
||||||
| Ok shortId -> (SkillId >> Ok) shortId
|
|
||||||
| Error err -> Error err
|
|
||||||
|
|
||||||
|
|
||||||
/// The ID for a success report record
|
|
||||||
type SuccessId =
|
|
||||||
| SuccessId of ShortId
|
|
||||||
|
|
||||||
/// Functions for manipulating success report IDs
|
|
||||||
module SuccessId =
|
|
||||||
/// Convert a success report ID to its string representation
|
|
||||||
let toString = function SuccessId shortId -> ShortId.toString shortId
|
|
||||||
|
|
||||||
/// Create a new success report ID
|
|
||||||
let create () = async {
|
|
||||||
let! shortId = ShortId.create ()
|
|
||||||
return SuccessId shortId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to parse a string into a SuccessId
|
|
||||||
let tryParse text =
|
|
||||||
match ShortId.tryParse text with
|
|
||||||
| Ok shortId -> (SuccessId >> Ok) shortId
|
|
||||||
| Error err -> Error err
|
|
||||||
|
|
||||||
|
|
||||||
/// A number representing milliseconds since the epoch (AKA JavaScript time)
|
|
||||||
type Millis =
|
|
||||||
| Millis of int64
|
|
||||||
|
|
||||||
/// Functions to manipulate ticks
|
|
||||||
module Millis =
|
|
||||||
/// Convert a Ticks instance to its primitive value
|
|
||||||
let toLong = function Millis millis -> millis
|
|
||||||
|
|
||||||
|
|
||||||
/// A string that holds Markdown-formatted text
|
|
||||||
type MarkdownString =
|
|
||||||
| MarkdownString of string
|
|
||||||
|
|
||||||
/// Functions to manipulate Markdown-formatted text
|
|
||||||
module MarkdownString =
|
|
||||||
|
|
||||||
open Markdig
|
|
||||||
|
|
||||||
/// Markdown pipeline that supports all built-in Markdown extensions
|
|
||||||
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
|
|
||||||
|
|
||||||
/// Get the plain-text (non-rendered) representation of the text
|
|
||||||
let toText = function MarkdownString str -> str
|
|
||||||
|
|
||||||
/// Get the HTML (rendered) representation of the text
|
|
||||||
let toHtml = function MarkdownString str -> Markdown.ToHtml (str, pipeline)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// A user
|
|
||||||
type Citizen = {
|
|
||||||
/// The ID of the user
|
|
||||||
id : CitizenId
|
|
||||||
/// The user's handle on No Agenda Social
|
|
||||||
naUser : string
|
|
||||||
/// The user's display name from No Agenda Social (as of their last login here)
|
|
||||||
displayName : string
|
|
||||||
/// The URL to the user's profile on No Agenda Social
|
|
||||||
profileUrl : string
|
|
||||||
/// When the user signed up here
|
|
||||||
joinedOn : Millis
|
|
||||||
/// When the user last logged on here
|
|
||||||
lastSeenOn : Millis
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// A continent
|
|
||||||
type Continent = {
|
|
||||||
/// The ID of the continent
|
|
||||||
id : ContinentId
|
|
||||||
/// The name of the continent
|
|
||||||
name : string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// An employment / skills profile
|
|
||||||
type Profile = {
|
|
||||||
/// The ID of the user to whom the profile applies
|
|
||||||
citizenId : CitizenId
|
|
||||||
/// Whether this user is actively seeking employment
|
|
||||||
seekingEmployment : bool
|
|
||||||
/// Whether information from this profile should appear in the public anonymous list of available skills
|
|
||||||
isPublic : bool
|
|
||||||
/// The continent on which the user is seeking employment
|
|
||||||
continent : Continent
|
|
||||||
/// The region within that continent where the user would prefer to work
|
|
||||||
region : string
|
|
||||||
/// Whether the user is looking for remote work
|
|
||||||
remoteWork : bool
|
|
||||||
/// Whether the user is looking for full-time work
|
|
||||||
fullTime : bool
|
|
||||||
/// The user's professional biography
|
|
||||||
biography : MarkdownString
|
|
||||||
/// When this profile was last updated
|
|
||||||
lastUpdatedOn : Millis
|
|
||||||
/// The user's experience
|
|
||||||
experience : MarkdownString option
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// A skill which a user possesses
|
|
||||||
type Skill = {
|
|
||||||
/// The ID of the skill
|
|
||||||
id : SkillId
|
|
||||||
/// The ID of the user who possesses this skill
|
|
||||||
citizenId : CitizenId
|
|
||||||
/// The skill
|
|
||||||
skill : string
|
|
||||||
/// Notes about the skill (proficiency, experience, etc.)
|
|
||||||
notes : string option
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// A success story
|
|
||||||
type Success = {
|
|
||||||
/// The ID of the success story
|
|
||||||
id : SuccessId
|
|
||||||
/// The ID of the user who experienced this success story
|
|
||||||
citizenId : CitizenId
|
|
||||||
/// When this story was recorded
|
|
||||||
recordedOn : Millis
|
|
||||||
/// Whether the success came from here; if Jobs, Jobs, Jobs led them to eventual employment
|
|
||||||
fromHere : bool
|
|
||||||
/// Their success story
|
|
||||||
story : MarkdownString option
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Configuration required for authentication with No Agenda Social
|
|
||||||
type AuthConfig = {
|
|
||||||
/// The client ID
|
|
||||||
clientId : string
|
|
||||||
/// The cryptographic secret
|
|
||||||
secret : string
|
|
||||||
/// The base URL for Mastodon's API access
|
|
||||||
apiUrl : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Application configuration format
|
|
||||||
type JobsJobsJobsConfig = {
|
|
||||||
/// Auth0 configuration
|
|
||||||
auth : AuthConfig
|
|
||||||
/// Database connection URI
|
|
||||||
dbUri : string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
open System.IO
|
|
||||||
|
|
||||||
/// Configuration instance
|
|
||||||
let config =
|
|
||||||
(lazy
|
|
||||||
(let root =
|
|
||||||
ConfigurationBuilder()
|
|
||||||
.SetBasePath(Directory.GetCurrentDirectory ())
|
|
||||||
.AddJsonFile("appsettings.json")
|
|
||||||
.AddJsonFile("appsettings.Development.json", true)
|
|
||||||
.AddJsonFile("appsettings.Production.json", true)
|
|
||||||
.AddEnvironmentVariables("JJJ_")
|
|
||||||
.Build()
|
|
||||||
let auth = root.GetSection "Auth"
|
|
||||||
{ dbUri = root.["dbUri"]
|
|
||||||
auth = {
|
|
||||||
clientId = auth.["ClientId"]
|
|
||||||
secret = auth.["Secret"]
|
|
||||||
apiUrl = auth.["ApiUrl"]
|
|
||||||
}
|
|
||||||
})).Force()
|
|
|
@ -1,31 +0,0 @@
|
||||||
[<AutoOpen>]
|
|
||||||
module JobsJobsJobs.Api.Extensions
|
|
||||||
|
|
||||||
open System
|
|
||||||
|
|
||||||
// fsharplint:disable MemberNames
|
|
||||||
|
|
||||||
/// Extensions for the DateTime object
|
|
||||||
type DateTime with
|
|
||||||
|
|
||||||
/// Constant for the ticks at the Unix epoch
|
|
||||||
member __.UnixEpochTicks = (DateTime (1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).Ticks
|
|
||||||
|
|
||||||
/// Convert this DateTime to a JavaScript milliseconds-past-the-epoch value
|
|
||||||
member this.toMillis () =
|
|
||||||
(this.ToUniversalTime().Ticks - this.UnixEpochTicks) / 10000L |> Domain.Millis
|
|
||||||
|
|
||||||
|
|
||||||
open FSharp.Json
|
|
||||||
open Suave
|
|
||||||
open System.Text
|
|
||||||
|
|
||||||
/// Extensions for Suave's context
|
|
||||||
type HttpContext with
|
|
||||||
|
|
||||||
/// Deserialize an object from a JSON request body
|
|
||||||
member this.fromJsonBody<'T> () =
|
|
||||||
try
|
|
||||||
Encoding.UTF8.GetString this.request.rawForm |> Json.deserialize<'T> |> Ok
|
|
||||||
with x ->
|
|
||||||
Error x
|
|
|
@ -1,162 +0,0 @@
|
||||||
module JobsJobsJobs.Api.Handlers
|
|
||||||
|
|
||||||
open Data
|
|
||||||
open Domain
|
|
||||||
open FSharp.Json
|
|
||||||
open Suave
|
|
||||||
open Suave.Operators
|
|
||||||
open System
|
|
||||||
|
|
||||||
[<AutoOpen>]
|
|
||||||
module private Internal =
|
|
||||||
|
|
||||||
open Suave.Writers
|
|
||||||
|
|
||||||
/// Read the JWT and get the authorized user ID
|
|
||||||
let authorizedUser : WebPart =
|
|
||||||
fun ctx ->
|
|
||||||
match ctx.request.header "Authorization" with
|
|
||||||
| Choice1Of2 bearer ->
|
|
||||||
let token = (bearer.Split " ").[1]
|
|
||||||
match Auth.validateJwt token with
|
|
||||||
| Ok citizenId ->
|
|
||||||
setUserData "citizenId" citizenId ctx
|
|
||||||
| Error err ->
|
|
||||||
RequestErrors.BAD_REQUEST err ctx
|
|
||||||
| Choice2Of2 _ ->
|
|
||||||
RequestErrors.BAD_REQUEST "Authorization header must be specified" ctx
|
|
||||||
|
|
||||||
/// Send a JSON response
|
|
||||||
let json x =
|
|
||||||
Successful.OK (Json.serialize x)
|
|
||||||
>=> setMimeType "application/json; charset=utf-8"
|
|
||||||
|
|
||||||
/// Get the current citizen ID from the context
|
|
||||||
let currentCitizenId ctx =
|
|
||||||
ctx.userState.["citizenId"] :?> CitizenId
|
|
||||||
|
|
||||||
|
|
||||||
/// Handler to return the Vue application
|
|
||||||
module Vue =
|
|
||||||
|
|
||||||
/// The application index page
|
|
||||||
let app = Files.file "wwwroot/index.html"
|
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for error conditions
|
|
||||||
module Error =
|
|
||||||
|
|
||||||
open Suave.Logging
|
|
||||||
open Suave.Logging.Message
|
|
||||||
|
|
||||||
/// Handle errors
|
|
||||||
let error (ex : Exception) msg =
|
|
||||||
fun ctx ->
|
|
||||||
seq {
|
|
||||||
yield string ctx.request.url
|
|
||||||
match msg with
|
|
||||||
| "" -> ()
|
|
||||||
| _ -> yield " ~ "; yield msg
|
|
||||||
yield "\n"; yield (ex.GetType().Name); yield ": "; yield ex.Message; yield "\n"
|
|
||||||
yield ex.StackTrace
|
|
||||||
}
|
|
||||||
|> Seq.reduce (+)
|
|
||||||
|> (eventX >> ctx.runtime.logger.error)
|
|
||||||
ServerErrors.INTERNAL_ERROR (Json.serialize {| error = ex.Message |}) ctx
|
|
||||||
|
|
||||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
|
||||||
let notFound : WebPart =
|
|
||||||
fun ctx ->
|
|
||||||
[ "/user"; "/jobs" ]
|
|
||||||
|> List.filter ctx.request.path.StartsWith
|
|
||||||
|> List.length
|
|
||||||
|> function
|
|
||||||
| 0 -> RequestErrors.NOT_FOUND "err" ctx
|
|
||||||
| _ -> Vue.app ctx
|
|
||||||
|
|
||||||
|
|
||||||
/// /api/citizen route handlers
|
|
||||||
module Citizen =
|
|
||||||
|
|
||||||
open ViewModels.Citizen
|
|
||||||
|
|
||||||
/// Either add the user, or update their display name and last seen date
|
|
||||||
let establishCitizen result profile = async {
|
|
||||||
match result with
|
|
||||||
| Some citId ->
|
|
||||||
match! Citizens.update citId profile.displayName with
|
|
||||||
| Ok _ -> return Ok citId
|
|
||||||
| Error exn -> return Error exn
|
|
||||||
| None ->
|
|
||||||
let now = DateTime.Now.toMillis ()
|
|
||||||
let! citId = CitizenId.create ()
|
|
||||||
match! Citizens.add
|
|
||||||
{ id = citId
|
|
||||||
naUser = profile.username
|
|
||||||
displayName = profile.displayName
|
|
||||||
profileUrl = profile.url
|
|
||||||
joinedOn = now
|
|
||||||
lastSeenOn = now
|
|
||||||
} with
|
|
||||||
| Ok _ -> return Ok citId
|
|
||||||
| Error exn -> return Error exn
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST: /api/citizen/log-on
|
|
||||||
let logOn : WebPart =
|
|
||||||
fun ctx -> async {
|
|
||||||
match ctx.fromJsonBody<LogOn> () with
|
|
||||||
| Ok data ->
|
|
||||||
match! Auth.verifyWithMastodon data.accessToken with
|
|
||||||
| Ok profile ->
|
|
||||||
match! Citizens.findIdByNaUser profile.username with
|
|
||||||
| Ok idResult ->
|
|
||||||
match! establishCitizen idResult profile with
|
|
||||||
| Ok citizenId ->
|
|
||||||
match! Auth.createJwt citizenId with
|
|
||||||
| Ok jwt -> return! json {| accessToken = jwt |} ctx
|
|
||||||
| Error exn -> return! Error.error exn "Could not issue access token" ctx
|
|
||||||
| Error exn -> return! Error.error exn "Could not update Jobs, Jobs, Jobs database" ctx
|
|
||||||
| Error exn -> return! Error.error exn "Token not received" ctx
|
|
||||||
| Error msg -> return! Error.error (exn msg) "Could not authenticate with NAS" ctx
|
|
||||||
| Error exn -> return! Error.error exn "Token not received" ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// /api/profile route handlers
|
|
||||||
module Profile =
|
|
||||||
|
|
||||||
/// GET: /api/profile
|
|
||||||
let get citizenId : WebPart =
|
|
||||||
authorizedUser
|
|
||||||
>=> fun ctx -> async {
|
|
||||||
match (match citizenId with "" -> Ok (currentCitizenId ctx) | _ -> CitizenId.tryParse citizenId) with
|
|
||||||
| Ok citId ->
|
|
||||||
match! Profiles.tryFind citId with
|
|
||||||
| Ok (Some profile) -> return! json profile ctx
|
|
||||||
| Ok None -> return! Successful.NO_CONTENT ctx
|
|
||||||
| Error exn -> return! Error.error exn "Cannot retrieve profile" ctx
|
|
||||||
| Error _ -> return! Error.notFound ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
open Suave.Filters
|
|
||||||
|
|
||||||
/// The routes for Jobs, Jobs, Jobs
|
|
||||||
let webApp =
|
|
||||||
choose
|
|
||||||
[ GET >=> choose
|
|
||||||
[ pathScan "/api/profile/%s" Profile.get
|
|
||||||
path "/api/profile" >=> Profile.get ""
|
|
||||||
path "/" >=> Vue.app
|
|
||||||
Files.browse "wwwroot/"
|
|
||||||
]
|
|
||||||
// PUT >=> choose
|
|
||||||
// [ ]
|
|
||||||
// PATCH >=> choose
|
|
||||||
// [ ]
|
|
||||||
POST >=> choose
|
|
||||||
[ path "/api/citizen/log-on" >=> Citizen.logOn
|
|
||||||
]
|
|
||||||
Error.notFound
|
|
||||||
]
|
|
|
@ -1,31 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="Domain.fs" />
|
|
||||||
<Compile Include="Extensions.fs" />
|
|
||||||
<Compile Include="Data.fs" />
|
|
||||||
<Compile Include="ViewModels.fs" />
|
|
||||||
<Compile Include="Auth.fs" />
|
|
||||||
<Compile Include="Handlers.fs" />
|
|
||||||
<Compile Include="Program.fs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="FSharp.Json" Version="0.4.0" />
|
|
||||||
<PackageReference Include="JWT" Version="7.2.1" />
|
|
||||||
<PackageReference Include="Markdig" Version="0.21.1" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.8" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.8" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="3.1.8" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" />
|
|
||||||
<PackageReference Include="Nanoid" Version="2.1.0" />
|
|
||||||
<PackageReference Include="Npgsql.FSharp" Version="3.7.0" />
|
|
||||||
<PackageReference Include="Suave" Version="2.5.6" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
|
@ -1,18 +0,0 @@
|
||||||
module JobsJobsJobs.Api.App
|
|
||||||
|
|
||||||
// Learn more about F# at http://fsharp.org
|
|
||||||
|
|
||||||
open System
|
|
||||||
open Suave
|
|
||||||
|
|
||||||
[<EntryPoint>]
|
|
||||||
let main argv =
|
|
||||||
{ defaultConfig with
|
|
||||||
bindings = [ HttpBinding.createSimple HTTP "127.0.0.1" 3002; HttpBinding.createSimple HTTP "::1" 3002 ]
|
|
||||||
// errorHandler = Handlers.Error.error
|
|
||||||
// serverKey = config.serverKey
|
|
||||||
// cookieSerialiser = FSharpJsonCookieSerialiser()
|
|
||||||
// homeFolder = Some "./wwwroot/"
|
|
||||||
}
|
|
||||||
|> (flip startWebServer) Handlers.webApp
|
|
||||||
0
|
|
|
@ -1,27 +0,0 @@
|
||||||
module JobsJobsJobs.Api.ViewModels
|
|
||||||
|
|
||||||
// fsharplint:disable RecordFieldNames MemberNames
|
|
||||||
|
|
||||||
/// View models uses for /api/citizen routes
|
|
||||||
module Citizen =
|
|
||||||
|
|
||||||
open FSharp.Json
|
|
||||||
|
|
||||||
/// The payload for the log on route
|
|
||||||
type LogOn = {
|
|
||||||
/// The access token obtained from No Agenda Social
|
|
||||||
accessToken : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The variables we need from the account information we get from No Agenda Social
|
|
||||||
type MastodonAccount = {
|
|
||||||
/// The user name (what we store as naUser)
|
|
||||||
username : string
|
|
||||||
/// The account name; will be the same as username for local (non-federated) accounts
|
|
||||||
acct : string
|
|
||||||
/// The user's display name as it currently shows on No Agenda Social
|
|
||||||
[<JsonField "display_name">]
|
|
||||||
displayName : string
|
|
||||||
/// The user's profile URL
|
|
||||||
url : string
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
> 1%
|
|
||||||
last 2 versions
|
|
||||||
not dead
|
|
|
@ -1,18 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true
|
|
||||||
},
|
|
||||||
'extends': [
|
|
||||||
'plugin:vue/vue3-essential',
|
|
||||||
'eslint:recommended',
|
|
||||||
'@vue/typescript'
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
parser: '@typescript-eslint/parser'
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
|
|
||||||
}
|
|
||||||
}
|
|
26
src/jobs-jobs-jobs/.gitignore
vendored
26
src/jobs-jobs-jobs/.gitignore
vendored
|
@ -1,26 +0,0 @@
|
||||||
.DS_Store
|
|
||||||
node_modules
|
|
||||||
/dist
|
|
||||||
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Log files
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
dev-client.txt
|
|
||||||
src/auth/config.ts
|
|
|
@ -1,5 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
'@vue/cli-plugin-babel/preset'
|
|
||||||
]
|
|
||||||
}
|
|
29663
src/jobs-jobs-jobs/package-lock.json
generated
29663
src/jobs-jobs-jobs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -1,31 +0,0 @@
|
||||||
{
|
|
||||||
"name": "jobs-jobs-jobs",
|
|
||||||
"version": "0.8.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"serve": "vue-cli-service serve --port 3005",
|
|
||||||
"build": "vue-cli-service build --modern",
|
|
||||||
"lint": "vue-cli-service lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"core-js": "^3.6.5",
|
|
||||||
"vue": "^3.0.0-0",
|
|
||||||
"vue-class-component": "^8.0.0-0",
|
|
||||||
"vue-router": "^4.0.0-0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@typescript-eslint/eslint-plugin": "^4.8.0",
|
|
||||||
"@typescript-eslint/parser": "^4.8.0",
|
|
||||||
"@vue/cli-plugin-babel": "~4.5.0",
|
|
||||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
|
||||||
"@vue/cli-plugin-router": "^4.5.4",
|
|
||||||
"@vue/cli-plugin-typescript": "^4.5.4",
|
|
||||||
"@vue/cli-service": "~4.5.0",
|
|
||||||
"@vue/compiler-sfc": "^3.0.0-0",
|
|
||||||
"@vue/eslint-config-typescript": "^7.0.0",
|
|
||||||
"babel-eslint": "^10.1.0",
|
|
||||||
"eslint": "^6.0.0",
|
|
||||||
"eslint-plugin-vue": "^7.0.0-0",
|
|
||||||
"typescript": "~4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
|
@ -1,17 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
|
||||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
|
||||||
</noscript>
|
|
||||||
<div id="app"></div>
|
|
||||||
<!-- built files will be auto injected -->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
Binary file not shown.
Binary file not shown.
|
@ -1,44 +0,0 @@
|
||||||
<template>
|
|
||||||
<div id="app">
|
|
||||||
<div id="nav">
|
|
||||||
<router-link to="/">Home</router-link> |
|
|
||||||
<router-link to="/about">About</router-link> |
|
|
||||||
<a href="#" @click.stop="authorize">Log On</a>
|
|
||||||
</div>
|
|
||||||
<router-view/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { authorize } from '@/auth'
|
|
||||||
export default {
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
authorize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#app {
|
|
||||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
text-align: center;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
#nav {
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#nav a {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
#nav a.router-link-exact-active {
|
|
||||||
color: #42b983;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,100 +0,0 @@
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Profile } from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jobs, Jobs, Jobs API interface
|
|
||||||
*
|
|
||||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
|
||||||
* @version 1
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** The base URL for the Jobs, Jobs, Jobs API */
|
|
||||||
const API_URL = `${location.protocol}//${location.host}/api`
|
|
||||||
|
|
||||||
/** Local storage key for the Jobs, Jobs, Jobs access token */
|
|
||||||
const JJJ_TOKEN = 'jjj-token'
|
|
||||||
|
|
||||||
/** HTTP status for "No Content"; used by the API to indicate a valid query with no results vs. 404 (invalid URL) */
|
|
||||||
const NO_CONTENT = 204
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A holder for the JSON Web Token (JWT) returned from Jobs, Jobs, Jobs
|
|
||||||
*/
|
|
||||||
class JwtHolder {
|
|
||||||
private jwt: string | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current token (refreshing from local storage if needed).
|
|
||||||
*/
|
|
||||||
get token(): string | null {
|
|
||||||
if (!this.jwt) this.jwt = localStorage.getItem(JJJ_TOKEN)
|
|
||||||
return this.jwt
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the current token (both here and in local storage).
|
|
||||||
*
|
|
||||||
* @param tokn The token to be set
|
|
||||||
*/
|
|
||||||
set token(tokn: string | null) {
|
|
||||||
if (tokn) localStorage.setItem(JJJ_TOKEN, tokn); else localStorage.removeItem(JJJ_TOKEN)
|
|
||||||
this.jwt = tokn
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasToken(): boolean {
|
|
||||||
return this.token !== null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The user's current JWT */
|
|
||||||
const jwt = new JwtHolder()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute an HTTP request using the fetch API.
|
|
||||||
*
|
|
||||||
* @param url The URL to which the request should be made
|
|
||||||
* @param method The HTTP method for the request (defaults to GET)
|
|
||||||
* @param payload The payload to send along with the request (defaults to none)
|
|
||||||
* @returns The response (if the request is successful)
|
|
||||||
* @throws An error (if the request is unsuccessful)
|
|
||||||
*/
|
|
||||||
export async function doRequest(url: string, method?: string, payload?: string) {
|
|
||||||
const headers: [string, string][] = [ [ 'Content-Type', 'application/json' ] ]
|
|
||||||
if (jwt.hasToken) headers.push([ 'Authorization', `Bearer ${jwt.token}`])
|
|
||||||
const options: RequestInit = {
|
|
||||||
method: method || 'GET',
|
|
||||||
headers: headers
|
|
||||||
}
|
|
||||||
if (method === 'POST' && payload) options.body = payload
|
|
||||||
const actualUrl = (options.method === 'GET' && payload) ? `url?${payload}` : url
|
|
||||||
const resp = await fetch(actualUrl, options)
|
|
||||||
if (resp.ok) return resp
|
|
||||||
throw new Error(`Error executing API request: ${resp.status} ~ ${resp.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorize with Jobs, Jobs, Jobs using a No Agenda Social token.
|
|
||||||
*
|
|
||||||
* @param nasToken The token obtained from No Agenda Social
|
|
||||||
* @returns True if it is successful
|
|
||||||
*/
|
|
||||||
export async function jjjAuthorize(nasToken: string): Promise<boolean> {
|
|
||||||
const resp = await doRequest(`${API_URL}/citizen/log-on`, 'POST', JSON.stringify({ accessToken: nasToken }))
|
|
||||||
const jjjToken = await resp.json()
|
|
||||||
jwt.token = jjjToken.accessToken
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve the employment profile for the current user.
|
|
||||||
*
|
|
||||||
* @returns The profile if it is found; undefined otherwise
|
|
||||||
*/
|
|
||||||
export async function userProfile(): Promise<Profile | undefined> {
|
|
||||||
const resp = await doRequest(`${API_URL}/profile`)
|
|
||||||
if (resp.status === NO_CONTENT) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const profile = await resp.json()
|
|
||||||
return profile as Profile
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
/**
|
|
||||||
* Client-side Type Definitions for Jobs, Jobs, Jobs.
|
|
||||||
*
|
|
||||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
|
||||||
* @version 1
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A continent (one of the 7).
|
|
||||||
*/
|
|
||||||
export interface Continent {
|
|
||||||
/** The ID of the continent */
|
|
||||||
id: string
|
|
||||||
|
|
||||||
/** The name of the continent */
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A user's employment profile.
|
|
||||||
*/
|
|
||||||
export interface Profile {
|
|
||||||
/** The ID of the user to whom the profile applies */
|
|
||||||
citizenId: string
|
|
||||||
|
|
||||||
/** Whether this user is actively seeking employment */
|
|
||||||
seekingEmployment: boolean
|
|
||||||
|
|
||||||
/** Whether information from this profile should appear in the public anonymous list of available skills */
|
|
||||||
isPublic: boolean
|
|
||||||
|
|
||||||
/** The continent on which the user is seeking employment */
|
|
||||||
continent: Continent
|
|
||||||
|
|
||||||
/** The region within that continent where the user would prefer to work */
|
|
||||||
region: string
|
|
||||||
|
|
||||||
/** Whether the user is looking for remote work */
|
|
||||||
remoteWork: boolean
|
|
||||||
|
|
||||||
/** Whether the user is looking for full-time work */
|
|
||||||
fullTime: boolean
|
|
||||||
|
|
||||||
/** The user's professional biography */
|
|
||||||
biography: string
|
|
||||||
|
|
||||||
/** When this profile was last updated */
|
|
||||||
lastUpdatedOn: number
|
|
||||||
|
|
||||||
/** The user's experience */
|
|
||||||
experience?: string
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 6.7 KiB |
|
@ -1,61 +0,0 @@
|
||||||
/**
|
|
||||||
* Authentication and Authorization.
|
|
||||||
*
|
|
||||||
* This contains authentication and authorization functions needed to permit secure access to the application.
|
|
||||||
*
|
|
||||||
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
|
||||||
* @version 1
|
|
||||||
*/
|
|
||||||
import { CLIENT_SECRET } from './config'
|
|
||||||
import { doRequest, jjjAuthorize } from '../api'
|
|
||||||
|
|
||||||
/** Client ID for Jobs, Jobs, Jobs */
|
|
||||||
const CLIENT_ID = '6Ook3LBff00dOhyBgbf4eXSqIpAroK72aioIdGaDqxs'
|
|
||||||
|
|
||||||
/** No Agenda Social's base URL */
|
|
||||||
const NAS_URL = 'https://noagendasocial.com/'
|
|
||||||
|
|
||||||
/** The base URL for Jobs, Jobs, Jobs */
|
|
||||||
const JJJ_URL = `${location.protocol}//${location.host}/`
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorize access to this application from No Agenda Social.
|
|
||||||
*
|
|
||||||
* This is the first step in a 2-step log on process; this step will prompt the user to authorize Jobs, Jobs, Jobs to
|
|
||||||
* get information from their No Agenda Social profile. Once that authorization has been granted, we receive an access
|
|
||||||
* code which we can use to request a full token.
|
|
||||||
*/
|
|
||||||
export function authorize() {
|
|
||||||
const params = new URLSearchParams([
|
|
||||||
[ 'client_id', CLIENT_ID ],
|
|
||||||
[ 'scope', 'read' ],
|
|
||||||
[ 'redirect_uri', `${JJJ_URL}user/authorized` ],
|
|
||||||
[ 'response_type', 'code' ]
|
|
||||||
]).toString()
|
|
||||||
location.assign(`${NAS_URL}oauth/authorize?${params}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log on a user with an authorzation code.
|
|
||||||
*
|
|
||||||
* @param authCode The authorization code obtained from No Agenda Social
|
|
||||||
*/
|
|
||||||
export async function logOn(authCode: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
const resp = await doRequest(`${NAS_URL}oauth/token`, 'POST',
|
|
||||||
JSON.stringify({
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
client_secret: CLIENT_SECRET,
|
|
||||||
redirect_uri: `${JJJ_URL}user/authorized`,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code: authCode,
|
|
||||||
scope: 'read'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const token = await resp.json()
|
|
||||||
await jjjAuthorize(token.access_token)
|
|
||||||
return ''
|
|
||||||
} catch (e) {
|
|
||||||
return `${e}`
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,75 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="hello">
|
|
||||||
<h1>
|
|
||||||
Jobs, Jobs, Jobs<br>
|
|
||||||
<small><small><small><em>
|
|
||||||
(and Jobs - <a class="audio" @click="playJobs">Let's Vote for Jobs!</a>)
|
|
||||||
</em></small></small></small>
|
|
||||||
</h1>
|
|
||||||
<p>
|
|
||||||
Future home of No Agenda Jobs, where citizens of Gitmo Nation can assist one another in finding or enhancing
|
|
||||||
their employment. This will enable them to continue providing value for value to Adam and John, as they continue
|
|
||||||
their work deconstructing the misinformation that passes for news on a day-to-day basis.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Do you not understand the terms in the paragraph above? No worries; just head over to
|
|
||||||
<a href="https://noagendashow.net">The Best Podcast in the Universe</a> <em><a class="audio" @click="playTrue">(it's true!)</a></em> and find out what
|
|
||||||
you’re missing.
|
|
||||||
</p>
|
|
||||||
<audio ref="jobsAudio">
|
|
||||||
<source src="/pelosi-jobs.mp3">
|
|
||||||
</audio>
|
|
||||||
<audio ref="trueAudio">
|
|
||||||
<source src="/thats-true.mp3">
|
|
||||||
</audio>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
msg: String
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const jobsAudio = ref(null)
|
|
||||||
const trueAudio = ref(null)
|
|
||||||
|
|
||||||
const playJobs = () => jobsAudio.value.play()
|
|
||||||
const playTrue = () => trueAudio.value.play()
|
|
||||||
return {
|
|
||||||
jobsAudio,
|
|
||||||
trueAudio,
|
|
||||||
playJobs,
|
|
||||||
playTrue
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 40px 0 0;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #42b983;
|
|
||||||
}
|
|
||||||
a.audio {
|
|
||||||
color: inherit;
|
|
||||||
border-bottom: dotted 1px lightgray;
|
|
||||||
}
|
|
||||||
a.audio:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { createApp } from 'vue'
|
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
|
|
||||||
createApp(App).use(router).mount('#app')
|
|
|
@ -1,39 +0,0 @@
|
||||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
|
||||||
|
|
||||||
import Home from '../views/Home.vue'
|
|
||||||
import Welcome from '../views/citizen/Welcome.vue'
|
|
||||||
import Authorized from '../views/user/Authorized.vue'
|
|
||||||
|
|
||||||
const routes: Array<RouteRecordRaw> = [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
name: 'Home',
|
|
||||||
component: Home
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/about',
|
|
||||||
name: 'About',
|
|
||||||
// route level code-splitting
|
|
||||||
// this generates a separate chunk (about.[hash].js) for this route
|
|
||||||
// which is lazy-loaded when the route is visited.
|
|
||||||
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/user/authorized',
|
|
||||||
name: 'Authorized',
|
|
||||||
component: Authorized,
|
|
||||||
props: (route) => ({ code: route.query.code })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/citizen/welcome',
|
|
||||||
name: 'Welcome',
|
|
||||||
component: Welcome
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(process.env.BASE_URL),
|
|
||||||
routes
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
5
src/jobs-jobs-jobs/src/shims-vue.d.ts
vendored
5
src/jobs-jobs-jobs/src/shims-vue.d.ts
vendored
|
@ -1,5 +0,0 @@
|
||||||
declare module '*.vue' {
|
|
||||||
import { defineComponent } from 'vue'
|
|
||||||
const component: ReturnType<typeof defineComponent>
|
|
||||||
export default component
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="about">
|
|
||||||
<h1>This is an about page</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -1,75 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="hello">
|
|
||||||
<h1>
|
|
||||||
Jobs, Jobs, Jobs<br>
|
|
||||||
<small><small><small><em>
|
|
||||||
(and Jobs - <a class="audio" @click="playJobs">Let's Vote for Jobs!</a>)
|
|
||||||
</em></small></small></small>
|
|
||||||
</h1>
|
|
||||||
<p>
|
|
||||||
Future home of No Agenda Jobs, where citizens of Gitmo Nation can assist one another in finding or enhancing
|
|
||||||
their employment. This will enable them to continue providing value for value to Adam and John, as they continue
|
|
||||||
their work deconstructing the misinformation that passes for news on a day-to-day basis.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Do you not understand the terms in the paragraph above? No worries; just head over to
|
|
||||||
<a href="https://noagendashow.net">The Best Podcast in the Universe</a> <em><a class="audio" @click="playTrue">(it's true!)</a></em> and find out what
|
|
||||||
you’re missing.
|
|
||||||
</p>
|
|
||||||
<audio ref="jobsAudio">
|
|
||||||
<source src="/pelosi-jobs.mp3">
|
|
||||||
</audio>
|
|
||||||
<audio ref="trueAudio">
|
|
||||||
<source src="/thats-true.mp3">
|
|
||||||
</audio>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
msg: String
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const jobsAudio = ref(null)
|
|
||||||
const trueAudio = ref(null)
|
|
||||||
|
|
||||||
const playJobs = () => jobsAudio.value.play()
|
|
||||||
const playTrue = () => trueAudio.value.play()
|
|
||||||
return {
|
|
||||||
jobsAudio,
|
|
||||||
trueAudio,
|
|
||||||
playJobs,
|
|
||||||
playTrue
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 40px 0 0;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #42b983;
|
|
||||||
}
|
|
||||||
a.audio {
|
|
||||||
color: inherit;
|
|
||||||
border-bottom: dotted 1px lightgray;
|
|
||||||
}
|
|
||||||
a.audio:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,27 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<p>Welcome!</p>
|
|
||||||
<p>Profile Established: <strong><span v-if="profile?.value">Yes</span><span v-else>No</span></strong></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { onBeforeMount, ref } from 'vue'
|
|
||||||
|
|
||||||
import { userProfile } from '@/api'
|
|
||||||
import { Profile } from '@/api/types'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
setup() {
|
|
||||||
const profile = ref<Profile | undefined>(undefined)
|
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
|
||||||
profile.value = await userProfile()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
profile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,30 +0,0 @@
|
||||||
<template>
|
|
||||||
<p>{{message}}</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { logOn } from '@/auth'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
code: {
|
|
||||||
type: String
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const message = ref('Logging you on with No Agenda Social...')
|
|
||||||
return {
|
|
||||||
message
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async mounted() {
|
|
||||||
const result = await logOn(this.code)
|
|
||||||
if (result === '') {
|
|
||||||
this.$router.push('/citizen/welcome')
|
|
||||||
} else {
|
|
||||||
this.message = `Unable to log on via No Agenda Social:\n${result}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "esnext",
|
|
||||||
"module": "esnext",
|
|
||||||
"strict": false,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"importHelpers": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"types": [
|
|
||||||
"webpack-env"
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lib": [
|
|
||||||
"esnext",
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"scripthost"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.tsx",
|
|
||||||
"src/**/*.vue",
|
|
||||||
"tests/**/*.ts",
|
|
||||||
"tests/**/*.tsx"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user