diff options
Diffstat (limited to 'front-end/src/views/Account/components')
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> |