Convert to Blazor #6

Merged
danieljsummers merged 7 commits from blazor into main 2020-12-19 02:46:28 +00:00
34 changed files with 0 additions and 31062 deletions
Showing only changes of commit ea8edb2937 - Show all commits

View File

@ -1,3 +0,0 @@
obj/
bin/
appsettings.*.json

View File

@ -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"

View File

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

View File

@ -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()

View File

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

View File

@ -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
]

View File

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

View File

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

View File

@ -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
}

View File

@ -1,3 +0,0 @@
{
}

View File

@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

View File

@ -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'
}
}

View File

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

View File

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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
}

View File

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

View File

@ -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}`
}
}

View File

@ -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&rsquo;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>

View File

@ -1,5 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')

View File

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

View File

@ -1,5 +0,0 @@
declare module '*.vue' {
import { defineComponent } from 'vue'
const component: ReturnType<typeof defineComponent>
export default component
}

View File

@ -1,5 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View File

@ -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&rsquo;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>

View File

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

View File

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

View File

@ -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"
]
}