aboutsummaryrefslogtreecommitdiff
path: root/front-end/src/views/Account/components/oauth
diff options
context:
space:
mode:
Diffstat (limited to 'front-end/src/views/Account/components/oauth')
-rw-r--r--front-end/src/views/Account/components/oauth/CreateApp.vue182
-rw-r--r--front-end/src/views/Account/components/oauth/Oauth.vue78
-rw-r--r--front-end/src/views/Account/components/oauth/SingleApplication.vue198
-rw-r--r--front-end/src/views/Account/components/oauth/o2AppValidation.ts43
4 files changed, 501 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..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