summaryrefslogtreecommitdiff
path: root/front-end/src/views/Account/components
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
commitf64955c69d91e578e580b409ba31ac4b3477da96 (patch)
tree16f01392ddf1abfea13d7d1ede3bfb0459fe8f0d /front-end/src/views/Account/components
Initial commit
Diffstat (limited to 'front-end/src/views/Account/components')
-rw-r--r--front-end/src/views/Account/components/oauth/CreateApp.vue183
-rw-r--r--front-end/src/views/Account/components/oauth/Oauth.vue93
-rw-r--r--front-end/src/views/Account/components/oauth/SingleApplication.vue190
-rw-r--r--front-end/src/views/Account/components/oauth/o2Api.ts176
-rw-r--r--front-end/src/views/Account/components/profile/Profile.vue199
-rw-r--r--front-end/src/views/Account/components/profile/profile-schema.ts310
-rw-r--r--front-end/src/views/Account/components/settings/Fido.vue53
-rw-r--r--front-end/src/views/Account/components/settings/PasswordReset.vue235
-rw-r--r--front-end/src/views/Account/components/settings/Pki.vue182
-rw-r--r--front-end/src/views/Account/components/settings/Security.vue81
-rw-r--r--front-end/src/views/Account/components/settings/Settings.vue16
-rw-r--r--front-end/src/views/Account/components/settings/TotpSettings.vue263
12 files changed, 1981 insertions, 0 deletions
diff --git a/front-end/src/views/Account/components/oauth/CreateApp.vue b/front-end/src/views/Account/components/oauth/CreateApp.vue
new file mode 100644
index 0000000..2321743
--- /dev/null
+++ b/front-end/src/views/Account/components/oauth/CreateApp.vue
@@ -0,0 +1,183 @@
+<template>
+ <Dialog :open="isOpen" @close="close" class="relative z-10">
+ <div class="fixed inset-0 bg-black/30" aria-hidden="true" />
+ <div class="fixed inset-0 flex justify-center top-20">
+ <DialogPanel class="new-o2-app-dialog">
+ <DialogTitle>Create app</DialogTitle>
+ <div class="flex">
+ <div class="m-auto mb-3 text-sm">
+ <p class="my-1">
+ Step 1: Enter a name for your app.
+ </p>
+ <p class="my-1">
+ Step 2: Submit the form.
+ </p>
+ <p class="my-1 text-red-500">
+ Step 3: Save your Client ID and Secret somewhere safe.
+ </p>
+ </div>
+ </div>
+ <!-- If secret is set, show the scret window -->
+ <div v-if="newAppBuffer.secret" class="mt-2">
+ <div class="block mx-1 sm:inline">
+ Secret:
+ </div>
+ <div class="px-1 py-4 my-2 break-all border-2 border-gray-300 rounded-lg">
+ <div class="text-center secret">
+ <span class="block mx-1 sm:inline">
+ {{ newAppBuffer.secret }}
+ </span>
+ </div>
+ </div>
+ <div class="text-sm">
+ <p class="p-2">
+ This secret will only be displayed <strong>once</strong>, and you cannot request it again.
+ If you lose it, you will need to update the secret from the app edit pane.
+ </p>
+ <p class="p-2">
+ Please remember to keep this secret somewhere safe. If an attacker gains
+ access to it, they will be able to access any APIs on your behalf!
+ </p>
+ </div>
+ <div class="flex justify-end">
+ <button v-if="!copied" class="btn primary" @click="copy(newAppBuffer.secret)">
+ Copy
+ </button>
+ <button v-else class="btn primary" @click="close">
+ Done
+ </button>
+ </div>
+ </div>
+ <div v-else>
+ <form id="o2-app-creation" class="" @submit.prevent="onFormSubmit">
+ <fieldset class="flex flex-col gap-4">
+ <div class="input-container">
+ <label>App Name</label>
+ <input
+ class="w-full mt-1 input primary"
+ :class="{'invalid':v$.name.$invalid, 'dirty': v$.name.$dirty}"
+ name="name"
+ type="text"
+ v-model="v$.name.$model"
+ />
+ </div>
+ <div class="input-container">
+ <label>Description</label>
+ <textarea
+ class="w-full mt-1 input primary"
+ :class="{ 'invalid': v$.description.$invalid, 'dirty': v$.name.$dirty }"
+ name="description"
+ v-model="v$.description.$model"
+ rows="3"
+ />
+ </div>
+ <div class="input-container">
+ <label>Permissions</label>
+ <div class="flex flex-col flex-wrap sm:flex-row">
+ <div v-for="permission in appPermissions" :key="permission.type" class="my-2 sm:m-3">
+ <label class="flex cursor-pointer">
+ <input class="w-5 cursor-pointer" type="checkbox" :name="permission.type" @change="permissionChanged">
+ <span class="pl-1">{{ permission.label }}</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ </fieldset>
+ <div class="flex justify-end mt-4">
+ <div class="button-group">
+ <button type="submit" form="o2-app-creation" class="btn primary">Submit</button>
+ <button class="btn" @click.prevent="close">Cancel</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+</template>
+
+<script setup lang="ts">
+import { indexOf, pull } from 'lodash'
+import { ref, toRefs } from 'vue';
+import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
+import { apiCall } from '@vnuge/vnlib.browser'
+import { useOAuth2Apps, getAppValidator, getAppPermissions } from './o2Api'
+import { useClipboard } from '@vueuse/core'
+
+const emit = defineEmits(['close'])
+
+const props = defineProps<{
+ isOpen: boolean
+}>()
+
+const { isOpen } = toRefs(props);
+
+const { copied, copy } = useClipboard();
+//Init the oauth2 app api
+const { createApp } = useOAuth2Apps('/oauth/apps');
+const appPermissions = getAppPermissions();
+
+const newAppBuffer = ref({});
+const newAppPermissions = ref([]);
+
+const { v$, validate, reset } = getAppValidator(newAppBuffer);
+
+const close = () => {
+ newAppBuffer.value = {}
+ reset()
+ emit('close')
+}
+
+const onFormSubmit = async () =>{
+
+ // Validate the new app form
+ if (!await validate()) {
+ return
+ }
+
+ // Create the new app
+ await apiCall(async () => {
+
+ const { secret } = await createApp(newAppBuffer.value)
+
+ // Reset the new app buffer and pass the secret value
+ newAppBuffer.value = { secret }
+ })
+
+ // reset the validator
+ v$.value.$reset()
+}
+
+const permissionChanged = (e : any) => {
+ if (e.target.checked) {
+ // Make sure the permission is not already in the list
+ if (indexOf(newAppPermissions.value, e.target.name) > -1) {
+ return
+ }
+ // Add the permission to the list
+ newAppPermissions.value.push(e.target.name)
+ } else {
+ // Remove the permission from the list
+ pull(newAppPermissions.value, e.target.name)
+ }
+ // Update the permissions model
+ v$.value.permissions.$model = newAppPermissions.value.join(',')
+}
+
+</script>
+
+<style lang="scss">
+
+.new-o2-app-dialog{
+ @apply w-full max-w-lg p-8 pt-4 m-auto mt-0 shadow-md sm:rounded-md;
+ @apply bg-white dark:bg-dark-600 dark:text-gray-200;
+
+ #o2-app-creation{
+ input.dirty.invalid,
+ textarea.dirty.invalid{
+ @apply border-red-500 focus:border-red-500;
+ }
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Account/components/oauth/Oauth.vue b/front-end/src/views/Account/components/oauth/Oauth.vue
new file mode 100644
index 0000000..119aa50
--- /dev/null
+++ b/front-end/src/views/Account/components/oauth/Oauth.vue
@@ -0,0 +1,93 @@
+<template>
+ <div id="oauth-apps" class="acnt-content-container">
+ <div class="app-container panel-container">
+ <div class="mb-6 panel-header">
+ <div class="flex ml-0 mr-auto">
+ <div class="my-auto panel-title">
+ <h4>Your applications</h4>
+ </div>
+ </div>
+ <div class="ml-auto mr-0">
+ <div class="button-container">
+ <button class="btn primary sm" :disabled="!isLocalAccount" @click="editNew = true">
+ Create App
+ </button>
+ </div>
+ </div>
+ </div>
+ <div v-if="apps?.length == 0" class="no-apps-container">
+ <div class="m-auto">
+ You dont have any OAuth2 client applications yet.
+ </div>
+ </div>
+ <div v-else>
+ <div v-for="app in apps" :key="app.data.Id" class="panel-content">
+ <SingleApplication :application="app" :allow-edit="isLocalAccount" @appDeleted="loadApps" />
+ </div>
+ </div>
+ </div>
+ <div class="px-2 my-10">
+ <div class="m-auto text-sm">
+ OAuth2 applications allow you grant api access to OAuth2 clients using the Client Credentials grant type.
+ <a class="link" href="https://oauth.net" target="_blank">
+ Learn more
+ </a>
+ </div>
+ <div v-show="!isLocalAccount" class="mt-3 text-center text-red-500">
+ You may not create or edit applications if you are using external authentication.
+ </div>
+ </div>
+ <CreateApp :is-open="editNew" @close="newAppClose" />
+ </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import CreateApp from './CreateApp.vue'
+import { useSession, apiCall } from '@vnuge/vnlib.browser'
+
+import SingleApplication from './SingleApplication.vue'
+import { AppBuffer, OAuth2Application, useOAuth2Apps } from './o2Api'
+
+const { isLocalAccount } = useSession()
+const { getApps } = useOAuth2Apps('/oauth/apps');
+
+const apps = ref<AppBuffer<OAuth2Application>[]>();
+const editNew = ref(false);
+
+const loadApps = async () => {
+ await apiCall(async () => {
+ const appList = await getApps();
+ // sort apps from newest to oldest
+ appList.sort((a, b) => {
+ if (a.data.Created > b.data.Created) return -1
+ if (a.data.Created < b.data.Created) return 1
+ return 0
+ })
+ // set the apps
+ apps.value = appList
+ })
+}
+
+const newAppClose = () => {
+ editNew.value = false;
+ //Reload apps on close
+ loadApps();
+}
+
+//Load apps, but do not await
+loadApps()
+
+</script>
+
+<style>
+
+#oauth-apps {
+ @apply m-auto max-w-3xl;
+}
+
+#oauth-apps .app-container .no-apps-container {
+ @apply w-full flex h-36 sm:border sm:rounded-md mt-4 mb-20 dark:border-dark-500 border-gray-300;
+}
+
+</style>
diff --git a/front-end/src/views/Account/components/oauth/SingleApplication.vue b/front-end/src/views/Account/components/oauth/SingleApplication.vue
new file mode 100644
index 0000000..9fcc5e3
--- /dev/null
+++ b/front-end/src/views/Account/components/oauth/SingleApplication.vue
@@ -0,0 +1,190 @@
+<template>
+ <div :id="data.Id">
+ <div class="flex flex-row">
+ <div class="flex ml-0 mr-auto">
+ <div class="flex w-8 h-8 rounded-full bg-primary-500">
+ <div class="m-auto text-white dark:text-dark-500">
+ <fa-icon icon="key"></fa-icon>
+ </div>
+ </div>
+ <div class="inline my-auto ml-2">
+ <h5 class="m-0">{{ name }}</h5>
+ </div>
+ </div>
+ <div v-if="allowEdit && editMode" class="button-group">
+ <button class="btn primary xs" :disabled="modified" @click="onSubmit">Update</button>
+ <button class="btn xs" @click="onCancel">Cancel</button>
+ </div>
+ <div v-else class="">
+ <button class="btn no-border xs" @click="editMode = true">Edit</button>
+ </div>
+ </div>
+ <div class="px-3 py-1 text-gray-500">
+ <div class="my-1">
+ <span> Client ID: </span>
+ <span class="font-mono text-black dark:text-white">{{ clientId }}</span>
+ </div>
+ <div class="text-sm">
+ <span> Created: </span>
+ <span>{{ createdTime }}</span>
+ </div>
+ <div v-if="!editMode" class="text-sm">
+ <span>{{ data.description }}</span>
+ </div>
+ </div>
+ <div v-if="newSecret" class="flex">
+ <div class="max-w-md py-4 mx-auto">
+ <div class="pl-1 mb-2">
+ New secret
+ </div>
+ <div class="p-4 text-sm break-all border-2 rounded-lg dark:border-dark-400">
+ {{ newSecret }}
+ </div>
+ <div class="flex justify-end my-3">
+ <button v-if="!copied" class="rounded btn" @click="copy(newSecret)">
+ Copy
+ </button>
+ <button v-else class="rounded btn" @click="closeNewSecret">
+ Done
+ </button>
+ </div>
+ </div>
+ </div>
+ <div v-else-if="editMode" class="app-form-container">
+ <div class="py-4">
+ <form :id="formId" class="max-w-md mx-auto">
+ <fieldset :disabled="waiting" class="">
+ <div class="input-container">
+ <div class="pl-1 mb-1">
+ App name
+ </div>
+ <input class="w-full input primary" :class="{ 'invalid': v$.name.$invalid }" v-model="v$.name.$model" type="text" name="name" />
+ </div>
+ <div class="mt-3 input-container">
+ <div class="pl-1 mb-1">
+ App description
+ <span class="text-sm">(optional)</span>
+ </div>
+ <textarea class="w-full input primary" :class="{ 'invalid': v$.description.$invalid }" v-model="v$.description.$model" name="description" rows="3" />
+ </div>
+ </fieldset>
+ </form>
+ </div>
+ <div class="mt-3">
+ <div class="flex flex-row justify-center gap-3 mx-auto">
+ <div class="">
+ <button class="w-full btn yellow" @click="updateSecret">
+ Update Secret
+ </button>
+ </div>
+ <div class="">
+ <button class="w-full btn red" @click="onDelete">
+ Delete
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { toUpper } from 'lodash'
+import { apiCall, useWait, useConfirm, usePassConfirm } from '@vnuge/vnlib.browser'
+import { ref, computed, toRefs } from 'vue'
+import { useClipboard, useTimeAgo } from '@vueuse/core'
+import { useOAuth2Apps, getAppValidator, AppBuffer, OAuth2Application } from './o2Api'
+
+const props = defineProps<{
+ application: AppBuffer<OAuth2Application>
+ allowEdit: boolean
+}>()
+
+const emit = defineEmits(['secretUpdated', 'AppDeleted'])
+
+const { application, allowEdit } = toRefs(props)
+const { data, buffer, revert, modified } = application.value;
+
+const { waiting } = useWait()
+const { reveal } = useConfirm()
+const { elevatedApiCall } = usePassConfirm()
+const { copied, copy } = useClipboard()
+const { deleteApp, updateAppMeta, updateAppSecret } = useOAuth2Apps('/oauth/apps');
+
+const { v$, validate, reset } = getAppValidator(buffer)
+
+const editMode = ref(false)
+const newSecret = ref<string | null>(null);
+
+const name = computed(() => data.name)
+const clientId = computed(() => toUpper(data.client_id))
+const createdTime = useTimeAgo(data.Created);
+const formId = computed(() => `app-form-${data.client_id}`)
+
+const onCancel = function () {
+ revert()
+ reset()
+ editMode.value = false
+}
+
+const onSubmit = async function () {
+ // Validate the new app form
+ if (!await validate()) {
+ return
+ }
+ // Create the new app
+ await apiCall(async ({ toaster }) => {
+ // Update does not return anything, if successful
+ await updateAppMeta(application.value)
+ toaster.general.success({
+ text: 'Application successfully updated',
+ title: 'Success'
+ })
+ reset()
+ editMode.value = false
+ })
+}
+
+const updateSecret = async function () {
+ // Show a confrimation prompt
+ const { isCanceled } = await reveal({
+ title: 'Update Secret',
+ text: `Are you sure you want to update the secret? Any active sessions will be invalidated, and the old secret will be invalidated.`
+ })
+ if (isCanceled) {
+ return
+ }
+ await elevatedApiCall(async ({ password }) => {
+ // Submit the secret update with the new challenge
+ newSecret.value = await updateAppSecret(application.value, password)
+ })
+}
+
+const onDelete = async function () {
+ // Show a confirmation prompt
+ const { isCanceled } = await reveal({
+ title: 'Delete Application',
+ text: 'Are you sure you want to delete this application?',
+ subtext: 'This action cannot be undone'
+ })
+ if (isCanceled) {
+ return
+ }
+ await elevatedApiCall(async ({ password, toaster }) => {
+ await deleteApp(application.value, password)
+ toaster.general.success({
+ text: 'Application deleted successfully',
+ title: 'Success'
+ })
+ emit('AppDeleted')
+ })
+}
+
+const closeNewSecret = () => newSecret.value = null;
+
+</script>
+
+<style lang="scss">
+
+
+</style>
diff --git a/front-end/src/views/Account/components/oauth/o2Api.ts b/front-end/src/views/Account/components/oauth/o2Api.ts
new file mode 100644
index 0000000..c21e4ed
--- /dev/null
+++ b/front-end/src/views/Account/components/oauth/o2Api.ts
@@ -0,0 +1,176 @@
+import { forEach } from 'lodash'
+import { Ref } from 'vue'
+import useVuelidate from '@vuelidate/core'
+import { maxLength, helpers, required } from '@vuelidate/validators'
+import { useAxios, useDataBuffer, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import { AxiosResponse } from 'axios'
+
+export interface OAuth2Application{
+ readonly Id: string,
+ readonly name: string,
+ readonly description: string,
+ readonly permissions: string[],
+ readonly client_id: string,
+ readonly Created: Date,
+ readonly LastModified: Date,
+}
+
+export interface NewAppResponse {
+ readonly secret: string
+ readonly app: AppBuffer<OAuth2Application>
+}
+
+export interface AppBuffer<T>{
+ readonly data: T,
+ buffer: T
+ readonly modified: Readonly<Ref<boolean>>
+ apply: (data: T) => void
+ revert(): void
+}
+
+/**
+ * Initializes the oauth2 applications api
+ * @param o2EndpointUrl The url of the oauth2 applications endpoint
+ * @returns The oauth2 applications api
+ */
+export const useOAuth2Apps = (o2EndpointUrl : string) => {
+ const { post, get, put } = useAxios(null);
+
+ /**
+ * Gets all of the user's oauth2 applications from the server
+ * @returns The user's oauth2 applications
+ */
+ const getApps = async (): Promise<AppBuffer<OAuth2Application>[]>=> {
+ // Get all apps
+ const { data } = await get(o2EndpointUrl);
+
+ const apps: AppBuffer<OAuth2Application>[] = []
+
+ //Loop through the apps and create a new state manager for each
+ forEach(data, (appData) => {
+
+ //Store the created time as a date object
+ appData.created = new Date(appData?.Created ?? 0)
+
+ //create a new state manager for the user's profile
+ const app: AppBuffer<OAuth2Application> = useDataBuffer(appData)
+
+ apps.push(app)
+ })
+
+ return apps
+ }
+
+ /**
+ * Creates a new application from the given data
+ * @param param0 The application server buffer
+ * @returns The newly created application
+ */
+ const createApp = async ({ name, description, permissions } : OAuth2Application): Promise<NewAppResponse> => {
+
+ // make the post request, response is the new app data with a secret
+ const { data } = await post(`${o2EndpointUrl}?action=create`, { name, description, permissions })
+
+ // Store secret
+ const secret = data.raw_secret
+
+ // remove secre tfrom the response
+ delete data.raw_secret
+
+ return { secret, app: useDataBuffer(data) }
+ }
+
+ /**
+ * Updates an Oauth2 application's metadata
+ */
+ const updateAppMeta = async (app: AppBuffer<OAuth2Application>): Promise<void> => {
+
+ //Update the app metadata
+ await put(o2EndpointUrl, app.buffer)
+
+ //Get the app data from the server to update the local copy
+ const response = await get(`${o2EndpointUrl}?Id=${app.data.Id}`)
+
+ //Update the app
+ app.apply(response.data)
+ }
+
+ /**
+ * Requets a new secret for an application from the server
+ * @param app The app to request a new secret for
+ * @param password The user's password
+ * @returns The new secret
+ */
+ const updateAppSecret = async (app: AppBuffer<OAuth2Application>, password: string): Promise<string> => {
+ const response = await post(`${o2EndpointUrl}?action=secret`, { Id: app.data.Id, password })
+ return response.data.raw_secret
+ }
+
+ /**
+ * Deletes an application from the server
+ * @param app The application to delete
+ * @param password The user's password
+ * @returns The response from the server
+ */
+ const deleteApp = (app: AppBuffer<OAuth2Application>, password: string): Promise<AxiosResponse> => {
+ return post(`${o2EndpointUrl}?action=delete`, { password, Id: app.data.Id });
+ }
+
+ return { getApps, createApp, updateAppMeta, updateAppSecret, deleteApp }
+}
+
+
+//Custom alpha numeric regex
+const alphaNumRegex = helpers.regex(/^[a-zA-Z0-9_,\s]*$/)
+
+const rules = {
+ name: {
+ alphaNumSpace: helpers.withMessage("Name contains invalid characters", alphaNumRegex),
+ maxLength: helpers.withMessage('App name must be less than 50 characters', maxLength(50)),
+ required: helpers.withMessage('Oauth Application name is required', required)
+ },
+ description: {
+ alphaNumSpace: helpers.withMessage("Description contains invalid characters", alphaNumRegex),
+ maxLength: helpers.withMessage('Description must be less than 50 characters', maxLength(50))
+ },
+ permissions: {
+ alphaNumSpace: helpers.regex(/^[a-zA-Z0-9_,:\s]*$/),
+ maxLength: helpers.withMessage('Permissions must be less than 64 characters', maxLength(64))
+ }
+}
+
+export interface AppValidator {
+ readonly v$: ReturnType<typeof useVuelidate>
+ readonly validate: () => Promise<boolean>
+ readonly reset: () => void
+}
+
+/**
+ * Gets the validator for the given application (or new appication) buffer
+ * @param buffer The app buffer to validate
+ * @returns The validator instance, validate function, and reset function
+ */
+export const getAppValidator = <T>(buffer: T) : AppValidator => {
+ //App validator
+ const v$ = useVuelidate(rules, buffer, { $lazy: true, $autoDirty: true })
+ //validate wrapper function
+ const { validate } = useVuelidateWrapper(v$);
+ return { v$, validate, reset: v$.value.$reset };
+}
+
+export const getAppPermissions = () =>{
+ return [
+ {
+ type: 'account:read',
+ label: 'Account Read'
+ },
+ {
+ type: 'account:write',
+ label: 'Account Write'
+ },
+ {
+ type: 'email:send',
+ label: 'Send Emails'
+ }
+ ]
+} \ No newline at end of file
diff --git a/front-end/src/views/Account/components/profile/Profile.vue b/front-end/src/views/Account/components/profile/Profile.vue
new file mode 100644
index 0000000..e01707c
--- /dev/null
+++ b/front-end/src/views/Account/components/profile/Profile.vue
@@ -0,0 +1,199 @@
+<template>
+ <div id="account-profile" class="acnt-content-container panel-container">
+ <div class="acnt-content profile-container panel-content">
+
+ <div id="profile-control-container" class="flex flex-row" :modified="modified">
+ <div class="m-0">
+ <div class="flex rounded-full w-14 h-14 bg-primary-500 dark:bg-primary-600">
+ <div class="m-auto text-white dark:text-dark-400">
+ <fa-icon :icon="['fas', 'user']" size="2xl" />
+ </div>
+ </div>
+ </div>
+
+ <div class="my-auto ml-6">
+ <h3 class="m-0">Profile</h3>
+ </div>
+
+ <div class="gap-3 ml-auto">
+ <div v-if="editMode" class="button-group">
+ <button form="profile-edit-form" class="btn primary sm" :disabled="waiting" @click="onSubmit">Submit</button>
+ <button class="btn sm" @click="revertProfile">Cancel</button>
+ </div>
+ <div v-else class="">
+ <button class="btn no-border" @click="editMode = true">Edit</button>
+ </div>
+ </div>
+ </div>
+
+ <div>
+
+ <p class="profile-text">
+ You may set or change your profile information here. All fields are optional,
+ but some features may not work without some information.
+ </p>
+
+ <div class="locked-info">
+ <div class="mx-auto my-1 sm:mx-0 sm:my-2">
+ <span class="pr-2">Email:</span>
+ <span class="">{{ data.email }}</span>
+ </div>
+ <div class="mx-auto my-1 sm:mx-0 sm:my-2">
+ <span class="pr-2">Created:</span>
+ <span>{{ createdTime }}</span>
+ </div>
+ </div>
+
+ <dynamic-form id="profile-edit-form"
+ :form="FormSchema"
+ :disabled="!editMode"
+ :validator="v$"
+ @submit="onSubmit"
+ @input="onInput"
+ />
+ </div>
+
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { defaultTo } from 'lodash'
+import useVuelidate from '@vuelidate/core'
+import { ref, computed, watch } from 'vue'
+import { Rules, FormSchema } from './profile-schema.ts'
+import { apiCall, useMessage, useWait, useDataBuffer, useUser, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import { IUserProfile } from '@vnuge/vnlib.browser/dist/user'
+
+const ACCOUNT_URL = '/account/profile'
+
+interface UserProfile extends IUserProfile{
+ created : string | Date
+}
+
+const { waiting } = useWait()
+const { getProfile } = useUser()
+const { onInput, clearMessage } = useMessage()
+const { data, buffer, apply, revert, modified } = useDataBuffer<UserProfile>({} as UserProfile)
+
+const editMode = ref(false)
+
+// Create validator based on the profile buffer as a data model
+const v$ = useVuelidate(Rules, buffer, { $lazy:true, $autoDirty:false })
+
+// Setup the validator wrapper
+const { validate } = useVuelidateWrapper(v$);
+
+//const modified = computed(() => profile.value.Modified)
+const createdTime = computed(() => defaultTo(data.created?.toLocaleString(), ''))
+
+const loadProfileData = async () => {
+ await apiCall(async () => {
+ // Get the user's profile
+ const profile = await getProfile<UserProfile>()
+ profile.created = new Date(profile.created)
+ //Apply the profile to the buffer
+ apply(profile)
+ })
+}
+
+const revertProfile = () => {
+ //Revert the buffer
+ revert()
+ clearMessage()
+ editMode.value = false
+}
+
+const onSubmit = async () => {
+ if(waiting.value){
+ return;
+ }
+ // Validate the form
+ if (!await validate()) {
+ return
+ }
+ // Init the api call
+ await apiCall(async ({ axios, toaster }) => {
+ // Apply the buffer to the profile
+ const response = await axios.post(ACCOUNT_URL, buffer)
+
+ if(!response.data.success){
+ throw { response }
+ }
+
+ //No longer in edit mode
+ editMode.value = false
+
+ //Show success message
+ toaster.general.success({
+ title: 'Update successful',
+ text: response.data.result,
+ })
+
+ //reload the profile data
+ loadProfileData()
+ })
+}
+
+watch(editMode, () => v$.value.$reset())
+
+//Inital profile data load, dont await
+loadProfileData()
+
+</script>
+
+<style lang="scss">
+
+#account-profile {
+
+ p.profile-text{
+ @apply p-2 md:py-3 md:my-1 text-sm;
+ }
+
+ .locked-info{
+ @apply w-full flex flex-col sm:flex-row sm:justify-evenly pt-3 sm:pb-1;
+ }
+
+ #profile-edit-form .input-group {
+ @apply pt-4;
+
+ .input-container{
+ @apply p-2 rounded-md flex sm:flex-row flex-col sm:gap-4;
+
+ &.dirty.data-invalid.dynamic-form .dynamic-input{
+ @apply border-red-600;
+ }
+
+ &.dirty.data-invalid.dynamic-form label.dynamic-form{
+ @apply text-red-500;
+ }
+
+ &.dirty.dynamic-form label.dynamic-form{
+ @apply text-primary-500;
+ }
+
+ select:disabled{
+ @apply appearance-none;
+ }
+ }
+
+ .input-container:nth-child(odd) {
+ @apply bg-slate-50 dark:bg-dark-700;
+ }
+
+ .dynamic-form.dynamic-input{
+ @apply py-2 w-full bg-transparent border-x-0 border-t-0 border-b border-gray-300 dark:border-dark-300 pl-2;
+ @apply focus:bg-gray-200 focus:dark:bg-transparent;
+
+ &:disabled{
+ @apply py-1 border-transparent;
+ }
+ }
+
+ label.dynamic-form{
+ flex-basis: 15%;
+ @apply sm:text-right my-auto;
+ }
+ }
+}
+</style>
diff --git a/front-end/src/views/Account/components/profile/profile-schema.ts b/front-end/src/views/Account/components/profile/profile-schema.ts
new file mode 100644
index 0000000..85dacff
--- /dev/null
+++ b/front-end/src/views/Account/components/profile/profile-schema.ts
@@ -0,0 +1,310 @@
+
+import { maxLength, helpers, numeric, alpha, alphaNum } from '@vuelidate/validators'
+
+export const Rules = {
+ first: {
+ alpha,
+ maxLength: helpers.withMessage('First name must be less than 50 characters', maxLength(50))
+ },
+ last: {
+ alpha,
+ maxLength: helpers.withMessage('Last name must be less than 50 characters', maxLength(50))
+ },
+ company: {
+ alphaNum: helpers.regex(/^[a-zA-Z0-9\s.&!]*$/),
+ maxLength: helpers.withMessage('Company name must be less than 50 characters', maxLength(50))
+ },
+ phone: {
+ numeric: helpers.withMessage('Phone number must contain only numbers', numeric),
+ maxLength: helpers.withMessage('Phone number must be less than 11 numbers', maxLength(11))
+ },
+ street: {
+ alphaNum: helpers.regex(/^[a-zA-Z0-9\s&]*$/),
+ maxLength: helpers.withMessage('Street name must be less than 50 characters', maxLength(50))
+ },
+ city: {
+ alphaNum,
+ maxLength: helpers.withMessage('City name must be less than 50 characters', maxLength(50))
+ },
+ state: {
+ alpha,
+ maxLength: helpers.withMessage('State code is invalid', maxLength(2))
+ },
+ zip: {
+ numeric,
+ maxLength: helpers.withMessage('Zip code must be exactly 5 numbers', maxLength(5))
+ }
+}
+
+
+export const FormSchema = {
+ id: 'profile-edit-form',
+ fields: [
+ {
+ label: 'First',
+ name: 'first',
+ type: 'text',
+ id: 'first-name'
+ },
+ {
+ label: 'Last',
+ name: 'last',
+ type: 'text',
+ id: 'last-name'
+ },
+ {
+ label: 'Company',
+ name: 'company',
+ type: 'text',
+ id: 'company'
+ },
+ {
+ label: 'Phone',
+ name: 'phone',
+ type: 'text',
+ id: 'phone'
+ },
+ {
+ label: 'Street',
+ name: 'street',
+ type: 'text',
+ id: 'street'
+ },
+ {
+ label: 'City',
+ name: 'city',
+ type: 'text',
+ id: 'city'
+ },
+ {
+ label: 'State',
+ name: 'state',
+ type: 'select',
+ id: 'state',
+ options: [
+ {
+ 'label':'Select State',
+ 'value': ''
+ },
+ {
+ "label": "Alabama",
+ "value": "AL"
+ },
+ {
+ "label": "Alaska",
+ "value": "AK"
+ },
+ {
+ "label": "Arizona",
+ "value": "AZ"
+ },
+ {
+ "label": "Arkansas",
+ "value": "AR"
+ },
+ {
+ "label": "California",
+ "value": "CA"
+ },
+ {
+ "label": "Colorado",
+ "value": "CO"
+ },
+ {
+ "label": "Connecticut",
+ "value": "CT"
+ },
+ {
+ "label": "Delaware",
+ "value": "DE"
+ },
+ {
+ "label": "District Of Columbia",
+ "value": "DC"
+ },
+ {
+ "label": "Florida",
+ "value": "FL"
+ },
+ {
+ "label": "Georgia",
+ "value": "GA"
+ },
+ {
+ "label": "Guam",
+ "value": "GU"
+ },
+ {
+ "label": "Hawaii",
+ "value": "HI"
+ },
+ {
+ "label": "Idaho",
+ "value": "ID"
+ },
+ {
+ "label": "Illinois",
+ "value": "IL"
+ },
+ {
+ "label": "Indiana",
+ "value": "IN"
+ },
+ {
+ "label": "Iowa",
+ "value": "IA"
+ },
+ {
+ "label": "Kansas",
+ "value": "KS"
+ },
+ {
+ "label": "Kentucky",
+ "value": "KY"
+ },
+ {
+ "label": "Louisiana",
+ "value": "LA"
+ },
+ {
+ "label": "Maine",
+ "value": "ME"
+ },
+ {
+ "label": "Maryland",
+ "value": "MD"
+ },
+ {
+ "label": "Massachusetts",
+ "value": "MA"
+ },
+ {
+ "label": "Michigan",
+ "value": "MI"
+ },
+ {
+ "label": "Minnesota",
+ "value": "MN"
+ },
+ {
+ "label": "Mississippi",
+ "value": "MS"
+ },
+ {
+ "label": "Missouri",
+ "value": "MO"
+ },
+ {
+ "label": "Montana",
+ "value": "MT"
+ },
+ {
+ "label": "Nebraska",
+ "value": "NE"
+ },
+ {
+ "label": "Nevada",
+ "value": "NV"
+ },
+ {
+ "label": "New Hampshire",
+ "value": "NH"
+ },
+ {
+ "label": "New Jersey",
+ "value": "NJ"
+ },
+ {
+ "label": "New Mexico",
+ "value": "NM"
+ },
+ {
+ "label": "New York",
+ "value": "NY"
+ },
+ {
+ "label": "North Carolina",
+ "value": "NC"
+ },
+ {
+ "label": "North Dakota",
+ "value": "ND"
+ },
+ {
+ "label": "Ohio",
+ "value": "OH"
+ },
+ {
+ "label": "Oklahoma",
+ "value": "OK"
+ },
+ {
+ "label": "Oregon",
+ "value": "OR"
+ },
+ {
+ "label": "Pennsylvania",
+ "value": "PA"
+ },
+ {
+ "label": "Puerto Rico",
+ "value": "PR"
+ },
+ {
+ "label": "Rhode Island",
+ "value": "RI"
+ },
+ {
+ "label": "South Carolina",
+ "value": "SC"
+ },
+ {
+ "label": "South Dakota",
+ "value": "SD"
+ },
+ {
+ "label": "Tennessee",
+ "value": "TN"
+ },
+ {
+ "label": "Texas",
+ "value": "TX"
+ },
+ {
+ "label": "Utah",
+ "value": "UT"
+ },
+ {
+ "label": "Vermont",
+ "value": "VT"
+ },
+ {
+ "label": "Virginia",
+ "value": "VA"
+ },
+ {
+ "label": "Washington",
+ "value": "WA"
+ },
+ {
+ "label": "West Virginia",
+ "value": "WV"
+ },
+ {
+ "label": "Wisconsin",
+ "value": "WI"
+ },
+ {
+ "label": "Wyoming",
+ "value": "WY"
+ }
+ ]
+ },
+ {
+ label: 'Zip',
+ name: 'zip',
+ type: 'text',
+ id: 'zip'
+ }
+ ]
+} \ No newline at end of file
diff --git a/front-end/src/views/Account/components/settings/Fido.vue b/front-end/src/views/Account/components/settings/Fido.vue
new file mode 100644
index 0000000..340d6d9
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/Fido.vue
@@ -0,0 +1,53 @@
+<template>
+ <div id="account-fido-settings">
+ <div v-if="!isLocalAccount" class="flex flex-row justify-between">
+ <h6 class="block">
+ FIDO/WebAuthN Authentication
+ </h6>
+ <div class="text-red-500">
+ Unavailable for external auth
+ </div>
+ </div>
+ <div v-else class="flex flex-row flex-wrap justify-between">
+ <h6>FIDO/WebAuthN Authentication</h6>
+ <div class="">
+ <div v-if="fidoEnabled" class="">
+ <button class="ml-1 btn red sm" @click.prevent="Disable">
+ <fa-icon icon="minus-circle" />
+ <span class="pl-3">Disable</span>
+ </button>
+ </div>
+ <div v-else>
+ <button class="btn primary sm" @click.prevent="Setup">
+ <fa-icon icon="plus" />
+ <span class="pl-3">Setup</span>
+ </button>
+ </div>
+ </div>
+ <p class="p-1 pt-3 text-sm text-gray-600">
+ WebAuthN/FIDO is not yet supported, due to complexity and browser support.
+ </p>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useSession } from '@vnuge/vnlib.browser'
+import { toRefs } from 'vue';
+
+const props = defineProps<{
+ fidoEnabled?: boolean
+}>()
+
+const { fidoEnabled } = toRefs(props)
+
+const { isLocalAccount } = useSession()
+
+const Disable = () => {}
+const Setup = () => {}
+
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/PasswordReset.vue b/front-end/src/views/Account/components/settings/PasswordReset.vue
new file mode 100644
index 0000000..ff04193
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/PasswordReset.vue
@@ -0,0 +1,235 @@
+<template>
+ <div id="pwreset-settings" class="container">
+ <div class="panel-content">
+
+ <h5>Password Reset</h5>
+
+ <div v-if="!pwResetShow" class="py-2">
+ <div class="flex flex-wrap items-center justify-between">
+
+ <div class="my-auto">
+ Click to reset
+ </div>
+
+ <div class="flex justify-end">
+ <button class="btn red sm" @click="showForm">
+ <fa-icon icon="sync" />
+ <span class="pl-3">Reset Password</span>
+ </button>
+ </div>
+ </div>
+
+ <p class="mt-3 text-sm">
+ You may only reset your password if you have an internal user account. If you exclusivly use an external
+ authentication provider (like GitHub or Discord), you will need to reset your password externally.
+ </p>
+ </div>
+
+ <div v-else class="px-2 my-2">
+
+ <p class="my-3 text-center">
+ Enter your current password, new password, and confirm the new password.
+ </p>
+
+ <dynamic-form
+ id="password-reset-form"
+ class="pwreset-form primary"
+ :form="formSchema"
+ :disabled="waiting"
+ :validator="v$"
+ @submit="onSubmit"
+ @input="onInput"
+ />
+
+ <div class="flex flex-row justify-end my-2">
+ <div class="button-group">
+ <button type="submit" form="password-reset-form" class="btn primary sm" :disabled="waiting">
+ <fa-icon v-if="!waiting" icon="check" />
+ <fa-icon v-else class="animate-spin" icon="spinner" />
+ Update
+ </button>
+ <button class="btn sm cancel-btn" :disabled="waiting" @click="resetForm">
+ Cancel
+ </button>
+ </div>
+ </div>
+
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { toSafeInteger } from 'lodash';
+import useVuelidate from '@vuelidate/core'
+import { required, maxLength, minLength, helpers } from '@vuelidate/validators'
+import { useUser, apiCall, useMessage, useWait, useConfirm, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import { computed, reactive, ref, toRefs, watch } from 'vue'
+
+const props = defineProps<{
+ totpEnabled: boolean,
+ fidoEnabled: boolean
+}>()
+
+const { totpEnabled, fidoEnabled } = toRefs(props)
+
+const formSchema = ref({
+ fields: [
+ {
+ label: 'Current Password',
+ name: 'current',
+ type: 'password',
+ id: 'current-password'
+ },
+ {
+ label: 'New Password',
+ name: 'newPassword',
+ type: 'password',
+ id: 'new-password'
+ },
+ {
+ label: 'Confirm Password',
+ name: 'repeatPassword',
+ type: 'password',
+ id: 'confirm-password'
+ }
+ ]
+})
+
+const { waiting } = useWait()
+const { onInput } = useMessage()
+const { reveal } = useConfirm()
+const { resetPassword } = useUser()
+
+const pwResetShow = ref(false)
+
+const vState = reactive({
+ newPassword: '',
+ repeatPassword: '',
+ current: '',
+ totpCode:''
+})
+
+const rules = computed(() =>{
+ return {
+ current: {
+ required: helpers.withMessage('Current password cannot be empty', required),
+ minLength: helpers.withMessage('Current password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage('Current password must have less than 128 characters', maxLength(128))
+ },
+ newPassword: {
+ notOld: helpers.withMessage('New password cannot be the same as your current password', (value : string) => value != vState.current),
+ required: helpers.withMessage('New password cannot be empty', required),
+ minLength: helpers.withMessage('New password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage('New password must have less than 128 characters', maxLength(128))
+ },
+ repeatPassword: {
+ sameAs: helpers.withMessage('Your new passwords do not match', (value : string) => value == vState.newPassword),
+ required: helpers.withMessage('Repeast password cannot be empty', required),
+ minLength: helpers.withMessage('Repeast password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage('Repeast password must have less than 128 characters', maxLength(128))
+ },
+ totpCode:{
+ required: helpers.withMessage('TOTP code cannot be empty', required),
+ minLength: helpers.withMessage('TOTP code must be at least 6 characters', minLength(6)),
+ maxLength: helpers.withMessage('TOTP code must have less than 12 characters', maxLength(12))
+ }
+ }
+})
+
+const v$ = useVuelidate(rules, vState, { $lazy: true })
+const { validate } = useVuelidateWrapper(v$)
+
+const showTotpCode = computed(() => totpEnabled.value && !fidoEnabled.value)
+
+watch(showTotpCode, (val) => {
+ if(val){
+ //Add totp code field
+ formSchema.value.fields.push({
+ label: 'TOTP Code',
+ name: 'totpCode',
+ type: 'text',
+ id: 'totp-code'
+ })
+ }
+})
+
+const showForm = async function () {
+ const { isCanceled } = await reveal({
+ title: 'Reset Password',
+ text: 'Are you sure you want to reset your password? This cannot be reversed.'
+ })
+ pwResetShow.value = !isCanceled
+}
+
+const onSubmit = async () => {
+
+ // validate
+ if (! await validate()) {
+ return
+ }
+
+ interface IResetPasswordArgs {
+ totp_code?: number
+ }
+
+ await apiCall(async ({ toaster }) => {
+ const args : IResetPasswordArgs = {}
+
+ //Add totp code if enabled
+ if(showTotpCode.value){
+ args.totp_code = toSafeInteger(v$.value.totpCode.$model)
+ }
+
+ //Exec pw reset
+ const { getResultOrThrow } = await resetPassword(v$.value.current.$model, v$.value.newPassword.$model, args)
+
+ //Get result or raise exception to handler
+ const result = getResultOrThrow()
+
+ // success
+ resetForm()
+
+ // Push a success toast
+ toaster.general.success({
+ title: 'Success',
+ text: result
+ })
+
+ })
+}
+
+const resetForm = () => {
+ v$.value.current.$model = ''
+ v$.value.newPassword.$model = ''
+ v$.value.repeatPassword.$model = ''
+ v$.value.totpCode.$model = ''
+ v$.value.$reset()
+ pwResetShow.value = false
+}
+
+</script>
+
+<style lang="scss">
+
+#password-reset-form{
+
+ .dynamic-form.input-container{
+ @apply flex flex-col sm:flex-row my-4 max-w-lg mx-auto;
+
+ label{
+ flex-basis: 40%;
+ @apply pl-1 text-sm sm:text-right my-auto mr-2 mb-1 sm:mb-auto;
+ }
+ }
+
+ .dynamic-form.dynamic-input.input {
+ @apply p-2 w-full border rounded-md;
+ @apply focus:border-primary-500 focus:dark:border-primary-600 dark:border-dark-400 bg-transparent dark:bg-dark-800;
+ }
+ .dirty.data-invalid.dynamic-form.input-container input{
+ @apply border-red-500 focus:border-red-500;
+ }
+}
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/Pki.vue b/front-end/src/views/Account/components/settings/Pki.vue
new file mode 100644
index 0000000..a621bf2
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/Pki.vue
@@ -0,0 +1,182 @@
+<template>
+ <div id="pki-settings" v-show="pkiEnabled" class="container">
+ <div class="panel-content">
+ <h5>PKI Authentication</h5>
+ <div class="flex flex-row flex-wrap justify-between">
+ <h6>Authentication keys</h6>
+
+ <div v-if="enabled" class="button-group">
+ <button class="btn yellow sm" @click.prevent="setIsOpen(true)">
+ <fa-icon icon="sync" />
+ <span class="pl-3">Update Key</span>
+ </button>
+ <button class="btn red sm" @click.prevent="onDisable">
+ <fa-icon icon="minus-circle" />
+ <span class="pl-3">Disable</span>
+ </button>
+ </div>
+
+ <div v-else class="">
+ <button class="btn primary sm" @click.prevent="setIsOpen(true)">
+ <fa-icon icon="plus" />
+ <span class="pl-3">Add Key</span>
+ </button>
+ </div>
+
+ <p class="p-1 pt-3 text-sm text-gray-600">
+ PKI authentication is a method of authenticating your user account with signed messages and a shared public key. This method implementation
+ uses client signed Json Web Tokens to authenticate user generated outside this website as a One Time Password (OTP). This allows for you to
+ use your favorite hardware or software tools, to generate said OTPs to authenticate your user.
+
+ </p>
+ </div>
+ </div>
+ </div>
+ <Dialog :open="isOpen" @close="setIsOpen" class="relative z-30">
+ <div class="fixed inset-0 bg-black/30" aria-hidden="true" />
+
+ <div class="fixed inset-0 flex justify-center">
+ <DialogPanel class="w-full max-w-lg p-4 m-auto mt-24 bg-white rounded dark:bg-dark-600 dark:text-gray-300">
+ <h4>Configure your authentication key</h4>
+ <p class="mt-2 text-sm">
+ Please paste your authenticator's public key as a Json Web Key (JWK) object. Your JWK must include a kid (key id) and a kty (key type) field.
+ </p>
+ <div class="p-2 mt-3">
+ <textarea class="w-full p-1 text-sm border dark:bg-dark-700 ring-0 dark:border-dark-400" rows="10" v-model="keyData" />
+ </div>
+ <div class="flex justify-end gap-2 mt-4">
+ <button class="rounded btn sm primary" @click.prevent="onSubmitKeys">Submit</button>
+ <button class="rounded btn sm red" @click.prevent="setIsOpen(false)">Cancel</button>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+</template>
+
+<script setup lang="ts">
+import { isEmpty, isNil } from 'lodash'
+import { apiCall, useConfirm, useSession, debugLog, useFormToaster } from '@vnuge/vnlib.browser'
+import { computed, ref, watch } from 'vue'
+import { Dialog, DialogPanel } from '@headlessui/vue'
+import { PkiApi } from '@vnuge/vnlib.browser/dist/mfa';
+
+const props = defineProps<{
+ pkaiApi: PkiApi
+}>()
+
+const { reveal } = useConfirm()
+const { isLocalAccount } = useSession()
+const { error } = useFormToaster()
+
+const pkiEnabled = computed(() => isLocalAccount.value && !isNil(import.meta.env.VITE_PKI_ENDPOINT) && window.crypto.subtle)
+const { enabled } = props.pkaiApi
+
+const isOpen = ref(false)
+const keyData = ref('')
+const pemFormat = ref(false)
+const explicitCurve = ref("")
+
+watch(isOpen, () =>{
+ keyData.value = ''
+ pemFormat.value = false
+ explicitCurve.value = ""
+ //Reload status
+ props.pkaiApi.refresh()
+})
+
+const setIsOpen = (value : boolean) => isOpen.value = value
+
+const onDisable = async () => {
+ const { isCanceled } = await reveal({
+ title: 'Are you sure?',
+ text: 'This will disable PKI authentication for your account.'
+ })
+ if (isCanceled) {
+ return;
+ }
+
+ //Delete pki
+ await apiCall(async ({ toaster }) =>{
+
+ //Disable pki
+ //TODO: require password or some upgrade to disable
+ const { success } = await props.pkaiApi.disable();
+
+ if(success){
+ toaster.general.success({
+ title: 'Success',
+ text: 'PKI authentication has been disabled.'
+ })
+ }
+ else{
+ toaster.general.error({
+ title: 'Error',
+ text: 'PKI authentication could not be disabled.'
+ })
+ }
+
+ //Refresh the status
+ props.pkaiApi.refresh();
+ });
+}
+
+//Server requires the JWK to set a keyid (kid) field
+interface IdJsonWebKey extends JsonWebKey {
+ readonly kid?: string
+}
+
+const onSubmitKeys = async () =>{
+
+ if(window.crypto.subtle == null){
+ error({ title: "Your browser does not support PKI authentication." })
+ return;
+ }
+
+ //Validate key data
+ if(isEmpty(keyData.value)){
+ error({ title: "Please enter key data" })
+ return;
+ }
+
+ let jwk : IdJsonWebKey;
+ try {
+ //Try to parse as jwk
+ jwk = JSON.parse(keyData.value)
+ if(isEmpty(jwk.use)
+ || isEmpty(jwk.kty)
+ || isEmpty(jwk.alg)
+ || isEmpty(jwk.kid)
+ || isEmpty(jwk.x)
+ || isEmpty(jwk.y)){
+ throw new Error("Invalid JWK");
+ }
+ }
+ catch (e) {
+ //Write error to debug log
+ debugLog(e)
+ error({ title:"The key is not a valid Json Web Key (JWK)"})
+ return;
+ }
+
+ //Send to server
+ await apiCall(async ({ toaster }) => {
+
+ //init/update the key
+ //TODO: require password or some upgrade to disable
+ const { getResultOrThrow } = await props.pkaiApi.initOrUpdate(jwk);
+
+ const result = getResultOrThrow();
+
+ toaster.general.success({
+ title: 'Success',
+ text: result
+ })
+ setIsOpen(false)
+ })
+}
+
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/Security.vue b/front-end/src/views/Account/components/settings/Security.vue
new file mode 100644
index 0000000..9ba83f7
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/Security.vue
@@ -0,0 +1,81 @@
+<template>
+ <div id="account-security-settings">
+ <div class="panel-container">
+
+ <div class="panel-header">
+ <div class="panel-title">
+ <h4>Security</h4>
+ </div>
+ </div>
+
+ <password-reset :totpEnabled="totpEnabled" :fido-enabled="fidoEnabled" />
+
+ <div id="account-mfa-settings" class="panel-content">
+ <h5>Multi Factor Authentication</h5>
+ <div class="py-2 border-b-2 border-gray-200 dark:border-dark-400">
+ <TotpSettings :mfa="mfaApi" />
+ </div>
+ <div class="py-2">
+ <Fido :fido-enabled="fidoEnabled"/>
+ </div>
+ </div>
+
+ <Pki :pkai-api="pkiApi" />
+
+ <div id="browser-poll-settings" class="panel-content" >
+ <div class="flex justify-between">
+ <h5>Keep me logged in</h5>
+ <div class="pl-1">
+ <Switch
+ v-model="enabled"
+ :class="enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-400'"
+ class="relative inline-flex items-center h-6 rounded-full w-11"
+ >
+ <span class="sr-only">Enable auto heartbeat</span>
+ <span
+ :class="enabled ? 'translate-x-6' : 'translate-x-1'"
+ class="inline-block w-4 h-4 transition transform bg-white rounded-full"
+ />
+ </Switch>
+ </div>
+ </div>
+
+ <p class="p-1 text-sm">
+ When enabled, continuously regenerates your login credentials to keep you logged in. The longer you are logged in,
+ the easier session fixation attacks become. If disabled, you will need to log when your credentials have expired.
+ It is recommneded that you leave this disabled <span class="text-yellow-500">Disabled</span>
+ </p>
+
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useAutoHeartbeat } from '@vnuge/vnlib.browser'
+import { useMfaConfig, MfaMethod, usePkiConfig } from '@vnuge/vnlib.browser/dist/mfa'
+import { computed } from 'vue'
+import { Switch } from '@headlessui/vue'
+import { includes } from 'lodash'
+import Fido from './Fido.vue'
+import Pki from './Pki.vue'
+import TotpSettings from './TotpSettings.vue'
+import PasswordReset from './PasswordReset.vue'
+
+const { enabled } = useAutoHeartbeat()
+
+const mfaApi = useMfaConfig('/account/mfa')
+const pkiApi = usePkiConfig(import.meta.env.VITE_PKI_ENDPOINT, mfaApi)
+
+const fidoEnabled = computed(() => includes(mfaApi.enabledMethods.value, 'fido' as MfaMethod))
+const totpEnabled = computed(() => includes(mfaApi.enabledMethods.value, MfaMethod.TOTP))
+
+</script>
+
+<style>
+
+#account-security-settings .modal-body{
+ @apply w-full sm:max-w-md ;
+}
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/Settings.vue b/front-end/src/views/Account/components/settings/Settings.vue
new file mode 100644
index 0000000..cd2ab48
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/Settings.vue
@@ -0,0 +1,16 @@
+<template>
+ <div id="account-settings" class="container">
+ <div class="acnt-content-container">
+ <Security />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import Security from './security.vue'
+
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/TotpSettings.vue b/front-end/src/views/Account/components/settings/TotpSettings.vue
new file mode 100644
index 0000000..20ee0d0
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/TotpSettings.vue
@@ -0,0 +1,263 @@
+<template>
+ <div id="totp-settings">
+
+ <div v-if="!isLocalAccount" class="flex flex-row justify-between">
+ <h6 class="block">
+ TOTP Authenticator App
+ </h6>
+ <div class="text-red-500">
+ Unavailable for external auth
+ </div>
+ </div>
+
+ <div v-else-if="showTotpCode" class="w-full py-2 text-center">
+ <h5 class="text-center" />
+ <p class="py-2">
+ Scan the QR code with your TOTP authenticator app.
+ </p>
+
+ <div class="flex">
+ <VueQrcode class="m-auto" :value="qrCode" />
+ </div>
+
+ <p class="py-2">
+ Your secret, if your application requires it.
+ </p>
+
+ <p class="flex flex-row flex-wrap justify-center p-2 bg-gray-200 border border-gray-300 dark:bg-dark-800 dark:border-dark-300">
+ <span v-for="code in secretSegments" :key="code" class="px-2 font-mono tracking-wider" >
+ {{ code }}
+ </span>
+ </p>
+
+ <p class="py-2">
+ Please enter your code from your authenticator app to continue.
+ </p>
+
+ <div class="m-auto w-min">
+ <VOtpInput
+ class="otp-input"
+ input-type="letter-numeric"
+ separator=""
+ :is-disabled="showSubmitButton"
+ input-classes="primary input rounded"
+ :num-inputs="6"
+ @on-change="onInput"
+ @on-complete="VerifyTotp"
+ />
+ </div>
+
+ <div v-if="showSubmitButton" class="flex flex-row justify-end my-2">
+ <button class="btn primary" @click.prevent="CloseQrWindow">
+ Complete
+ </button>
+ </div>
+ </div>
+
+ <div v-else class="flex flex-row flex-wrap justify-between">
+ <h6>TOTP Authenticator App</h6>
+
+ <div v-if="totpEnabled" class="button-group">
+ <button class="btn yellow sm" @click.prevent="regenTotp">
+ <fa-icon icon="sync" />
+ <span class="pl-3">Regenerate</span>
+ </button>
+ <button class="btn red sm" @click.prevent="disable">
+ <fa-icon icon="minus-circle" />
+ <span class="pl-3">Disable</span>
+ </button>
+ </div>
+
+ <div v-else>
+ <button class="btn primary sm" @click.prevent="configTotp">
+ <fa-icon icon="plus" />
+ <span class="pl-3">Setup</span>
+ </button>
+ </div>
+ <p class="p-1 pt-3 text-sm text-gray-600">
+ TOTP is a time based one time password. You can use it as a form of Multi Factor Authentication when
+ using another device such as a smart phone or TOTP hardware device. You can use TOTP with your smart
+ phone
+ using apps like Google Authenticator, Authy, or Duo. Read more on
+ <a class="link" href="https://en.wikipedia.org/wiki/Time-based_one-time_password" target="_blank">
+ Wikipedia.
+ </a>
+ </p>
+ </div>
+
+ </div>
+</template>
+
+<script setup lang="ts">
+import { isNil, chunk, defaultTo, includes, map, join } from 'lodash'
+import { TOTP } from 'otpauth'
+import base32Encode from 'base32-encode'
+import VueQrcode from '@chenfengyuan/vue-qrcode'
+import VOtpInput from "vue3-otp-input";
+import { computed, ref } from 'vue'
+import {
+ useSessionUtils,
+ useSession,
+ useUser,
+ useMessage,
+ useConfirm,
+ usePassConfirm,
+ useFormToaster
+} from '@vnuge/vnlib.browser'
+import { MfaApi, MfaMethod } from '@vnuge/vnlib.browser/dist/mfa';
+
+interface TotpConfig{
+ secret: string;
+ readonly issuer: string;
+ readonly algorithm: string;
+ readonly digits?: number;
+ readonly period?: number;
+}
+
+const props = defineProps<{
+ mfa: MfaApi
+}>()
+
+const { isLocalAccount } = useSession()
+const { KeyStore } = useSessionUtils()
+const { userName } = useUser()
+const { reveal } = useConfirm()
+const { elevatedApiCall } = usePassConfirm()
+const { onInput, setMessage } = useMessage()
+
+const { enabledMethods, disableMethod, initOrUpdateMethod, refreshMethods } = props.mfa;
+const totpEnabled = computed(() => includes(enabledMethods.value, MfaMethod.TOTP))
+
+const totpMessage = ref<TotpConfig>()
+const showSubmitButton = ref(false)
+const toaster = useFormToaster()
+
+const showTotpCode = computed(() => !isNil(totpMessage.value?.secret))
+
+const secretSegments = computed<string[]>(() => {
+ //Chunk the secret into 6 character segments
+ const chunks = chunk(totpMessage.value?.secret, 6)
+ //Join the chunks into their chunk arrays
+ return map(chunks, chunk => join(chunk, ''))
+})
+
+const qrCode = computed(() => {
+ if (isNil(totpMessage.value?.secret)) {
+ return ''
+ }
+
+ const m = totpMessage.value!;
+
+ // Build the totp qr codeurl
+ const params = new URLSearchParams()
+ params.append('secret', m.secret)
+ params.append('issuer', m.issuer)
+ params.append('algorithm', m.algorithm)
+ params.append('digits', defaultTo(m.digits, 6).toString())
+ params.append('period', defaultTo(m.period, 30).toString())
+ const url = `otpauth://totp/${m.issuer}:${userName.value}?${params.toString()}`
+ return url
+})
+
+const ProcessAddOrUpdate = async () => {
+ await elevatedApiCall(async ({ password }) => {
+
+ // Init or update the totp method and get the encrypted totp message
+ const res = await initOrUpdateMethod<TotpConfig>(MfaMethod.TOTP, password);
+
+ //Get the encrypted totp message
+ const totp = res.getResultOrThrow()
+
+ // Decrypt the totp secret
+ const secretBuf = await KeyStore.decryptDataAsync(totp.secret)
+
+ // Encode the secret to base32
+ totp.secret = base32Encode(secretBuf, 'RFC3548', { padding: false })
+
+ totpMessage.value = totp
+ })
+}
+
+const configTotp = async () => {
+ const { isCanceled } = await reveal({
+ title: 'Enable TOTP multi factor?',
+ text: 'Are you sure you understand TOTP multi factor and wish to enable it?',
+ })
+
+ if(!isCanceled){
+ ProcessAddOrUpdate()
+ }
+}
+
+const regenTotp = async () => {
+ // If totp is enabled, show a prompt to regenerate totp
+ if (!totpEnabled.value) {
+ return
+ }
+
+ const { isCanceled } = await reveal({
+ title: 'Are you sure?',
+ text: 'If you continue your previous TOTP authenticator and recovery codes will no longer be valid.'
+ })
+
+ if(!isCanceled){
+ ProcessAddOrUpdate()
+ }
+}
+
+const disable = async () => {
+ // Show a confrimation prompt
+ const { isCanceled } = await reveal({
+ title: 'Disable TOTP',
+ text: 'Are you sure you want to disable TOTP? You may re-enable TOTP later.'
+ })
+
+ if (isCanceled) {
+ return
+ }
+
+ await elevatedApiCall(async ({ password }) => {
+
+ // Disable the totp method
+ const res = await disableMethod(MfaMethod.TOTP, password)
+ res.getResultOrThrow()
+
+ refreshMethods()
+ })
+}
+
+const VerifyTotp = async (code : string) => {
+ // Create a new TOTP instance from the current message
+ const totp = new TOTP(totpMessage.value)
+
+ // validate the code
+ const valid = totp.validate({ token: code, window: 4 })
+
+ if (valid) {
+ showSubmitButton.value = true
+ toaster.success({
+ title: 'Success',
+ text: 'Your TOTP code is valid and your account is now verified.'
+ })
+ } else {
+ setMessage('Your TOTP code is not valid.')
+ }
+}
+
+const CloseQrWindow = () => {
+ showSubmitButton.value = false
+ totpMessage.value = undefined
+
+ //Fresh methods
+ refreshMethods()
+}
+
+</script>
+
+<style>
+
+#totp-settings .otp-input input {
+ @apply w-12 text-center text-lg mx-1 focus:border-primary-500;
+}
+
+</style>