diff options
Diffstat (limited to 'front-end/src/views')
20 files changed, 1186 insertions, 697 deletions
diff --git a/front-end/src/views/Account/[comp].vue b/front-end/src/views/Account/[comp].vue index d4f1c4d..713a6fe 100644 --- a/front-end/src/views/Account/[comp].vue +++ b/front-end/src/views/Account/[comp].vue @@ -1,3 +1,51 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { get, set } from '@vueuse/core' +import { useRouteParams } from '@vueuse/router' +import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue' +import { useStore } from '../../store' +import Settings from './components/settings/Settings.vue' +import Profile from './components/profile/Profile.vue' +import OauthApps from './components/oauth/Oauth.vue' + +const store = useStore() +store.setPageTitle('Account') + +const oauthEnabled = computed(() => store.oauth2?.apps); + +type ComponentType = 'profile' | 'oauth' | 'settings' | '' + +const comp = useRouteParams<ComponentType>('comp', '') + +const tabId = computed<number>(() => { + switch (comp.value) { + case 'oauth': + //If oauth is not enabled, redirect to profile + return get(oauthEnabled) ? 2 : 0 + case 'settings': + return 1 + case 'profile': + default: + return 0 + } +}) + +const onTabChange = (tabid: number) => { + switch (tabid) { + case 1: + set(comp, 'settings') + break + case 2: + set(comp, 'oauth') + break + case 0: + default: + set(comp, 'profile') + break + } +} + +</script> <template> <div id="account-template" class="app-component-entry"> <TabGroup :selectedIndex="tabId" @change="onTabChange" as="div" class="container h-full m-auto mt-0 mb-10 duration-150 ease-linear text-color-foreground"> @@ -18,6 +66,12 @@ </span> </tab> + <Tab v-if="oauthEnabled" v-slot="{ selected }" > + <span class="page-link" :class="{ 'active': selected }"> + OAuth + </span> + </tab> + </TabList> </div> @@ -31,54 +85,16 @@ <Settings /> </TabPanel> + <TabPanel v-if="oauthEnabled" :unmount="false"> + <OauthApps /> + </TabPanel> + </TabPanels> </TabGroup> </div> </template> -<script setup lang="ts"> -import { computed } from 'vue' -import { useRouteParams } from '@vueuse/router' -import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue' -import { useStore } from '../../store' -import Settings from './components/settings/Settings.vue' -import Profile from './components/profile/Profile.vue' - -const { setPageTitle } = useStore() -setPageTitle('Account') - -enum ComponentType{ - Profile = 'profile', - Settings = 'settings' -} - -const comp = useRouteParams<ComponentType>('comp', '') - -const tabId = computed<number>(() => { - switch (comp.value) { - case ComponentType.Settings: - return 1 - case ComponentType.Profile: - default: - return 0 - } -}) - -const onTabChange = (tabid : number) =>{ - switch (tabid) { - case 1: - comp.value = ComponentType.Settings - break - case 0: - default: - comp.value = ComponentType.Profile - break - } -} - -</script> - <style lang="scss"> #account-template{ 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..038c43f --- /dev/null +++ b/front-end/src/views/Account/components/oauth/CreateApp.vue @@ -0,0 +1,182 @@ +<script setup lang="ts"> +import { indexOf, pull } from 'lodash-es' +import { Ref, ref, toRefs } from 'vue'; +import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue' +import { set, useClipboard } from '@vueuse/core' +import { apiCall } from '@vnuge/vnlib.browser' +import { getAppValidator } from './o2AppValidation' +import { useStore } from '../../../../store'; +import { OAuth2Application } from '../../../../store/oauthAppsPlugin'; + +const emit = defineEmits(['close']) +const props = defineProps<{ + isOpen: boolean +}>() + +const { isOpen } = toRefs(props); + +//Init the oauth2 app api +const store = useStore() + +const { copied, copy } = useClipboard(); + +const newAppBuffer = ref<Partial<OAuth2Application& { secret: string }>>(); +const newAppPermissions = ref<string[]>([]); + +const { v$, validate, reset } = getAppValidator(newAppBuffer as Ref<OAuth2Application>); + +const close = () => { + set(newAppBuffer, {}); + 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 store.oauth2.createApp(newAppBuffer.value as OAuth2Application) + + // Reset the new app buffer and pass the secret value + set(newAppBuffer, { 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 as string) + } 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> +<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> + <ul class="text-sm"> + <li v-for="scope in store.oauth2.scopes" :key="scope" class="my-1.5"> + <label class="flex cursor-pointer"> + <input class="w-3.5 cursor-pointer" type="checkbox" :name="`02scope-${scope}`" @change="permissionChanged"> + <span class="my-auto ml-1.5">{{ scope }}</span> + </label> + </li> + </ul> + </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> +<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 border dark:border-dark-500; + @apply bg-white dark:bg-dark-700 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..d269689 --- /dev/null +++ b/front-end/src/views/Account/components/oauth/Oauth.vue @@ -0,0 +1,78 @@ +<script setup lang="ts"> +import { defineAsyncComponent } from 'vue' +import { storeToRefs } from 'pinia' +import { useStore } from '../../../../store' +const CreateApp = defineAsyncComponent(() => import('./CreateApp.vue')) + +import SingleApplication from './SingleApplication.vue' +import { useToggle } from '@vueuse/core'; + +const store = useStore() +const { isLocalAccount } = storeToRefs(store) + +const [editNew, toggleEdit] = useToggle() + +const newAppClose = () => { + toggleEdit(false); + //Reload apps on close + store.oauth2.refresh(); +} + +//Load apps +store.oauth2.refresh(); + +</script> +<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="toggleEdit(true)"> + Create App + </button> + </div> + </div> + </div> + <div v-if="store.oauth2.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 store.oauth2.apps" :key="app.Id" class="panel-content"> + <SingleApplication :application="app" :allow-edit="isLocalAccount" /> + </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> + +<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..60bad68 --- /dev/null +++ b/front-end/src/views/Account/components/oauth/SingleApplication.vue @@ -0,0 +1,198 @@ +<script setup lang="ts"> +import { toUpper } from 'lodash-es' +import { apiCall, useWait, useConfirm, usePassConfirm, useDataBuffer } from '@vnuge/vnlib.browser' +import { ref, computed, toRefs, watch } from 'vue' +import { get, set, useClipboard, useTimeAgo, useToggle } from '@vueuse/core' +import { getAppValidator } from './o2AppValidation' +import { OAuth2Application } from '../../../../store/oauthAppsPlugin' +import { useStore } from '../../../../store' + +const props = defineProps<{ + application: OAuth2Application + allowEdit: boolean +}>() + +const store = useStore() +const { application, allowEdit } = toRefs(props) + +//Init data buffer around application +const { data, revert, modified, buffer, update, apply } = useDataBuffer(get(application), + async (adb) => { + await store.oauth2.updateAppMeta(adb.buffer); + store.oauth2.refresh(); + }) + +//Watch for store app changes and apply them to the buffer +watch(application, apply) + +const { waiting } = useWait() +const { reveal } = useConfirm() +const { elevatedApiCall } = usePassConfirm() +const { copied, copy } = useClipboard() + +const { v$, validate, reset } = getAppValidator(buffer) + +const [showEdit, toggleEdit] = useToggle() +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() + toggleEdit(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 update() + + toaster.general.success({ + text: 'Application successfully updated', + title: 'Success' + }) + reset() + toggleEdit(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 store.oauth2.updateAppSecret(data, password) + store.oauth2.refresh() + }) +} + +const onDelete = async function () { + // Show a confirmation prompt + const { isCanceled } = await reveal({ + title: 'Delete?', + text: 'You are about to permanently delete this application. This will invalidate any active sessions.', + subtext: '' + }) + if (isCanceled) { + return + } + await elevatedApiCall(async ({ password, toaster }) => { + await store.oauth2.deleteApp(data, password) + toaster.general.success({ + text: 'Application deleted successfully', + title: 'Success' + }) + store.oauth2.refresh() + }) +} + +const closeNewSecret = () => set(newSecret, null); + +</script> + +<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 && showEdit" 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="toggleEdit(true)">Edit</button> + </div> + </div> + <div class="px-3 py-1 text-color-background"> + <div class="my-1"> + <span> Client ID: </span> + <span class="font-mono text-color-foreground">{{ clientId }}</span> + </div> + <div class="text-sm"> + <span> Created: </span> + <span>{{ createdTime }}</span> + </div> + <div v-if="!showEdit" class="text-sm"> + <span>{{ data.description }}</span> + </div> + </div> + <div v-if="newSecret" class="flex"> + <div class="py-4 mx-auto"> + <div class="pl-1 mb-2"> + New secret + </div> + <div class="p-4 text-sm break-all border-2 rounded dark:border-dark-500 dark:bg-dark-700"> + {{ 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="showEdit" 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="mx-auto w-fit"> + <div class="button-group"> + <button class="btn xs" @click.prevent="updateSecret"> + <fa-icon icon="sync" /> + <span class="pl-2">New Secret</span> + </button> + <button class="btn red xs" @click.prevent="onDelete"> + <fa-icon icon="minus-circle" /> + <span class="pl-2">Delete</span> + </button> + </div> + </div> + + </div> + </div> + </div> +</template> diff --git a/front-end/src/views/Account/components/oauth/o2AppValidation.ts b/front-end/src/views/Account/components/oauth/o2AppValidation.ts new file mode 100644 index 0000000..0596f1e --- /dev/null +++ b/front-end/src/views/Account/components/oauth/o2AppValidation.ts @@ -0,0 +1,43 @@ +import { MaybeRef } from 'vue' +import { useVuelidate } from '@vuelidate/core' +import { maxLength, helpers, required } from '@vuelidate/validators' +import { useVuelidateWrapper } from '@vnuge/vnlib.browser' +import { OAuth2Application } from '../../../../store/oauthAppsPlugin' + +//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 = (buffer: MaybeRef<OAuth2Application>) : AppValidator => { + //App validator + const v$ = useVuelidate(rules, buffer) + //validate wrapper function + const { validate } = useVuelidateWrapper(v$); + return { v$, validate, reset: v$.value.$reset }; +}
\ 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 index 0db7192..106c8b9 100644 --- a/front-end/src/views/Account/components/profile/Profile.vue +++ b/front-end/src/views/Account/components/profile/Profile.vue @@ -1,3 +1,62 @@ + +<script setup lang="ts"> +import { defaultTo } from 'lodash-es' +import { useVuelidate } from '@vuelidate/core' +import { ref, computed, watch, type Ref } from 'vue' +import { Rules, FormSchema } from './profile-schema.ts' +import { apiCall, useMessage, useWait, useVuelidateWrapper, type VuelidateInstance } from '@vnuge/vnlib.browser' +import { useStore } from '../../../../store' + +const { waiting } = useWait() +const { onInput, clearMessage } = useMessage() + +const store = useStore() +const editMode = ref(false) + +// Create validator based on the profile buffer as a data model +const v$ = useVuelidate(Rules, store.userProfile.buffer as any, { $lazy: true }) + +// Setup the validator wrapper +const { validate } = useVuelidateWrapper(v$ as Ref<VuelidateInstance>); + +//const modified = computed(() => profile.value.Modified) +const createdTime = computed(() => defaultTo(store.userProfile.data.created?.toLocaleString(), '')) + +const revertProfile = () => { + //Revert the buffer + store.userProfile.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 ({ toaster }) => { + const res = await store.userProfile.update(); + + const successm = res.getResultOrThrow(); + + //No longer in edit mode + editMode.value = false + + //Show success message + toaster.general.success({ + title: 'Update successful', + text: successm, + }) + }) +} + +watch(editMode, () => v$.value.$reset()) + +</script> <template> <div id="account-profile" class="acnt-content-container panel-container"> <div class="acnt-content profile-container panel-content"> @@ -57,66 +116,6 @@ </div> </template> -<script setup lang="ts"> -import { defaultTo } from 'lodash-es' -import useVuelidate from '@vuelidate/core' -import { ref, computed, watch } from 'vue' -import { Rules, FormSchema } from './profile-schema.ts' -import { apiCall, useMessage, useWait, useVuelidateWrapper, WebMessage } from '@vnuge/vnlib.browser' -import { useStore } from '../../../../store' - -const { waiting } = useWait() -const { onInput, clearMessage } = useMessage() - -const store = useStore() - -const editMode = ref(false) - -// Create validator based on the profile buffer as a data model -const v$ = useVuelidate(Rules, store.userProfile.buffer, { $lazy:true }) - -// Setup the validator wrapper -const { validate } = useVuelidateWrapper(v$); - -//const modified = computed(() => profile.value.Modified) -const createdTime = computed(() => defaultTo(store.userProfile.data.created?.toLocaleString(), '')) - -const revertProfile = () => { - //Revert the buffer - store.userProfile.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 ({ toaster }) => { - const res = await store.userProfile.update(); - - const successm = (res as WebMessage<string>).getResultOrThrow(); - - //No longer in edit mode - editMode.value = false - - //Show success message - toaster.general.success({ - title: 'Update successful', - text: successm, - }) - }) -} - -watch(editMode, () => v$.value.$reset()) - - -</script> <style lang="scss"> diff --git a/front-end/src/views/Account/components/settings/Fido.vue b/front-end/src/views/Account/components/settings/Fido.vue index d453378..9303541 100644 --- a/front-end/src/views/Account/components/settings/Fido.vue +++ b/front-end/src/views/Account/components/settings/Fido.vue @@ -1,3 +1,19 @@ +<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> + <template> <div id="account-fido-settings"> <div v-if="!isLocalAccount" class="flex flex-row justify-between"> @@ -30,24 +46,3 @@ </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 index 24dced6..61fda7d 100644 --- a/front-end/src/views/Account/components/settings/PasswordReset.vue +++ b/front-end/src/views/Account/components/settings/PasswordReset.vue @@ -1,68 +1,9 @@ -<template> - <div id="pwreset-settings" class="container"> - <div class="panel-content"> - - <div v-if="!pwResetShow" class=""> - <div class="flex flex-wrap items-center justify-between"> - - <div class=""> - <h5>Password Reset</h5> - </div> - - <div class="flex justify-end"> - <button class="btn xs" @click="showForm"> - <fa-icon icon="sync" /> - <span class="pl-2">Reset Password</span> - </button> - </div> - </div> - - <p class="mt-3 text-sm text-color-background"> - 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 { isEmpty, toSafeInteger } from 'lodash-es'; 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' +import { useUser, apiCall, useMessage, useWait, useConfirm, useVuelidateWrapper, VuelidateInstance } from '@vnuge/vnlib.browser' +import { MaybeRef, computed, reactive, ref, toRefs, watch } from 'vue' const props = defineProps<{ totpEnabled: boolean, @@ -136,7 +77,7 @@ const rules = computed(() =>{ }) const v$ = useVuelidate(rules, vState, { $lazy: true }) -const { validate } = useVuelidateWrapper(v$) +const { validate } = useVuelidateWrapper(v$ as MaybeRef<VuelidateInstance>) const showTotpCode = computed(() => totpEnabled.value && !fidoEnabled.value) @@ -208,6 +149,58 @@ const resetForm = () => { </script> +<template> + <div id="pwreset-settings" class="container"> + <div class="panel-content"> + + <div v-if="!pwResetShow" class=""> + <div class="flex flex-wrap items-center justify-between"> + + <div class=""> + <h5>Password Reset</h5> + </div> + + <div class="flex justify-end"> + <button class="btn xs" @click="showForm"> + <fa-icon icon="sync" /> + <span class="pl-2">Reset Password</span> + </button> + </div> + </div> + + <p class="mt-3 text-sm text-color-background"> + 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> + <style lang="scss"> #password-reset-form{ diff --git a/front-end/src/views/Account/components/settings/Pki.vue b/front-end/src/views/Account/components/settings/Pki.vue index afe606f..957a188 100644 --- a/front-end/src/views/Account/components/settings/Pki.vue +++ b/front-end/src/views/Account/components/settings/Pki.vue @@ -1,128 +1,35 @@ -<template> - <div id="pki-settings" class="container"> - <div class="panel-content"> - - <div class="flex flex-row flex-wrap justify-between"> - <h5>PKI Authentication</h5> - <div class=""> - <div v-if="pkiEnabled" class="button-group"> - <button class="btn xs" @click.prevent="setIsOpen(true)"> - <fa-icon icon="plus" /> - <span class="pl-2">Add Key</span> - </button> - <button class="btn red xs" @click.prevent="onDisable"> - <fa-icon icon="minus-circle" /> - <span class="pl-2">Disable</span> - </button> - </div> - <div v-else class=""> - <button class="btn primary xs" @click.prevent="setIsOpen(true)"> - <fa-icon icon="plus" /> - <span class="pl-2">Add Key</span> - </button> - </div> - </div> - - <div v-if="store.pkiPublicKeys && store.pkiPublicKeys.length > 0" class="w-full mt-4"> - <table class="min-w-full text-sm divide-y-2 divide-gray-200 dark:divide-dark-500"> - <thead class="text-left"> - <tr> - <th class="p-2 font-medium whitespace-nowrap dark:text-white" > - KeyID - </th> - <th class="p-2 font-medium whitespace-nowrap dark:text-white"> - Algorithm - </th> - <th class="p-2 font-medium whitespace-nowrap dark:text-white"> - Curve - </th> - <th class="p-2"></th> - </tr> - </thead> - - <tbody class="divide-y divide-gray-200 dark:divide-dark-500"> - <tr v-for="key in store.pkiPublicKeys"> - <td class="p-2 t font-medium truncate max-w-[8rem] whitespace-nowrap dark:text-white"> - {{ key.kid }} - </td> - <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> - {{ key.alg }} - </td> - <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> - {{ key.crv }} - </td> - <td class="p-2 text-right whitespace-nowrap"> - <button class="rounded btn red xs borderless" @click="onRemoveKey(key)"> - <span class="hidden sm:inline">Remove</span> - <fa-icon icon="trash-can" class="inline sm:hidden" /> - </button> - </td> - </tr> - </tbody> - </table> - </div> - - <p v-else class="p-1 pt-3 text-sm text-color-background"> - 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-700 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" @click.prevent="setIsOpen(false)">Cancel</button> - </div> - </DialogPanel> - </div> - </Dialog> -</template> - <script setup lang="ts"> import { includes, isEmpty } from 'lodash-es' -import { apiCall, useConfirm, useSession, debugLog, useFormToaster, PkiPublicKey } from '@vnuge/vnlib.browser' +import { apiCall, useConfirm, useSession, debugLog, useFormToaster, type PkiPublicKey, MfaMethod } from '@vnuge/vnlib.browser' import { computed, ref, watch } from 'vue' import { Dialog, DialogPanel } from '@headlessui/vue' import { useStore } from '../../../../store' -import { } from 'pinia' +import { useToggle } from '@vueuse/core' const store = useStore() const { reveal } = useConfirm() const { isLocalAccount } = useSession() const { error } = useFormToaster() +const { refresh, pkiConfig } = store.pki! -const pkiEnabled = computed(() => isLocalAccount.value && includes(store.mfaEndabledMethods, "pki") && window.crypto.subtle) +const pkiEnabled = computed(() => isLocalAccount.value && includes(store.mfa.enabledMethods, "pki" as MfaMethod) && window.crypto.subtle) +const pkiPublicKeys = computed(() => store.pki!.publicKeys) -const isOpen = ref(false) +const [isOpen, toggleOpen] = useToggle() const keyData = ref('') const pemFormat = ref(false) const explicitCurve = ref("") -watch(isOpen, () =>{ +watch(isOpen, () => { keyData.value = '' pemFormat.value = false explicitCurve.value = "" //Reload status - store.mfaRefreshMethods() + refresh() }) -const setIsOpen = (value : boolean) => isOpen.value = value - -const onRemoveKey = async (single: PkiPublicKey) =>{ - const { isCanceled } = await reveal({ +const onRemoveKey = async (single: PkiPublicKey) => { + const { isCanceled } = await reveal({ title: 'Are you sure?', text: `This will remove key ${single.kid} from your account.` }) @@ -130,11 +37,11 @@ const onRemoveKey = async (single: PkiPublicKey) =>{ return; } - //Delete pki + //Delete pki await apiCall(async ({ toaster }) => { - + //TODO: require password or some upgrade to disable - const { success } = await store.pkiConfig.removeKey(single.kid); + const { success } = await pkiConfig.removeKey(single.kid); if (success) { toaster.general.success({ @@ -150,33 +57,33 @@ const onRemoveKey = async (single: PkiPublicKey) =>{ } //Refresh the status - store.mfaRefreshMethods() + refresh() }); } const onDisable = async () => { - const { isCanceled } = await reveal({ + const { isCanceled } = await reveal({ title: 'Are you sure?', text: 'This will disable PKI authentication for your account.' }) if (isCanceled) { - return; + return; } //Delete pki - await apiCall(async ({ toaster }) =>{ + await apiCall(async ({ toaster }) => { //Disable pki //TODO: require password or some upgrade to disable - const { success } = await store.pkiConfig.disable(); - - if(success){ + const { success } = await pkiConfig.disable(); + + if (success) { toaster.general.success({ title: 'Success', text: 'PKI authentication has been disabled.' }) } - else{ + else { toaster.general.error({ title: 'Error', text: 'PKI authentication could not be disabled.' @@ -184,40 +91,40 @@ const onDisable = async () => { } //Refresh the status - store.mfaRefreshMethods() + refresh() }); } -const onSubmitKeys = async () =>{ - - if(window.crypto.subtle == null){ +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)){ + if (isEmpty(keyData.value)) { error({ title: "Please enter key data" }) return; } - let jwk : PkiPublicKey & JsonWebKey; + let jwk: PkiPublicKey & JsonWebKey; 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)){ + 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)"}) + error({ title: "The key is not a valid Json Web Key (JWK)" }) return; } @@ -226,7 +133,7 @@ const onSubmitKeys = async () =>{ //init/update the key //TODO: require password or some upgrade to disable - const { getResultOrThrow } = await store.pkiConfig.addOrUpdate(jwk); + const { getResultOrThrow } = await pkiConfig.addOrUpdate(jwk); const result = getResultOrThrow(); @@ -234,12 +141,103 @@ const onSubmitKeys = async () =>{ title: 'Success', text: result }) - setIsOpen(false) + toggleOpen(false) }) } </script> -<style> +<template> + <div id="pki-settings" class="container"> + <div class="panel-content"> + + <div class="flex flex-row flex-wrap justify-between"> + <h5>PKI Authentication</h5> + <div class=""> + <div v-if="pkiEnabled" class="button-group"> + <button class="btn xs" @click.prevent="toggleOpen(true)"> + <fa-icon icon="plus" /> + <span class="pl-2">Add Key</span> + </button> + <button class="btn red xs" @click.prevent="onDisable"> + <fa-icon icon="minus-circle" /> + <span class="pl-2">Disable</span> + </button> + </div> + <div v-else class=""> + <button class="btn primary xs" @click.prevent="toggleOpen(true)"> + <fa-icon icon="plus" /> + <span class="pl-2">Add Key</span> + </button> + </div> + </div> + + <div v-if="pkiPublicKeys && pkiPublicKeys.length > 0" class="w-full mt-4"> + <table class="min-w-full text-sm divide-y-2 divide-gray-200 dark:divide-dark-500"> + <thead class="text-left"> + <tr> + <th class="p-2 font-medium whitespace-nowrap dark:text-white" > + KeyID + </th> + <th class="p-2 font-medium whitespace-nowrap dark:text-white"> + Algorithm + </th> + <th class="p-2 font-medium whitespace-nowrap dark:text-white"> + Curve + </th> + <th class="p-2"></th> + </tr> + </thead> + + <tbody class="divide-y divide-gray-200 dark:divide-dark-500"> + <tr v-for="key in pkiPublicKeys"> + <td class="p-2 t font-medium truncate max-w-[8rem] whitespace-nowrap dark:text-white"> + {{ key.kid }} + </td> + <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> + {{ key.alg }} + </td> + <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> + {{ key.crv }} + </td> + <td class="p-2 text-right whitespace-nowrap"> + <button class="rounded btn red xs borderless" @click="onRemoveKey(key)"> + <span class="hidden sm:inline">Remove</span> + <fa-icon icon="trash-can" class="inline sm:hidden" /> + </button> + </td> + </tr> + </tbody> + </table> + </div> + + <p v-else class="p-1 pt-3 text-sm text-color-background"> + 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="toggleOpen(false)" 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-700 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" @click.prevent="toggleOpen(false)">Cancel</button> + </div> + </DialogPanel> + </div> + </Dialog> +</template> + -</style> diff --git a/front-end/src/views/Account/components/settings/Security.vue b/front-end/src/views/Account/components/settings/Security.vue index e6075f9..ae0d143 100644 --- a/front-end/src/views/Account/components/settings/Security.vue +++ b/front-end/src/views/Account/components/settings/Security.vue @@ -1,3 +1,25 @@ +<script setup lang="ts"> +import { MfaMethod } from '@vnuge/vnlib.browser' +import { computed } from 'vue' +import { Switch } from '@headlessui/vue' +import { includes, isNil } from 'lodash-es' +import { useStore } from '../../../../store' +import Fido from './Fido.vue' +import Pki from './Pki.vue' +import TotpSettings from './TotpSettings.vue' +import PasswordReset from './PasswordReset.vue' + +const store = useStore(); + +//Load mfa methods +store.mfa.refresh(); + +const fidoEnabled = computed(() => includes(store.mfa.enabledMethods, 'fido' as MfaMethod)) +const totpEnabled = computed(() => includes(store.mfa.enabledMethods, MfaMethod.TOTP)) +const pkiEnabled = computed(() => !isNil(store.pki)) + +</script> + <template> <div id="account-security-settings"> <div class="panel-container"> @@ -20,20 +42,20 @@ </div> </div> - <Pki /> + <Pki v-if="pkiEnabled" /> <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="autoHeartbeat" - :class="autoHeartbeat ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-500'" + v-model="store.autoHeartbeat" + :class="store.autoHeartbeat ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-500'" class="relative inline-flex items-center h-6 rounded-full w-11" > <span class="sr-only">Enable auto heartbeat</span> <span - :class="autoHeartbeat ? 'translate-x-6' : 'translate-x-1'" + :class="store.autoHeartbeat ? 'translate-x-6' : 'translate-x-1'" class="inline-block w-4 h-4 transition transform bg-white rounded-full" /> </Switch> @@ -51,29 +73,6 @@ </div> </template> -<script setup lang="ts"> -import { MfaMethod } from '@vnuge/vnlib.browser' -import { computed } from 'vue' -import { Switch } from '@headlessui/vue' -import { includes } from 'lodash-es' -import { storeToRefs } from 'pinia' -import { useStore } from '../../../../store' -import Fido from './Fido.vue' -import Pki from './Pki.vue' -import TotpSettings from './TotpSettings.vue' -import PasswordReset from './PasswordReset.vue' - -const store = useStore(); -const { autoHeartbeat } = storeToRefs(store); - -//Load mfa methods -store.mfaRefreshMethods(); - -const fidoEnabled = computed(() => includes(store.mfaEndabledMethods, 'fido' as MfaMethod)) -const totpEnabled = computed(() => includes(store.mfaEndabledMethods, MfaMethod.TOTP)) - -</script> - <style> #account-security-settings .modal-body{ diff --git a/front-end/src/views/Account/components/settings/Settings.vue b/front-end/src/views/Account/components/settings/Settings.vue index fb86951..0580b58 100644 --- a/front-end/src/views/Account/components/settings/Settings.vue +++ b/front-end/src/views/Account/components/settings/Settings.vue @@ -1,3 +1,7 @@ +<script setup lang="ts"> +import Security from './Security.vue' +</script> + <template> <div id="account-settings" class="container"> <div class="acnt-content-container"> @@ -5,12 +9,3 @@ </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 index 0fcfe31..04a261b 100644 --- a/front-end/src/views/Account/components/settings/TotpSettings.vue +++ b/front-end/src/views/Account/components/settings/TotpSettings.vue @@ -1,99 +1,11 @@ -<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-500"> - <span v-for="code in secretSegments" :key="code" class="px-2 font-mono tracking-wider" > - {{ code }} - </span> - </p> - - <p class="py-2 text-color-background"> - 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 xs" @click.prevent="regenTotp"> - <fa-icon icon="sync" /> - <span class="pl-2">Regenerate</span> - </button> - <button class="btn red xs" @click.prevent="disable"> - <fa-icon icon="minus-circle" /> - <span class="pl-2">Disable</span> - </button> - </div> - - <div v-else> - <button class="btn primary xs" @click.prevent="configTotp"> - <fa-icon icon="plus" /> - <span class="pl-2">Setup</span> - </button> - </div> - <p class="p-1 pt-3 text-sm text-color-background"> - 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-es' import { TOTP } from 'otpauth' -import { computed, ref, defineAsyncComponent } from 'vue' import base32Encode from 'base32-encode' -import { +import QrCodeVue from 'qrcode.vue' +import VOtpInput from "vue3-otp-input"; +import { computed, ref } from 'vue' +import { useSession, useMessage, useConfirm, @@ -104,26 +16,23 @@ import { import { useStore } from '../../../../store'; import { storeToRefs } from 'pinia'; -const VueQrcode = defineAsyncComponent(() => import('@chenfengyuan/vue-qrcode')) -const VOtpInput = defineAsyncComponent(() => import('vue3-otp-input')); - -interface TotpConfig{ - secret: string; - readonly issuer: string; - readonly algorithm: string; - readonly digits?: number; - readonly period?: number; +interface TotpConfig { + secret: string; + readonly issuer: string; + readonly algorithm: string; + readonly digits?: number; + readonly period?: number; } const store = useStore(); -const { userName, isLocalAccount, mfaEndabledMethods } = storeToRefs(store); +const { isLocalAccount } = storeToRefs(store); const { KeyStore } = useSession() const { reveal } = useConfirm() const { elevatedApiCall } = usePassConfirm() const { onInput, setMessage } = useMessage() -const totpEnabled = computed(() => includes(mfaEndabledMethods.value, MfaMethod.TOTP)) +const totpEnabled = computed(() => includes(store.mfa.enabledMethods, MfaMethod.TOTP)) const totpMessage = ref<TotpConfig>() const showSubmitButton = ref(false) @@ -152,7 +61,7 @@ const qrCode = computed(() => { 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()}` + const url = `otpauth://totp/${m.issuer}:${store.userName}?${params.toString()}` return url }) @@ -160,7 +69,7 @@ const ProcessAddOrUpdate = async () => { await elevatedApiCall(async ({ password }) => { // Init or update the totp method and get the encrypted totp message - const res = await store.mfaConfig.initOrUpdateMethod<TotpConfig>(MfaMethod.TOTP, password); + const res = await store.mfa.initOrUpdateMethod<TotpConfig>(MfaMethod.TOTP, password); //Get the encrypted totp message const totp = res.getResultOrThrow() @@ -181,7 +90,7 @@ const configTotp = async () => { text: 'Are you sure you understand TOTP multi factor and wish to enable it?', }) - if(!isCanceled){ + if (!isCanceled) { ProcessAddOrUpdate() } } @@ -197,7 +106,7 @@ const regenTotp = async () => { text: 'If you continue your previous TOTP authenticator and recovery codes will no longer be valid.' }) - if(!isCanceled){ + if (!isCanceled) { ProcessAddOrUpdate() } } @@ -214,16 +123,15 @@ const disable = async () => { } await elevatedApiCall(async ({ password }) => { - // Disable the totp method - const res = await store.mfaConfig.disableMethod(MfaMethod.TOTP, password) + const res = await store.mfa.disableMethod(MfaMethod.TOTP, password) res.getResultOrThrow() - - store.mfaRefreshMethods() + + store.mfa.refresh() }) } -const VerifyTotp = async (code : string) => { +const VerifyTotp = async (code: string) => { // Create a new TOTP instance from the current message const totp = new TOTP(totpMessage.value) @@ -231,10 +139,11 @@ const VerifyTotp = async (code : string) => { const valid = totp.validate({ token: code, window: 4 }) if (valid) { - showSubmitButton.value = true + showSubmitButton.value = true; + toaster.success({ title: 'Success', - text: 'Your TOTP code is valid and your account is now verified.' + text: 'Your TOTP code is valid and is now enabled' }) } else { setMessage('Your TOTP code is not valid.') @@ -244,12 +153,103 @@ const VerifyTotp = async (code : string) => { const CloseQrWindow = () => { showSubmitButton.value = false totpMessage.value = undefined - + //Fresh methods - store.mfaRefreshMethods() + store.mfa.refresh() } </script> +<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"> + <QrCodeVue class="m-auto" :size="180" render-as="svg" level="Q" :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-500"> + <span v-for="code in secretSegments" :key="code" class="px-2 font-mono tracking-wider" > + {{ code }} + </span> + </p> + + <p class="py-2 text-color-background"> + 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="" + value="" + :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 xs" @click.prevent="regenTotp"> + <fa-icon icon="sync" /> + <span class="pl-2">Regenerate</span> + </button> + <button class="btn red xs" @click.prevent="disable"> + <fa-icon icon="minus-circle" /> + <span class="pl-2">Disable</span> + </button> + </div> + + <div v-else> + <button class="btn primary xs" @click.prevent="configTotp"> + <fa-icon icon="plus" /> + <span class="pl-2">Setup</span> + </button> + </div> + <p class="p-1 pt-3 text-sm text-color-background"> + 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> + <style> diff --git a/front-end/src/views/Blog/components/Posts.vue b/front-end/src/views/Blog/components/Posts.vue index 647e093..b89d263 100644 --- a/front-end/src/views/Blog/components/Posts.vue +++ b/front-end/src/views/Blog/components/Posts.vue @@ -1,24 +1,3 @@ -<template> - <div id="post-editor" class=""> - <EditorTable title="Manage posts" :show-edit="showEdit" :pagination="pagination" @open-new="openNew"> - <template #table> - <PostTable - :items="items" - @open-edit="openEdit" - @delete="onDelete" - /> - </template> - <template #editor> - <PostEditor - @submit="onSubmit" - @close="closeEdit(true)" - @delete="onDelete" - /> - </template> - </EditorTable> - </div> -</template> - <script setup lang="ts"> import { computed, defineAsyncComponent } from 'vue'; import { isEmpty } from 'lodash-es'; @@ -49,7 +28,7 @@ const closeEdit = (update?: boolean) => { //reload channels if (update) { //must refresh content and posts when a post is updated - refresh(); + refresh(); } //Reset page to top window.scrollTo(0, 0); @@ -62,14 +41,14 @@ const openNew = () => { window.scrollTo(0, 0) } -const onSubmit = async ({post, content } : { post: PostMeta, content: string }) => { +const onSubmit = async ({ post, content }: { post: PostMeta, content: string }) => { debugLog('submitting', post, content); //Check for new channel, or updating old channel if (store.posts.selectedId === 'new') { //Exec create call - await apiCall(async ({toaster}) => { + await apiCall(async ({ toaster }) => { //endpoint returns the content const newMeta = await store.posts.add(post); @@ -86,9 +65,9 @@ const onSubmit = async ({post, content } : { post: PostMeta, content: string }) } else if (!isEmpty(store.posts.selectedId)) { //Exec update call - await apiCall(async ( {toaster} ) => { + await apiCall(async ({ toaster }) => { await store.posts.update(post); - + //Publish the content await store.content.updatePostContent(post, content) @@ -129,6 +108,26 @@ const onDelete = async (post: PostMeta) => { } </script> +<template> + <div id="post-editor" class=""> + <EditorTable title="Manage posts" :show-edit="showEdit" :pagination="pagination" @open-new="openNew"> + <template #table> + <PostTable + :items="items" + @open-edit="openEdit" + @delete="onDelete" + /> + </template> + <template #editor> + <PostEditor + @submit="onSubmit" + @close="closeEdit(true)" + @delete="onDelete" + /> + </template> + </EditorTable> + </div> +</template> <style lang="scss"> diff --git a/front-end/src/views/Blog/components/image-preview-dialog.vue b/front-end/src/views/Blog/components/image-preview-dialog.vue index 5cfe552..b134d19 100644 --- a/front-end/src/views/Blog/components/image-preview-dialog.vue +++ b/front-end/src/views/Blog/components/image-preview-dialog.vue @@ -1,34 +1,14 @@ -<template> - <div class=""> - <Dialog :open="isOpen" @close="onClose" class="relative z-50"> - <!-- The backdrop, rendered as a fixed sibling to the panel container --> - <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> - - <!-- Full-screen container to center the panel --> - <div class="fixed inset-0 flex items-center justify-center w-screen p-4"> - <!-- The actual dialog panel --> - <DialogPanel class="p-2 bg-white rounded dark:bg-dark-700" ref="dialog"> - <DialogDescription> - <img class="preview-image" :src="imgUrl" alt="preview" /> - </DialogDescription> - <!-- ... --> - </DialogPanel> - </div> - </Dialog> - </div> -</template> - <script setup lang="ts"> import { ref, computed, watch, toRefs } from 'vue' import { Dialog, DialogDescription } from '@headlessui/vue' -import { onClickOutside } from '@vueuse/core'; +import { onClickOutside, set } from '@vueuse/core'; import { useStore } from '../../../store'; import { ContentMeta } from '../../../../../lib/admin/dist'; import { isNil } from 'lodash-es'; import { apiCall } from '@vnuge/vnlib.browser'; const emit = defineEmits(['close']) -const props = defineProps<{ +const props = defineProps<{ item: ContentMeta | undefined, }>() @@ -52,19 +32,33 @@ const downloadImage = (item: ContentMeta) => { }) } -//load the image when open -watch(item, (item) => { - if (isNil(item)) { - imgUrl.value = undefined - } else { - downloadImage(item) - } -}) +//load the image when open or remove it if the item is undefined +watch(item, (item) => isNil(item) ? set(imgUrl, undefined) : downloadImage(item)) //Close dialog when clicking outside onClickOutside(dialog, onClose) </script> + +<template> + <div class=""> + <Dialog :open="isOpen" @close="onClose" class="relative z-50"> + <!-- The backdrop, rendered as a fixed sibling to the panel container --> + <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> + + <!-- Full-screen container to center the panel --> + <div class="fixed inset-0 flex items-center justify-center w-screen p-4"> + <!-- The actual dialog panel --> + <DialogPanel class="p-2 bg-white rounded dark:bg-dark-700" ref="dialog"> + <DialogDescription> + <img class="preview-image" :src="imgUrl" alt="preview" /> + </DialogDescription> + <!-- ... --> + </DialogPanel> + </div> + </Dialog> + </div> +</template> <style lang="scss"> .preview-image { diff --git a/front-end/src/views/Blog/index.vue b/front-end/src/views/Blog/index.vue index de435ec..af6ab88 100644 --- a/front-end/src/views/Blog/index.vue +++ b/front-end/src/views/Blog/index.vue @@ -1,3 +1,35 @@ +<script setup lang="ts"> +import { computed } from 'vue'; +import { useRouteQuery } from '@vueuse/router'; +import { TabGroup, TabList, Tab, TabPanels, TabPanel, Switch } from '@headlessui/vue' +import { defer, first } from 'lodash-es'; +import { useStore, SortType } from '../../store'; +import Channels from './components/Channels.vue'; +import Posts from './components/Posts.vue'; +import Content from './components/Content.vue'; + +//Protect page +const store = useStore() +store.setPageTitle('Blog Admin') + +const firstLetter = computed(() => first(store.userName)) +const tabIdQ = useRouteQuery<string>('tabid', '', { mode: 'push' }) + +//Map queries to their respective computed values +const tabId = computed(() => tabIdQ.value ? parseInt(tabIdQ.value) : 0); +const lastModified = computed({ + get: () => store.queryState.sort === SortType.ModifiedTime, + set: (value: boolean) => { + store.queryState.sort = value ? SortType.ModifiedTime : SortType.CreatedTime + } +}) + +const onTabChange = (id: number) => tabIdQ.value = id.toString(10) + +//Load channels on page load +defer(() => store.channels.refresh()); + +</script> <template> <div class="container mx-auto mt-10 mb-[10rem]"> <div id="blog-admin-template" class=""> @@ -107,40 +139,6 @@ </div> </template> -<script setup lang="ts"> -import { computed } from 'vue'; -import { useRouteQuery } from '@vueuse/router'; -import { TabGroup, TabList, Tab, TabPanels, TabPanel, Switch } from '@headlessui/vue' -import { defer, first } from 'lodash-es'; -import { useStore, SortType } from '../../store'; -import Channels from './components/Channels.vue'; -import Posts from './components/Posts.vue'; -import Content from './components/Content.vue'; - - -//Protect page -const store = useStore() -store.setPageTitle('Blog Admin') - -const firstLetter = computed(() => first(store.userName)) -const tabIdQ = useRouteQuery<string>('tabid', '', { mode: 'push' }) - -//Map queries to their respective computed values -const tabId = computed(() => tabIdQ.value ? parseInt(tabIdQ.value) : 0); -const lastModified = computed({ - get :() => store.queryState.sort === SortType.ModifiedTime, - set: (value:boolean) => { - store.queryState.sort = value ? SortType.ModifiedTime : SortType.CreatedTime - } -}) - -const onTabChange = (id:number) => tabIdQ.value = id.toString(10) - -//Load channels on page load -defer(() => store.channels.refresh()); - -</script> - <style lang="scss"> #blog-admin-template{ diff --git a/front-end/src/views/Login/components/Social.vue b/front-end/src/views/Login/components/Social.vue index 4d22074..7e92d99 100644 --- a/front-end/src/views/Login/components/Social.vue +++ b/front-end/src/views/Login/components/Social.vue @@ -1,19 +1,3 @@ -<template> - <div class="flex flex-col gap-3"> - <div v-for="method in methods" :key="method.Id" class=""> - <button - type="submit" - class="btn social-button" - :disabled="waiting" - @click.prevent="submitLogin(method)" - > - <fa-icon :icon="getIcon(method)" size="xl" /> - Login with {{ capitalize(method.Id) }} - </button> - </div> - </div> -</template> - <script setup lang="ts"> import { shallowRef } from 'vue' import { apiCall, useWait, type OAuthMethod } from '@vnuge/vnlib.browser' @@ -43,4 +27,22 @@ const getIcon = (method: OAuthMethod): string[] => { //Load methods once the fetch completes store.socialOauth().then(m => methods.value = m.methods); -</script>
\ No newline at end of file +</script> + +<template> + + <div class="flex flex-col gap-3"> + <div v-for="method in methods" :key="method.Id" class=""> + <button + type="submit" + class="btn social-button" + :disabled="waiting" + @click.prevent="submitLogin(method)" + > + <fa-icon :icon="getIcon(method)" size="xl" /> + Login with {{ capitalize(method.Id) }} + </button> + </div> + </div> + +</template> diff --git a/front-end/src/views/Login/components/UserPass.vue b/front-end/src/views/Login/components/UserPass.vue index 442abb1..bc9d8d1 100644 --- a/front-end/src/views/Login/components/UserPass.vue +++ b/front-end/src/views/Login/components/UserPass.vue @@ -55,14 +55,15 @@ </template> <script setup lang="ts"> -import { ref, shallowRef, reactive, defineAsyncComponent, type Ref } from 'vue' +import { ref, shallowRef, reactive, defineAsyncComponent, Ref } from 'vue' import { useTimeoutFn, set } from '@vueuse/core' import { useVuelidate } from '@vuelidate/core' import { isEqual } from 'lodash-es' import { required, maxLength, minLength, email, helpers } from '@vuelidate/validators' import { useVuelidateWrapper, useMfaLogin, totpMfaProcessor, IMfaFlowContinuiation, MfaMethod, - apiCall, useMessage, useWait, debugLog, WebMessage + apiCall, useMessage, useWait, debugLog, WebMessage, + type VuelidateInstance } from '@vnuge/vnlib.browser' const Totp = defineAsyncComponent(() => import('./Totp.vue')) @@ -97,7 +98,7 @@ const rules = { } const v$ = useVuelidate(rules, vState) -const { validate } = useVuelidateWrapper(v$); +const { validate } = useVuelidateWrapper(v$ as Ref<VuelidateInstance>); const SubmitLogin = async () => { diff --git a/front-end/src/views/Login/index.vue b/front-end/src/views/Login/index.vue index 6a55aeb..476ebf4 100644 --- a/front-end/src/views/Login/index.vue +++ b/front-end/src/views/Login/index.vue @@ -1,3 +1,38 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { apiCall, useWait } from '@vnuge/vnlib.browser' +import { isNil } from 'lodash-es' +import { useStore } from '../../store' +import { storeToRefs } from 'pinia' +import UserPass from './components/UserPass.vue' +import Social from './components/Social.vue' + +const store = useStore(); +const { loggedIn } = storeToRefs(store) +const pkiEnabled = computed(() => !isNil(store.pki?.pkiAuth)) + +store.setPageTitle('Login') + +const { waiting } = useWait() + +const submitLogout = async () => { + //Submit logout request + await apiCall(async ({ toaster }) => { + const { logout } = await store.socialOauth() + // Attempt to logout + await logout() + // Push a new toast message + toaster.general.success({ + id: 'logout-success', + title: 'Success', + text: 'You have been logged out', + duration: 5000 + }) + }) +} + +</script> + <template> <div id="login-template" class="app-component-entry"> <div class="login-container"> @@ -25,7 +60,7 @@ <Social /> <!-- pki button, forward to the pki route --> - <div v-if="pkiEnabled" class="mt-3"> + <div v-if="pkiEnabled" class="mt-4"> <router-link to="/login/pki"> <button type="submit" class="btn red social-button" :disabled="waiting"> <fa-icon :icon="['fa','certificate']" size="xl" /> @@ -39,44 +74,6 @@ </div> </template> -<script setup lang="ts"> -import { } from 'vue' -import { apiCall, useWait } from '@vnuge/vnlib.browser' -import { isNil } from 'lodash-es' -import { useStore } from '../../store' -import { storeToRefs } from 'pinia' -import UserPass from './components/UserPass.vue' -import Social from './components/Social.vue' - -//pki enabled flag from env -const pkiEnabled = !isNil(import.meta.env.VITE_PKI_ENABLED); - -const store = useStore(); -const { loggedIn } = storeToRefs(store) - -store.setPageTitle('Login') - -const { waiting } = useWait() - -const submitLogout = async () => { - //Submit logout request - await apiCall(async ({ toaster }) => { - // Attempt to logout - const { logout } = await store.socialOauth() - await logout() - - // Push a new toast message - toaster.general.success({ - id: 'logout-success', - title: 'Success', - text: 'You have been logged out', - duration: 5000 - }) - }) -} - -</script> - <style lang="scss"> #login-template { .login-container{ diff --git a/front-end/src/views/Login/pki/index.vue b/front-end/src/views/Login/pki/index.vue index 585942a..8edd063 100644 --- a/front-end/src/views/Login/pki/index.vue +++ b/front-end/src/views/Login/pki/index.vue @@ -1,3 +1,38 @@ +<script setup lang="ts"> +import { isEmpty } from 'lodash-es'; +import { apiCall, debugLog, useMessage } from '@vnuge/vnlib.browser'; +import { ref } from 'vue' +import { decodeJwt } from 'jose' +import { useRouter } from 'vue-router'; +import { useStore } from '../../../store'; + +const { setMessage } = useMessage() +const { push } = useRouter() +const store = useStore() + +const otp = ref('') + +const submit = () => { + + apiCall(async () => { + if (isEmpty(otp.value)) { + setMessage('Please enter your OTP') + return + } + + //try to decode the jwt to confirm its form is valid + const jwt = decodeJwt(otp.value) + debugLog(jwt) + + await store.pki!.pkiAuth.login(otp.value) + + //Go back to login page + push({ name: 'Login' }) + }) +} + +</script> + <template> <div id="pki-login-template" class="app-component-entry"> <div class="container max-w-lg mx-auto mt-6 lg:mt-20"> @@ -30,38 +65,3 @@ </div> </div> </template> - -<script setup lang="ts"> -import { isEmpty } from 'lodash-es'; -import { apiCall, debugLog, useMessage } from '@vnuge/vnlib.browser'; -import { ref } from 'vue' -import { decodeJwt } from 'jose' -import { useRouter } from 'vue-router'; -import { useStore } from '../../../store'; - -const { setMessage } = useMessage() -const { push } = useRouter() -const store = useStore() - -const otp = ref('') - -const submit = () =>{ - - apiCall(async () =>{ - if(isEmpty(otp.value)){ - setMessage('Please enter your OTP') - return - } - - //try to decode the jwt to confirm its form is valid - const jwt = decodeJwt(otp.value) - debugLog(jwt) - - await store.pkiAuth.login(otp.value) - - //Go back to login page - push({ name: 'Login' }) - }) -} - -</script>
\ No newline at end of file diff --git a/front-end/src/views/Login/social/[type].vue b/front-end/src/views/Login/social/[type].vue index 51da94f..f011f9c 100644 --- a/front-end/src/views/Login/social/[type].vue +++ b/front-end/src/views/Login/social/[type].vue @@ -1,35 +1,3 @@ -<template> - <div id="social-login-template" class="app-component-entry"> - <div class="container flex flex-col m-auto my-16"> - <div id="social-final-template" class="flex justify-center"> - <div class="entry-container"> - <h3>Finalizing login</h3> - <div class="mt-6 mb-4"> - <div v-if="message?.length > 0" class="text-lg text-red-500 dark:text-rose-500"> - <p>{{ message }}</p> - <div class="flex justify-center mt-5"> - <router-link to="/login"> - <button type="submit" class="btn primary" :disabled="waiting"> - <fa-icon icon="sign-in-alt" /> - Try again - </button> - </router-link> - </div> - </div> - <div v-else> - <div class="flex justify-center"> - <div class="m-auto"> - <fa-icon class="animate-spin" icon="spinner" size="2x"/> - </div> - </div> - <p>Please wait while we log you in.</p> - </div> - </div> - </div> - </div> - </div> - </div> -</template> <script setup lang="ts"> import { defer } from 'lodash-es' @@ -62,19 +30,20 @@ tryOnMounted(() => defer(() => { //try to complete an oauth login apiCall(async ({ toaster }) => { - try{ - //Complete the login - const { completeLogin } = await store.socialOauth(); - await completeLogin() - - toaster.general.success({ - title:'Login Successful', - text: 'You have successfully logged in.' - }) - - router.push({ name: 'Login' }) + try { + const { completeLogin } = await store.socialOauth(); + + //Complete the login + await completeLogin(); + + toaster.general.success({ + title: 'Login Successful', + text: 'You have successfully logged in.' + }) + + router.push({ name: 'Login' }) } - catch(err: any){ + catch (err: any) { set(message, err.message) } }) @@ -82,6 +51,39 @@ tryOnMounted(() => defer(() => { </script> +<template> + <div id="social-login-template" class="app-component-entry"> + <div class="container flex flex-col m-auto my-16"> + <div id="social-final-template" class="flex justify-center"> + <div class="entry-container"> + <h3>Finalizing login</h3> + <div class="mt-6 mb-4"> + <div v-if="message?.length > 0" class="text-lg text-red-500 dark:text-rose-500"> + <p>{{ message }}</p> + <div class="flex justify-center mt-5"> + <router-link to="/login"> + <button type="submit" class="btn primary" :disabled="waiting"> + <fa-icon icon="sign-in-alt" /> + Try again + </button> + </router-link> + </div> + </div> + <div v-else> + <div class="flex justify-center"> + <div class="m-auto"> + <fa-icon class="animate-spin" icon="spinner" size="2x"/> + </div> + </div> + <p>Please wait while we log you in.</p> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + <style lang="scss"> #social-login-template{ |