Env swap #21

Merged
danieljsummers merged 30 commits from env-swap into help-wanted 2021-08-10 03:23:50 +00:00
17 changed files with 534 additions and 160 deletions
Showing only changes of commit e8696e0e94 - Show all commits

View File

@ -22,6 +22,7 @@ let configureApp (app : IApplicationBuilder) =
e.MapFallbackToFile "index.html" |> ignore)
|> ignore
open Newtonsoft.Json
open NodaTime
open Microsoft.AspNetCore.Authentication.JwtBearer
open Microsoft.Extensions.Configuration
@ -36,6 +37,10 @@ let configureServices (svc : IServiceCollection) =
svc.AddLogging () |> ignore
svc.AddCors () |> ignore
let jsonCfg = JsonSerializerSettings ()
Data.Converters.all () |> List.iter jsonCfg.Converters.Add
svc.AddSingleton<Json.ISerializer> (NewtonsoftJson.Serializer jsonCfg) |> ignore
let svcs = svc.BuildServiceProvider ()
let cfg = svcs.GetRequiredService<IConfiguration> ()

View File

@ -1,4 +1,4 @@
import { Count, LogOnSuccess, Profile } from './types'
import { Citizen, Continent, Count, LogOnSuccess, Profile } from './types'
/**
* Create a URL that will access the API
@ -39,6 +39,46 @@ export default {
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: 'GET', mode: 'cors' })
if (resp.status === 200) return await resp.json() as LogOnSuccess
return `Error logging on - ${await resp.text()}`
},
/**
* Retrieve a citizen by their ID
*
* @param id The citizen ID to be retrieved
* @param user The currently logged-on user
* @returns The citizen, or an error
*/
retrieve: async (id : string, user : LogOnSuccess) : Promise<Citizen | string> => {
const resp = await fetch(apiUrl(`citizen/get/${id}`), reqInit('GET', user))
if (resp.status === 200) return await resp.json() as Citizen
return `Error retrieving citizen ${id} - ${await resp.text()}`
},
/**
* Delete the current citizen's entire Jobs, Jobs, Jobs record
*
* @param user The currently logged-on user
* @returns Undefined if successful, an error if not
*/
delete: async (user : LogOnSuccess) : Promise<string | undefined> => {
const resp = await fetch(apiUrl('citizen'), reqInit('DELETE', user))
if (resp.status === 200) return undefined
return `Error deleting citizen - ${await resp.text()}`
}
},
/** API functions for continents */
continent: {
/**
* Get all continents
*
* @returns All continents, or an error
*/
all: async () : Promise<Continent[] | string> => {
const resp = await fetch(apiUrl('continent/all'), { method: 'GET' })
if (resp.status === 200) return await resp.json() as Continent[]
return `Error retrieving continents - ${await resp.text()}`
}
},
@ -71,7 +111,19 @@ export default {
const result = await resp.json() as Count
return result.count
}
return `Error counting profiles = ${await resp.text()}`
return `Error counting profiles - ${await resp.text()}`
},
/**
* Delete the current user's employment profile
*
* @param user The currently logged-on user
* @returns Undefined if successful, an error if not
*/
delete: async (user : LogOnSuccess) : Promise<string | undefined> => {
const resp = await fetch(apiUrl('profile'), reqInit('DELETE', user))
if (resp.status === 200) return undefined
return `Error deleting profile - ${await resp.text()}`
}
}
}

View File

@ -1,4 +1,30 @@
/** A user of Jobs, Jobs, Jobs */
export interface Citizen {
/** The ID of the user */
id : string
/** The handle by which the user is known on Mastodon */
naUser : string
/** The user's display name from Mastodon (updated every login) */
displayName : string | undefined
/** The user's real name */
realName : string | undefined
/** The URL for the user's Mastodon profile */
profileUrl : string
/** When the user joined Jobs, Jobs, Jobs */
joinedOn : number
/** When the user last logged in */
lastSeenOn : number
}
/** A continent */
export interface Continent {
/** The ID of the continent */
id : string
/** The name of the continent */
name : string
}
/** A successful logon */
export interface LogOnSuccess {
/** The JSON Web Token (JWT) to use for API access */

View File

@ -0,0 +1,52 @@
<template>
<nav class="nav nav-pills">
<a href="#" class="nav-link @MarkdownClass" @click.prevent="showMarkdown">Markdown</a>
<a href="#" class="nav-link @PreviewClass" @click.prevent="showPreview">Preview</a>
</nav>
<section v-if="preview" class="preview" v-html="previewHtml">
</section>
<textarea v-else :id="id" class="form-control" rows="10" v-text="text"
@input="$emit('update:text', $event.target.value)"></textarea>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
name: 'MarkdownEditor',
props: {
id: {
type: String,
required: true
},
text: {
type: String,
required: true
}
},
emits: ['update:text'],
setup (props) {
/** Whether to show the Markdown preview */
const preview = ref(false)
/** The HTML rendered for preview purposes */
const previewHtml = ref('')
/** Show the Markdown source */
const showMarkdown = () => { preview.value = false }
/** Show the Markdown preview */
const showPreview = () => {
// TODO: render markdown as HTML
previewHtml.value = props.text
preview.value = true
}
return {
preview,
previewHtml,
showMarkdown,
showPreview
}
}
})
</script>

View File

@ -0,0 +1,48 @@
<template>
<div v-if="loading">Loading&hellip;</div>
<template v-else>
<div v-if="errors.length > 0">
<p v-for="(error, idx) in errors" :key="idx">{{error}}</p>
</div>
<slot v-else></slot>
</template>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
export default defineComponent({
name: 'LoadData',
props: {
load: {
type: Function,
required: true
}
},
setup (props) {
/** Type the input function */
const func = props.load as (errors: string[]) => Promise<unknown>
/** Errors encountered during loading */
const errors : string[] = []
/** Whether we are currently loading data */
const loading = ref(true)
/** Call the data load function */
const loadData = async () => {
try {
await func(errors)
} finally {
loading.value = false
}
}
onMounted(loadData)
return {
loading,
errors
}
}
})
</script>

View File

@ -17,7 +17,42 @@ const routes: Array<RouteRecordRaw> = [
},
{
path: '/citizen/profile',
component: () => import(/* webpackChurchName: "profedit" */ '../views/citizen/EditProfile.vue')
component: () => import(/* webpackChunkName: "profedit" */ '../views/citizen/EditProfile.vue')
},
{
path: '/citizen/log-off',
component: () => import(/* webpackChunkName: "logoff" */ '../views/citizen/LogOff.vue')
},
// Profile URLs
{
path: '/profile/view/:id',
component: () => import(/* webpackChunkName: "profview" */ '../views/profile/ProfileView.vue')
},
{
path: '/profile/search',
component: () => import(/* webpackChunkName: "profview" */ '../views/profile/ProfileSearch.vue')
},
{
path: '/profile/seeking',
component: () => import(/* webpackChunkName: "seeking" */ '../views/profile/Seeking.vue')
},
// "So Long" URLs
{
path: '/so-long/options',
component: () => import(/* webpackChunkName: "so-long" */ '../views/so-long/DeletionOptions.vue')
},
{
path: '/so-long/success',
component: () => import(/* webpackChunkName: "so-long" */ '../views/so-long/DeletionSuccess.vue')
},
// Success Story URLs
{
path: '/success-story/list',
component: () => import(/* webpackChunkName: "succview" */ '../views/success-story/StoryList.vue')
},
{
path: '/success-story/add',
component: () => import(/* webpackChunkName: "succedit" */ '../views/success-story/StoryAdd.vue')
}
]

View File

@ -1,11 +1,12 @@
import { InjectionKey } from 'vue'
import { createStore, Store, useStore as baseUseStore } from 'vuex'
import api, { LogOnSuccess } from '../api'
import api, { Continent, LogOnSuccess } from '../api'
/** The state tracked by the application */
export interface State {
user: LogOnSuccess | undefined
logOnState: string
continents: Continent[]
}
/** An injection key to identify this state with Vue */
@ -20,15 +21,22 @@ export default createStore({
state: () : State => {
return {
user: undefined,
logOnState: 'Logging you on with No Agenda Social...'
logOnState: 'Logging you on with No Agenda Social...',
continents: []
}
},
mutations: {
setUser (state, user: LogOnSuccess) {
state.user = user
},
clearUser (state) {
state.user = undefined
},
setLogOnState (state, message) {
state.logOnState = message
},
setContinents (state, continents : Continent[]) {
state.continents = continents
}
},
actions: {
@ -39,6 +47,15 @@ export default createStore({
} else {
commit('setUser', logOnResult)
}
},
async ensureContinents ({ state, commit }) {
if (state.continents.length > 0) return
const theSeven = await api.continent.all()
if (typeof theSeven === 'string') {
console.error(theSeven)
} else {
commit('setContinents', theSeven)
}
}
},
modules: {

View File

@ -1,6 +1,6 @@
<template>
<h3>Welcome, {{user.name}}</h3>
<load-data :load="retrieveData">
<template v-if="profile">
<p>
Your employment profile was last updated {{profile.lastUpdatedOn}}. Your profile currently lists
@ -24,6 +24,7 @@
employment profile<span v-if="profileCount !== 1">s</span> from citizens of Gitmo Nation.
<span v-if="profileCount > 0">Take a look around and see if you can help them find work!</span>
</p>
</load-data>
<hr>
<p>
To see how this application works, check out &ldquo;How It Works&rdquo; in the sidebar (last updated June
@ -32,47 +33,44 @@
</template>
<script lang="ts">
import { defineComponent, onMounted, Ref, ref } from 'vue'
import { defineComponent, Ref, ref } from 'vue'
import api, { LogOnSuccess, Profile } from '../../api'
import { useStore } from '../../store'
import LoadData from '../../components/shared/LoadData.vue'
export default defineComponent({
name: 'Dashboard',
components: { LoadData },
setup () {
const store = useStore()
/** The currently logged-in user */
const user = store.state.user as LogOnSuccess
/** Error messages from data retrieval */
const errorMessages : string[] = []
/** The user's profile */
const profile : Ref<Profile | undefined> = ref(undefined)
/** A count of profiles in the system */
const profileCount = ref(0)
const retrieveData = async () => {
const retrieveData = async (errors : string[]) => {
const profileResult = await api.profile.retreive(undefined, user)
if (typeof profileResult === 'string') {
errorMessages.push(profileResult)
errors.push(profileResult)
} else if (typeof profileResult !== 'undefined') {
profile.value = profileResult
}
const count = await api.profile.count(user)
if (typeof count === 'string') {
errorMessages.push(count)
errors.push(count)
} else {
profileCount.value = count
}
}
onMounted(retrieveData)
return {
retrieveData,
user,
errorMessages,
profile,
profileCount
}

View File

@ -1,80 +1,61 @@
<template>
<h3>Employment Profile</h3>
<v-form>
<load-data :load="retrieveData">
<form>
<v-container>
<v-row>
<v-col cols="12" sm="10" md="8" lg="6">
<v-text-field label="Real Name"
placeholder="Leave blank to use your NAS display name"
counter="255"
maxlength="255"
:value="realName"></v-text-field>
<div class="form-group">
<label for="realName" class="jjj-label">Real Name</label>
[InputText id="realName" @bind-Value=@ProfileForm.RealName class="form-control"
placeholder="Leave blank to use your NAS display name" /]
[ValidationMessage For=@(() => ProfileForm.RealName) /]
</div>
<label for="realName">Real Name</label>
<input type="text" id="realName" v-model="realName" maxlength="255"
placeholder="Leave blank to use your NAS display name">
</v-col>
</v-row>
<div class="form-row">
<div class="col">
<div class="form-check">
[InputCheckbox id="seeking" class="form-check-input" @bind-Value=@ProfileForm.IsSeekingEmployment /]
<label for="seeking" class="form-check-label">I am currently seeking employment</label>
<v-row>
<v-col>
<label>
<input type="checkbox" v-model="profile.seekingEmployment">
I am currently seeking employment
</label>
<em v-if="profile?.seekingEmployment">&nbsp; &nbsp; If you have found employment, consider
<router-link to="/success-story/add">telling your fellow citizens about it</router-link>
</em>
</div>
</div>
</div>
<div class="form-row">
<div class="col col-xs-12 col-sm-6 col-md-4">
<div class="form-group">
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<label for="continentId" class="jjj-required">Continent</label>
[InputSelect id="continentId" @bind-Value=@ProfileForm.ContinentId class="form-control"]
<option>&ndash; Select &ndash;</option>
@foreach (var (id, name) in Continents)
{
<option value="@id">@name</option>
}
[/InputSelect]
[ValidationMessage For=@(() => ProfileForm.ContinentId) /]
</div>
</div>
<div class="col col-xs-12 col-sm-6 col-md-8">
<div class="form-group">
<select id="continentId">
<option v-for="c in continents" :key="c.id" :value="c.id"
:selected="c.id === profile?.continentId ? 'selected' : null">{{c.name}}</option>
</select>
</v-col>
<v-col cols="12" sm="6" md="8">
<label for="region" class="jjj-required">Region</label>
[InputText id="region" @bind-Value=@ProfileForm.Region class="form-control"
placeholder="Country, state, geographic area, etc." /]
[ValidationMessage For=@(() => ProfileForm.Region) /]
</div>
</div>
</div>
<div class="form-row">
<div class="col">
<div class="form-group">
<input type="text" id="region" v-model="profile.region" maxlength="255"
placeholder="Country, state, geographic area, etc.">
</v-col>
</v-row>
<v-row>
<v-col>
<label for="bio" class="jjj-required">Professional Biography</label>
[MarkdownEditor Id="bio" @bind-Text=@ProfileForm.Biography /]
[ValidationMessage For=@(() => ProfileForm.Biography) /]
</div>
</div>
</div>
<div class="form-row">
<div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4">
<div class="form-check">
[InputCheckbox id="isRemote" class="form-check-input" @bind-Value=@ProfileForm.RemoteWork /]
<label for="isRemote" class="form-check-label">I am looking for remote work</label>
</div>
</div>
<div class="col col-xs-12 col-sm-12 col-md-4">
<div class="form-check">
[InputCheckbox id="isFull" class="form-check-input" @bind-Value=@ProfileForm.FullTime /]
<label for="isFull" class="form-check-label">I am looking for full-time work</label>
</div>
</div>
</div>
<markdown-editor id="bio" v-model:text="profile.biography" />
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="12" offset-md="2" md="4">
<label>
<input type="checkbox" v-model="profile.remoteWork">
I am looking for remote work
</label>
</v-col>
<v-col cols="12" sm="12" md="4">
<label>
<input type="checkbox" v-model="profile.fullTime">
I am looking for full-time work
</label>
</v-col>
</v-row>
<hr>
<h4>
Skills &nbsp;
@ -91,33 +72,32 @@
use this area to list prior jobs, their dates, and anything else you want to include that&rsquo;s not already a
part of your Professional Biography above.
</p>
<div class="form-row">
<div class="col">
[MarkdownEditor Id="experience" @bind-Text=@ProfileForm.Experience /]
</div>
</div>
<div class="form-row">
<div class="col">
<div class="form-check">
[InputCheckbox id="isPublic" class="form-check-input" @bind-Value=@ProfileForm.IsPublic /]
<label for="isPublic" class="form-check-label">
<v-row>
<v-col>
<markdown-editor id="experience" v-model:text="profile.experience" />
</v-col>
</v-row>
<v-row>
<v-col>
<label>
<input type="checkbox" v-model="profile.isPublic">
Allow my profile to be searched publicly (outside NA Social)
</label>
</div>
</div>
</div>
<div class="form-row">
<div class="col">
</v-col>
</v-row>
<v-row>
<v-col>
<br>
<button type="submit" class="btn btn-outline-primary">Save</button>
</div>
</div>
</v-col>
</v-row>
</v-container>
</v-form>
</form>
<p v-if="!isNew">
<br><router-link :to="'/profile/view/' + user.citizenId"><v-icon icon="file-account-outline" /> View Your User
<br><router-link :to="`/profile/view/${user.citizenId}`"><v-icon icon="file-account-outline" /> View Your User
Profile</router-link>
</p>
</load-data>
<p>
<br>If you want to delete your profile, or your entire account, <router-link to="/so-long/options">see your deletion
options here</router-link>.
@ -126,12 +106,18 @@
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import { computed, defineComponent, Ref, ref } from 'vue'
import api, { LogOnSuccess, Profile } from '../../api'
import MarkdownEditor from '../../components/MarkdownEditor.vue'
import LoadData from '../../components/shared/LoadData.vue'
import { useStore } from '../../store'
export default defineComponent({
name: 'EditProfile',
components: {
LoadData,
MarkdownEditor
},
setup () {
const store = useStore()
@ -141,35 +127,39 @@ export default defineComponent({
/** Whether this is a new profile */
const isNew = ref(false)
/** Errors that may be encountered */
const errorMessages : string[] = []
/** The user's current profile */
let profile : Profile | undefined
const profile : Ref<Profile | undefined> = ref(undefined)
/** The user's real name */
let realName : string | undefined
const realName : Ref<string | undefined> = ref(undefined)
/** Retrieve the user's profile */
const loadProfile = async () => {
/** Retrieve the user's profile and their real name */
const retrieveData = async (errors : string[]) => {
await store.dispatch('ensureContinents')
const profileResult = await api.profile.retreive(undefined, user)
if (typeof profileResult === 'string') {
errorMessages.push(profileResult)
errors.push(profileResult)
} else if (typeof profileResult === 'undefined') {
isNew.value = true
} else {
profile = profileResult
profile.value = profileResult
// console.info(JSON.stringify(profile))
}
const nameResult = await api.citizen.retrieve(user.citizenId, user)
if (typeof nameResult === 'string') {
errors.push(nameResult)
} else {
realName.value = nameResult.realName || ''
}
}
onMounted(loadProfile)
return {
retrieveData,
user,
isNew,
errorMessages,
profile,
realName
realName,
continents: computed(() => store.state.continents)
}
}
})

View File

@ -0,0 +1,25 @@
<template>
<p>Logging off&hellip;</p>
</template>
<script lang="ts">
import { defineComponent, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from '../../store'
export default defineComponent({
name: 'LogOff',
setup () {
const store = useStore()
const router = useRouter()
onMounted(() => {
store.commit('clearUser')
router.push('/')
// TODO: toast
})
return { }
}
})
</script>

View File

@ -0,0 +1,3 @@
<template>
<p>TODO: convert this view</p>
</template>

View File

@ -0,0 +1,20 @@
<template>
<p>TODO: convert this template</p>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { useRoute } from 'vue-router'
export default defineComponent({
name: 'ProfileEdit',
setup () {
const route = useRoute()
const profileId = route.params.id
return {
profileId
}
}
})
</script>

View File

@ -0,0 +1,3 @@
<template>
<p>TODO: convert this view</p>
</template>

View File

@ -0,0 +1,82 @@
<template>
<h3>Account Deletion Options</h3>
<p v-if="error !== ''">{{error}}</p>
<h4>Option 1 &ndash; Delete Your Profile</h4>
<p>
Utilizing this option will remove your current employment profile and skills. This will preserve any success stories
you may have written, and preserves this application&rsquo;s knowledge of you. This is what you want to use if you
want to clear out your profile and start again (and remove the current one from others&rsquo; view).
</p>
<p class="text-center">
<button class="btn btn-danger" @click="deleteProfile">Delete Your Profile</button>
</p>
<hr>
<h4>Option 2 &ndash; Delete Your Account</h4>
<p>
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and
account. This is what you want to use if you want to disappear from this application. Clicking the button below
<strong>will not</strong> affect your No Agenda Social account in any way; its effects are limited to Jobs, Jobs,
Jobs.
</p>
<p>
<em>
(This will not revoke this application&rsquo;s permissions on No Agenda Social; you will have to remove this
yourself. The confirmation message has a link where you can do this; once the page loads, find the
<strong>Jobs, Jobs, Jobs</strong> entry, and click the <strong>&times; Revoke</strong> link for that entry.)
</em>
</p>
<p class="text-center">
<button class="btn btn-danger" @click="deleteAccount">Delete Your Entire Account</button>
</p>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { useRouter } from 'vue-router'
import api, { LogOnSuccess } from '../../api'
import { useStore } from '../../store'
export default defineComponent({
name: 'DeletionOptions',
setup () {
const store = useStore()
const router = useRouter()
/** Error message encountered during actions */
const error = ref('')
/** Delete the profile only; redirect to home page on success */
const deleteProfile = async () => {
const resp = await api.profile.delete(store.state.user as LogOnSuccess)
if (typeof resp === 'string') {
error.value = resp
} else {
// TODO: notify
router.push('/citizen/dashboard')
}
}
/** Delete everything pertaining to the user's account */
const deleteAccount = async () => {
const resp = await api.citizen.delete(store.state.user as LogOnSuccess)
if (typeof resp === 'string') {
error.value = resp
} else {
store.commit('clearUser')
// TODO: notify
router.push('/so-long/success')
}
}
return {
error,
deleteProfile,
deleteAccount
}
}
})
</script>

View File

@ -0,0 +1,12 @@
<template>
<h3>Account Deletion Success</h3>
<p>
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
application, find it in <a href="https://noagendasocial.com/oauth/authorized_applications">this list</a> and click
<strong>&times; Revoke</strong>. Otherwise, clicking &ldquo;Log On&rdquo; in the left-hand menu will create a new,
empty account without prompting you further.
</p>
<p>
Thank you for participating, and thank you for your courage. #GitmoNation
</p>
</template>

View File

@ -0,0 +1,3 @@
<template>
<p>TODO: convert this view</p>
</template>

View File

@ -0,0 +1,3 @@
<template>
<p>TODO: convert this view</p>
</template>