aboutsummaryrefslogtreecommitdiff
path: root/front-end/src/components/Settings
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2024-01-20 23:49:29 -0500
committerLibravatar vnugent <public@vaughnnugent.com>2024-01-20 23:49:29 -0500
commit6cb7da37824d02a1898d08d0f9495c77fde4dd1d (patch)
tree95e37ea3c20f416d6a205ee4ab050c307b18eafe /front-end/src/components/Settings
inital commit
Diffstat (limited to 'front-end/src/components/Settings')
-rw-r--r--front-end/src/components/Settings/Oauth2Apps.vue85
-rw-r--r--front-end/src/components/Settings/PasswordReset.vue156
-rw-r--r--front-end/src/components/Settings/PkiSettings.vue216
-rw-r--r--front-end/src/components/Settings/TotpSettings.vue169
4 files changed, 626 insertions, 0 deletions
diff --git a/front-end/src/components/Settings/Oauth2Apps.vue b/front-end/src/components/Settings/Oauth2Apps.vue
new file mode 100644
index 0000000..11f5d1e
--- /dev/null
+++ b/front-end/src/components/Settings/Oauth2Apps.vue
@@ -0,0 +1,85 @@
+<script setup lang="ts">
+import { defineAsyncComponent, shallowRef } from 'vue';
+import { useStore } from '../../store';
+import { formatTimeAgo, set } from '@vueuse/core';
+import { OAuth2Application } from '../../store/userProfile';
+import { ServerDataBuffer, useDataBuffer } from '@vnuge/vnlib.browser';
+const Dialog = defineAsyncComponent(() => import('../global/Dialog.vue'));
+
+const { oauth2 } = useStore();
+
+const editBuffer = shallowRef<ServerDataBuffer<OAuth2Application, void> | undefined>();
+const editApp = (app: OAuth2Application) =>{
+ //Init new server data buffer to push updates
+ const buffer = useDataBuffer(app, ({ buffer }) => oauth2.updateMeta(buffer));
+ set(editBuffer, buffer);
+}
+
+const cancelEdit = () => set(editBuffer, undefined);
+
+</script>
+
+<template>
+ <div class="">
+ <div class="flex flex-row justify-between">
+ <h3 class="text-xl font-bold">OAuth2 Apps</h3>
+ <div class="">
+
+ <button type="button" class="btn blue" @click.prevent="">
+ <div class="flex flex-row items-center gap-1.5 ">
+ New
+ </div>
+ </button>
+
+ </div>
+ </div>
+
+ <div class="relative mt-2 overflow-x-auto shadow-md sm:rounded">
+ <table class="w-full text-sm text-left text-gray-500 rtl:text-right dark:text-gray-400">
+ <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
+ <tr>
+ <th scope="col" class="px-4 py-2">
+ Client ID
+ </th>
+ <th scope="col" class="px-4 py-2">
+ Name
+ </th>
+ <th scope="col" class="px-4 py-2">
+ Created
+ </th>
+ <th scope="col" class="px-4 py-2">
+ Action
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="app in oauth2.apps" :key="app.Id" class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
+ <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+ {{ app.client_id }}
+ </th>
+ <td class="px-4 py-3">
+ {{ app.name }}
+ </td>
+ <td class="px-4 py-3">
+ {{ formatTimeAgo(app.Created) }}
+ </td>
+ <td class="px-4 py-3">
+ <button
+ @click.prevent="editApp(app)"
+ class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
+ Edit
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <Dialog :open="editBuffer!= undefined" title="Edit App" @cancel="cancelEdit">
+ <template #body>
+ Hello world
+ </template>
+ </Dialog>
+ </div>
+</template>
+
+<style lang="scss"></style> \ No newline at end of file
diff --git a/front-end/src/components/Settings/PasswordReset.vue b/front-end/src/components/Settings/PasswordReset.vue
new file mode 100644
index 0000000..41633b7
--- /dev/null
+++ b/front-end/src/components/Settings/PasswordReset.vue
@@ -0,0 +1,156 @@
+<script setup lang="ts">
+import { computed, defineAsyncComponent, shallowReactive } from 'vue';
+import { useStore } from '../../store';
+import { get, useToggle } from '@vueuse/core';
+import { MfaMethod, apiCall, useUser, useVuelidateWrapper } from '@vnuge/vnlib.browser';
+import { includes, isEmpty, toSafeInteger } from 'lodash-es';
+import { useVuelidate } from '@vuelidate/core'
+import { required, maxLength, minLength, helpers } from '@vuelidate/validators'
+const Dialog = defineAsyncComponent(() => import('../global/Dialog.vue'));
+
+const store = useStore();
+const { resetPassword } = useUser()
+const totpEnabled = computed(() => includes(store.mfaEndabledMethods, MfaMethod.TOTP))
+
+const [ isOpen, toggleOpen ] = useToggle(false)
+const vState = shallowReactive({
+ current: '',
+ newPassword: '',
+ repeatPassword: '',
+ 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', (value: string) => get(totpEnabled) ? !isEmpty(value) : true),
+ 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)
+const { validate } = useVuelidateWrapper(v$ as any)
+
+const onSubmit = async () => {
+ if (!await validate()) return
+
+ apiCall(async ({toaster}) => {
+ //Rest password and pass totp code
+ const { getResultOrThrow } = await resetPassword(vState.current, vState.newPassword, { totp_code: toSafeInteger(vState.totpCode) })
+ getResultOrThrow()
+
+ toaster.general.success({
+ title: 'Password Reset',
+ text: 'Your password has been reset'
+ });
+
+ onCancel()
+ })
+}
+
+const onCancel = () => {
+ vState.current = ''
+ vState.newPassword = ''
+ vState.repeatPassword = ''
+ vState.totpCode = ''
+ v$.value.$reset()
+ toggleOpen(false)
+}
+
+</script>
+
+<template>
+ <div class="">
+
+ <div class="flex flex-row justify-between w-full">
+ <h3 class="text-xl font-bold">Password</h3>
+
+ <div class="flex flex-row justify-end">
+ <button class="btn blue" @click="toggleOpen(true)">Reset Password</button>
+ </div>
+ </div>
+
+ <Dialog :open="isOpen" title="Reset Password" @cancel="onCancel">
+ <template #body>
+ <div class="p-4">
+
+ <form @submit.prevent="onSubmit">
+
+ <div class="mb-4">
+ <label for="current_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Current</label>
+ <input
+ v-model="v$.current.$model"
+ type="password"
+ id="current_password"
+ class="input"
+ :class="{ 'error': v$.current.$error, 'dirty': v$.current.$dirty }"
+ placeholder="•••••••••"
+ required
+ >
+ </div>
+ <div class="mb-4">
+ <label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">New Password</label>
+ <input
+ v-model="v$.newPassword.$model"
+ type="password"
+ id="password"
+ class="input"
+ :class="{ 'error': v$.newPassword.$error, 'dirty': v$.newPassword.$dirty }"
+ placeholder="•••••••••"
+ required
+ >
+ </div>
+ <div class="mb-4">
+ <label for="confirm_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm password</label>
+ <input
+ v-model="v$.repeatPassword.$model"
+ type="password"
+ id="confirm_password"
+ class="input"
+ :class="{ 'error': v$.repeatPassword.$error, 'dirty': v$.repeatPassword.$dirty }"
+ placeholder="•••••••••"
+ required
+ >
+ </div>
+ <div v-if="totpEnabled" class="mb-4">
+ <label for="totp_code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">TOTP Code</label>
+ <input
+ v-model="v$.totpCode.$model"
+ type="text"
+ id="totp_code"
+ class="input"
+ :class="{ 'error': v$.totpCode.$error, 'dirty': v$.totpCode.$dirty }"
+ placeholder="6 Digit Code"
+ required
+ >
+ </div>
+ <div class="ml-auto w-fit">
+ <button type="submit" class="btn blue">
+ Submit
+ </button>
+ </div>
+ </form>
+ </div>
+ </template>
+ </Dialog>
+ </div>
+</template> \ No newline at end of file
diff --git a/front-end/src/components/Settings/PkiSettings.vue b/front-end/src/components/Settings/PkiSettings.vue
new file mode 100644
index 0000000..5fa5c93
--- /dev/null
+++ b/front-end/src/components/Settings/PkiSettings.vue
@@ -0,0 +1,216 @@
+<script setup lang="ts">
+import { defineAsyncComponent, shallowRef } from 'vue';
+import { useStore } from '../../store';
+import { get, set } from '@vueuse/core';
+import { PkiPublicKey, apiCall, debugLog, useConfirm, useGeneralToaster } from '@vnuge/vnlib.browser';
+import { storeToRefs } from 'pinia';
+import { isEmpty, toLower } from 'lodash-es';
+const Dialog = defineAsyncComponent(() => import('../global/Dialog.vue'));
+
+const store = useStore();
+const { pkiPublicKeys } = storeToRefs(store);
+const { reveal } = useConfirm()
+const toaster = useGeneralToaster()
+
+const keyData = shallowRef<string | undefined>()
+const showAddKeyDialog = () => set(keyData, '');
+const hideAddKeyDialog = () => set(keyData, undefined);
+
+const removeKey = async (key: PkiPublicKey) => {
+ const { isCanceled } = await reveal({
+ title: 'Remove Key',
+ text: `Are you sure you want to remove ${key.kid}?`
+ });
+
+ if (isCanceled) return
+
+ apiCall(async ({ toaster }) => {
+ //const { getResultOrThrow } = await store.pkiConfig.removeKey(key.kid);
+ //await getResultOrThrow();
+
+ toaster.general.success({
+ title: 'Key Removed',
+ text: `${key.kid} has been successfully removed`
+ })
+ })
+}
+
+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 () => {
+
+ //Disable pki
+ //TODO: require password or some upgrade to disable
+ const { success } = await store.pkiConfig.disable();
+
+ if (success) {
+ toaster.success({
+ title: 'Success',
+ text: 'PKI authentication has been disabled.'
+ })
+ }
+ else {
+ toaster.error({
+ title: 'Error',
+ text: 'PKI authentication could not be disabled.'
+ })
+ }
+
+ //Refresh the status
+ store.mfaRefreshMethods()
+ });
+}
+
+const onAddKey = async () => {
+
+ if (window.crypto.subtle == null) {
+ toaster.error({ title: "Your browser does not support PKI authentication." })
+ return;
+ }
+
+ const jwkKeyData = get(keyData)
+
+ //Validate key data
+ if (!jwkKeyData || isEmpty(jwkKeyData)) {
+ toaster.error({ title: "Please enter key data" })
+ return;
+ }
+
+ let jwk: PkiPublicKey & JsonWebKey;
+ try {
+ //Try to parse as jwk
+ jwk = JSON.parse(jwkKeyData)
+ 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)
+ toaster.error({ title: "Invalid JWK", text:"The public key you entered is not a valid JWK" })
+ return;
+ }
+
+ //Send to server
+ await apiCall(async () => {
+
+ //init/update the key
+ //TODO: require password or some upgrade to disable
+ const { getResultOrThrow } = await store.pkiConfig.addOrUpdate(jwk);
+ const result = getResultOrThrow();
+
+ toaster.success({
+ title: 'Success',
+ text: result
+ })
+
+ hideAddKeyDialog()
+ })
+}
+
+</script>
+
+<template>
+ <div class="">
+ <div class="flex flex-row justify-between">
+ <h3 class="text-xl font-bold">Authentication Keys</h3>
+ <div class="">
+
+ <button type="button" class="btn blue" @click.prevent="showAddKeyDialog">
+ <div class="flex flex-row items-center gap-1.5">
+ Add
+ </div>
+ </button>
+ <button type="button" class="btn red" @click.prevent="onDisable">
+ <div class="flex flex-row items-center gap-1.5">
+ Disable
+ </div>
+ </button>
+ </div>
+ </div>
+
+ <div class="relative mt-2 overflow-x-auto shadow-md sm:rounded">
+ <table class="w-full text-sm text-left text-gray-500 rtl:text-right dark:text-gray-400">
+ <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
+ <tr>
+ <th scope="col" class="px-4 py-2">
+ KeyID
+ </th>
+ <th scope="col" class="px-4 py-2">
+ Algorithm
+ </th>
+ <th scope="col" class="px-4 py-2">
+ Curve
+ </th>
+ <th scope="col" class="px-4 py-2">
+ Action
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="key in pkiPublicKeys" :key="key.kid"
+ class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
+ <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
+ {{ toLower(key.kid) }}
+ </th>
+ <td class="px-4 py-3">
+ {{ key.alg }}
+ </td>
+ <td class="px-4 py-3">
+ {{ key.crv }}
+ </td>
+ <td class="px-4 py-3">
+ <button @click.prevent="removeKey(key)"
+ class="font-medium text-red-600 dark:text-red-500 hover:underline">
+ Remove
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ </div>
+ <p class="p-3 text-sm text-gray-500 rtl:text-right dark:text-gray-400">
+ Above are your account authentication keys. You can use these keys to sign in to your account using the PKI
+ authentication method.
+ </p>
+ <Dialog :open="keyData != undefined" title="Add Key" @cancel="hideAddKeyDialog">
+ <template #body>
+ <div class="p-4">
+
+ <label for="message" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
+ Add a single Json Web Key (JWK) encoded public key to your account.
+ </label>
+
+ <textarea
+ id="add-pki-key"
+ rows="6"
+ v-model="keyData"
+ placeholder="Paste your JWK..."
+ class="block w-full text-sm text-gray-900 border border-gray-300 rounded bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
+ >
+ </textarea>
+
+ <div class="flex justify-end mt-3 row">
+ <button class="btn blue" @click="onAddKey()">Add Key</button>
+ </div>
+ </div>
+ </template>
+ </Dialog>
+ </div>
+</template>
+
+<style lang="scss"></style> \ No newline at end of file
diff --git a/front-end/src/components/Settings/TotpSettings.vue b/front-end/src/components/Settings/TotpSettings.vue
new file mode 100644
index 0000000..cc8642f
--- /dev/null
+++ b/front-end/src/components/Settings/TotpSettings.vue
@@ -0,0 +1,169 @@
+<script setup lang="ts">
+import { computed, defineAsyncComponent, shallowRef } from 'vue';
+import { useStore } from '../../store';
+import { set, get } from '@vueuse/core';
+import { MfaMethod, useGeneralToaster, usePassConfirm, useSession } from '@vnuge/vnlib.browser';
+import { defaultTo, includes, isEmpty, isNil } from 'lodash-es';
+import { TOTP } from 'otpauth'
+import base32Encode from 'base32-encode'
+const QrCode = defineAsyncComponent(() => import('qrcode.vue'));
+const Dialog = defineAsyncComponent(() => import('../global/Dialog.vue'));
+const VOtpInput = defineAsyncComponent(() => import('vue3-otp-input'))
+
+interface TotpConfig {
+ secret: string;
+ readonly issuer: string;
+ readonly algorithm: string;
+ readonly digits?: number;
+ readonly period?: number;
+}
+
+const { KeyStore } = useSession()
+const { elevatedApiCall } = usePassConfirm()
+const { success, error } = useGeneralToaster()
+const store = useStore()
+const newTotpConfig = shallowRef<TotpConfig | undefined>()
+const totpEnabled = computed(() => includes(store.mfaEndabledMethods, MfaMethod.TOTP))
+
+const qrCode = computed(() => {
+
+ const m = get(newTotpConfig);
+
+ if (isNil(m)) {
+ return ''
+ }
+
+ // 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())
+
+ return `otpauth://totp/${m.issuer}:${store.userName}?${params.toString()}`
+})
+
+const showUpdateDialog = computed(() => !isEmpty(get(qrCode)))
+
+const disableTotp = async () => {
+
+ elevatedApiCall(async ({ password, toaster }) =>{
+ const { getResultOrThrow } = await store.mfaConfig.disableMethod(MfaMethod.TOTP, password);
+ getResultOrThrow();
+
+ toaster.general.success({
+ title: 'TOTP Disabled',
+ text: 'TOTP has been disabled for your account.'
+ })
+ })
+}
+
+const addOrUpdate = async () => {
+
+ elevatedApiCall(async ({ password }) =>{
+ const { getResultOrThrow } = await store.mfaConfig.initOrUpdateMethod<TotpConfig>(MfaMethod.TOTP, password);
+ const newConfig = getResultOrThrow();
+
+ // Decrypt the server sent secret
+ const decSecret = await KeyStore.decryptDataAsync(newConfig.secret);
+ // Encode the secret to base32
+ newConfig.secret = base32Encode(decSecret, 'RFC3548', { padding: false })
+
+ set(newTotpConfig, newConfig);
+ })
+}
+
+const onVerifyOtp = async (code: string) => {
+ // Create a new TOTP instance from the current message
+ const totp = new TOTP(get(newTotpConfig))
+
+ // validate the code
+ const valid = totp.validate({ token: code, window: 4 })
+
+ if (valid) {
+ success({
+ title: 'Success',
+ text: 'Your code is valid and TOPT has been enabled.'
+ })
+
+ //Close the dialog
+ set(newTotpConfig, undefined)
+
+ } else {
+ error({ title: 'The code you entered is invalid.'})
+ }
+}
+
+</script>
+
+<template>
+
+ <div class="flex flex-row items-center justify-between">
+ <div class="relative me-4">
+ <h4 class="text-base font-bold">
+ TOTP
+ </h4>
+ <span
+ :class="[totpEnabled ? 'visible' : 'invisible' ]"
+ class="absolute top-0 w-3 h-3 bg-green-500 border-2 border-white rounded-full -end-5 dark:border-gray-800"
+ >
+ </span>
+ </div>
+ <div v-if="totpEnabled" class="flex" @click="addOrUpdate()">
+ <button class="btn light">
+ Regenerate
+ </button>
+ <button class="btn red" @click="disableTotp()">
+ Disable
+ </button>
+ </div>
+ <div v-else>
+ <button class="btn green" @click="addOrUpdate()">
+ Enable
+ </button>
+ </div>
+ </div>
+ <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
+ Use Time based One Time Passcodes (TOTP) to secure your account as a second factor authentication.
+ </p>
+
+ <Dialog :open="showUpdateDialog" title="TOTP Secret" @cancel="">
+ <template #body>
+ <div class="p-4 pb-8">
+ <div class="flex flex-col items-center justify-center">
+ <div>
+ <p class="text-sm text-gray-500 dark:text-gray-400">
+ Scan this QR code with your authenticator app to add this account.
+ </p>
+ </div>
+ <div class="mt-4">
+ <QrCode :size="200" level="L" :value="qrCode" />
+ </div>
+ <div class="w-full mt-4">
+ <input
+ type="text"
+ id="disabled-input-2"
+ aria-label="disabled input 2"
+ class="bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500"
+ :value="qrCode"
+ disabled
+ readonly
+ >
+ </div>
+
+ <div class="mx-auto mt-4">
+ <VOtpInput class="otp-input" input-type="letter-numeric" separator=""
+ input-classes="rounded" :num-inputs="6" value="" @on-change=""
+ @on-complete="onVerifyOtp" />
+ </div>
+ <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
+ Enter the 6 digit code from your authenticator app to verify.
+ </p>
+ </div>
+ </div>
+ </template>
+ </Dialog>
+</template>
+
+<style lang="scss"></style> \ No newline at end of file