aboutsummaryrefslogtreecommitdiff
path: root/front-end/src
diff options
context:
space:
mode:
Diffstat (limited to 'front-end/src')
-rw-r--r--front-end/src/bootstrap/Environment.vue79
-rw-r--r--front-end/src/bootstrap/components/ConfirmPrompt.vue76
-rw-r--r--front-end/src/bootstrap/components/CookieWarning.vue30
-rw-r--r--front-end/src/bootstrap/components/Footer.vue24
-rw-r--r--front-end/src/bootstrap/components/Header.vue164
-rw-r--r--front-end/src/main.ts15
-rw-r--r--front-end/src/store/index.ts4
-rw-r--r--front-end/src/store/mfaSettingsPlugin.ts109
-rw-r--r--front-end/src/store/oauthAppsPlugin.ts154
-rw-r--r--front-end/src/store/pageProtectionPlugin.ts10
-rw-r--r--front-end/src/store/socialMfaPlugin.ts31
-rw-r--r--front-end/src/store/userProfile.ts30
-rw-r--r--front-end/src/views/Account/[comp].vue100
-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
-rw-r--r--front-end/src/views/Account/components/profile/Profile.vue119
-rw-r--r--front-end/src/views/Account/components/settings/Fido.vue37
-rw-r--r--front-end/src/views/Account/components/settings/PasswordReset.vue117
-rw-r--r--front-end/src/views/Account/components/settings/Pki.vue262
-rw-r--r--front-end/src/views/Account/components/settings/Security.vue53
-rw-r--r--front-end/src/views/Account/components/settings/Settings.vue13
-rw-r--r--front-end/src/views/Account/components/settings/TotpSettings.vue232
-rw-r--r--front-end/src/views/Blog/components/Posts.vue51
-rw-r--r--front-end/src/views/Blog/components/image-preview-dialog.vue54
-rw-r--r--front-end/src/views/Blog/index.vue66
-rw-r--r--front-end/src/views/Login/components/Social.vue36
-rw-r--r--front-end/src/views/Login/components/UserPass.vue7
-rw-r--r--front-end/src/views/Login/index.vue75
-rw-r--r--front-end/src/views/Login/pki/index.vue70
-rw-r--r--front-end/src/views/Login/social/[type].vue90
32 files changed, 1645 insertions, 964 deletions
diff --git a/front-end/src/bootstrap/Environment.vue b/front-end/src/bootstrap/Environment.vue
index 618ee62..c14b7b6 100644
--- a/front-end/src/bootstrap/Environment.vue
+++ b/front-end/src/bootstrap/Environment.vue
@@ -1,3 +1,42 @@
+<script setup lang="ts">
+
+import { computed, defineAsyncComponent } from 'vue'
+import { RouteLocation, useRouter } from 'vue-router'
+import { filter, map, without, find, includes } from 'lodash-es'
+import { storeToRefs } from 'pinia'
+import { useEnvSize } from '@vnuge/vnlib.browser'
+import { useStore } from '../store'
+import siteHeader from './components/Header.vue'
+import siteFooter from './components/Footer.vue'
+const ConfirmPrompt = defineAsyncComponent(() => import('./components/ConfirmPrompt.vue'));
+const CookieWarning = defineAsyncComponent(() => import('./components/CookieWarning.vue'));
+const PasswordPrompt = defineAsyncComponent(() => import('./components/PasswordPrompt.vue'));
+
+const emit = defineEmits(['logout'])
+const store = useStore()
+const { showCookieWarning, currentRoutes } = storeToRefs(store)
+const { getRoutes } = useRouter();
+
+//Use the env size to calculate the header and footer heights for us
+const { header, footer, content, headerHeight, footerHeight } = useEnvSize(true)
+
+const routes = computed<RouteLocation[]>(() => {
+ // Get routes that are defined above but only if they are defined in the router
+ // This is a computed property because loggedin is a reactive property
+
+ const routes = filter(getRoutes(), (pageName) => includes(currentRoutes.value, pageName.name))
+
+ const activeRoutes = map(currentRoutes.value, route => find(routes, { name: route }))
+
+ return without<RouteLocation>(activeRoutes, undefined)
+})
+
+//Forces the page content to be exactly the height of the viewport - header and footer sizes
+const bodyStyle = computed(() => ({ 'min-height': `calc(100vh - ${headerHeight.value + footerHeight.value}px)` }))
+const generalToastStyle = computed(() => ({ top: `${headerHeight.value + 5}px` }))
+const formToastStyle = computed(() => ({ top: `${headerHeight.value}px` }))
+
+</script>
<template>
<div id="env-entry" ref="content" class="absolute top-0 left-0 w-full min-h-screen env-bg">
<div class="absolute flex w-full">
@@ -44,43 +83,3 @@
<ConfirmPrompt />
</div>
</template>
-
-<script setup lang="ts">
-
-import { computed } from 'vue'
-import { RouteLocation, useRouter } from 'vue-router'
-import { filter, map, without, find, includes } from 'lodash-es'
-import { storeToRefs } from 'pinia'
-import { useEnvSize } from '@vnuge/vnlib.browser'
-import { useStore } from '../store'
-import CookieWarning from './components/CookieWarning.vue'
-import PasswordPrompt from './components/PasswordPrompt.vue'
-import siteHeader from './components/Header.vue'
-import siteFooter from './components/Footer.vue'
-import ConfirmPrompt from './components/ConfirmPrompt.vue'
-
-const emit = defineEmits(['logout'])
-const store = useStore()
-const { showCookieWarning, currentRoutes } = storeToRefs(store)
-const { getRoutes } = useRouter();
-
-//Use the env size to calculate the header and footer heights for us
-const { header, footer, content, headerHeight, footerHeight } = useEnvSize(true)
-
-const routes = computed<RouteLocation[]>(() => {
- // Get routes that are defined above but only if they are defined in the router
- // This is a computed property because loggedin is a reactive property
-
- const routes = filter(getRoutes(), (pageName) => includes(currentRoutes.value, pageName.name))
-
- const activeRoutes = map(currentRoutes.value, route => find(routes, { name: route }))
-
- return without<RouteLocation>(activeRoutes, undefined)
-})
-
-//Forces the page content to be exactly the height of the viewport - header and footer sizes
-const bodyStyle = computed(() => ({ 'min-height': `calc(100vh - ${headerHeight.value + footerHeight.value}px)` }))
-const generalToastStyle = computed(() => ({ top: `${headerHeight.value + 5}px` }))
-const formToastStyle = computed(() => ({ top: `${headerHeight.value}px` }))
-
-</script>
diff --git a/front-end/src/bootstrap/components/ConfirmPrompt.vue b/front-end/src/bootstrap/components/ConfirmPrompt.vue
index c67bcfc..3994672 100644
--- a/front-end/src/bootstrap/components/ConfirmPrompt.vue
+++ b/front-end/src/bootstrap/components/ConfirmPrompt.vue
@@ -1,3 +1,37 @@
+<script setup lang="ts">
+import { defaultTo, noop } from 'lodash-es'
+import { computed, ref } from 'vue'
+import { Dialog, DialogPanel, DialogTitle, DialogDescription } from '@headlessui/vue'
+import { onClickOutside } from '@vueuse/core'
+import { useConfirm, useEnvSize } from '@vnuge/vnlib.browser'
+
+export interface ConfirmMessage {
+ title: string
+ text: string
+ subtext?: string
+}
+
+const { headerHeight } = useEnvSize()
+//Use component side of confirm
+const { isRevealed, confirm, cancel, onReveal } = useConfirm()
+
+const dialog = ref(null)
+const message = ref<ConfirmMessage>()
+
+//Cancel prompt when user clicks outside of dialog, only when its open
+onClickOutside(dialog, () => isRevealed.value ? cancel() : noop())
+
+//Set message on reveal
+onReveal(m => message.value = defaultTo(m, {}));
+
+const style = computed(() => {
+ return {
+ 'height': `calc(100vh - ${headerHeight.value}px)`,
+ 'top': `${headerHeight.value}px`
+ }
+})
+
+</script>
<template>
<div id="confirm-prompt">
@@ -5,15 +39,15 @@
<div class="modal-content-container">
<DialogPanel>
<DialogTitle class="modal-title">
- {{ message.title ?? 'Confirm' }}
+ {{ message?.title ?? 'Confirm' }}
</DialogTitle>
<DialogDescription class="modal-description">
- {{ message.text }}
+ {{ message?.text }}
</DialogDescription>
<p class="modal-text-secondary">
- {{ message.subtext }}
+ {{ message?.subtext }}
</p>
<div class="modal-button-container">
@@ -29,39 +63,3 @@
</Dialog>
</div>
</template>
-
-<script setup lang="ts">
-import { defaultTo } from 'lodash-es'
-import { computed, ref } from 'vue'
-
-import {
- Dialog,
- DialogPanel,
- DialogTitle,
- DialogDescription,
-} from '@headlessui/vue'
-
-import { onClickOutside } from '@vueuse/core'
-import { useConfirm, useEnvSize } from '@vnuge/vnlib.browser'
-
-const { headerHeight } = useEnvSize()
-//Use component side of confirm
-const { isRevealed, confirm, cancel, onReveal } = useConfirm()
-
-const dialog = ref(null)
-const message = ref({})
-
-//Cancel prompt when user clicks outside of dialog, only when its open
-onClickOutside(dialog, () => isRevealed.value ? cancel() : null)
-
-//Set message on reveal
-onReveal(m => message.value = defaultTo(m, {}));
-
-const style = computed(() => {
- return {
- 'height': `calc(100vh - ${headerHeight.value}px)`,
- 'top': `${headerHeight.value}px`
- }
-})
-
-</script> \ No newline at end of file
diff --git a/front-end/src/bootstrap/components/CookieWarning.vue b/front-end/src/bootstrap/components/CookieWarning.vue
index b5239f5..2651cd1 100644
--- a/front-end/src/bootstrap/components/CookieWarning.vue
+++ b/front-end/src/bootstrap/components/CookieWarning.vue
@@ -1,15 +1,4 @@
-<template>
- <div v-if="show" class="fixed top-0 left-0 z-10 w-full" :style="style">
- <div class="flex w-full p-2 text-center text-white bg-blue-600">
- <div class="m-auto text-sm font-semibold md:text-base">
- You must have cookies enabled for this site to work properly
- </div>
- </div>
- </div>
-</template>
-
<script setup lang="ts">
-
import { computed, toRefs } from 'vue'
import { useEnvSize } from '@vnuge/vnlib.browser'
@@ -18,19 +7,20 @@ const props = defineProps<{
}>()
const { hidden } = toRefs(props)
-
const { headerHeight } = useEnvSize()
const show = computed(() => (!window.navigator.cookieEnabled) && !hidden.value)
-
-const style = computed(() => {
- return {
- top: headerHeight.value + 'px'
- }
-})
+const style = computed(() => ({ top: headerHeight.value + 'px' }))
</script>
+<template>
+ <div v-if="show" class="fixed top-0 left-0 z-10 w-full" :style="style">
+ <div class="flex w-full p-2 text-center text-white bg-blue-600">
+ <div class="m-auto text-sm font-semibold md:text-base">
+ You must have cookies enabled for this site to work properly
+ </div>
+ </div>
+ </div>
+</template>
-<style>
-</style>
diff --git a/front-end/src/bootstrap/components/Footer.vue b/front-end/src/bootstrap/components/Footer.vue
index 7c306c9..bdd0c92 100644
--- a/front-end/src/bootstrap/components/Footer.vue
+++ b/front-end/src/bootstrap/components/Footer.vue
@@ -1,9 +1,19 @@
+<script setup lang="ts">
+import { useDark } from '@vueuse/core'
+import { debounce } from 'lodash-es'
+
+const isDark = useDark()
+
+const Dark = debounce(() => isDark.value = true, 50)
+const Light = debounce(() => isDark.value = false, 50)
+</script>
+
<template>
<footer id="vn-footer" class="bottom-0 left-0 z-10 w-full">
<div id="footer-content" class="footer-content" >
<div class="footer-main-container">
<div id="footer-text-container" class="col-span-4 sm:col-span-6 lg:col-span-3">
- <p class="my-4 text-sm leading-normal">
+ <p class="my-4 text-xs leading-normal">
CMNext ia a AGPL3 licensed free and open source content management system
</p>
</div>
@@ -50,19 +60,9 @@
</p>
</div>
<div class="mb-6 text-left md:mb-0">
- Copyright &copy; 2023 Vaughn Nugent. All Rights Reserved.
+ Copyright &copy; 2024 Vaughn Nugent.
</div>
</div>
</div>
</footer>
</template>
-
-<script setup lang="ts">
-import { useDark } from '@vueuse/core'
-import { debounce } from 'lodash-es'
-
-const isDark = useDark()
-
-const Dark = debounce(() => isDark.value = true, 50)
-const Light = debounce(() => isDark.value = false, 50)
-</script>
diff --git a/front-end/src/bootstrap/components/Header.vue b/front-end/src/bootstrap/components/Header.vue
index 43a805b..6093fdc 100644
--- a/front-end/src/bootstrap/components/Header.vue
+++ b/front-end/src/bootstrap/components/Header.vue
@@ -1,78 +1,4 @@
<!-- eslint-disable vue/max-attributes-per-line -->
-<template>
- <header class="sticky top-0 left-0 z-40">
- <div class="flex header-container">
- <div id="header-mobile-menu" ref="sideMenu" class="side-menu" :style="sideMenuStyle">
- <div class="pt-4 pl-4 pr-6">
- <nav id="header-mobile-nav" class="relative flex flex-col pr-3">
- <div v-for="route in routes" :key="route.path" class="m-auto ml-0">
- <div class="my-1" @click="closeSideMenu">
- <router-link :to="route">
- {{ route.name }}
- </router-link>
- </div>
- </div>
- </nav>
- </div>
- </div>
- <div class="flex flex-row w-full md:mx-3">
- <div class="hidden w-4 lg:block" />
- <div class="flex px-4 my-auto text-xl md:hidden">
- <div v-if="!sideMenuActive" class="w-7" @click.prevent="openSideMenu">
- <fa-icon icon="bars" />
- </div>
- <div v-else class="text-2xl w-7">
- <fa-icon icon="times" />
- </div>
- </div>
- <div id="site-title-container" class="flex m-0 mr-3">
- <div class="inline-block px-1">
- <slot name="site_logo" />
- </div>
- <div id="site-title" class="inline-block m-auto mx-1">
- <router-link to="/">
- <h3>{{ siteTitle }}</h3>
- </router-link>
- </div>
- </div>
- <div class="hidden w-4 lg:block" />
- <nav id="header-desktop-nav" class="flex-row hidden mr-2 md:flex">
- <span v-for="route in routes" :key="route.fullPath" class="flex px-1 lg:px-3">
- <div v-if="!route.hide" class="m-auto">
- <router-link :to="route" class="flex-auto">
- {{ route.name }}
- </router-link>
- </div>
- </span>
- </nav>
- <div id="user-menu" ref="userMenu" class="drop-controller" :class="{ 'hovered': userMenuHovered }">
- <div class="user-menu">
- Hello <span class="font-semibold">{{ uname }}</span>
- </div>
- <div ref="userDrop" class="absolute top-0 right-0 duration-100 ease-in-out" style="z-index:-1" :style="dropStyle">
- <div class="drop-menu" @click.prevent="userMenuHovered = false">
- <span class="space-x-2" />
- <a v-if="!loggedIn" href="#" data-header-dropdown="register" @click="gotoRoute('/register')">
- Register
- </a>
- <a v-else href="#" data-header-dropdown="account" @click="gotoRoute('/account')">
- Account
- </a>
- <a v-if="!loggedIn" href="#" data-header-dropdown="login" @click="gotoRoute('/login')">
- Login
- </a>
- <a v-else href="#" data-header-dropdown="logout" @click.prevent="OnLogout">
- Logout
- </a>
- </div>
- </div>
- </div>
- <div class="hidden space-x-4 lg:block" />
- </div>
- </div>
- </header>
-</template>
-
<script setup lang="ts">
import { debounce, find } from 'lodash-es'
@@ -80,7 +6,7 @@ import { useElementSize, onClickOutside, useElementHover } from '@vueuse/core'
import { computed, ref, toRefs } from 'vue'
import { useEnvSize } from '@vnuge/vnlib.browser'
import { RouteLocation, useRouter } from 'vue-router';
-import { storeToRefs } from 'pinia';
+import { storeToRefs } from 'pinia';
import { useStore } from '../../store';
const emit = defineEmits(['logout'])
@@ -92,7 +18,6 @@ const { routes } = toRefs(props)
const store = useStore();
const { loggedIn, siteTitle } = storeToRefs(store);
-
const { headerHeight } = useEnvSize()
//Get the router for navigation
@@ -132,7 +57,7 @@ const openSideMenu = debounce(() => sideMenuActive.value = true, 50)
onClickOutside(sideMenu, closeSideMenu)
//Redirect to the route when clicking on it
-const gotoRoute = (route : string) =>{
+const gotoRoute = (route: string) => {
//Get all routes from the router
const allRoutes = router.getRoutes();
@@ -140,19 +65,90 @@ const gotoRoute = (route : string) =>{
//Try to find the route by its path
const goto = find(allRoutes, { path: route });
- if(goto){
+ if (goto) {
//navigate to the route manually
router.push(goto);
}
- else{
+ else {
//Fallback to full navigation
window.location.assign(route);
}
}
-const OnLogout = () =>{
- //Emit logout event
- emit('logout')
-}
+//Emit logout event
+const OnLogout = () => emit('logout')
-</script> \ No newline at end of file
+</script>
+<template>
+ <header class="sticky top-0 left-0 z-40">
+ <div class="flex header-container">
+ <div id="header-mobile-menu" ref="sideMenu" class="side-menu" :style="sideMenuStyle">
+ <div class="pt-4 pl-4 pr-6">
+ <nav id="header-mobile-nav" class="relative flex flex-col pr-3">
+ <div v-for="route in routes" :key="route.path" class="m-auto ml-0">
+ <div class="my-1" @click="closeSideMenu">
+ <router-link :to="route">
+ {{ route.name }}
+ </router-link>
+ </div>
+ </div>
+ </nav>
+ </div>
+ </div>
+ <div class="flex flex-row w-full md:mx-3">
+ <div class="hidden w-4 lg:block" />
+ <div class="flex px-4 my-auto text-xl md:hidden">
+ <div v-if="!sideMenuActive" class="w-7" @click.prevent="openSideMenu">
+ <fa-icon icon="bars" />
+ </div>
+ <div v-else class="text-2xl w-7">
+ <fa-icon icon="times" />
+ </div>
+ </div>
+ <div id="site-title-container" class="flex m-0 mr-3">
+ <div class="inline-block px-1">
+ <slot name="site_logo" />
+ </div>
+ <div id="site-title" class="inline-block m-auto mx-1">
+ <router-link to="/">
+ <h3>{{ siteTitle }}</h3>
+ </router-link>
+ </div>
+ </div>
+ <div class="hidden w-4 lg:block" />
+ <nav id="header-desktop-nav" class="flex-row hidden mr-2 md:flex">
+ <span v-for="route in routes" :key="route.fullPath" class="flex px-1 lg:px-3">
+ <div v-if="!route.hide" class="m-auto">
+ <router-link :to="route" class="flex-auto">
+ {{ route.name }}
+ </router-link>
+ </div>
+ </span>
+ </nav>
+ <div id="user-menu" ref="userMenu" class="drop-controller" :class="{ 'hovered': userMenuHovered }">
+ <div class="user-menu">
+ Hello <span class="font-semibold">{{ uname }}</span>
+ </div>
+ <div ref="userDrop" class="absolute top-0 right-0 duration-100 ease-in-out" style="z-index:-1" :style="dropStyle">
+ <div class="drop-menu" @click.prevent="userMenuHovered = false">
+ <span class="space-x-2" />
+ <a v-if="!loggedIn" href="#" data-header-dropdown="register" @click="gotoRoute('/register')">
+ Register
+ </a>
+ <a v-else href="#" data-header-dropdown="account" @click="gotoRoute('/account')">
+ Account
+ </a>
+ <a v-if="!loggedIn" href="#" data-header-dropdown="login" @click="gotoRoute('/login')">
+ Login
+ </a>
+ <a v-else href="#" data-header-dropdown="logout" @click.prevent="OnLogout">
+ Logout
+ </a>
+ </div>
+ </div>
+ </div>
+ <div class="hidden space-x-4 lg:block" />
+ </div>
+ </div>
+ </header>
+</template>
diff --git a/front-end/src/main.ts b/front-end/src/main.ts
index 4d62df4..3cc3bfa 100644
--- a/front-end/src/main.ts
+++ b/front-end/src/main.ts
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 Vaughn Nugent
+// Copyright (C) 2024 Vaughn Nugent
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
@@ -35,7 +35,7 @@ import { faGithub, faDiscord, faMarkdown } from '@fortawesome/free-brands-svg-ic
library.add(faSignInAlt, faGithub, faDiscord, faSpinner, faCertificate, faKey, faSync, faPlus, faMinusCircle, faUser, faCheck, faTrash, faCopy,
faPencil, faLink, faPhotoFilm, faRotateLeft, faMarkdown, faBullhorn, faFolderOpen, faComment, faChevronLeft, faChevronRight, faFileDownload,
faCode, faFile, faVideo, faImage, faHeadphones, faFileZipper
- );
+);
//Add icons to library
import router from './router'
@@ -47,6 +47,7 @@ import SiteLogo from './components/Site-Logo.vue'
import DynamicFormVue from './components/DynamicForm.vue'
import { globalStatePlugin } from './store/globalState'
+import { oauth2AppsPlugin } from './store/oauthAppsPlugin'
import { profilePlugin } from './store/userProfile'
import { mfaSettingsPlugin } from './store/mfaSettingsPlugin'
import { pageProtectionPlugin } from './store/pageProtectionPlugin'
@@ -90,16 +91,18 @@ createVnApp({
app.use(router)
store.use(globalStatePlugin)
+ //Add page protection plugin
+ .use(pageProtectionPlugin(router))
//User-profile plugin
.use(profilePlugin('/account/profile'))
- //setup page protection plugin with the router
- .use(pageProtectionPlugin(router))
//Enable mfa with totp settings plugin (optional pki config)
.use(mfaSettingsPlugin('/account/mfa', '/account/pki'))
- //Setup social mfa plugin
- .use(socialMfaPlugin())
+ //Setup social oauth
+ .use(socialMfaPlugin("/login/social/portals"))
//Setup blog state
.use(cmnextAdminPlugin(router, 'https://cdn.ckeditor.com/ckeditor5/40.0.0/super-build/ckeditor.js', 15))
+ //Use the oauth2 plugin store (disabled for now)
+ //.use(oauth2AppsPlugin('/oauth/apps', '/oauth/scopes'))
//Add the home-page component
router.addRoute({
diff --git a/front-end/src/store/index.ts b/front-end/src/store/index.ts
index 1b2d7ee..936dddf 100644
--- a/front-end/src/store/index.ts
+++ b/front-end/src/store/index.ts
@@ -16,10 +16,12 @@
import { useSession } from "@vnuge/vnlib.browser";
import { set } from "@vueuse/core";
import { defineStore } from "pinia";
-import { computed, shallowRef } from "vue";
+import { computed, shallowRef, type UnwrapNestedRefs } from "vue";
export { SortType, QueryType } from './sharedTypes'
+export const storeExport = <T>(val: T): UnwrapNestedRefs<T> => val as UnwrapNestedRefs<T>;
+
/**
* Loads the main store for the application
*/
diff --git a/front-end/src/store/mfaSettingsPlugin.ts b/front-end/src/store/mfaSettingsPlugin.ts
index dffafce..b801f32 100644
--- a/front-end/src/store/mfaSettingsPlugin.ts
+++ b/front-end/src/store/mfaSettingsPlugin.ts
@@ -1,62 +1,99 @@
import 'pinia'
-import { MaybeRef, shallowRef, watch } from 'vue';
-import { MfaMethod, PkiPublicKey, apiCall, useMfaConfig, usePkiConfig, usePkiAuth } from '@vnuge/vnlib.browser';
-import { useToggle, get } from '@vueuse/core';
+import { MaybeRef, ref, shallowRef, watch } from 'vue';
+import { MfaMethod, PkiPublicKey, apiCall, useMfaConfig, usePkiConfig, usePkiAuth, MfaApi } from '@vnuge/vnlib.browser';
+import { useToggle, get, set } from '@vueuse/core';
import { PiniaPluginContext, PiniaPlugin, storeToRefs } from 'pinia'
import { includes } from 'lodash-es';
+import { storeExport, } from './index';
+
+interface PkiStore {
+ publicKeys: PkiPublicKey[]
+ pkiConfig: ReturnType<typeof usePkiConfig>
+ pkiAuth: ReturnType<typeof usePkiAuth>
+ refresh: () => void
+}
+
+export interface MfaSettingsStore{
+ mfa:{
+ enabledMethods: MfaMethod[]
+ refresh: () => void
+ } & MfaApi
+ pki?: PkiStore
+}
declare module 'pinia' {
- export interface PiniaCustomProperties {
- mfaEndabledMethods: MfaMethod[]
- mfaConfig: ReturnType<typeof useMfaConfig>
- pkiConfig: ReturnType<typeof usePkiConfig>
- pkiAuth: ReturnType<typeof usePkiAuth>
- pkiPublicKeys: PkiPublicKey[]
- mfaRefreshMethods: () => void
+ export interface PiniaCustomProperties extends MfaSettingsStore {
+
}
}
export const mfaSettingsPlugin = (mfaEndpoint: MaybeRef<string>, pkiEndpoint?:MaybeRef<string>): PiniaPlugin => {
- return ({ store }: PiniaPluginContext) => {
+ return ({ store }: PiniaPluginContext): MfaSettingsStore => {
const { loggedIn } = storeToRefs(store)
const mfaConfig = useMfaConfig(mfaEndpoint)
- const pkiConfig = usePkiConfig(pkiEndpoint || '/')
- const pkiAuth = usePkiAuth(pkiEndpoint || '/')
- const [onRefresh, mfaRefreshMethods] = useToggle()
+
+ const [onRefresh, refresh] = useToggle()
+
+ const enabledMethods = ref<MfaMethod[]>([])
+
+ const usePki = () => {
+
+ const publicKeys = shallowRef<PkiPublicKey[]>([])
+
+ const pkiConfig = usePkiConfig(pkiEndpoint || '/')
+ const pkiAuth = usePkiAuth(pkiEndpoint || '/')
+
+ //Watch for changes to mfa methods (refresh) and update the pki keys
+ watch([enabledMethods], ([methods]) => {
+ if (!includes(methods, 'pki' as MfaMethod) || !get(pkiEndpoint)) {
+ set(publicKeys, [])
+ return
+ }
- const mfaEndabledMethods = shallowRef<MfaMethod[]>([])
- const pkiPublicKeys = shallowRef<PkiPublicKey[]>([])
+ //load the pki keys if pki is enabled
+ apiCall(async () => publicKeys.value = await pkiConfig.getAllKeys())
+ })
+
+ return{
+ publicKeys,
+ pkiConfig,
+ pkiAuth,
+ refresh
+ }
+ }
watch([loggedIn, onRefresh], ([ li ]) => {
if(!li){
- mfaEndabledMethods.value = []
+ set(enabledMethods, [])
return
}
//load the mfa methods if the user is logged in
- apiCall(async () => mfaEndabledMethods.value = await mfaConfig.getMethods())
- })
-
- //Watch for changes to mfa methods (refresh) and update the pki keys
- watch([mfaEndabledMethods], ([ methods ]) => {
- if(!includes(methods, 'pki' as MfaMethod) || !get(pkiEndpoint)){
- pkiPublicKeys.value = []
- return
- }
-
- //load the pki keys if pki is enabled
- apiCall(async () => pkiPublicKeys.value = await pkiConfig.getAllKeys())
+ apiCall(async () => enabledMethods.value = await mfaConfig.getMethods())
})
- return{
- mfaRefreshMethods,
- mfaEndabledMethods,
- mfaConfig,
- pkiConfig,
- pkiAuth,
- pkiPublicKeys
+ //Only return the pki store if pki is enabled
+ if(get(pkiEndpoint)){
+ return storeExport({
+ mfa:{
+ enabledMethods,
+ refresh,
+ ...mfaConfig
+ },
+ pki: usePki()
+ })
+ }
+ else{
+ return storeExport({
+ mfa:{
+ enabledMethods,
+ refresh,
+ ...mfaConfig
+ },
+ })
+
}
}
} \ No newline at end of file
diff --git a/front-end/src/store/oauthAppsPlugin.ts b/front-end/src/store/oauthAppsPlugin.ts
new file mode 100644
index 0000000..7a76992
--- /dev/null
+++ b/front-end/src/store/oauthAppsPlugin.ts
@@ -0,0 +1,154 @@
+import 'pinia'
+import { MaybeRef, computed, ref, shallowRef, watch } from 'vue';
+import { apiCall, useAxios } from '@vnuge/vnlib.browser';
+import { get, set, useToggle } from '@vueuse/core';
+import { PiniaPlugin, PiniaPluginContext, storeToRefs } from 'pinia'
+import { map, sortBy, isArray } from 'lodash-es';
+import { storeExport } from '.';
+
+export interface OAuth2Application {
+ readonly Id: string,
+ readonly name: string,
+ readonly description: string,
+ readonly permissions: string[],
+ readonly client_id: string,
+ Created: Date,
+ readonly LastModified: Date,
+}
+
+export interface NewAppResponse {
+ readonly secret: string
+ readonly app: OAuth2Application
+}
+
+export interface Oauth2Store{
+ oauth2: {
+ apps: OAuth2Application[],
+ scopes: string[],
+ getApps(): Promise<OAuth2Application[]>
+ createApp(app: OAuth2Application): Promise<NewAppResponse>
+ updateAppSecret(app: OAuth2Application, password: string): Promise<string>
+ updateAppMeta(app: OAuth2Application): Promise<void>
+ deleteApp(app: OAuth2Application, password: string): Promise<void>
+ refresh(): void
+ }
+}
+
+declare module 'pinia' {
+ export interface PiniaCustomProperties extends Oauth2Store{
+
+ }
+}
+
+export const oauth2AppsPlugin = (o2EndpointUrl: MaybeRef<string>, scopeEndpoint: MaybeRef<string>): PiniaPlugin =>{
+
+ return ({ store }: PiniaPluginContext): Oauth2Store => {
+
+ const axios = useAxios(null);
+ const { loggedIn } = storeToRefs(store)
+
+ const [onRefresh, refresh] = useToggle()
+
+ const _oauth2Apps = shallowRef<OAuth2Application[]>([])
+ const scopes = ref<string[]>([])
+
+ /**
+ * Updates an Oauth2 application's metadata
+ */
+ const updateAppMeta = async (app: OAuth2Application): Promise<void> => {
+ //Update the app metadata
+ await axios.put(get(o2EndpointUrl), app)
+ }
+
+ /**
+ * Gets all of the user's oauth2 applications from the server
+ * @returns The user's oauth2 applications
+ */
+ const getApps = async () => {
+ // Get all apps
+ const { data } = await axios.get<OAuth2Application[]>(get(o2EndpointUrl));
+
+ if(!isArray(data)){
+ throw new Error("Invalid response from server")
+ }
+
+ return map(data, (appData) => {
+ //Store the created time as a date object
+ appData.Created = new Date(appData?.Created ?? 0)
+ //create a new state manager for the user's profile
+ return appData;
+ })
+ }
+
+ /**
+ * Creates a new application from the given data
+ * @param param0 The application server buffer
+ * @returns The newly created application
+ */
+ const createApp = async ({ name, description, permissions }: OAuth2Application): Promise<NewAppResponse> => {
+
+ // make the post request, response is the new app data with a secret
+ const { data } = await axios.post<OAuth2Application & { raw_secret: string }>(`${get(o2EndpointUrl)}?action=create`, { name, description, permissions })
+
+ // Store secret
+ const secret = data.raw_secret
+
+ // remove secre tfrom the response
+ delete (data as any).raw_secret
+
+ return { secret, app: data }
+ }
+
+ /**
+ * Requets a new secret for an application from the server
+ * @param app The app to request a new secret for
+ * @param password The user's password
+ * @returns The new secret
+ */
+ const updateAppSecret = async (app: OAuth2Application, password: string): Promise<string> => {
+ const { data } = await axios.post(`${o2EndpointUrl}?action=secret`, { Id: app.Id, password })
+ return data.raw_secret
+ }
+
+ /**
+ * Deletes an application from the server
+ * @param app The application to delete
+ * @param password The user's password
+ * @returns The response from the server
+ */
+ const deleteApp = async ({ Id }: OAuth2Application, password: string): Promise<void> => {
+ await axios.post(`${o2EndpointUrl}?action=delete`, { password, Id });
+ }
+
+ const apps = computed(() => sortBy(_oauth2Apps.value, a => a.Created))
+
+ watch([loggedIn, onRefresh], async ([li]) => {
+ if (!li){
+ set(_oauth2Apps, [])
+ return;
+ }
+
+ //Load the user's oauth2 apps
+ apiCall(async () => {
+ _oauth2Apps.value = await getApps()
+
+ //Load the oauth2 scopes
+ const { data } = await axios.get<string[]>(get(scopeEndpoint))
+ set(scopes, data)
+ })
+ })
+
+ return storeExport({
+ oauth2:{
+ apps,
+ scopes,
+ getApps,
+ createApp,
+ updateAppMeta,
+ updateAppSecret,
+ deleteApp,
+ refresh
+ }
+ })
+ }
+} \ No newline at end of file
diff --git a/front-end/src/store/pageProtectionPlugin.ts b/front-end/src/store/pageProtectionPlugin.ts
index 9831dad..a747e49 100644
--- a/front-end/src/store/pageProtectionPlugin.ts
+++ b/front-end/src/store/pageProtectionPlugin.ts
@@ -60,14 +60,12 @@ export const pageProtectionPlugin = (router: ReturnType<typeof useRouter>): Pini
return true;
})
- router.afterEach(() => {
- //scroll window back to top
- window.scrollTo(0, 0)
- })
+ //scroll window back to top
+ router.afterEach(() => window.scrollTo(0, 0))
- watch(loggedIn, (loggedIn) => {
+ watch(loggedIn, (li) => {
//If the user gets logged out, redirect to login
- if(loggedIn === false && router.currentRoute.value.name !== 'Login'){
+ if(li === false && router.currentRoute.value.name !== 'Login'){
router.push({ name: 'Login' })
}
})
diff --git a/front-end/src/store/socialMfaPlugin.ts b/front-end/src/store/socialMfaPlugin.ts
index b9bce27..3968cf1 100644
--- a/front-end/src/store/socialMfaPlugin.ts
+++ b/front-end/src/store/socialMfaPlugin.ts
@@ -1,3 +1,4 @@
+
import 'pinia'
import { MaybeRef } from 'vue';
import { useSocialOauthLogin, useUser, SocialOAuthPortal, fromPortals, useAxios } from '@vnuge/vnlib.browser'
@@ -34,30 +35,42 @@ export const socialMfaPlugin = (portalEndpoint?: MaybeRef<string>): PiniaPlugin
}
}
- const _loadPromise = new Promise<SocialMfaPlugin>((resolve, reject) => {
+ const _loadPromise = new Promise<SocialMfaPlugin>((resolve, _) => {
- if(get(portalEndpoint) == null) {
+ if (get(portalEndpoint) == null) {
const socialOauth = useSocialOauthLogin([])
setLogoutMethod(socialOauth)
return resolve(socialOauth)
}
+ /*
+ Try to load social methods from server, if it fails, then we will
+ fall back to default
+ */
+
defer(async () => {
+
+ let portals: SocialOAuthPortal[] = []
+
try {
//Get axios instance
const axios = useAxios(null)
//Get all enabled portals
- const { data } = await axios.get<SocialOAuthPortal[]>(get(portalEndpoint));
- //Setup social providers from server portals
- const socialOauth = useSocialOauthLogin(fromPortals(data));
- setLogoutMethod(socialOauth);
-
- resolve(socialOauth)
+ const { data, headers } = await axios.get<SocialOAuthPortal[]>(get(portalEndpoint)!);
+
+ if(headers['content-type'] === 'application/json') {
+ portals = data
+ }
} catch (error) {
- reject(error)
+ //Let failure fall back to default
}
+
+ //Create social login from available portals
+ const socialOauth = useSocialOauthLogin(fromPortals(portals));
+ setLogoutMethod(socialOauth);
+ resolve(socialOauth)
})
})
diff --git a/front-end/src/store/userProfile.ts b/front-end/src/store/userProfile.ts
index a4ea469..0320ace 100644
--- a/front-end/src/store/userProfile.ts
+++ b/front-end/src/store/userProfile.ts
@@ -3,7 +3,8 @@ import { MaybeRef, watch } from 'vue';
import { ServerDataBuffer, ServerObjectBuffer, UserProfile, WebMessage, apiCall, useAxios, useDataBuffer, useUser } from '@vnuge/vnlib.browser';
import { get, useToggle } from '@vueuse/core';
import { PiniaPlugin, PiniaPluginContext, storeToRefs } from 'pinia'
-import { defer } from 'lodash-es';
+import { defer, noop } from 'lodash-es';
+import { storeExport } from './index';
export interface OAuth2Application {
readonly Id: string,
@@ -24,17 +25,21 @@ interface ExUserProfile extends UserProfile {
created: string | Date
}
+export interface UserProfileStore{
+ userProfile: ServerDataBuffer<ExUserProfile, WebMessage<string>>
+ userName: string | undefined
+ refreshProfile(): void;
+}
+
declare module 'pinia' {
- export interface PiniaCustomProperties {
- userProfile: ServerDataBuffer<ExUserProfile, WebMessage<string>>
- userName: string | undefined
- refreshProfile(): void;
+ export interface PiniaCustomProperties extends UserProfileStore {
+
}
}
export const profilePlugin = (accountsUrl:MaybeRef<string>) :PiniaPlugin => {
- return ({ store }: PiniaPluginContext) => {
+ return ({ store }: PiniaPluginContext): UserProfileStore => {
const { loggedIn } = storeToRefs(store)
const { getProfile, userName } = useUser()
@@ -64,19 +69,16 @@ export const profilePlugin = (accountsUrl:MaybeRef<string>) :PiniaPlugin => {
userProfile.apply(profile)
}
- watch([loggedIn, onRefresh], ([li]) => {
- //If the user is logged in, load the profile buffer
- if (li) {
- apiCall(loadProfile)
- }
- })
+ //If the user is logged in, load the profile buffer
+ watch([loggedIn, onRefresh], ([li]) => li ? apiCall(loadProfile) : noop())
+ //Defer intiial profile load
defer(refreshProfile);
- return {
+ return storeExport({
userProfile,
refreshProfile,
userName
- }
+ })
}
} \ No newline at end of file
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{