aboutsummaryrefslogtreecommitdiff
path: root/front-end/src
diff options
context:
space:
mode:
Diffstat (limited to 'front-end/src')
-rw-r--r--front-end/src/App.vue14
-rw-r--r--front-end/src/assets/main.scss0
-rw-r--r--front-end/src/bootstrap/Environment.vue106
-rw-r--r--front-end/src/bootstrap/components/ConfirmPrompt.vue69
-rw-r--r--front-end/src/bootstrap/components/CookieWarning.vue36
-rw-r--r--front-end/src/bootstrap/components/Footer.vue69
-rw-r--r--front-end/src/bootstrap/components/Header.vue162
-rw-r--r--front-end/src/bootstrap/components/PasswordPrompt.vue110
-rw-r--r--front-end/src/bootstrap/index.ts113
-rw-r--r--front-end/src/bootstrap/style/all.scss14
-rw-r--r--front-end/src/bootstrap/style/buttons.scss44
-rw-r--r--front-end/src/bootstrap/style/etc.scss67
-rw-r--r--front-end/src/bootstrap/style/footer.scss67
-rw-r--r--front-end/src/bootstrap/style/header.scss40
-rw-r--r--front-end/src/bootstrap/style/headings.scss32
-rw-r--r--front-end/src/bootstrap/style/inputs.scss65
-rw-r--r--front-end/src/bootstrap/style/modals.scss29
-rw-r--r--front-end/src/bootstrap/style/toast.scss26
-rw-r--r--front-end/src/components/DynamicForm.vue92
-rw-r--r--front-end/src/components/FooterNav1.vue11
-rw-r--r--front-end/src/components/FooterNav2.vue21
-rw-r--r--front-end/src/components/Site-Logo.vue7
-rw-r--r--front-end/src/main.ts119
-rw-r--r--front-end/src/router/index.ts8
-rw-r--r--front-end/src/views/Account/[comp].vue136
-rw-r--r--front-end/src/views/Account/components/oauth/CreateApp.vue183
-rw-r--r--front-end/src/views/Account/components/oauth/Oauth.vue93
-rw-r--r--front-end/src/views/Account/components/oauth/SingleApplication.vue190
-rw-r--r--front-end/src/views/Account/components/oauth/o2Api.ts176
-rw-r--r--front-end/src/views/Account/components/profile/Profile.vue199
-rw-r--r--front-end/src/views/Account/components/profile/profile-schema.ts310
-rw-r--r--front-end/src/views/Account/components/settings/Fido.vue53
-rw-r--r--front-end/src/views/Account/components/settings/PasswordReset.vue235
-rw-r--r--front-end/src/views/Account/components/settings/Pki.vue182
-rw-r--r--front-end/src/views/Account/components/settings/Security.vue81
-rw-r--r--front-end/src/views/Account/components/settings/Settings.vue16
-rw-r--r--front-end/src/views/Account/components/settings/TotpSettings.vue263
-rw-r--r--front-end/src/views/Blog/blog-api/index.ts22
-rw-r--r--front-end/src/views/Blog/ckeditor/Editor.vue155
-rw-r--r--front-end/src/views/Blog/ckeditor/build.ts125
-rw-r--r--front-end/src/views/Blog/components/Channels.vue99
-rw-r--r--front-end/src/views/Blog/components/Channels/ChannelEdit.vue157
-rw-r--r--front-end/src/views/Blog/components/Channels/ChannelTable.vue48
-rw-r--r--front-end/src/views/Blog/components/Content.vue133
-rw-r--r--front-end/src/views/Blog/components/Content/ContentEditor.vue225
-rw-r--r--front-end/src/views/Blog/components/Content/ContentTable.vue75
-rw-r--r--front-end/src/views/Blog/components/ContentSearch.vue115
-rw-r--r--front-end/src/views/Blog/components/EditorTable.vue96
-rw-r--r--front-end/src/views/Blog/components/FeedFields.vue140
-rw-r--r--front-end/src/views/Blog/components/Posts.vue113
-rw-r--r--front-end/src/views/Blog/components/Posts/PostEdit.vue155
-rw-r--r--front-end/src/views/Blog/components/Posts/PostTable.vue62
-rw-r--r--front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue164
-rw-r--r--front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts174
-rw-r--r--front-end/src/views/Blog/form-helpers/channels.ts227
-rw-r--r--front-end/src/views/Blog/form-helpers/index.ts17
-rw-r--r--front-end/src/views/Blog/form-helpers/posts.ts116
-rw-r--r--front-end/src/views/Blog/index.vue324
-rw-r--r--front-end/src/views/Login/components/Social.vue57
-rw-r--r--front-end/src/views/Login/components/Totp.vue65
-rw-r--r--front-end/src/views/Login/components/UserPass.vue92
-rw-r--r--front-end/src/views/Login/index.vue182
-rw-r--r--front-end/src/views/Login/pki/index.vue80
-rw-r--r--front-end/src/views/Login/social/[type].vue127
-rw-r--r--front-end/src/views/Register/components/CompleteReg.vue116
-rw-r--r--front-end/src/views/Register/index.vue161
-rw-r--r--front-end/src/views/[...all].vue24
-rw-r--r--front-end/src/views/index.vue18
-rw-r--r--front-end/src/vite-env.d.ts1
69 files changed, 7103 insertions, 0 deletions
diff --git a/front-end/src/App.vue b/front-end/src/App.vue
new file mode 100644
index 0000000..92b8d5c
--- /dev/null
+++ b/front-end/src/App.vue
@@ -0,0 +1,14 @@
+<template>
+ <!-- Import environment component top level as the entrypoint -->
+ <Environment>
+ <template #main>
+ <router-view />
+ </template>
+ </Environment>
+
+</template>
+
+<script setup lang="ts">
+import Environment from './bootstrap/Environment.vue';
+
+</script> \ No newline at end of file
diff --git a/front-end/src/assets/main.scss b/front-end/src/assets/main.scss
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/front-end/src/assets/main.scss
diff --git a/front-end/src/bootstrap/Environment.vue b/front-end/src/bootstrap/Environment.vue
new file mode 100644
index 0000000..c077869
--- /dev/null
+++ b/front-end/src/bootstrap/Environment.vue
@@ -0,0 +1,106 @@
+<template>
+ <head>
+ <title>{{ metaTile }}</title>
+ </head>
+ <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">
+ <notifications
+ class="general-toast"
+ group="general"
+ position="top"
+ :style="generalToastStyle"
+ />
+ </div>
+ <div class="absolute flex w-full">
+ <notifications
+ class="form-toast"
+ group="form"
+ position="top"
+ :style="formToastStyle"
+ />
+ </div>
+
+ <site-header ref="header" :site-title="siteTitle" :routes="routes" >
+ <template #site_logo>
+ <!-- Use the global site-logo if enabled -->
+ <site-logo />
+ </template>
+ </site-header>
+
+ <div id="env-body" class="flex w-full" :style="bodyStyle">
+ <cookie-warning :hidden="showCookieWarning" />
+
+ <slot name="main" />
+ </div>
+
+ <!-- Setup footer with nav elements from global config -->
+ <site-footer ref="footer">
+ <template #footer-nav-1>
+ <footer-nav-1/>
+ </template>
+ <template #footer-nav-2>
+ <footer-nav-2/>
+ </template>
+ </site-footer>
+
+ <PasswordPrompt />
+ <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'
+import { useEnvSize, useScrollOnRouteChange, useSession, useTitle } from '@vnuge/vnlib.browser'
+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'
+import { headerRoutes, authRoutes, siteTitle, showCookieWarning } from './index'
+
+const { loggedIn } = useSession();
+const { getRoutes } = useRouter();
+const { title } = useTitle(siteTitle.value);
+
+//Use the env size to calculate the header and footer heights for us
+const { header, footer, content, headerHeight, footerHeight } = useEnvSize(true)
+
+//setup autoscroll
+useScrollOnRouteChange();
+
+//Compute meta title from the default site title and the page title
+const metaTile = computed(() => title.value ? `${title.value} | ${siteTitle.value}` : siteTitle.value)
+
+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 routeNames = loggedIn.value ? authRoutes.value : headerRoutes.value
+
+ const routes = filter(getRoutes(), (pageName) => includes(routeNames, pageName.name))
+
+ const activeRoutes = map(routeNames, 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(() => {
+ return { 'min-height': `calc(100vh - ${headerHeight.value + footerHeight.value}px)` }
+})
+
+const generalToastStyle = computed(() => {
+ return { top: `${headerHeight.value + 5}px` }
+})
+
+const formToastStyle = computed(() => {
+ return { top: `${headerHeight.value}px` }
+})
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/bootstrap/components/ConfirmPrompt.vue b/front-end/src/bootstrap/components/ConfirmPrompt.vue
new file mode 100644
index 0000000..8114387
--- /dev/null
+++ b/front-end/src/bootstrap/components/ConfirmPrompt.vue
@@ -0,0 +1,69 @@
+
+<template>
+ <div id="confirm-prompt">
+ <Dialog class="modal-entry" :style="style" :open="isRevealed" @close="cancel" >
+ <div class="modal-content-container">
+ <DialogPanel>
+ <DialogTitle class="modal-title">
+ {{ title }}
+ </DialogTitle>
+
+ <DialogDescription class="modal-description">
+ {{ message.text }}
+ </DialogDescription>
+
+ <p class="modal-text-secondary">
+ {{ message.subtext }}
+ </p>
+
+ <div class="modal-button-container">
+ <button class="rounded btn sm primary" @click="confirm">
+ Confirm
+ </button>
+ <button class="rounded btn sm" @click="cancel">
+ Close
+ </button>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { defaultTo } from 'lodash'
+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 = m);
+
+const title = computed(() => defaultTo(message.value.title, 'Confirm'))
+
+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
new file mode 100644
index 0000000..b5239f5
--- /dev/null
+++ b/front-end/src/bootstrap/components/CookieWarning.vue
@@ -0,0 +1,36 @@
+<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'
+
+const props = defineProps<{
+ hidden?: boolean
+}>()
+
+const { hidden } = toRefs(props)
+
+const { headerHeight } = useEnvSize()
+
+const show = computed(() => (!window.navigator.cookieEnabled) && !hidden.value)
+
+const style = computed(() => {
+ return {
+ top: headerHeight.value + 'px'
+ }
+})
+
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/bootstrap/components/Footer.vue b/front-end/src/bootstrap/components/Footer.vue
new file mode 100644
index 0000000..98f6f55
--- /dev/null
+++ b/front-end/src/bootstrap/components/Footer.vue
@@ -0,0 +1,69 @@
+<template>
+ <footer class="bottom-0 left-0 z-10 w-full">
+ <div id="footer-content" class="footer-content" >
+ <div class="footer-main-container">
+ <div class="col-span-4 sm:col-span-6 lg:col-span-3">
+ <p class="my-4 text-xs leading-normal">
+ No tracking, no external dependencies, no ads. You shouldn't trust anyone with your data,
+ and I am no exception.
+ </p>
+ </div>
+ <nav>
+ <slot name="footer-nav-1" />
+ </nav>
+ <nav>
+ <slot name="footer-nav-2" />
+ </nav>
+ <nav>
+ <p class="nav-title">
+ Built with
+ </p>
+ <a class="footer-link" href="https://www.vaughnnugent.com/resources/software/modules">VNLib HTTP v1.0.1</a>
+ <a class="footer-link" href="https://tailwindcss.com/">Tailwindcss</a>
+ <a class="footer-link" href="https://vuejs.org/">Vuejs v3</a>
+ <a class="footer-link" href="https://fontawesome.com/">Font Awesome</a>
+ </nav>
+ <div class="color-selector-container">
+ <p class="nav-title">
+ Color Scheme
+ </p>
+ <div class="flex flex-row gap-6 md:my-auto">
+ <div class="">
+ <button class="bg-sel-btn" @click.prevent="Dark" >
+ Dark
+ </button>
+ </div>
+ <div class="">
+ <fa-icon icon="lightbulb" />
+ </div>
+ <div class="">
+ <button class="bg-sel-btn" @click.prevent="Light">
+ Light
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="text-sm footer-lower">
+ <div class="mb-6 md:mb-0">
+ <p class="text-left">
+ Highly angular trousers ~ Pete Jordanson
+ </p>
+ </div>
+ <div class="mb-6 text-left md:mb-0">
+ Copyright &copy; 2023 Vaughn Nugent. All Rights Reserved.
+ </div>
+ </div>
+ </div>
+ </footer>
+</template>
+
+<script setup lang="ts">
+import { useDark } from '@vueuse/core'
+import { debounce } from 'lodash'
+
+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
new file mode 100644
index 0000000..f7481a3
--- /dev/null
+++ b/front-end/src/bootstrap/components/Header.vue
@@ -0,0 +1,162 @@
+<!-- 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="#" @click="gotoRoute('/register')">
+ Register
+ </a>
+ <a v-else href="#" @click="gotoRoute('/account')">
+ Account
+ </a>
+ <a v-if="!loggedIn" href="#" @click="gotoRoute('/login')">
+ Login
+ </a>
+ <a v-else href="#" @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'
+import { useElementSize, onClickOutside, useElementHover } from '@vueuse/core'
+import { computed, ref, toRefs } from 'vue'
+import { useSession, useUser, useEnvSize, apiCall } from '@vnuge/vnlib.browser'
+import { RouteLocation, useRouter } from 'vue-router';
+
+const props = defineProps<{
+ siteTitle: string,
+ routes: RouteLocation[]
+}>()
+
+const { siteTitle, routes } = toRefs(props)
+
+const { loggedIn } = useSession()
+const { userName, logout } = useUser()
+const { headerHeight } = useEnvSize()
+
+//Get the router for navigation
+const router = useRouter()
+
+const sideMenuActive = ref(false)
+
+const userDrop = ref(null)
+const sideMenu = ref(null)
+const userMenu = ref(null)
+
+const dropMenuSize = useElementSize(userDrop)
+const sideMenuSize = useElementSize(sideMenu)
+const userMenuHovered = useElementHover(userMenu)
+
+const uname = computed(() => userName.value || 'Visitor')
+const sideMenuStyle = computed(() => {
+ // Side menu should be the exact height of the page and under the header,
+ // So menu height is the height of the page minus the height of the header
+ return {
+ height: `calc(100vh - ${headerHeight.value}px)`,
+ left: sideMenuActive.value ? '0' : `-${sideMenuSize.width.value}px`,
+ top: `${headerHeight.value}px`
+ }
+})
+
+const dropStyle = computed(() => {
+ return {
+ 'margin-top': userMenuHovered.value ? `${headerHeight.value}px` : `-${(dropMenuSize.height.value - headerHeight.value)}px`
+ }
+})
+
+const closeSideMenu = debounce(() => sideMenuActive.value = false, 50)
+const openSideMenu = debounce(() => sideMenuActive.value = true, 50)
+
+//Close side menu when clicking outside of it
+onClickOutside(sideMenu, closeSideMenu)
+
+//Redirect to the route when clicking on it
+const gotoRoute = (route : string) =>{
+
+ //Get all routes from the router
+ const allRoutes = router.getRoutes();
+
+ //Try to find the route by its path
+ const goto = find(allRoutes, { path: route });
+
+ if(goto){
+ //navigate to the route manually
+ router.push(goto);
+ }
+ else{
+ //Fallback to full navigation
+ window.location.assign(route);
+ }
+}
+
+const OnLogout = () =>{
+ apiCall(async ({ toaster }) => {
+ await logout()
+ toaster.general.success({
+ id: 'logout-success',
+ title: 'Success',
+ text: 'You have been logged out',
+ duration: 5000
+ })
+ })
+}
+
+</script> \ No newline at end of file
diff --git a/front-end/src/bootstrap/components/PasswordPrompt.vue b/front-end/src/bootstrap/components/PasswordPrompt.vue
new file mode 100644
index 0000000..ae29358
--- /dev/null
+++ b/front-end/src/bootstrap/components/PasswordPrompt.vue
@@ -0,0 +1,110 @@
+<template>
+ <div id="password-prompt">
+ <Dialog
+ class="modal-entry"
+ :style="style"
+ :open="isRevealed"
+ @close="close"
+ >
+ <div ref="dialog" class="modal-content-container" >
+ <DialogPanel>
+ <DialogTitle class="modal-title">
+ Enter your password
+ </DialogTitle>
+
+ <DialogDescription class="modal-description">
+ Please re-enter your password to continue.
+ </DialogDescription>
+
+ <form id="password-form" @submit.prevent="formSubmitted" :disabled="waiting">
+ <fieldset>
+ <div class="input-container">
+ <input v-model="v$.password.$model" type="password" class="rounded input primary" placeholder="Password" @input="onInput">
+ </div>
+ </fieldset>
+ </form>
+
+ <div class="modal-button-container">
+ <button class="rounded btn sm primary" form="password-form">
+ Submit
+ </button>
+ <button class="rounded btn sm" @click="close" >
+ Close
+ </button>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { onClickOutside } from '@vueuse/core'
+import useVuelidate from '@vuelidate/core'
+import { reactive, ref, computed } from 'vue'
+import { helpers, required, maxLength } from '@vuelidate/validators'
+import { useWait, useMessage, usePassConfirm, useEnvSize, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import { Dialog, DialogPanel, DialogTitle, DialogDescription } from '@headlessui/vue'
+
+const { headerHeight } = useEnvSize()
+
+//Use component side of pw prompt
+const { isRevealed, confirm, cancel } = usePassConfirm()
+
+const { waiting } = useWait()
+const { onInput } = useMessage()
+
+//Dialog html ref
+const dialog = ref(null)
+
+const pwState = reactive({ password: '' })
+
+const rules = {
+ password: {
+ required: helpers.withMessage('Please enter your password', required),
+ maxLength: helpers.withMessage('Password must be less than 100 characters', maxLength(100))
+ }
+}
+
+const v$ = useVuelidate(rules, pwState, { $lazy: true })
+
+//Wrap validator so we an display error message on validation, defaults to the form toaster
+const { validate } = useVuelidateWrapper(v$);
+
+const style = computed(() => {
+ return {
+ 'height': `calc(100vh - ${headerHeight.value}px)`,
+ 'top': `${headerHeight.value}px`
+ }
+})
+
+const formSubmitted = async function () {
+ //Calls validate on the vuelidate instance
+ if (!await validate()) {
+ return
+ }
+
+ //Store pw copy
+ const password = v$.value.password.$model;
+
+ //Clear the password form
+ v$.value.password.$model = '';
+ v$.value.$reset();
+
+ //Pass the password to the confirm function
+ confirm({ password });
+}
+
+const close = function () {
+ // Clear the password form
+ v$.value.password.$model = '';
+ v$.value.$reset();
+
+ //Close prompt
+ cancel(null);
+}
+
+//Cancel prompt when user clicks outside of dialog, only when its open
+onClickOutside(dialog, () => isRevealed.value ? cancel() : null)
+
+</script> \ No newline at end of file
diff --git a/front-end/src/bootstrap/index.ts b/front-end/src/bootstrap/index.ts
new file mode 100644
index 0000000..ead41e1
--- /dev/null
+++ b/front-end/src/bootstrap/index.ts
@@ -0,0 +1,113 @@
+import App from '../App.vue'
+import Notifications, { notify } from '@kyvg/vue3-notification'
+import { configureNotifier } from '@vnuge/vnlib.browser'
+import { createApp as vueCreateApp, ref } from "vue";
+import { useDark } from "@vueuse/core";
+
+//Font awesome support
+import { Library } from "@fortawesome/fontawesome-svg-core";
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { faBars, faLightbulb, faTimes } from '@fortawesome/free-solid-svg-icons'
+
+
+//Required global state elements for app components
+export const headerRoutes = ref<string[]>([]);
+export const authRoutes = ref<string[]>([]);
+export const siteTitle = ref<string>("");
+export const showCookieWarning = ref<boolean>(false);
+
+
+export interface AppConfig {
+ /**
+ * Routes to be displayed in the header when the user is not logged in
+ */
+ headerRoutes: string[];
+
+ /**
+ * Routes to be displayed in the header when the user is logged in
+ */
+ authRoutes: string[];
+
+ /**
+ * The title of the site, used in the title bar
+ */
+ siteTitle: string;
+
+ /**
+ * Enables dark mode support
+ */
+ useDarkMode: boolean;
+
+ /**
+ * The element to mount the app to
+ */
+ mountElement: string;
+
+ /**
+ * library instance for adding required icons
+ */
+ faLibrary: Library;
+
+ /**
+ * If true, the cookie warning will not be displayed
+ */
+ hideCookieWarning?: boolean;
+
+ /**
+ * Called when the app is created for you to add custom elements
+ * and configure the app
+ * @param app The app instance
+ */
+ onCreate: (app: any) => void;
+}
+
+/**
+ * Creates a new vn-minimal vuejs web-app with the given configuration
+ * @param config The configuration for the app
+ * @returns The app instance
+ */
+export const createVnApp = (config: AppConfig, createApp?: (app: any) => any) => {
+ headerRoutes.value = config.headerRoutes;
+ authRoutes.value = config.authRoutes;
+ siteTitle.value = config.siteTitle;
+
+ //Allow the user to override the createApp function
+ createApp = createApp || vueCreateApp;
+
+ //Enable dark mode support
+ if (config.useDarkMode) {
+ useDark({
+ selector: 'html',
+ valueDark: 'dark',
+ valueLight: 'light',
+ });
+ }
+
+ if (!config.hideCookieWarning) {
+ //Configure the cookie warning to be displayed cookies are not enabled
+ showCookieWarning.value = navigator?.cookieEnabled === false;
+ }
+
+ //Add required icons to library
+ config.faLibrary.add(faBars, faLightbulb, faTimes);
+
+ //create the vue app
+ const app = createApp(App)
+
+ //Add the library to the app
+ app.component('fa-icon', FontAwesomeIcon)
+
+ //Add the notification and router to the app
+ app.use(Notifications);
+
+ //Call the onCreate callback
+ config.onCreate(app);
+
+ //Mount the app
+ app.mount(config.mountElement);
+
+ //Configure notification handler
+ configureNotifier({ notify, close: notify.close });
+
+ return app;
+} \ No newline at end of file
diff --git a/front-end/src/bootstrap/style/all.scss b/front-end/src/bootstrap/style/all.scss
new file mode 100644
index 0000000..ede0b30
--- /dev/null
+++ b/front-end/src/bootstrap/style/all.scss
@@ -0,0 +1,14 @@
+@tailwind base;
+
+@tailwind components;
+
+@tailwind utilities;
+
+@import "./buttons.scss";
+@import "./footer.scss";
+@import "./header.scss";
+@import "./inputs.scss";
+@import "./headings.scss";
+@import "./toast.scss";
+@import "./etc.scss";
+@import "./modals.scss"; \ No newline at end of file
diff --git a/front-end/src/bootstrap/style/buttons.scss b/front-end/src/bootstrap/style/buttons.scss
new file mode 100644
index 0000000..a440def
--- /dev/null
+++ b/front-end/src/bootstrap/style/buttons.scss
@@ -0,0 +1,44 @@
+.button-group {
+ @apply inline-flex -space-x-0 divide-x overflow-hidden rounded-lg border border-transparent shadow-sm;
+ @apply divide-gray-300 border-gray-300 dark:divide-dark-500 dark:border-dark-500;
+
+ & .btn {
+ @apply border-0 ring-0 focus:ring-0;
+ }
+}
+
+.btn {
+ @apply ease-in-out duration-100 border-2 px-4 py-2 text-center text-sm font-medium transition-all focus:ring-2;
+ @apply bg-white border-gray-300 text-gray-700 shadow-sm hover:bg-gray-100 focus:ring-gray-100;
+
+ .dark & {
+ @apply bg-transparent text-inherit border-dark-300 hover:bg-transparent hover:border-gray-400 focus:ring-gray-300;
+ }
+
+ &.borderless,
+ &.no-border,
+ &.b-0{
+ @apply bg-transparent border-0 shadow-none ring-0 focus:ring-0 active:ring-0;
+ }
+
+ &:disabled {
+ @apply cursor-not-allowed border-gray-100 bg-gray-50 text-gray-400;
+ @apply dark:bg-transparent dark:border-dark-400 dark:text-dark-300;
+ }
+
+ &.sm {
+ @apply px-3 py-1.5 text-sm;
+ }
+
+ &.xs {
+ @apply px-2 py-1;
+ }
+
+ &.lg {
+ @apply px-5 py-3 text-lg;
+ }
+
+ &.xl {
+ @apply px-6 py-4 text-xl;
+ }
+}
diff --git a/front-end/src/bootstrap/style/etc.scss b/front-end/src/bootstrap/style/etc.scss
new file mode 100644
index 0000000..5d65ff4
--- /dev/null
+++ b/front-end/src/bootstrap/style/etc.scss
@@ -0,0 +1,67 @@
+#header-mobile-nav a:hover,
+#header-desktop-nav a:hover,
+#header-mobile-nav .router-link-active,
+#header-desktop-nav .router-link-active,
+footer .footer-content .router-link-active {
+ @apply text-primary-500 dark:text-primary-600;
+}
+
+#header-mobile-nav a {
+ @apply text-2xl;
+}
+
+#site-title h3 {
+ @apply mb-0 text-xl;
+}
+
+
+.env-bg {
+ font-family: "Nunito";
+ background: #f7f7f7;
+ @apply dark:bg-dark-900;
+ @apply text-gray-700 dark:text-gray-300;
+}
+
+.env-bg-gradient {
+ background: #98E4C8;
+ background: -webkit-linear-gradient(bottom right, #98E4C8, #2C6BC3);
+ background: -moz-linear-gradient(bottom right, #98E4C8, #2C6BC3);
+ background: linear-gradient(to top left, #98E4C8, #2C6BC3);
+ @apply text-gray-700;
+}
+
+.app-component-entry {
+ @apply flex flex-col flex-auto mb-10;
+
+ @screen sm {
+ & {
+ min-height: 640px;
+ }
+ }
+}
+
+.vue-notification-group.general-toast {
+ @apply right-5;
+}
+
+.float-label {
+ @apply mt-5 relative;
+
+ &>label {
+ @apply absolute top-0 left-0 bottom-0 block pl-3 ease-linear duration-75 opacity-0;
+ }
+
+ &>input:focus+label {
+ @apply opacity-100 -mt-6;
+ }
+}
+
+.default-page-template {
+ min-height: 400px;
+ @apply container w-full max-w-4xl px-4 pt-3 mx-auto sm:pt-6 md:px-0;
+}
+
+a.link {
+ @apply duration-150 ease-in-out;
+ @apply text-primary-500 hover:text-primary-600;
+} \ No newline at end of file
diff --git a/front-end/src/bootstrap/style/footer.scss b/front-end/src/bootstrap/style/footer.scss
new file mode 100644
index 0000000..4d05928
--- /dev/null
+++ b/front-end/src/bootstrap/style/footer.scss
@@ -0,0 +1,67 @@
+footer{
+ @apply text-center shadow-md bg-white dark:bg-dark-800 dark:text-gray-500;
+
+ .footer-content{
+ @apply mx-auto max-w-7xl p-4;
+
+ nav,
+ .color-selector-container{
+ @apply col-span-4 sm:col-span-3 md:col-span-2 lg:col-span-2 ml-8 sm:ml-0;
+ }
+ .footer-link.router-link-active {
+ @apply text-primary-500 dark:text-primary-600;
+ }
+
+ .footer-main-container {
+ @apply grid mb-3 sm:pl-0;
+ @apply grid-cols-4 sm:grid-cols-6 md:grid-cols-6 lg:grid-cols-11 gap-10 lg:gap-20;
+ }
+
+ p.nav-title {
+ @apply mb-3 text-xs font-semibold tracking-wider uppercase text-left;
+ }
+
+ a.footer-link {
+ @apply flex mb-3 text-sm font-medium md:mb-2 text-center transform hover:scale-105 ease-in-out duration-75;
+ @apply hover:text-primary-500 dark:hover:text-primary-600;
+ }
+
+ button.bg-sel-btn {
+ @apply text-sm ease-in-out duration-100 transform hover:scale-105;
+ @apply hover:text-primary-500 dark:hover:text-primary-600;
+ }
+
+ .footer-lower {
+ @apply flex flex-col flex-wrap items-start justify-between pt-10 mt-10 border-t md:flex-row md:items-center dark:border-dark-500;
+ }
+ }
+
+ .env-bg-gradient &{
+ @apply text-current text-gray-700 bg-transparent;
+ .footer-content {
+ border-top: 1px solid;
+ @apply border-gray-200;
+
+ .nav-title {
+ @apply text-gray-700;
+ }
+
+ nav .footer-link,
+ button.bg-sel-btn{
+ @apply hover:text-white;
+
+ &.router-link-active {
+ @apply text-white;
+ }
+ }
+
+ button.bg-sel-btn{
+ @apply hover:text-white;
+ }
+ }
+
+ .footer-lower {
+ @apply border-t-0;
+ }
+ }
+} \ No newline at end of file
diff --git a/front-end/src/bootstrap/style/header.scss b/front-end/src/bootstrap/style/header.scss
new file mode 100644
index 0000000..bc5789d
--- /dev/null
+++ b/front-end/src/bootstrap/style/header.scss
@@ -0,0 +1,40 @@
+header{
+ .header-container{
+ @apply h-12;
+ }
+ .header-container,
+ .side-menu{
+ @apply shadow-md;
+ @apply bg-white dark:bg-dark-800 dark:text-gray-100;
+ }
+
+ .side-menu {
+ @apply absolute pt-2 ease-in-out duration-150;
+ }
+
+ .drop-controller {
+ min-width: 11rem;
+ @apply hidden md:flex mr-0 ml-auto my-0 relative;
+
+ .drop-menu{
+ @apply w-36 m-auto cursor-pointer text-left rounded-b-lg flex flex-col;
+
+ @apply text-gray-700 bg-white dark:bg-dark-800 dark:text-gray-300;
+
+ @apply bg-white border-b border-l border-r border-transparent;
+
+ a {
+ @apply px-3 py-1;
+ @apply text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-gray-100;
+ }
+ }
+
+ &.hovered .drop-menu {
+ @apply border-gray-200 shadow-md dark:border-dark-500;
+ }
+ }
+
+ .user-menu{
+ @apply m-auto cursor-default truncate whitespace-nowrap max-w-xs;
+ }
+} \ No newline at end of file
diff --git a/front-end/src/bootstrap/style/headings.scss b/front-end/src/bootstrap/style/headings.scss
new file mode 100644
index 0000000..326a7c2
--- /dev/null
+++ b/front-end/src/bootstrap/style/headings.scss
@@ -0,0 +1,32 @@
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ @apply font-medium leading-tight mt-0 mb-2;
+}
+
+h1 {
+ @apply sm:text-5xl text-4xl;
+}
+
+h2 {
+ @apply sm:text-4xl text-3xl;
+}
+
+h3 {
+ @apply sm:text-3xl text-2xl;
+}
+
+h4 {
+ @apply sm:text-2xl text-xl;
+}
+
+h5 {
+ @apply sm:text-xl text-lg;
+}
+
+h6 {
+ @apply sm:text-base text-sm;
+} \ No newline at end of file
diff --git a/front-end/src/bootstrap/style/inputs.scss b/front-end/src/bootstrap/style/inputs.scss
new file mode 100644
index 0000000..64f8901
--- /dev/null
+++ b/front-end/src/bootstrap/style/inputs.scss
@@ -0,0 +1,65 @@
+input.input,
+select.input,
+textarea.input {
+ @apply duration-100 ease-in-out outline-none border p-2;
+ @apply border-gray-200 bg-inherit dark:border-dark-400 dark:text-white hover:border-gray-300 hover:dark:border-dark-200;
+}
+
+
+/* CHECKBOXES */
+
+label.checkbox {
+ @apply flex items-center cursor-pointer;
+
+ input[type="checkbox"] {
+ @apply ease-in-out duration-100 w-5 h-5;
+ @apply border-2 rounded-sm border-gray-300 dark:border-dark-500;
+
+ &:checked {
+ @apply text-primary-500 dark:text-primary-600 border-primary-500 dark:border-primary-600;
+ }
+ }
+
+ &.primary {
+ input[type="checkbox"] {
+ @apply appearance-none;
+ @apply hover:border-primary-500 dark:hover:border-primary-600;
+
+ &:checked {
+ @apply bg-primary-500 dark:bg-primary-600 border-primary-500 dark:border-primary-600;
+ }
+
+ &+span.check {
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ @apply bg-white dark:bg-dark-500;
+ }
+ }
+
+ span.check {
+ margin: 0px 0px 1px 4px;
+ @apply absolute h-3 w-3;
+ }
+ }
+
+}
+
+/*Select */
+
+select.input.options {
+ @apply text-current;
+}
+
+select.input:disabled{
+ @apply appearance-none;
+}
+
+/*Validation inputs*/
+input.input.dirty.data-valid,
+select.input.dirty.data-valid {
+ @apply border-primary-500 dark:border-primary-600;
+}
+
+input.input.dirty.data-invalid,
+select.input.dirty.data-invalid {
+ @apply border-red-600 dark:border-red-500;
+}
diff --git a/front-end/src/bootstrap/style/modals.scss b/front-end/src/bootstrap/style/modals.scss
new file mode 100644
index 0000000..254b8e1
--- /dev/null
+++ b/front-end/src/bootstrap/style/modals.scss
@@ -0,0 +1,29 @@
+.modal-entry {
+ background: #00000077;
+ @apply fixed z-50 flex w-full px-6;
+
+ .modal-content-container {
+ @apply w-full max-w-md p-5 m-auto rounded-md shadow-2xl mt-44;
+ @apply bg-white border border-transparent dark:bg-dark-600 dark:border-primary-500 dark:text-white;
+
+ .modal-title {
+ @apply text-xl font-bold;
+ }
+
+ .modal-description {
+ @apply text-sm;
+ }
+ }
+
+ .modal-button-container {
+ @apply flex flex-row justify-end pt-3 gap-3;
+ }
+
+ .input-container {
+ @apply pt-5;
+
+ input {
+ @apply w-full;
+ }
+ }
+} \ No newline at end of file
diff --git a/front-end/src/bootstrap/style/toast.scss b/front-end/src/bootstrap/style/toast.scss
new file mode 100644
index 0000000..3ddc3ac
--- /dev/null
+++ b/front-end/src/bootstrap/style/toast.scss
@@ -0,0 +1,26 @@
+.general-toast {
+ .notification-title {
+ font-size: 16px;
+ }
+
+ .notification-content {
+ font-size: 14px;
+ }
+
+ .vue-notification {
+ @apply duration-200 ease-in-out shadow-md hover:shadow-lg;
+ }
+}
+
+.form-toast {
+ left: calc(50% - 150px);
+ @apply pt-2 mx-auto mb-3;
+
+ .notification-title {
+ font-size: 14px;
+ }
+
+ .notification-content {
+ font-size: 12px;
+ }
+}
diff --git a/front-end/src/components/DynamicForm.vue b/front-end/src/components/DynamicForm.vue
new file mode 100644
index 0000000..137ca3b
--- /dev/null
+++ b/front-end/src/components/DynamicForm.vue
@@ -0,0 +1,92 @@
+<template>
+
+ <form :id="form.id" class="dynamic-form form" :path="path" @submit.prevent="onSubmit">
+
+ <fieldset class="dynamic-form input-group" :disabled="disabled">
+
+ <!-- Create a new div element for each field in the form -->
+ <div v-show="!field.hidden"
+ v-for="field in fields"
+ :key="field"
+ :class="{ 'dirty': field.validator.$dirty, 'data-invalid': field.validator.$invalid }"
+ class="dynamic-form input-container"
+ >
+ <!-- label above the fields -->
+ <label :for="field.id" class="dynamic-form input-label" >
+ {{ field.label }}
+ </label>
+
+ <!-- Determine select, input, or textarea -->
+ <select v-if="isSelect(field)"
+ v-model="field.validator.$model"
+ :id="field.id"
+ :disabled="field.disabled"
+ class="dynamic-form dynamic-input input-select"
+ @change="onInput(field)"
+ >
+
+ <option v-for="option in field.options" :key="option.value" :value="option.value">
+ {{ option.label }}
+ </option>
+
+ </select>
+
+ <textarea v-else-if="isTextArea(field)"
+ v-model="field.validator.$model"
+ :id="field.id"
+ :disabled="field.disabled"
+ class="dynamic-form dynamic-input input-textarea"
+ @input="onInput(field)"
+ />
+
+ <input v-else
+ v-model="field.validator.$model"
+ :id="field.id"
+ :type="field.type"
+ :name="field.name"
+ :disabled="field.disabled"
+ class="dynamic-form dynamic-input input"
+ :placeholder="placeholder(field)"
+ @input="onInput(field)"
+ >
+
+ <div class="dynamic-form field-description">
+ <p>{{ field.description }}</p>
+ </div>
+ </div>
+ </fieldset>
+ </form>
+</template>
+
+<script setup lang="ts">
+import { defaultTo, cloneDeep, forEach } from 'lodash'
+import { toRefs, computed } from 'vue'
+
+const props = defineProps<{
+ form: any
+ disabled?: boolean
+ validator: any
+}>()
+
+const emit = defineEmits(['input', 'submit'])
+
+const { form, disabled, validator } = toRefs(props)
+
+const schema = computed(() => cloneDeep(form.value))
+const path = computed(() => defaultTo(form.value.path, '#'))
+
+const fields = computed(() =>{
+ const ff = defaultTo(schema.value.fields, [])
+ //Set validators for the field, storeing the fields in the schema item
+ forEach(ff, field => field.validator = validator.value[field.name])
+ return ff;
+})
+
+const isSelect = (field : any) => field.type === 'select'
+const isTextArea = (field : any) => field.type === 'textarea'
+const placeholder = (field : any) => defaultTo(field.placeholder, field.label)
+
+const onSubmit = () => emit('submit')
+const onInput = (field : string) => emit('input', field)
+
+</script> \ No newline at end of file
diff --git a/front-end/src/components/FooterNav1.vue b/front-end/src/components/FooterNav1.vue
new file mode 100644
index 0000000..2bc5b28
--- /dev/null
+++ b/front-end/src/components/FooterNav1.vue
@@ -0,0 +1,11 @@
+<template>
+ <div>
+
+ </div>
+</template>
+<script setup lang="ts">
+
+</script>
+<style lang="scss">
+
+</style> \ No newline at end of file
diff --git a/front-end/src/components/FooterNav2.vue b/front-end/src/components/FooterNav2.vue
new file mode 100644
index 0000000..9a50e58
--- /dev/null
+++ b/front-end/src/components/FooterNav2.vue
@@ -0,0 +1,21 @@
+<template>
+ <p class="nav-title">
+ Account
+ </p>
+ <router-link class="footer-link" to="/login" >
+ Login
+ </router-link>
+ <router-link class="footer-link" to="/register">
+ Regsiter
+ </router-link>
+ <router-link class="footer-link" to="/account">
+ Profile
+ </router-link>
+ <router-link class="footer-link" to="/account/settings">
+ Settings
+ </router-link>
+</template>
+<script setup lang="ts">
+
+</script>
+<style lang="scss"></style> \ No newline at end of file
diff --git a/front-end/src/components/Site-Logo.vue b/front-end/src/components/Site-Logo.vue
new file mode 100644
index 0000000..c1e28d0
--- /dev/null
+++ b/front-end/src/components/Site-Logo.vue
@@ -0,0 +1,7 @@
+<template>
+ <div></div>
+</template>
+
+<script setup lang="ts">
+
+</script> \ No newline at end of file
diff --git a/front-end/src/main.ts b/front-end/src/main.ts
new file mode 100644
index 0000000..8acd355
--- /dev/null
+++ b/front-end/src/main.ts
@@ -0,0 +1,119 @@
+// Copyright (C) 2023 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
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+//Get the create app from boostrap dir
+import { createVnApp } from './bootstrap'
+
+//Import all styles
+import './bootstrap/style/all.scss'
+import './assets/main.scss'
+
+//Use the Nunito font
+import "@fontsource/nunito"
+
+/* FONT AWESOME CONFIG */
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faBullhorn, faCertificate, faCheck, faChevronLeft, faChevronRight, faComment, faCopy, faFolderOpen, faKey, faLink, faMinusCircle, faPencil, faPhotoFilm, faPlus, faRotateLeft, faSignInAlt, faSpinner, faSync, faTrash, faUser } from '@fortawesome/free-solid-svg-icons'
+import { faGithub, faDiscord, faMarkdown } from '@fortawesome/free-brands-svg-icons'
+
+//Add required icons for the app
+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);
+
+//Add icons to library
+import router from './router'
+
+//Import nav components
+import FooterNav1 from './components/FooterNav1.vue'
+import FooterNav2 from './components/FooterNav2.vue'
+import SiteLogo from './components/Site-Logo.vue'
+import DynamicFormVue from './components/DynamicForm.vue'
+import { configureApi, useAutoHeartbeat } from '@vnuge/vnlib.browser'
+import { useLocalStorage } from '@vueuse/core'
+import { watch } from 'vue'
+
+createVnApp({
+ //The app mount point
+ mountElement: '#app',
+
+ //The site title
+ siteTitle: 'CMNext Admin',
+
+ //Routes to display in the header when the user is not logged in
+ headerRoutes: ['Home', 'Login'],
+
+ //Routes to display in the header when the user is logged in
+ authRoutes: ['Home', 'Blog', 'Account', 'Login'],
+
+ //Enable dark mode support
+ useDarkMode: true,
+
+ //Add the font awesome library
+ faLibrary: library,
+
+ //Called when the app is created for you to add custom elements
+ onCreate(app) {
+
+ //Add the router
+ app.use(router)
+
+ //Configure account page redirect to profile
+ router.addRoute({
+ path: '/account',
+ name: 'Account',
+ redirect: { path: '/account/profile' }
+ })
+
+ //Add the footer nav components
+ app.component('FooterNav1', FooterNav1)
+ app.component('FooterNav2', FooterNav2)
+
+ //Register site-logo component
+ app.component('SiteLogo', SiteLogo)
+
+ //Register the dynamic form component
+ app.component('dynamic-form', DynamicFormVue)
+ },
+})
+
+//Setup the vnlib api
+configureApi({
+ session: {
+ cookiesEnabled: navigator.cookieEnabled,
+ loginCookieName: import.meta.env.VITE_LOGIN_COOKIE_ID,
+ bidSize: 32,
+ storage: localStorage
+ },
+ user: {
+ accountBasePath: import.meta.env.VITE_ACCOUNTS_BASE_PATH,
+ storage: localStorage,
+ //The heartbeat interval in milliseconds, if you enable it
+ autoHearbeatInterval: 1000 * 60 * 5, //5 minute interval
+ },
+ axios: {
+ baseURL: import.meta.env.VITE_API_URL,
+ withCredentials: import.meta.env.VITE_CORS_ENABLED === 'true',
+ tokenHeader: import.meta.env.VITE_WEB_TOKEN_HEADER,
+ }
+})
+
+//Get shared global state storage
+const mainState = useLocalStorage("vn-state", { ahEnabled: false });
+
+//Setup interval from local storage that remembers the user's preferrence
+const { enabled } = useAutoHeartbeat(mainState.value.ahEnabled)
+//Update the local storage when the value changes
+watch(enabled, (val) => mainState.value.ahEnabled = val) \ No newline at end of file
diff --git a/front-end/src/router/index.ts b/front-end/src/router/index.ts
new file mode 100644
index 0000000..d5685b5
--- /dev/null
+++ b/front-end/src/router/index.ts
@@ -0,0 +1,8 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+import routes from '~pages'
+
+export default createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL),
+ routes
+}) \ No newline at end of file
diff --git a/front-end/src/views/Account/[comp].vue b/front-end/src/views/Account/[comp].vue
new file mode 100644
index 0000000..75fd086
--- /dev/null
+++ b/front-end/src/views/Account/[comp].vue
@@ -0,0 +1,136 @@
+<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">
+
+ <div class="flex w-full py-2 xl:w-auto lg:pt-4 xl:fixed">
+
+ <TabList as="div" class="flex flex-row mx-auto mb-1 xl:mx-0 xl:mb-0">
+
+ <Tab v-slot="{ selected }" >
+ <span class="page-link" :class="{ 'active': selected }">
+ Profile
+ </span>
+ </tab>
+
+ <Tab v-slot="{ selected }" >
+ <span class="page-link" :class="{ 'active': selected }">
+ OAuth
+ </span>
+ </tab>
+
+ <Tab v-slot="{ selected }" >
+ <span class="page-link" :class="{ 'active': selected }">
+ Settings
+ </span>
+ </tab>
+
+ </TabList>
+ </div>
+
+ <TabPanels as="div" class="xl:my-16 md:mb-4">
+
+ <TabPanel :unmount="false">
+ <Profile />
+ </TabPanel>
+
+ <TabPanel :unmount="false">
+ <OauthApps />
+ </TabPanel>
+
+ <TabPanel :unmount="false">
+ <Settings />
+ </TabPanel>
+
+ </TabPanels>
+
+ </TabGroup>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { usePageGuard, useTitle } from '@vnuge/vnlib.browser'
+import { useRouteParams } from '@vueuse/router'
+import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
+import Settings from './components/settings/Settings.vue'
+import Profile from './components/profile/Profile.vue'
+import OauthApps from './components/oauth/Oauth.vue'
+
+usePageGuard()
+useTitle('Account')
+
+enum ComponentType{
+ Profile = 'profile',
+ Oauth = 'oauth',
+ Settings = 'settings'
+}
+
+const comp = useRouteParams<ComponentType>('comp')
+
+const tabId = computed<number>(() =>{
+ switch (comp.value) {
+ case ComponentType.Oauth:
+ return 1
+ case ComponentType.Settings:
+ return 2
+ case ComponentType.Profile:
+ default:
+ return 0
+ }
+})
+
+const onTabChange = (tabid : number) =>{
+ switch (tabid) {
+ case 1:
+ comp.value = ComponentType.Oauth
+ break
+ case 2:
+ comp.value = ComponentType.Settings
+ break
+ case 0:
+ default:
+ comp.value = ComponentType.Profile
+ break
+ }
+}
+
+</script>
+
+<style lang="scss">
+#account-template{
+ p{
+ @apply text-gray-700 dark:text-gray-400;
+ }
+
+ .page-link{
+ font-size: 1.1rem;
+ @apply border-b-2 border-transparent cursor-pointer mx-2 px-1;
+ }
+
+ .page-link.active{
+ @apply border-primary-500;
+ }
+
+ .acnt-content-container{
+ @apply m-auto max-w-3xl;
+ }
+
+ .panel-container{
+
+ }
+
+ .panel-container .panel-header{
+ @apply flex flex-row px-2;
+ }
+
+ .panel-container .panel-content{
+ @apply bg-white dark:bg-dark-800 border-transparent dark:border-dark-500;
+ @apply m-auto max-w-3xl border sm:rounded-md shadow-md sm:p-4 p-3 sm:my-3 my-2;
+ }
+
+ .panel-container .panel-header .panel-title{
+ @apply my-auto;
+ }
+}
+
+</style>
diff --git a/front-end/src/views/Account/components/oauth/CreateApp.vue b/front-end/src/views/Account/components/oauth/CreateApp.vue
new file mode 100644
index 0000000..2321743
--- /dev/null
+++ b/front-end/src/views/Account/components/oauth/CreateApp.vue
@@ -0,0 +1,183 @@
+<template>
+ <Dialog :open="isOpen" @close="close" class="relative z-10">
+ <div class="fixed inset-0 bg-black/30" aria-hidden="true" />
+ <div class="fixed inset-0 flex justify-center top-20">
+ <DialogPanel class="new-o2-app-dialog">
+ <DialogTitle>Create app</DialogTitle>
+ <div class="flex">
+ <div class="m-auto mb-3 text-sm">
+ <p class="my-1">
+ Step 1: Enter a name for your app.
+ </p>
+ <p class="my-1">
+ Step 2: Submit the form.
+ </p>
+ <p class="my-1 text-red-500">
+ Step 3: Save your Client ID and Secret somewhere safe.
+ </p>
+ </div>
+ </div>
+ <!-- If secret is set, show the scret window -->
+ <div v-if="newAppBuffer.secret" class="mt-2">
+ <div class="block mx-1 sm:inline">
+ Secret:
+ </div>
+ <div class="px-1 py-4 my-2 break-all border-2 border-gray-300 rounded-lg">
+ <div class="text-center secret">
+ <span class="block mx-1 sm:inline">
+ {{ newAppBuffer.secret }}
+ </span>
+ </div>
+ </div>
+ <div class="text-sm">
+ <p class="p-2">
+ This secret will only be displayed <strong>once</strong>, and you cannot request it again.
+ If you lose it, you will need to update the secret from the app edit pane.
+ </p>
+ <p class="p-2">
+ Please remember to keep this secret somewhere safe. If an attacker gains
+ access to it, they will be able to access any APIs on your behalf!
+ </p>
+ </div>
+ <div class="flex justify-end">
+ <button v-if="!copied" class="btn primary" @click="copy(newAppBuffer.secret)">
+ Copy
+ </button>
+ <button v-else class="btn primary" @click="close">
+ Done
+ </button>
+ </div>
+ </div>
+ <div v-else>
+ <form id="o2-app-creation" class="" @submit.prevent="onFormSubmit">
+ <fieldset class="flex flex-col gap-4">
+ <div class="input-container">
+ <label>App Name</label>
+ <input
+ class="w-full mt-1 input primary"
+ :class="{'invalid':v$.name.$invalid, 'dirty': v$.name.$dirty}"
+ name="name"
+ type="text"
+ v-model="v$.name.$model"
+ />
+ </div>
+ <div class="input-container">
+ <label>Description</label>
+ <textarea
+ class="w-full mt-1 input primary"
+ :class="{ 'invalid': v$.description.$invalid, 'dirty': v$.name.$dirty }"
+ name="description"
+ v-model="v$.description.$model"
+ rows="3"
+ />
+ </div>
+ <div class="input-container">
+ <label>Permissions</label>
+ <div class="flex flex-col flex-wrap sm:flex-row">
+ <div v-for="permission in appPermissions" :key="permission.type" class="my-2 sm:m-3">
+ <label class="flex cursor-pointer">
+ <input class="w-5 cursor-pointer" type="checkbox" :name="permission.type" @change="permissionChanged">
+ <span class="pl-1">{{ permission.label }}</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ </fieldset>
+ <div class="flex justify-end mt-4">
+ <div class="button-group">
+ <button type="submit" form="o2-app-creation" class="btn primary">Submit</button>
+ <button class="btn" @click.prevent="close">Cancel</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+</template>
+
+<script setup lang="ts">
+import { indexOf, pull } from 'lodash'
+import { ref, toRefs } from 'vue';
+import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
+import { apiCall } from '@vnuge/vnlib.browser'
+import { useOAuth2Apps, getAppValidator, getAppPermissions } from './o2Api'
+import { useClipboard } from '@vueuse/core'
+
+const emit = defineEmits(['close'])
+
+const props = defineProps<{
+ isOpen: boolean
+}>()
+
+const { isOpen } = toRefs(props);
+
+const { copied, copy } = useClipboard();
+//Init the oauth2 app api
+const { createApp } = useOAuth2Apps('/oauth/apps');
+const appPermissions = getAppPermissions();
+
+const newAppBuffer = ref({});
+const newAppPermissions = ref([]);
+
+const { v$, validate, reset } = getAppValidator(newAppBuffer);
+
+const close = () => {
+ newAppBuffer.value = {}
+ reset()
+ emit('close')
+}
+
+const onFormSubmit = async () =>{
+
+ // Validate the new app form
+ if (!await validate()) {
+ return
+ }
+
+ // Create the new app
+ await apiCall(async () => {
+
+ const { secret } = await createApp(newAppBuffer.value)
+
+ // Reset the new app buffer and pass the secret value
+ newAppBuffer.value = { secret }
+ })
+
+ // reset the validator
+ v$.value.$reset()
+}
+
+const permissionChanged = (e : any) => {
+ if (e.target.checked) {
+ // Make sure the permission is not already in the list
+ if (indexOf(newAppPermissions.value, e.target.name) > -1) {
+ return
+ }
+ // Add the permission to the list
+ newAppPermissions.value.push(e.target.name)
+ } else {
+ // Remove the permission from the list
+ pull(newAppPermissions.value, e.target.name)
+ }
+ // Update the permissions model
+ v$.value.permissions.$model = newAppPermissions.value.join(',')
+}
+
+</script>
+
+<style lang="scss">
+
+.new-o2-app-dialog{
+ @apply w-full max-w-lg p-8 pt-4 m-auto mt-0 shadow-md sm:rounded-md;
+ @apply bg-white dark:bg-dark-600 dark:text-gray-200;
+
+ #o2-app-creation{
+ input.dirty.invalid,
+ textarea.dirty.invalid{
+ @apply border-red-500 focus:border-red-500;
+ }
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Account/components/oauth/Oauth.vue b/front-end/src/views/Account/components/oauth/Oauth.vue
new file mode 100644
index 0000000..119aa50
--- /dev/null
+++ b/front-end/src/views/Account/components/oauth/Oauth.vue
@@ -0,0 +1,93 @@
+<template>
+ <div id="oauth-apps" class="acnt-content-container">
+ <div class="app-container panel-container">
+ <div class="mb-6 panel-header">
+ <div class="flex ml-0 mr-auto">
+ <div class="my-auto panel-title">
+ <h4>Your applications</h4>
+ </div>
+ </div>
+ <div class="ml-auto mr-0">
+ <div class="button-container">
+ <button class="btn primary sm" :disabled="!isLocalAccount" @click="editNew = true">
+ Create App
+ </button>
+ </div>
+ </div>
+ </div>
+ <div v-if="apps?.length == 0" class="no-apps-container">
+ <div class="m-auto">
+ You dont have any OAuth2 client applications yet.
+ </div>
+ </div>
+ <div v-else>
+ <div v-for="app in apps" :key="app.data.Id" class="panel-content">
+ <SingleApplication :application="app" :allow-edit="isLocalAccount" @appDeleted="loadApps" />
+ </div>
+ </div>
+ </div>
+ <div class="px-2 my-10">
+ <div class="m-auto text-sm">
+ OAuth2 applications allow you grant api access to OAuth2 clients using the Client Credentials grant type.
+ <a class="link" href="https://oauth.net" target="_blank">
+ Learn more
+ </a>
+ </div>
+ <div v-show="!isLocalAccount" class="mt-3 text-center text-red-500">
+ You may not create or edit applications if you are using external authentication.
+ </div>
+ </div>
+ <CreateApp :is-open="editNew" @close="newAppClose" />
+ </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import CreateApp from './CreateApp.vue'
+import { useSession, apiCall } from '@vnuge/vnlib.browser'
+
+import SingleApplication from './SingleApplication.vue'
+import { AppBuffer, OAuth2Application, useOAuth2Apps } from './o2Api'
+
+const { isLocalAccount } = useSession()
+const { getApps } = useOAuth2Apps('/oauth/apps');
+
+const apps = ref<AppBuffer<OAuth2Application>[]>();
+const editNew = ref(false);
+
+const loadApps = async () => {
+ await apiCall(async () => {
+ const appList = await getApps();
+ // sort apps from newest to oldest
+ appList.sort((a, b) => {
+ if (a.data.Created > b.data.Created) return -1
+ if (a.data.Created < b.data.Created) return 1
+ return 0
+ })
+ // set the apps
+ apps.value = appList
+ })
+}
+
+const newAppClose = () => {
+ editNew.value = false;
+ //Reload apps on close
+ loadApps();
+}
+
+//Load apps, but do not await
+loadApps()
+
+</script>
+
+<style>
+
+#oauth-apps {
+ @apply m-auto max-w-3xl;
+}
+
+#oauth-apps .app-container .no-apps-container {
+ @apply w-full flex h-36 sm:border sm:rounded-md mt-4 mb-20 dark:border-dark-500 border-gray-300;
+}
+
+</style>
diff --git a/front-end/src/views/Account/components/oauth/SingleApplication.vue b/front-end/src/views/Account/components/oauth/SingleApplication.vue
new file mode 100644
index 0000000..9fcc5e3
--- /dev/null
+++ b/front-end/src/views/Account/components/oauth/SingleApplication.vue
@@ -0,0 +1,190 @@
+<template>
+ <div :id="data.Id">
+ <div class="flex flex-row">
+ <div class="flex ml-0 mr-auto">
+ <div class="flex w-8 h-8 rounded-full bg-primary-500">
+ <div class="m-auto text-white dark:text-dark-500">
+ <fa-icon icon="key"></fa-icon>
+ </div>
+ </div>
+ <div class="inline my-auto ml-2">
+ <h5 class="m-0">{{ name }}</h5>
+ </div>
+ </div>
+ <div v-if="allowEdit && editMode" class="button-group">
+ <button class="btn primary xs" :disabled="modified" @click="onSubmit">Update</button>
+ <button class="btn xs" @click="onCancel">Cancel</button>
+ </div>
+ <div v-else class="">
+ <button class="btn no-border xs" @click="editMode = true">Edit</button>
+ </div>
+ </div>
+ <div class="px-3 py-1 text-gray-500">
+ <div class="my-1">
+ <span> Client ID: </span>
+ <span class="font-mono text-black dark:text-white">{{ clientId }}</span>
+ </div>
+ <div class="text-sm">
+ <span> Created: </span>
+ <span>{{ createdTime }}</span>
+ </div>
+ <div v-if="!editMode" class="text-sm">
+ <span>{{ data.description }}</span>
+ </div>
+ </div>
+ <div v-if="newSecret" class="flex">
+ <div class="max-w-md py-4 mx-auto">
+ <div class="pl-1 mb-2">
+ New secret
+ </div>
+ <div class="p-4 text-sm break-all border-2 rounded-lg dark:border-dark-400">
+ {{ newSecret }}
+ </div>
+ <div class="flex justify-end my-3">
+ <button v-if="!copied" class="rounded btn" @click="copy(newSecret)">
+ Copy
+ </button>
+ <button v-else class="rounded btn" @click="closeNewSecret">
+ Done
+ </button>
+ </div>
+ </div>
+ </div>
+ <div v-else-if="editMode" class="app-form-container">
+ <div class="py-4">
+ <form :id="formId" class="max-w-md mx-auto">
+ <fieldset :disabled="waiting" class="">
+ <div class="input-container">
+ <div class="pl-1 mb-1">
+ App name
+ </div>
+ <input class="w-full input primary" :class="{ 'invalid': v$.name.$invalid }" v-model="v$.name.$model" type="text" name="name" />
+ </div>
+ <div class="mt-3 input-container">
+ <div class="pl-1 mb-1">
+ App description
+ <span class="text-sm">(optional)</span>
+ </div>
+ <textarea class="w-full input primary" :class="{ 'invalid': v$.description.$invalid }" v-model="v$.description.$model" name="description" rows="3" />
+ </div>
+ </fieldset>
+ </form>
+ </div>
+ <div class="mt-3">
+ <div class="flex flex-row justify-center gap-3 mx-auto">
+ <div class="">
+ <button class="w-full btn yellow" @click="updateSecret">
+ Update Secret
+ </button>
+ </div>
+ <div class="">
+ <button class="w-full btn red" @click="onDelete">
+ Delete
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { toUpper } from 'lodash'
+import { apiCall, useWait, useConfirm, usePassConfirm } from '@vnuge/vnlib.browser'
+import { ref, computed, toRefs } from 'vue'
+import { useClipboard, useTimeAgo } from '@vueuse/core'
+import { useOAuth2Apps, getAppValidator, AppBuffer, OAuth2Application } from './o2Api'
+
+const props = defineProps<{
+ application: AppBuffer<OAuth2Application>
+ allowEdit: boolean
+}>()
+
+const emit = defineEmits(['secretUpdated', 'AppDeleted'])
+
+const { application, allowEdit } = toRefs(props)
+const { data, buffer, revert, modified } = application.value;
+
+const { waiting } = useWait()
+const { reveal } = useConfirm()
+const { elevatedApiCall } = usePassConfirm()
+const { copied, copy } = useClipboard()
+const { deleteApp, updateAppMeta, updateAppSecret } = useOAuth2Apps('/oauth/apps');
+
+const { v$, validate, reset } = getAppValidator(buffer)
+
+const editMode = ref(false)
+const newSecret = ref<string | null>(null);
+
+const name = computed(() => data.name)
+const clientId = computed(() => toUpper(data.client_id))
+const createdTime = useTimeAgo(data.Created);
+const formId = computed(() => `app-form-${data.client_id}`)
+
+const onCancel = function () {
+ revert()
+ reset()
+ editMode.value = false
+}
+
+const onSubmit = async function () {
+ // Validate the new app form
+ if (!await validate()) {
+ return
+ }
+ // Create the new app
+ await apiCall(async ({ toaster }) => {
+ // Update does not return anything, if successful
+ await updateAppMeta(application.value)
+ toaster.general.success({
+ text: 'Application successfully updated',
+ title: 'Success'
+ })
+ reset()
+ editMode.value = false
+ })
+}
+
+const updateSecret = async function () {
+ // Show a confrimation prompt
+ const { isCanceled } = await reveal({
+ title: 'Update Secret',
+ text: `Are you sure you want to update the secret? Any active sessions will be invalidated, and the old secret will be invalidated.`
+ })
+ if (isCanceled) {
+ return
+ }
+ await elevatedApiCall(async ({ password }) => {
+ // Submit the secret update with the new challenge
+ newSecret.value = await updateAppSecret(application.value, password)
+ })
+}
+
+const onDelete = async function () {
+ // Show a confirmation prompt
+ const { isCanceled } = await reveal({
+ title: 'Delete Application',
+ text: 'Are you sure you want to delete this application?',
+ subtext: 'This action cannot be undone'
+ })
+ if (isCanceled) {
+ return
+ }
+ await elevatedApiCall(async ({ password, toaster }) => {
+ await deleteApp(application.value, password)
+ toaster.general.success({
+ text: 'Application deleted successfully',
+ title: 'Success'
+ })
+ emit('AppDeleted')
+ })
+}
+
+const closeNewSecret = () => newSecret.value = null;
+
+</script>
+
+<style lang="scss">
+
+
+</style>
diff --git a/front-end/src/views/Account/components/oauth/o2Api.ts b/front-end/src/views/Account/components/oauth/o2Api.ts
new file mode 100644
index 0000000..c21e4ed
--- /dev/null
+++ b/front-end/src/views/Account/components/oauth/o2Api.ts
@@ -0,0 +1,176 @@
+import { forEach } from 'lodash'
+import { Ref } from 'vue'
+import useVuelidate from '@vuelidate/core'
+import { maxLength, helpers, required } from '@vuelidate/validators'
+import { useAxios, useDataBuffer, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import { AxiosResponse } from 'axios'
+
+export interface OAuth2Application{
+ readonly Id: string,
+ readonly name: string,
+ readonly description: string,
+ readonly permissions: string[],
+ readonly client_id: string,
+ readonly Created: Date,
+ readonly LastModified: Date,
+}
+
+export interface NewAppResponse {
+ readonly secret: string
+ readonly app: AppBuffer<OAuth2Application>
+}
+
+export interface AppBuffer<T>{
+ readonly data: T,
+ buffer: T
+ readonly modified: Readonly<Ref<boolean>>
+ apply: (data: T) => void
+ revert(): void
+}
+
+/**
+ * Initializes the oauth2 applications api
+ * @param o2EndpointUrl The url of the oauth2 applications endpoint
+ * @returns The oauth2 applications api
+ */
+export const useOAuth2Apps = (o2EndpointUrl : string) => {
+ const { post, get, put } = useAxios(null);
+
+ /**
+ * Gets all of the user's oauth2 applications from the server
+ * @returns The user's oauth2 applications
+ */
+ const getApps = async (): Promise<AppBuffer<OAuth2Application>[]>=> {
+ // Get all apps
+ const { data } = await get(o2EndpointUrl);
+
+ const apps: AppBuffer<OAuth2Application>[] = []
+
+ //Loop through the apps and create a new state manager for each
+ forEach(data, (appData) => {
+
+ //Store the created time as a date object
+ appData.created = new Date(appData?.Created ?? 0)
+
+ //create a new state manager for the user's profile
+ const app: AppBuffer<OAuth2Application> = useDataBuffer(appData)
+
+ apps.push(app)
+ })
+
+ return apps
+ }
+
+ /**
+ * Creates a new application from the given data
+ * @param param0 The application server buffer
+ * @returns The newly created application
+ */
+ const createApp = async ({ name, description, permissions } : OAuth2Application): Promise<NewAppResponse> => {
+
+ // make the post request, response is the new app data with a secret
+ const { data } = await post(`${o2EndpointUrl}?action=create`, { name, description, permissions })
+
+ // Store secret
+ const secret = data.raw_secret
+
+ // remove secre tfrom the response
+ delete data.raw_secret
+
+ return { secret, app: useDataBuffer(data) }
+ }
+
+ /**
+ * Updates an Oauth2 application's metadata
+ */
+ const updateAppMeta = async (app: AppBuffer<OAuth2Application>): Promise<void> => {
+
+ //Update the app metadata
+ await put(o2EndpointUrl, app.buffer)
+
+ //Get the app data from the server to update the local copy
+ const response = await get(`${o2EndpointUrl}?Id=${app.data.Id}`)
+
+ //Update the app
+ app.apply(response.data)
+ }
+
+ /**
+ * Requets a new secret for an application from the server
+ * @param app The app to request a new secret for
+ * @param password The user's password
+ * @returns The new secret
+ */
+ const updateAppSecret = async (app: AppBuffer<OAuth2Application>, password: string): Promise<string> => {
+ const response = await post(`${o2EndpointUrl}?action=secret`, { Id: app.data.Id, password })
+ return response.data.raw_secret
+ }
+
+ /**
+ * Deletes an application from the server
+ * @param app The application to delete
+ * @param password The user's password
+ * @returns The response from the server
+ */
+ const deleteApp = (app: AppBuffer<OAuth2Application>, password: string): Promise<AxiosResponse> => {
+ return post(`${o2EndpointUrl}?action=delete`, { password, Id: app.data.Id });
+ }
+
+ return { getApps, createApp, updateAppMeta, updateAppSecret, deleteApp }
+}
+
+
+//Custom alpha numeric regex
+const alphaNumRegex = helpers.regex(/^[a-zA-Z0-9_,\s]*$/)
+
+const rules = {
+ name: {
+ alphaNumSpace: helpers.withMessage("Name contains invalid characters", alphaNumRegex),
+ maxLength: helpers.withMessage('App name must be less than 50 characters', maxLength(50)),
+ required: helpers.withMessage('Oauth Application name is required', required)
+ },
+ description: {
+ alphaNumSpace: helpers.withMessage("Description contains invalid characters", alphaNumRegex),
+ maxLength: helpers.withMessage('Description must be less than 50 characters', maxLength(50))
+ },
+ permissions: {
+ alphaNumSpace: helpers.regex(/^[a-zA-Z0-9_,:\s]*$/),
+ maxLength: helpers.withMessage('Permissions must be less than 64 characters', maxLength(64))
+ }
+}
+
+export interface AppValidator {
+ readonly v$: ReturnType<typeof useVuelidate>
+ readonly validate: () => Promise<boolean>
+ readonly reset: () => void
+}
+
+/**
+ * Gets the validator for the given application (or new appication) buffer
+ * @param buffer The app buffer to validate
+ * @returns The validator instance, validate function, and reset function
+ */
+export const getAppValidator = <T>(buffer: T) : AppValidator => {
+ //App validator
+ const v$ = useVuelidate(rules, buffer, { $lazy: true, $autoDirty: true })
+ //validate wrapper function
+ const { validate } = useVuelidateWrapper(v$);
+ return { v$, validate, reset: v$.value.$reset };
+}
+
+export const getAppPermissions = () =>{
+ return [
+ {
+ type: 'account:read',
+ label: 'Account Read'
+ },
+ {
+ type: 'account:write',
+ label: 'Account Write'
+ },
+ {
+ type: 'email:send',
+ label: 'Send Emails'
+ }
+ ]
+} \ No newline at end of file
diff --git a/front-end/src/views/Account/components/profile/Profile.vue b/front-end/src/views/Account/components/profile/Profile.vue
new file mode 100644
index 0000000..e01707c
--- /dev/null
+++ b/front-end/src/views/Account/components/profile/Profile.vue
@@ -0,0 +1,199 @@
+<template>
+ <div id="account-profile" class="acnt-content-container panel-container">
+ <div class="acnt-content profile-container panel-content">
+
+ <div id="profile-control-container" class="flex flex-row" :modified="modified">
+ <div class="m-0">
+ <div class="flex rounded-full w-14 h-14 bg-primary-500 dark:bg-primary-600">
+ <div class="m-auto text-white dark:text-dark-400">
+ <fa-icon :icon="['fas', 'user']" size="2xl" />
+ </div>
+ </div>
+ </div>
+
+ <div class="my-auto ml-6">
+ <h3 class="m-0">Profile</h3>
+ </div>
+
+ <div class="gap-3 ml-auto">
+ <div v-if="editMode" class="button-group">
+ <button form="profile-edit-form" class="btn primary sm" :disabled="waiting" @click="onSubmit">Submit</button>
+ <button class="btn sm" @click="revertProfile">Cancel</button>
+ </div>
+ <div v-else class="">
+ <button class="btn no-border" @click="editMode = true">Edit</button>
+ </div>
+ </div>
+ </div>
+
+ <div>
+
+ <p class="profile-text">
+ You may set or change your profile information here. All fields are optional,
+ but some features may not work without some information.
+ </p>
+
+ <div class="locked-info">
+ <div class="mx-auto my-1 sm:mx-0 sm:my-2">
+ <span class="pr-2">Email:</span>
+ <span class="">{{ data.email }}</span>
+ </div>
+ <div class="mx-auto my-1 sm:mx-0 sm:my-2">
+ <span class="pr-2">Created:</span>
+ <span>{{ createdTime }}</span>
+ </div>
+ </div>
+
+ <dynamic-form id="profile-edit-form"
+ :form="FormSchema"
+ :disabled="!editMode"
+ :validator="v$"
+ @submit="onSubmit"
+ @input="onInput"
+ />
+ </div>
+
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { defaultTo } from 'lodash'
+import useVuelidate from '@vuelidate/core'
+import { ref, computed, watch } from 'vue'
+import { Rules, FormSchema } from './profile-schema.ts'
+import { apiCall, useMessage, useWait, useDataBuffer, useUser, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import { IUserProfile } from '@vnuge/vnlib.browser/dist/user'
+
+const ACCOUNT_URL = '/account/profile'
+
+interface UserProfile extends IUserProfile{
+ created : string | Date
+}
+
+const { waiting } = useWait()
+const { getProfile } = useUser()
+const { onInput, clearMessage } = useMessage()
+const { data, buffer, apply, revert, modified } = useDataBuffer<UserProfile>({} as UserProfile)
+
+const editMode = ref(false)
+
+// Create validator based on the profile buffer as a data model
+const v$ = useVuelidate(Rules, buffer, { $lazy:true, $autoDirty:false })
+
+// Setup the validator wrapper
+const { validate } = useVuelidateWrapper(v$);
+
+//const modified = computed(() => profile.value.Modified)
+const createdTime = computed(() => defaultTo(data.created?.toLocaleString(), ''))
+
+const loadProfileData = async () => {
+ await apiCall(async () => {
+ // Get the user's profile
+ const profile = await getProfile<UserProfile>()
+ profile.created = new Date(profile.created)
+ //Apply the profile to the buffer
+ apply(profile)
+ })
+}
+
+const revertProfile = () => {
+ //Revert the buffer
+ revert()
+ clearMessage()
+ editMode.value = false
+}
+
+const onSubmit = async () => {
+ if(waiting.value){
+ return;
+ }
+ // Validate the form
+ if (!await validate()) {
+ return
+ }
+ // Init the api call
+ await apiCall(async ({ axios, toaster }) => {
+ // Apply the buffer to the profile
+ const response = await axios.post(ACCOUNT_URL, buffer)
+
+ if(!response.data.success){
+ throw { response }
+ }
+
+ //No longer in edit mode
+ editMode.value = false
+
+ //Show success message
+ toaster.general.success({
+ title: 'Update successful',
+ text: response.data.result,
+ })
+
+ //reload the profile data
+ loadProfileData()
+ })
+}
+
+watch(editMode, () => v$.value.$reset())
+
+//Inital profile data load, dont await
+loadProfileData()
+
+</script>
+
+<style lang="scss">
+
+#account-profile {
+
+ p.profile-text{
+ @apply p-2 md:py-3 md:my-1 text-sm;
+ }
+
+ .locked-info{
+ @apply w-full flex flex-col sm:flex-row sm:justify-evenly pt-3 sm:pb-1;
+ }
+
+ #profile-edit-form .input-group {
+ @apply pt-4;
+
+ .input-container{
+ @apply p-2 rounded-md flex sm:flex-row flex-col sm:gap-4;
+
+ &.dirty.data-invalid.dynamic-form .dynamic-input{
+ @apply border-red-600;
+ }
+
+ &.dirty.data-invalid.dynamic-form label.dynamic-form{
+ @apply text-red-500;
+ }
+
+ &.dirty.dynamic-form label.dynamic-form{
+ @apply text-primary-500;
+ }
+
+ select:disabled{
+ @apply appearance-none;
+ }
+ }
+
+ .input-container:nth-child(odd) {
+ @apply bg-slate-50 dark:bg-dark-700;
+ }
+
+ .dynamic-form.dynamic-input{
+ @apply py-2 w-full bg-transparent border-x-0 border-t-0 border-b border-gray-300 dark:border-dark-300 pl-2;
+ @apply focus:bg-gray-200 focus:dark:bg-transparent;
+
+ &:disabled{
+ @apply py-1 border-transparent;
+ }
+ }
+
+ label.dynamic-form{
+ flex-basis: 15%;
+ @apply sm:text-right my-auto;
+ }
+ }
+}
+</style>
diff --git a/front-end/src/views/Account/components/profile/profile-schema.ts b/front-end/src/views/Account/components/profile/profile-schema.ts
new file mode 100644
index 0000000..85dacff
--- /dev/null
+++ b/front-end/src/views/Account/components/profile/profile-schema.ts
@@ -0,0 +1,310 @@
+
+import { maxLength, helpers, numeric, alpha, alphaNum } from '@vuelidate/validators'
+
+export const Rules = {
+ first: {
+ alpha,
+ maxLength: helpers.withMessage('First name must be less than 50 characters', maxLength(50))
+ },
+ last: {
+ alpha,
+ maxLength: helpers.withMessage('Last name must be less than 50 characters', maxLength(50))
+ },
+ company: {
+ alphaNum: helpers.regex(/^[a-zA-Z0-9\s.&!]*$/),
+ maxLength: helpers.withMessage('Company name must be less than 50 characters', maxLength(50))
+ },
+ phone: {
+ numeric: helpers.withMessage('Phone number must contain only numbers', numeric),
+ maxLength: helpers.withMessage('Phone number must be less than 11 numbers', maxLength(11))
+ },
+ street: {
+ alphaNum: helpers.regex(/^[a-zA-Z0-9\s&]*$/),
+ maxLength: helpers.withMessage('Street name must be less than 50 characters', maxLength(50))
+ },
+ city: {
+ alphaNum,
+ maxLength: helpers.withMessage('City name must be less than 50 characters', maxLength(50))
+ },
+ state: {
+ alpha,
+ maxLength: helpers.withMessage('State code is invalid', maxLength(2))
+ },
+ zip: {
+ numeric,
+ maxLength: helpers.withMessage('Zip code must be exactly 5 numbers', maxLength(5))
+ }
+}
+
+
+export const FormSchema = {
+ id: 'profile-edit-form',
+ fields: [
+ {
+ label: 'First',
+ name: 'first',
+ type: 'text',
+ id: 'first-name'
+ },
+ {
+ label: 'Last',
+ name: 'last',
+ type: 'text',
+ id: 'last-name'
+ },
+ {
+ label: 'Company',
+ name: 'company',
+ type: 'text',
+ id: 'company'
+ },
+ {
+ label: 'Phone',
+ name: 'phone',
+ type: 'text',
+ id: 'phone'
+ },
+ {
+ label: 'Street',
+ name: 'street',
+ type: 'text',
+ id: 'street'
+ },
+ {
+ label: 'City',
+ name: 'city',
+ type: 'text',
+ id: 'city'
+ },
+ {
+ label: 'State',
+ name: 'state',
+ type: 'select',
+ id: 'state',
+ options: [
+ {
+ 'label':'Select State',
+ 'value': ''
+ },
+ {
+ "label": "Alabama",
+ "value": "AL"
+ },
+ {
+ "label": "Alaska",
+ "value": "AK"
+ },
+ {
+ "label": "Arizona",
+ "value": "AZ"
+ },
+ {
+ "label": "Arkansas",
+ "value": "AR"
+ },
+ {
+ "label": "California",
+ "value": "CA"
+ },
+ {
+ "label": "Colorado",
+ "value": "CO"
+ },
+ {
+ "label": "Connecticut",
+ "value": "CT"
+ },
+ {
+ "label": "Delaware",
+ "value": "DE"
+ },
+ {
+ "label": "District Of Columbia",
+ "value": "DC"
+ },
+ {
+ "label": "Florida",
+ "value": "FL"
+ },
+ {
+ "label": "Georgia",
+ "value": "GA"
+ },
+ {
+ "label": "Guam",
+ "value": "GU"
+ },
+ {
+ "label": "Hawaii",
+ "value": "HI"
+ },
+ {
+ "label": "Idaho",
+ "value": "ID"
+ },
+ {
+ "label": "Illinois",
+ "value": "IL"
+ },
+ {
+ "label": "Indiana",
+ "value": "IN"
+ },
+ {
+ "label": "Iowa",
+ "value": "IA"
+ },
+ {
+ "label": "Kansas",
+ "value": "KS"
+ },
+ {
+ "label": "Kentucky",
+ "value": "KY"
+ },
+ {
+ "label": "Louisiana",
+ "value": "LA"
+ },
+ {
+ "label": "Maine",
+ "value": "ME"
+ },
+ {
+ "label": "Maryland",
+ "value": "MD"
+ },
+ {
+ "label": "Massachusetts",
+ "value": "MA"
+ },
+ {
+ "label": "Michigan",
+ "value": "MI"
+ },
+ {
+ "label": "Minnesota",
+ "value": "MN"
+ },
+ {
+ "label": "Mississippi",
+ "value": "MS"
+ },
+ {
+ "label": "Missouri",
+ "value": "MO"
+ },
+ {
+ "label": "Montana",
+ "value": "MT"
+ },
+ {
+ "label": "Nebraska",
+ "value": "NE"
+ },
+ {
+ "label": "Nevada",
+ "value": "NV"
+ },
+ {
+ "label": "New Hampshire",
+ "value": "NH"
+ },
+ {
+ "label": "New Jersey",
+ "value": "NJ"
+ },
+ {
+ "label": "New Mexico",
+ "value": "NM"
+ },
+ {
+ "label": "New York",
+ "value": "NY"
+ },
+ {
+ "label": "North Carolina",
+ "value": "NC"
+ },
+ {
+ "label": "North Dakota",
+ "value": "ND"
+ },
+ {
+ "label": "Ohio",
+ "value": "OH"
+ },
+ {
+ "label": "Oklahoma",
+ "value": "OK"
+ },
+ {
+ "label": "Oregon",
+ "value": "OR"
+ },
+ {
+ "label": "Pennsylvania",
+ "value": "PA"
+ },
+ {
+ "label": "Puerto Rico",
+ "value": "PR"
+ },
+ {
+ "label": "Rhode Island",
+ "value": "RI"
+ },
+ {
+ "label": "South Carolina",
+ "value": "SC"
+ },
+ {
+ "label": "South Dakota",
+ "value": "SD"
+ },
+ {
+ "label": "Tennessee",
+ "value": "TN"
+ },
+ {
+ "label": "Texas",
+ "value": "TX"
+ },
+ {
+ "label": "Utah",
+ "value": "UT"
+ },
+ {
+ "label": "Vermont",
+ "value": "VT"
+ },
+ {
+ "label": "Virginia",
+ "value": "VA"
+ },
+ {
+ "label": "Washington",
+ "value": "WA"
+ },
+ {
+ "label": "West Virginia",
+ "value": "WV"
+ },
+ {
+ "label": "Wisconsin",
+ "value": "WI"
+ },
+ {
+ "label": "Wyoming",
+ "value": "WY"
+ }
+ ]
+ },
+ {
+ label: 'Zip',
+ name: 'zip',
+ type: 'text',
+ id: 'zip'
+ }
+ ]
+} \ No newline at end of file
diff --git a/front-end/src/views/Account/components/settings/Fido.vue b/front-end/src/views/Account/components/settings/Fido.vue
new file mode 100644
index 0000000..340d6d9
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/Fido.vue
@@ -0,0 +1,53 @@
+<template>
+ <div id="account-fido-settings">
+ <div v-if="!isLocalAccount" class="flex flex-row justify-between">
+ <h6 class="block">
+ FIDO/WebAuthN Authentication
+ </h6>
+ <div class="text-red-500">
+ Unavailable for external auth
+ </div>
+ </div>
+ <div v-else class="flex flex-row flex-wrap justify-between">
+ <h6>FIDO/WebAuthN Authentication</h6>
+ <div class="">
+ <div v-if="fidoEnabled" class="">
+ <button class="ml-1 btn red sm" @click.prevent="Disable">
+ <fa-icon icon="minus-circle" />
+ <span class="pl-3">Disable</span>
+ </button>
+ </div>
+ <div v-else>
+ <button class="btn primary sm" @click.prevent="Setup">
+ <fa-icon icon="plus" />
+ <span class="pl-3">Setup</span>
+ </button>
+ </div>
+ </div>
+ <p class="p-1 pt-3 text-sm text-gray-600">
+ WebAuthN/FIDO is not yet supported, due to complexity and browser support.
+ </p>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useSession } from '@vnuge/vnlib.browser'
+import { toRefs } from 'vue';
+
+const props = defineProps<{
+ fidoEnabled?: boolean
+}>()
+
+const { fidoEnabled } = toRefs(props)
+
+const { isLocalAccount } = useSession()
+
+const Disable = () => {}
+const Setup = () => {}
+
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/PasswordReset.vue b/front-end/src/views/Account/components/settings/PasswordReset.vue
new file mode 100644
index 0000000..ff04193
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/PasswordReset.vue
@@ -0,0 +1,235 @@
+<template>
+ <div id="pwreset-settings" class="container">
+ <div class="panel-content">
+
+ <h5>Password Reset</h5>
+
+ <div v-if="!pwResetShow" class="py-2">
+ <div class="flex flex-wrap items-center justify-between">
+
+ <div class="my-auto">
+ Click to reset
+ </div>
+
+ <div class="flex justify-end">
+ <button class="btn red sm" @click="showForm">
+ <fa-icon icon="sync" />
+ <span class="pl-3">Reset Password</span>
+ </button>
+ </div>
+ </div>
+
+ <p class="mt-3 text-sm">
+ You may only reset your password if you have an internal user account. If you exclusivly use an external
+ authentication provider (like GitHub or Discord), you will need to reset your password externally.
+ </p>
+ </div>
+
+ <div v-else class="px-2 my-2">
+
+ <p class="my-3 text-center">
+ Enter your current password, new password, and confirm the new password.
+ </p>
+
+ <dynamic-form
+ id="password-reset-form"
+ class="pwreset-form primary"
+ :form="formSchema"
+ :disabled="waiting"
+ :validator="v$"
+ @submit="onSubmit"
+ @input="onInput"
+ />
+
+ <div class="flex flex-row justify-end my-2">
+ <div class="button-group">
+ <button type="submit" form="password-reset-form" class="btn primary sm" :disabled="waiting">
+ <fa-icon v-if="!waiting" icon="check" />
+ <fa-icon v-else class="animate-spin" icon="spinner" />
+ Update
+ </button>
+ <button class="btn sm cancel-btn" :disabled="waiting" @click="resetForm">
+ Cancel
+ </button>
+ </div>
+ </div>
+
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { toSafeInteger } from 'lodash';
+import useVuelidate from '@vuelidate/core'
+import { required, maxLength, minLength, helpers } from '@vuelidate/validators'
+import { useUser, apiCall, useMessage, useWait, useConfirm, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import { computed, reactive, ref, toRefs, watch } from 'vue'
+
+const props = defineProps<{
+ totpEnabled: boolean,
+ fidoEnabled: boolean
+}>()
+
+const { totpEnabled, fidoEnabled } = toRefs(props)
+
+const formSchema = ref({
+ fields: [
+ {
+ label: 'Current Password',
+ name: 'current',
+ type: 'password',
+ id: 'current-password'
+ },
+ {
+ label: 'New Password',
+ name: 'newPassword',
+ type: 'password',
+ id: 'new-password'
+ },
+ {
+ label: 'Confirm Password',
+ name: 'repeatPassword',
+ type: 'password',
+ id: 'confirm-password'
+ }
+ ]
+})
+
+const { waiting } = useWait()
+const { onInput } = useMessage()
+const { reveal } = useConfirm()
+const { resetPassword } = useUser()
+
+const pwResetShow = ref(false)
+
+const vState = reactive({
+ newPassword: '',
+ repeatPassword: '',
+ current: '',
+ totpCode:''
+})
+
+const rules = computed(() =>{
+ return {
+ current: {
+ required: helpers.withMessage('Current password cannot be empty', required),
+ minLength: helpers.withMessage('Current password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage('Current password must have less than 128 characters', maxLength(128))
+ },
+ newPassword: {
+ notOld: helpers.withMessage('New password cannot be the same as your current password', (value : string) => value != vState.current),
+ required: helpers.withMessage('New password cannot be empty', required),
+ minLength: helpers.withMessage('New password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage('New password must have less than 128 characters', maxLength(128))
+ },
+ repeatPassword: {
+ sameAs: helpers.withMessage('Your new passwords do not match', (value : string) => value == vState.newPassword),
+ required: helpers.withMessage('Repeast password cannot be empty', required),
+ minLength: helpers.withMessage('Repeast password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage('Repeast password must have less than 128 characters', maxLength(128))
+ },
+ totpCode:{
+ required: helpers.withMessage('TOTP code cannot be empty', required),
+ minLength: helpers.withMessage('TOTP code must be at least 6 characters', minLength(6)),
+ maxLength: helpers.withMessage('TOTP code must have less than 12 characters', maxLength(12))
+ }
+ }
+})
+
+const v$ = useVuelidate(rules, vState, { $lazy: true })
+const { validate } = useVuelidateWrapper(v$)
+
+const showTotpCode = computed(() => totpEnabled.value && !fidoEnabled.value)
+
+watch(showTotpCode, (val) => {
+ if(val){
+ //Add totp code field
+ formSchema.value.fields.push({
+ label: 'TOTP Code',
+ name: 'totpCode',
+ type: 'text',
+ id: 'totp-code'
+ })
+ }
+})
+
+const showForm = async function () {
+ const { isCanceled } = await reveal({
+ title: 'Reset Password',
+ text: 'Are you sure you want to reset your password? This cannot be reversed.'
+ })
+ pwResetShow.value = !isCanceled
+}
+
+const onSubmit = async () => {
+
+ // validate
+ if (! await validate()) {
+ return
+ }
+
+ interface IResetPasswordArgs {
+ totp_code?: number
+ }
+
+ await apiCall(async ({ toaster }) => {
+ const args : IResetPasswordArgs = {}
+
+ //Add totp code if enabled
+ if(showTotpCode.value){
+ args.totp_code = toSafeInteger(v$.value.totpCode.$model)
+ }
+
+ //Exec pw reset
+ const { getResultOrThrow } = await resetPassword(v$.value.current.$model, v$.value.newPassword.$model, args)
+
+ //Get result or raise exception to handler
+ const result = getResultOrThrow()
+
+ // success
+ resetForm()
+
+ // Push a success toast
+ toaster.general.success({
+ title: 'Success',
+ text: result
+ })
+
+ })
+}
+
+const resetForm = () => {
+ v$.value.current.$model = ''
+ v$.value.newPassword.$model = ''
+ v$.value.repeatPassword.$model = ''
+ v$.value.totpCode.$model = ''
+ v$.value.$reset()
+ pwResetShow.value = false
+}
+
+</script>
+
+<style lang="scss">
+
+#password-reset-form{
+
+ .dynamic-form.input-container{
+ @apply flex flex-col sm:flex-row my-4 max-w-lg mx-auto;
+
+ label{
+ flex-basis: 40%;
+ @apply pl-1 text-sm sm:text-right my-auto mr-2 mb-1 sm:mb-auto;
+ }
+ }
+
+ .dynamic-form.dynamic-input.input {
+ @apply p-2 w-full border rounded-md;
+ @apply focus:border-primary-500 focus:dark:border-primary-600 dark:border-dark-400 bg-transparent dark:bg-dark-800;
+ }
+ .dirty.data-invalid.dynamic-form.input-container input{
+ @apply border-red-500 focus:border-red-500;
+ }
+}
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/Pki.vue b/front-end/src/views/Account/components/settings/Pki.vue
new file mode 100644
index 0000000..a621bf2
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/Pki.vue
@@ -0,0 +1,182 @@
+<template>
+ <div id="pki-settings" v-show="pkiEnabled" class="container">
+ <div class="panel-content">
+ <h5>PKI Authentication</h5>
+ <div class="flex flex-row flex-wrap justify-between">
+ <h6>Authentication keys</h6>
+
+ <div v-if="enabled" class="button-group">
+ <button class="btn yellow sm" @click.prevent="setIsOpen(true)">
+ <fa-icon icon="sync" />
+ <span class="pl-3">Update Key</span>
+ </button>
+ <button class="btn red sm" @click.prevent="onDisable">
+ <fa-icon icon="minus-circle" />
+ <span class="pl-3">Disable</span>
+ </button>
+ </div>
+
+ <div v-else class="">
+ <button class="btn primary sm" @click.prevent="setIsOpen(true)">
+ <fa-icon icon="plus" />
+ <span class="pl-3">Add Key</span>
+ </button>
+ </div>
+
+ <p class="p-1 pt-3 text-sm text-gray-600">
+ PKI authentication is a method of authenticating your user account with signed messages and a shared public key. This method implementation
+ uses client signed Json Web Tokens to authenticate user generated outside this website as a One Time Password (OTP). This allows for you to
+ use your favorite hardware or software tools, to generate said OTPs to authenticate your user.
+
+ </p>
+ </div>
+ </div>
+ </div>
+ <Dialog :open="isOpen" @close="setIsOpen" class="relative z-30">
+ <div class="fixed inset-0 bg-black/30" aria-hidden="true" />
+
+ <div class="fixed inset-0 flex justify-center">
+ <DialogPanel class="w-full max-w-lg p-4 m-auto mt-24 bg-white rounded dark:bg-dark-600 dark:text-gray-300">
+ <h4>Configure your authentication key</h4>
+ <p class="mt-2 text-sm">
+ Please paste your authenticator's public key as a Json Web Key (JWK) object. Your JWK must include a kid (key id) and a kty (key type) field.
+ </p>
+ <div class="p-2 mt-3">
+ <textarea class="w-full p-1 text-sm border dark:bg-dark-700 ring-0 dark:border-dark-400" rows="10" v-model="keyData" />
+ </div>
+ <div class="flex justify-end gap-2 mt-4">
+ <button class="rounded btn sm primary" @click.prevent="onSubmitKeys">Submit</button>
+ <button class="rounded btn sm red" @click.prevent="setIsOpen(false)">Cancel</button>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+</template>
+
+<script setup lang="ts">
+import { isEmpty, isNil } from 'lodash'
+import { apiCall, useConfirm, useSession, debugLog, useFormToaster } from '@vnuge/vnlib.browser'
+import { computed, ref, watch } from 'vue'
+import { Dialog, DialogPanel } from '@headlessui/vue'
+import { PkiApi } from '@vnuge/vnlib.browser/dist/mfa';
+
+const props = defineProps<{
+ pkaiApi: PkiApi
+}>()
+
+const { reveal } = useConfirm()
+const { isLocalAccount } = useSession()
+const { error } = useFormToaster()
+
+const pkiEnabled = computed(() => isLocalAccount.value && !isNil(import.meta.env.VITE_PKI_ENDPOINT) && window.crypto.subtle)
+const { enabled } = props.pkaiApi
+
+const isOpen = ref(false)
+const keyData = ref('')
+const pemFormat = ref(false)
+const explicitCurve = ref("")
+
+watch(isOpen, () =>{
+ keyData.value = ''
+ pemFormat.value = false
+ explicitCurve.value = ""
+ //Reload status
+ props.pkaiApi.refresh()
+})
+
+const setIsOpen = (value : boolean) => isOpen.value = value
+
+const onDisable = async () => {
+ const { isCanceled } = await reveal({
+ title: 'Are you sure?',
+ text: 'This will disable PKI authentication for your account.'
+ })
+ if (isCanceled) {
+ return;
+ }
+
+ //Delete pki
+ await apiCall(async ({ toaster }) =>{
+
+ //Disable pki
+ //TODO: require password or some upgrade to disable
+ const { success } = await props.pkaiApi.disable();
+
+ if(success){
+ toaster.general.success({
+ title: 'Success',
+ text: 'PKI authentication has been disabled.'
+ })
+ }
+ else{
+ toaster.general.error({
+ title: 'Error',
+ text: 'PKI authentication could not be disabled.'
+ })
+ }
+
+ //Refresh the status
+ props.pkaiApi.refresh();
+ });
+}
+
+//Server requires the JWK to set a keyid (kid) field
+interface IdJsonWebKey extends JsonWebKey {
+ readonly kid?: string
+}
+
+const onSubmitKeys = async () =>{
+
+ if(window.crypto.subtle == null){
+ error({ title: "Your browser does not support PKI authentication." })
+ return;
+ }
+
+ //Validate key data
+ if(isEmpty(keyData.value)){
+ error({ title: "Please enter key data" })
+ return;
+ }
+
+ let jwk : IdJsonWebKey;
+ try {
+ //Try to parse as jwk
+ jwk = JSON.parse(keyData.value)
+ if(isEmpty(jwk.use)
+ || isEmpty(jwk.kty)
+ || isEmpty(jwk.alg)
+ || isEmpty(jwk.kid)
+ || isEmpty(jwk.x)
+ || isEmpty(jwk.y)){
+ throw new Error("Invalid JWK");
+ }
+ }
+ catch (e) {
+ //Write error to debug log
+ debugLog(e)
+ error({ title:"The key is not a valid Json Web Key (JWK)"})
+ return;
+ }
+
+ //Send to server
+ await apiCall(async ({ toaster }) => {
+
+ //init/update the key
+ //TODO: require password or some upgrade to disable
+ const { getResultOrThrow } = await props.pkaiApi.initOrUpdate(jwk);
+
+ const result = getResultOrThrow();
+
+ toaster.general.success({
+ title: 'Success',
+ text: result
+ })
+ setIsOpen(false)
+ })
+}
+
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/Security.vue b/front-end/src/views/Account/components/settings/Security.vue
new file mode 100644
index 0000000..9ba83f7
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/Security.vue
@@ -0,0 +1,81 @@
+<template>
+ <div id="account-security-settings">
+ <div class="panel-container">
+
+ <div class="panel-header">
+ <div class="panel-title">
+ <h4>Security</h4>
+ </div>
+ </div>
+
+ <password-reset :totpEnabled="totpEnabled" :fido-enabled="fidoEnabled" />
+
+ <div id="account-mfa-settings" class="panel-content">
+ <h5>Multi Factor Authentication</h5>
+ <div class="py-2 border-b-2 border-gray-200 dark:border-dark-400">
+ <TotpSettings :mfa="mfaApi" />
+ </div>
+ <div class="py-2">
+ <Fido :fido-enabled="fidoEnabled"/>
+ </div>
+ </div>
+
+ <Pki :pkai-api="pkiApi" />
+
+ <div id="browser-poll-settings" class="panel-content" >
+ <div class="flex justify-between">
+ <h5>Keep me logged in</h5>
+ <div class="pl-1">
+ <Switch
+ v-model="enabled"
+ :class="enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-400'"
+ class="relative inline-flex items-center h-6 rounded-full w-11"
+ >
+ <span class="sr-only">Enable auto heartbeat</span>
+ <span
+ :class="enabled ? 'translate-x-6' : 'translate-x-1'"
+ class="inline-block w-4 h-4 transition transform bg-white rounded-full"
+ />
+ </Switch>
+ </div>
+ </div>
+
+ <p class="p-1 text-sm">
+ When enabled, continuously regenerates your login credentials to keep you logged in. The longer you are logged in,
+ the easier session fixation attacks become. If disabled, you will need to log when your credentials have expired.
+ It is recommneded that you leave this disabled <span class="text-yellow-500">Disabled</span>
+ </p>
+
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useAutoHeartbeat } from '@vnuge/vnlib.browser'
+import { useMfaConfig, MfaMethod, usePkiConfig } from '@vnuge/vnlib.browser/dist/mfa'
+import { computed } from 'vue'
+import { Switch } from '@headlessui/vue'
+import { includes } from 'lodash'
+import Fido from './Fido.vue'
+import Pki from './Pki.vue'
+import TotpSettings from './TotpSettings.vue'
+import PasswordReset from './PasswordReset.vue'
+
+const { enabled } = useAutoHeartbeat()
+
+const mfaApi = useMfaConfig('/account/mfa')
+const pkiApi = usePkiConfig(import.meta.env.VITE_PKI_ENDPOINT, mfaApi)
+
+const fidoEnabled = computed(() => includes(mfaApi.enabledMethods.value, 'fido' as MfaMethod))
+const totpEnabled = computed(() => includes(mfaApi.enabledMethods.value, MfaMethod.TOTP))
+
+</script>
+
+<style>
+
+#account-security-settings .modal-body{
+ @apply w-full sm:max-w-md ;
+}
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/Settings.vue b/front-end/src/views/Account/components/settings/Settings.vue
new file mode 100644
index 0000000..cd2ab48
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/Settings.vue
@@ -0,0 +1,16 @@
+<template>
+ <div id="account-settings" class="container">
+ <div class="acnt-content-container">
+ <Security />
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import Security from './security.vue'
+
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/views/Account/components/settings/TotpSettings.vue b/front-end/src/views/Account/components/settings/TotpSettings.vue
new file mode 100644
index 0000000..20ee0d0
--- /dev/null
+++ b/front-end/src/views/Account/components/settings/TotpSettings.vue
@@ -0,0 +1,263 @@
+<template>
+ <div id="totp-settings">
+
+ <div v-if="!isLocalAccount" class="flex flex-row justify-between">
+ <h6 class="block">
+ TOTP Authenticator App
+ </h6>
+ <div class="text-red-500">
+ Unavailable for external auth
+ </div>
+ </div>
+
+ <div v-else-if="showTotpCode" class="w-full py-2 text-center">
+ <h5 class="text-center" />
+ <p class="py-2">
+ Scan the QR code with your TOTP authenticator app.
+ </p>
+
+ <div class="flex">
+ <VueQrcode class="m-auto" :value="qrCode" />
+ </div>
+
+ <p class="py-2">
+ Your secret, if your application requires it.
+ </p>
+
+ <p class="flex flex-row flex-wrap justify-center p-2 bg-gray-200 border border-gray-300 dark:bg-dark-800 dark:border-dark-300">
+ <span v-for="code in secretSegments" :key="code" class="px-2 font-mono tracking-wider" >
+ {{ code }}
+ </span>
+ </p>
+
+ <p class="py-2">
+ Please enter your code from your authenticator app to continue.
+ </p>
+
+ <div class="m-auto w-min">
+ <VOtpInput
+ class="otp-input"
+ input-type="letter-numeric"
+ separator=""
+ :is-disabled="showSubmitButton"
+ input-classes="primary input rounded"
+ :num-inputs="6"
+ @on-change="onInput"
+ @on-complete="VerifyTotp"
+ />
+ </div>
+
+ <div v-if="showSubmitButton" class="flex flex-row justify-end my-2">
+ <button class="btn primary" @click.prevent="CloseQrWindow">
+ Complete
+ </button>
+ </div>
+ </div>
+
+ <div v-else class="flex flex-row flex-wrap justify-between">
+ <h6>TOTP Authenticator App</h6>
+
+ <div v-if="totpEnabled" class="button-group">
+ <button class="btn yellow sm" @click.prevent="regenTotp">
+ <fa-icon icon="sync" />
+ <span class="pl-3">Regenerate</span>
+ </button>
+ <button class="btn red sm" @click.prevent="disable">
+ <fa-icon icon="minus-circle" />
+ <span class="pl-3">Disable</span>
+ </button>
+ </div>
+
+ <div v-else>
+ <button class="btn primary sm" @click.prevent="configTotp">
+ <fa-icon icon="plus" />
+ <span class="pl-3">Setup</span>
+ </button>
+ </div>
+ <p class="p-1 pt-3 text-sm text-gray-600">
+ TOTP is a time based one time password. You can use it as a form of Multi Factor Authentication when
+ using another device such as a smart phone or TOTP hardware device. You can use TOTP with your smart
+ phone
+ using apps like Google Authenticator, Authy, or Duo. Read more on
+ <a class="link" href="https://en.wikipedia.org/wiki/Time-based_one-time_password" target="_blank">
+ Wikipedia.
+ </a>
+ </p>
+ </div>
+
+ </div>
+</template>
+
+<script setup lang="ts">
+import { isNil, chunk, defaultTo, includes, map, join } from 'lodash'
+import { TOTP } from 'otpauth'
+import base32Encode from 'base32-encode'
+import VueQrcode from '@chenfengyuan/vue-qrcode'
+import VOtpInput from "vue3-otp-input";
+import { computed, ref } from 'vue'
+import {
+ useSessionUtils,
+ useSession,
+ useUser,
+ useMessage,
+ useConfirm,
+ usePassConfirm,
+ useFormToaster
+} from '@vnuge/vnlib.browser'
+import { MfaApi, MfaMethod } from '@vnuge/vnlib.browser/dist/mfa';
+
+interface TotpConfig{
+ secret: string;
+ readonly issuer: string;
+ readonly algorithm: string;
+ readonly digits?: number;
+ readonly period?: number;
+}
+
+const props = defineProps<{
+ mfa: MfaApi
+}>()
+
+const { isLocalAccount } = useSession()
+const { KeyStore } = useSessionUtils()
+const { userName } = useUser()
+const { reveal } = useConfirm()
+const { elevatedApiCall } = usePassConfirm()
+const { onInput, setMessage } = useMessage()
+
+const { enabledMethods, disableMethod, initOrUpdateMethod, refreshMethods } = props.mfa;
+const totpEnabled = computed(() => includes(enabledMethods.value, MfaMethod.TOTP))
+
+const totpMessage = ref<TotpConfig>()
+const showSubmitButton = ref(false)
+const toaster = useFormToaster()
+
+const showTotpCode = computed(() => !isNil(totpMessage.value?.secret))
+
+const secretSegments = computed<string[]>(() => {
+ //Chunk the secret into 6 character segments
+ const chunks = chunk(totpMessage.value?.secret, 6)
+ //Join the chunks into their chunk arrays
+ return map(chunks, chunk => join(chunk, ''))
+})
+
+const qrCode = computed(() => {
+ if (isNil(totpMessage.value?.secret)) {
+ return ''
+ }
+
+ const m = totpMessage.value!;
+
+ // Build the totp qr codeurl
+ const params = new URLSearchParams()
+ params.append('secret', m.secret)
+ params.append('issuer', m.issuer)
+ params.append('algorithm', m.algorithm)
+ params.append('digits', defaultTo(m.digits, 6).toString())
+ params.append('period', defaultTo(m.period, 30).toString())
+ const url = `otpauth://totp/${m.issuer}:${userName.value}?${params.toString()}`
+ return url
+})
+
+const ProcessAddOrUpdate = async () => {
+ await elevatedApiCall(async ({ password }) => {
+
+ // Init or update the totp method and get the encrypted totp message
+ const res = await initOrUpdateMethod<TotpConfig>(MfaMethod.TOTP, password);
+
+ //Get the encrypted totp message
+ const totp = res.getResultOrThrow()
+
+ // Decrypt the totp secret
+ const secretBuf = await KeyStore.decryptDataAsync(totp.secret)
+
+ // Encode the secret to base32
+ totp.secret = base32Encode(secretBuf, 'RFC3548', { padding: false })
+
+ totpMessage.value = totp
+ })
+}
+
+const configTotp = async () => {
+ const { isCanceled } = await reveal({
+ title: 'Enable TOTP multi factor?',
+ text: 'Are you sure you understand TOTP multi factor and wish to enable it?',
+ })
+
+ if(!isCanceled){
+ ProcessAddOrUpdate()
+ }
+}
+
+const regenTotp = async () => {
+ // If totp is enabled, show a prompt to regenerate totp
+ if (!totpEnabled.value) {
+ return
+ }
+
+ const { isCanceled } = await reveal({
+ title: 'Are you sure?',
+ text: 'If you continue your previous TOTP authenticator and recovery codes will no longer be valid.'
+ })
+
+ if(!isCanceled){
+ ProcessAddOrUpdate()
+ }
+}
+
+const disable = async () => {
+ // Show a confrimation prompt
+ const { isCanceled } = await reveal({
+ title: 'Disable TOTP',
+ text: 'Are you sure you want to disable TOTP? You may re-enable TOTP later.'
+ })
+
+ if (isCanceled) {
+ return
+ }
+
+ await elevatedApiCall(async ({ password }) => {
+
+ // Disable the totp method
+ const res = await disableMethod(MfaMethod.TOTP, password)
+ res.getResultOrThrow()
+
+ refreshMethods()
+ })
+}
+
+const VerifyTotp = async (code : string) => {
+ // Create a new TOTP instance from the current message
+ const totp = new TOTP(totpMessage.value)
+
+ // validate the code
+ const valid = totp.validate({ token: code, window: 4 })
+
+ if (valid) {
+ showSubmitButton.value = true
+ toaster.success({
+ title: 'Success',
+ text: 'Your TOTP code is valid and your account is now verified.'
+ })
+ } else {
+ setMessage('Your TOTP code is not valid.')
+ }
+}
+
+const CloseQrWindow = () => {
+ showSubmitButton.value = false
+ totpMessage.value = undefined
+
+ //Fresh methods
+ refreshMethods()
+}
+
+</script>
+
+<style>
+
+#totp-settings .otp-input input {
+ @apply w-12 text-center text-lg mx-1 focus:border-primary-500;
+}
+
+</style>
diff --git a/front-end/src/views/Blog/blog-api/index.ts b/front-end/src/views/Blog/blog-api/index.ts
new file mode 100644
index 0000000..678883b
--- /dev/null
+++ b/front-end/src/views/Blog/blog-api/index.ts
@@ -0,0 +1,22 @@
+// Copyright (C) 2023 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
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { ComputedChannels, ComputedContent, ComputedPosts } from '@vnuge/cmnext-admin'
+
+export interface BlogState {
+ readonly channels: ComputedChannels,
+ readonly posts: ComputedPosts,
+ readonly content: ComputedContent
+} \ No newline at end of file
diff --git a/front-end/src/views/Blog/ckeditor/Editor.vue b/front-end/src/views/Blog/ckeditor/Editor.vue
new file mode 100644
index 0000000..87c0595
--- /dev/null
+++ b/front-end/src/views/Blog/ckeditor/Editor.vue
@@ -0,0 +1,155 @@
+<template>
+ <div class="pt-6">
+ <div class="flex justify-end w-full gap-2 my-2">
+ <div class="w-fit">
+ <Popover class="relative">
+ <PopoverButton class="btn">
+ Add
+ <fa-icon class="ml-2" icon="photo-film" />
+ </PopoverButton>
+ <PopoverPanel class="absolute right-0 z-10 top-10">
+ <div class="md-pannel">
+ <div class="">
+ Search for content by its id or file name.
+ </div>
+ <ContentSearch :blog="$props.blog"/>
+ </div>
+ </PopoverPanel>
+ </Popover>
+ </div>
+ <div class="w-fit">
+ <Popover class="relative">
+ <PopoverButton class="btn" @click="recoverMd">
+ Markdown
+ <fa-icon class="ml-2" :icon="['fab','markdown']" />
+ </PopoverButton>
+ <PopoverPanel class="absolute right-0 z-10 top-10">
+ <div class="md-pannel">
+ <div class="">
+ Paste your markdown here to convert it to html.
+ </div>
+ <div class="my-4">
+ <textarea class="w-full h-40 p-2 bg-transparent border" v-model="mdBuffer"></textarea>
+ </div>
+ <div class="flex justify-end">
+ <button class="btn primary" @click="convertMarkdown">Convert</button>
+ </div>
+ </div>
+ </PopoverPanel>
+ </Popover>
+ </div>
+ <div class="w-fit">
+ <button class="btn" @click="recoverFromCrash">
+ Recover
+ <fa-icon class="ml-2" icon="rotate-left" />
+ </button>
+ </div>
+ </div>
+ <div id="ck-editor-frame" ref="editorFrame">
+ <div class="w-full text-center">
+ <h5>Loading editor...</h5>
+ <fa-icon class="text-2xl" icon="spinner" spin />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { debounce } from 'lodash';
+import { ref } from 'vue';
+import { useSessionStorage } from '@vueuse/core';
+import { tryOnMounted } from '@vueuse/shared';
+import { apiCall } from '@vnuge/vnlib.browser';
+import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
+import { BlogState } from '../blog-api'
+import { Converter } from 'showdown'
+
+//Import the editor config
+import { config } from './build.ts'
+import ContentSearch from '../components/ContentSearch.vue';
+
+const emit = defineEmits(['change', 'load'])
+
+defineProps<{
+ blog: BlogState
+}>()
+
+let editor = {}
+//Init new shodown converter
+const showdownConverter = new Converter()
+const mdBuffer = ref('')
+const editorFrame = ref(null)
+const crashBuffer = useSessionStorage('post-crash', '')
+
+const recoverFromCrash = () => {
+ //Set editor content from crash buffer
+ editor.setData(crashBuffer.value);
+}
+
+const onChange = (content:string) =>{
+ //Save the content to the crash buffer
+ crashBuffer.value = content;
+ emit('change', content)
+}
+
+const convertMarkdown = () => {
+
+ const html = showdownConverter.makeHtml(mdBuffer.value);
+
+ //Set initial data
+ editor.setData(html)
+
+ //manually trigger change event
+ onChange(html)
+
+ //Clear the buffer
+ mdBuffer.value = ''
+}
+
+const recoverMd = () => {
+ const current = editor.getData();
+ const md = showdownConverter.makeMd(current);
+ mdBuffer.value = md;
+}
+
+tryOnMounted(() =>
+ //Load the editor once the component is mounted
+ apiCall(async ({ toaster }) => {
+
+ //Entry script creates promise that resolves when the editor script is loaded
+ if(window.editorLoadResult){
+ //Wait for the editor script to load
+ await (window.editorLoadResult as Promise<boolean>)
+ }
+
+ if (!window['CKEDITOR']) {
+ toaster.general.error({
+ title: 'Script Error',
+ text: 'The CKEditor script failed to load, check script permissions.'
+ })
+ return;
+ }
+
+ //CKEditor 5 superbuild in global scope
+ const { ClassicEditor } = window['CKEDITOR']
+
+ //Init editor when loading is complete
+ editor = await ClassicEditor.create(editorFrame.value, config);
+
+ //Update the local copy when the editor data changes
+ editor.model.document.on('change:data', debounce(() => onChange(editor.getData())), 500)
+
+ //Call initial load hook
+ emit('load', editor);
+ })
+)
+
+</script>
+
+<style lang="scss">
+
+.md-pannel{
+ @apply p-6 min-w-[32rem] bg-white shadow-md dark:bg-dark-700 border dark:border-dark-300;
+}
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/ckeditor/build.ts b/front-end/src/views/Blog/ckeditor/build.ts
new file mode 100644
index 0000000..83b46a7
--- /dev/null
+++ b/front-end/src/views/Blog/ckeditor/build.ts
@@ -0,0 +1,125 @@
+export const config = {
+ // https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html#extended-toolbar-configuration-format
+ toolbar: {
+ items: [
+ 'findAndReplace', 'selectAll', '|',
+ 'heading', '|',
+ 'bold', 'italic', 'strikethrough', 'underline', 'code', 'subscript', 'superscript', 'removeFormat', '|',
+ 'bulletedList', 'numberedList', 'todoList', '|',
+ 'outdent', 'indent', 'alignment', '|',
+ 'undo', 'redo',
+ '-',
+ 'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'highlight', '|',
+ 'link', 'insertImage', 'blockQuote', 'insertTable', 'mediaEmbed', 'codeBlock', 'htmlEmbed', '|',
+ 'specialCharacters', 'horizontalLine', 'pageBreak', '|',
+ 'exportPDF', 'exportWord', 'sourceEditing'
+ ],
+ shouldNotGroupWhenFull: true
+ },
+ // Changing the language of the interface requires loading the language file using the <script> tag.
+ // language: 'es',
+ list: {
+ properties: {
+ styles: true,
+ startIndex: true,
+ reversed: true
+ }
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/headings.html#configuration
+ heading: {
+ options: [
+ { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
+ { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
+ { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
+ { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
+ { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' },
+ { model: 'heading5', view: 'h5', title: 'Heading 5', class: 'ck-heading_heading5' },
+ { model: 'heading6', view: 'h6', title: 'Heading 6', class: 'ck-heading_heading6' }
+ ]
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/editor-placeholder.html#using-the-editor-configuration
+ placeholder: 'Welcome to CKEditor 5!',
+ // https://ckeditor.com/docs/ckeditor5/latest/features/font.html#configuring-the-font-family-feature
+ fontFamily: {
+ options: [
+ 'default',
+ 'Arial, Helvetica, sans-serif',
+ 'Courier New, Courier, monospace',
+ 'Georgia, serif',
+ 'Lucida Sans Unicode, Lucida Grande, sans-serif',
+ 'Tahoma, Geneva, sans-serif',
+ 'Times New Roman, Times, serif',
+ 'Trebuchet MS, Helvetica, sans-serif',
+ 'Verdana, Geneva, sans-serif'
+ ],
+ supportAllValues: true
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/font.html#configuring-the-font-size-feature
+ fontSize: {
+ options: [10, 12, 14, 'default', 18, 20, 22],
+ supportAllValues: true
+ },
+ // Be careful with the setting below. It instructs CKEditor to accept ALL HTML markup.
+ // https://ckeditor.com/docs/ckeditor5/latest/features/general-html-support.html#enabling-all-html-features
+ htmlSupport: {
+ allow: [
+ {
+ name: /.*/,
+ attributes: true,
+ classes: true,
+ styles: true
+ }
+ ]
+ },
+ // Be careful with enabling previews
+ // https://ckeditor.com/docs/ckeditor5/latest/features/html-embed.html#content-previews
+ htmlEmbed: {
+ showPreviews: true
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/link.html#custom-link-attributes-decorators
+ link: {
+ decorators: {
+ addTargetToExternalLinks: true,
+ defaultProtocol: 'https://',
+ toggleDownloadable: {
+ mode: 'manual',
+ label: 'Downloadable',
+ attributes: {
+ download: 'file'
+ }
+ }
+ }
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/mentions.html#configuration
+ mention: {
+ },
+ // The "super-build" contains more premium features that require additional configuration, disable them below.
+ // Do not turn them on unless you read the documentation and know how to configure them and setup the editor.
+ removePlugins: [
+ // These two are commercial, but you can try them out without registering to a trial.
+ // 'ExportPdf',
+ // 'ExportWord',
+ 'CKBox',
+ 'CKFinder',
+ 'EasyImage',
+ // This sample uses the Base64UploadAdapter to handle image uploads as it requires no configuration.
+ // https://ckeditor.com/docs/ckeditor5/latest/features/images/image-upload/base64-upload-adapter.html
+ // Storing images as Base64 is usually a very bad idea.
+ // Replace it on production website with other solutions:
+ // https://ckeditor.com/docs/ckeditor5/latest/features/images/image-upload/image-upload.html
+ // 'Base64UploadAdapter',
+ 'RealTimeCollaborativeComments',
+ 'RealTimeCollaborativeTrackChanges',
+ 'RealTimeCollaborativeRevisionHistory',
+ 'PresenceList',
+ 'Comments',
+ 'TrackChanges',
+ 'TrackChangesData',
+ 'RevisionHistory',
+ 'Pagination',
+ 'WProofreader',
+ // Careful, with the Mathtype plugin CKEditor will not load when loading this sample
+ // from a local file system (file://) - load this site via HTTP server if you enable MathType
+ 'MathType'
+ ],
+} \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Channels.vue b/front-end/src/views/Blog/components/Channels.vue
new file mode 100644
index 0000000..ad88e50
--- /dev/null
+++ b/front-end/src/views/Blog/components/Channels.vue
@@ -0,0 +1,99 @@
+<template>
+ <div id="channel-editor">
+ <EditorTable title="Manage channels" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
+ <template v-slot:table>
+ <ChannelTable
+ :channels="items"
+ @open-edit="openEdit"
+ />
+ </template>
+ <template #editor>
+ <ChannelEdit
+ :blog="$props.blog"
+ @close="closeEdit"
+ @on-submit="onSubmit"
+ @on-delete="onDelete"
+ />
+ </template>
+ </EditorTable>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { BlogState } from '../blog-api';
+import { isEmpty, filter as _filter } from 'lodash';
+import { apiCall } from '@vnuge/vnlib.browser';
+import { BlogChannel, ChannelFeed, useFilteredPages } from '@vnuge/cmnext-admin';
+import ChannelEdit from './Channels/ChannelEdit.vue';
+import ChannelTable from './Channels/ChannelTable.vue';
+import EditorTable from './EditorTable.vue';
+
+const emit = defineEmits(['close', 'reload'])
+
+const props = defineProps<{
+ blog: BlogState,
+}>()
+
+const { updateChannel, addChannel, deleteChannel, getQuery } = props.blog.channels;
+const { channelEdit } = getQuery()
+
+//Setup channel filter
+const { items, pagination } = useFilteredPages(props.blog.channels, 15)
+
+const showEdit = computed(() => !isEmpty(channelEdit.value))
+
+const openEdit = (channel: BlogChannel) => channelEdit.value = channel.id;
+
+const closeEdit = (update?:boolean) => {
+ channelEdit.value = ''
+ //reload channels
+ if(update){
+ emit('reload')
+ }
+ //Reset page to top
+ window.scrollTo(0, 0)
+}
+
+const openNew = () => {
+ channelEdit.value = 'new'
+ //Reset page to top
+ window.scrollTo(0, 0)
+}
+
+const onSubmit = async ({channel, feed} : { channel:BlogChannel, feed? : ChannelFeed}) => {
+
+ //Check for new channel, or updating old channel
+ if(channelEdit.value === 'new'){
+ //Exec create call
+ await apiCall(async () => {
+ await addChannel(channel, feed);
+ //Close the edit panel
+ closeEdit(true);
+ })
+ }
+ else if(!isEmpty(channelEdit.value)){
+ //Exec update call
+ await apiCall(async () => {
+ await updateChannel(channel, feed);
+ //Close the edit panel
+ closeEdit(true);
+ })
+ }
+ //Notify error state
+}
+
+const onDelete = async (channel : BlogChannel) => {
+ //Exec delete call
+ await apiCall(async () => {
+ await deleteChannel(channel);
+ //Close the edit panel
+ closeEdit(true);
+ })
+}
+
+</script>
+
+<style lang="scss">
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Channels/ChannelEdit.vue b/front-end/src/views/Blog/components/Channels/ChannelEdit.vue
new file mode 100644
index 0000000..56376fe
--- /dev/null
+++ b/front-end/src/views/Blog/components/Channels/ChannelEdit.vue
@@ -0,0 +1,157 @@
+<template>
+ <div class="flex flex-col w-full">
+ <div class="my-4 ml-auto">
+ <div class="button-group">
+ <button class="btn primary" form="channel-edit-form">Save</button>
+ <button class="btn" @click="close">Cancel</button>
+ </div>
+ </div>
+ <div class="mx-auto">
+ <h4 v-if="editMode" class="text-center">Edit Channel</h4>
+ <h4 v-else class="text-center">Create Channel</h4>
+ <p>
+ Your root directory and index file name must be unique within your S3 bucket.
+ </p>
+ </div>
+ <dynamic-form
+ id="channel-edit-form"
+ class="mx-auto"
+ :form="channelSchema"
+ :validator="channelVal.v$"
+ @submit="onSubmit"
+ />
+ <div class="relative">
+ <div class="absolute top-0 right-10">
+ <button class="btn xs no-border red" @click="disableFeed" v-if="feedEnabled">
+ Disable
+ </button>
+ </div>
+ </div>
+ <div class="max-w-xl mx-auto mt-6">
+ <h4 v-if="editMode" class="text-center">Edit Feed</h4>
+ <h4 v-else class="text-center">Create Feed</h4>
+ <p>
+ Optionally define the rss feed for this channel. If you do not configure the feed, posts
+ to this channel will not be published to an rss feed, you may configure this feed at any time.
+ </p>
+ </div>
+
+ <!-- Feed edit form -->
+ <dynamic-form
+ id="feed-edit-form"
+ class="mx-auto mt-4"
+ :form="feedSchema"
+ :validator="feedVal.v$"
+ @submit="onSubmit"
+ />
+
+ <!-- Feed properties -->
+ <FeedFields :properties="feedProps" />
+
+ <div class="mt-6">
+ <div class="mx-auto w-fit">
+ <button class="btn red" @click="onDelete" v-if="editMode">
+ Delete Permenantly
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { BlogState } from '../../blog-api';
+import { forEach, isEmpty, cloneDeep, isNil } from 'lodash';
+import { reactiveComputed } from '@vueuse/core';
+import { useConfirm } from '@vnuge/vnlib.browser';
+import FeedFields from '../FeedFields.vue';
+import { BlogChannel, ChannelFeed, useXmlProperties } from '@vnuge/cmnext-admin';
+import { getChannelForm } from '../../form-helpers';
+
+const emit = defineEmits(['close', 'onSubmit', 'onDelete'])
+
+const props = defineProps<{
+ blog: BlogState
+}>()
+
+//Disallow empty channels
+const channel = computed(() => props.blog.channels.editChannel.value || {} as BlogChannel)
+const editMode = computed(() => !isNil(channel.value.id))
+
+const { getChannelValidator, channelSchema, feedSchema, getFeedValidator } = getChannelForm(editMode);
+const { reveal } = useConfirm();
+
+//Get the feed properties
+const feedProps = useXmlProperties(computed(() => channel.value.feed));
+
+//Must have reactive buffers for the channel and feed
+const channelBuffer = reactiveComputed(() => cloneDeep(channel.value) as BlogChannel)
+const feedBuffer = reactiveComputed(() => cloneDeep(channel.value.feed || {}) as ChannelFeed)
+
+//Get validators for channel and feed
+const channelVal = getChannelValidator(channelBuffer);
+const feedVal = getFeedValidator(feedBuffer);
+
+const feedEnabled = computed(() => !isEmpty(feedBuffer.url))
+
+const disableFeed = () => {
+ //Clear the feed
+ forEach(feedBuffer, (_value, key) => feedBuffer[key] = null)
+ //Reset the feed validator
+ feedVal.reset();
+}
+
+const onSubmit = async () => {
+ //validate
+ if(!await channelVal.validate()){
+ return;
+ }
+
+ //Feed may not be defined, if it is validate it
+ if(feedEnabled.value){
+
+ if(!await feedVal.validate()){
+ return;
+ }
+
+ //set/overwite feed properties
+ const feed = {
+ ...feedBuffer,
+ properties:feedProps.getCurrentProperties()
+ }
+
+ //Invoke submitted with feed
+ emit('onSubmit', { channel: channelBuffer, feed })
+ }
+ else{
+ //Invoke submitted without feed
+ emit('onSubmit', { channel: channelBuffer, feed: null})
+ }
+
+}
+
+const onDelete = async () => {
+ //Show confirm
+ const { isCanceled } = await reveal({
+ title: 'Delete Channel?',
+ text: 'Are you sure you want to delete this channel? This action cannot be undone.',
+ })
+ if(isCanceled){
+ return;
+ }
+
+ if(!confirm('Are you sure you want to delete this channel forever?')){
+ return;
+ }
+
+ emit('onDelete', channelBuffer)
+}
+
+const close = () => emit('close')
+
+</script>
+
+<style lang="scss">
+
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Channels/ChannelTable.vue b/front-end/src/views/Blog/components/Channels/ChannelTable.vue
new file mode 100644
index 0000000..cdf15e0
--- /dev/null
+++ b/front-end/src/views/Blog/components/Channels/ChannelTable.vue
@@ -0,0 +1,48 @@
+<template>
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Path</th>
+ <th>Index</th>
+ <th>Feed?</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="channel in channels" :key="channel.id" class="table-row">
+ <td>
+ {{ channel.name }}
+ </td>
+ <td>
+ {{ channel.path }}
+ </td>
+ <td>
+ {{ channel.index }}
+ </td>
+ <td>
+ {{ feedEnabled(channel) }}
+ </td>
+ <td class="w-12">
+ <button class="btn xs no-border" @click="openEdit(channel)">
+ <fa-icon icon="pencil" />
+ </button>
+ </td>
+ </tr>
+ </tbody>
+</template>
+
+<script setup lang="ts">
+import { BlogChannel } from '@vnuge/cmnext-admin';
+import { toRefs } from 'vue';
+const emit = defineEmits(['open-edit'])
+
+const props = defineProps<{
+ channels:BlogChannel[]
+}>()
+
+const { channels } = toRefs(props)
+
+const feedEnabled = (channel: BlogChannel) => channel.feed ? 'Enabled' : 'Disabled'
+const openEdit = (channel: BlogChannel) => emit('open-edit', channel)
+
+</script>
diff --git a/front-end/src/views/Blog/components/Content.vue b/front-end/src/views/Blog/components/Content.vue
new file mode 100644
index 0000000..00f8602
--- /dev/null
+++ b/front-end/src/views/Blog/components/Content.vue
@@ -0,0 +1,133 @@
+<template>
+ <div id="content-editor" class="">
+ <EditorTable title="Manage content" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
+ <template v-slot:table>
+ <ContentTable
+ :content="items"
+ @open-edit="openEdit"
+ @copy-link="copyLink"
+ />
+ </template>
+ <template #editor>
+ <ContentEditor
+ :blog="$props.blog"
+ @submit="onSubmit"
+ @close="closeEdit"
+ @delete="onDelete"
+ />
+ </template>
+ </EditorTable>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { BlogState } from '../blog-api';
+import { isEmpty } from 'lodash';
+import { apiCall } from '@vnuge/vnlib.browser';
+import EditorTable from './EditorTable.vue';
+import ContentEditor from './Content/ContentEditor.vue';
+import ContentTable from './Content/ContentTable.vue';
+import { useClipboard } from '@vueuse/core';
+import { ContentMeta, useFilteredPages } from '@vnuge/cmnext-admin';
+
+const emit = defineEmits(['reload'])
+
+const props = defineProps<{
+ blog: BlogState
+}>()
+
+
+//Get the computed content
+const { selectedId,
+ updateContent,
+ uploadContent,
+ deleteContent,
+ updateContentName,
+ getPublicUrl
+ } = props.blog.content;
+
+ //Setup content filter
+ const { items, pagination } = useFilteredPages(props.blog.content, 15)
+
+const showEdit = computed(() => !isEmpty(selectedId.value));
+
+const openEdit = async (item: ContentMeta) => selectedId.value = item.id
+
+const closeEdit = (update?: boolean) => {
+ selectedId.value = ''
+ //reload channels
+ if (update) {
+ emit('reload')
+ }
+ //Reset page to top
+ window.scrollTo(0, 0)
+}
+
+const openNew = () => {
+ selectedId.value = 'new'
+ //Reset page to top
+ window.scrollTo(0, 0)
+}
+
+interface OnSubmitValue{
+ item: ContentMeta,
+ file: File | undefined
+}
+
+//Allow copying of the public url to clipboard
+const { copy } = useClipboard()
+const copyLink = async (item : ContentMeta) =>{
+ apiCall(async ({toaster}) =>{
+ const url = await getPublicUrl(item);
+ await copy(url);
+ toaster.general.info({ title: 'Copied link to clipboard' })
+ });
+}
+
+const onSubmit = async (value : OnSubmitValue) => {
+
+ //Check for new channel, or updating old channel
+ if (selectedId.value === 'new') {
+ //Exec create call
+ await apiCall(async () => {
+
+ if(!value.file?.name){
+ throw Error('No file selected')
+ }
+
+ //endpoint returns the content
+ await uploadContent(value.file, value.item.name!);
+
+ //Close the edit panel
+ closeEdit(true);
+ })
+ }
+ else if (!isEmpty(selectedId.value)) {
+ //Exec update call
+ await apiCall(async () => {
+ //If no file was attached, just update the file name
+ if(value.file?.name){
+ await updateContent(value.item, value.file);
+ }
+ else{
+ await updateContentName(value.item, value.item.name!);
+ }
+ //Close the edit panel
+ closeEdit(true);
+ })
+ }
+ //Notify error state
+}
+
+const onDelete = async (item: ContentMeta) => {
+ //Exec delete call
+ await apiCall(async () => {
+ await deleteContent(item);
+ //Close the edit panel
+ closeEdit(true);
+ })
+}
+
+
+</script> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Content/ContentEditor.vue b/front-end/src/views/Blog/components/Content/ContentEditor.vue
new file mode 100644
index 0000000..4de7f8a
--- /dev/null
+++ b/front-end/src/views/Blog/components/Content/ContentEditor.vue
@@ -0,0 +1,225 @@
+<template>
+ <div id="content-editor" class="flex flex-col w-full">
+ <div class="my-4 ml-auto">
+ <div class="button-group">
+ <!-- Submit the post form -->
+ <button :disabled="waiting" class="btn primary" form="content-upload-form">
+ <fa-icon icon="spinner" v-if="waiting" class="animate-spin" />
+ <span v-else>Save</span>
+ </button>
+ <button class="btn" @click="onClose">Cancel</button>
+ </div>
+ </div>
+ <div class="mx-auto sm:min-w-[20rem]">
+ <h4 class="text-center">Edit Content</h4>
+ <p>
+ Add or edit content file
+ </p>
+ <div class="mt-3 text-sm">
+ Publishing to:
+ </div>
+ <div class="p-2 px-3 bg-gray-200 border border-gray-300 rounded-md dark:bg-transparent">
+ {{ selectedChannelName }}
+ </div>
+ </div>
+ <div id="content-edit-body" class="min-h-[24rem] my-10">
+ <form id="content-upload-form" class="flex" @submit.prevent="onSubmit">
+ <fieldset class="mx-auto flex flex-col gap-10 w-[32rem]">
+ <div class="flex flex-col">
+ <div class="p-3 py-0.5">
+ <label class="">File name</label>
+ <input
+ type="text"
+ class="w-full input primary"
+ placeholder="Title"
+ v-model="v$.name.$model"
+ :class="{'invalid':v$.name.$invalid && v$.name.$dirty}"
+ />
+ </div>
+ <div v-if="editFile?.id" class="mt-3">
+ <div class="p-3 py-0.5">
+ <label>Content Id</label>
+ <input type="text" class="w-full input primary" :value="editFile.id" readonly />
+ </div>
+ </div>
+
+ <div v-if="uploadedFile.name" class="border border-gray-300 p-4 w-[24rem] mx-auto rounded-sm relative mt-5">
+ <div class="absolute top-0 text-right -right-12">
+ <button class="rounded-sm btn sm red" @click.prevent="removeNewFile">
+ <fa-icon :icon="['fas', 'trash']" />
+ </button>
+ </div>
+ <div class="">
+ Name:
+ <span class="border-b border-coolGray-400">
+ {{ getFileName(uploadedFile) }}
+ </span>
+ </div>
+ <div class="mt-3">
+ Size: {{ getFileSize(uploadedFile) }}
+ </div>
+ <div class="mt-3">
+ Content-Type: {{ getContentType(uploadedFile) }}
+ </div>
+ </div>
+ <div v-else-if="editFile?.id" >
+ <div class="border border-gray-300 p-4 min-w-[24rem] mx-auto rounded-sm relative mt-5">
+ <div class="">
+ Name: {{ getFileName(editFile) }}
+ </div>
+ <div class="mt-3">
+ Size: {{ getSizeinKb(editFile?.length) }}
+ </div>
+ <div class="mt-3">
+ File Path: {{ editFile.path }}
+ </div>
+ <div class="mt-3">
+ Content-Type: {{ editFile.content_type }}
+ </div>
+ </div>
+ </div>
+ <div v-if="!uploadedFile.name" class="m-auto mt-5 w-fit">
+ <button class="btn" @click.prevent="open()">
+ {{ editFile?.id ? 'Overwrite file' : 'Select File' }}
+ </button>
+ </div>
+ </div>
+ </fieldset>
+ </form>
+ </div>
+ <div class="mt-4">
+ <div class="mx-auto w-fit">
+ <button class="btn red" @click="onDelete">Delete Forever</button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { reactiveComputed, useFileDialog } from '@vueuse/core';
+import { ContentMeta } from '@vnuge/cmnext-admin';
+import { useConfirm, useVuelidateWrapper, useFormToaster, useWait } from '@vnuge/vnlib.browser';
+import { defaultTo, first, isEmpty, round, truncate } from 'lodash';
+import { required, helpers, maxLength } from '@vuelidate/validators'
+import useVuelidate from '@vuelidate/core';
+import { BlogState } from '../../blog-api';
+
+const emit = defineEmits(['close', 'submit', 'delete']);
+const props = defineProps<{
+ blog: BlogState
+}>();
+
+const { reveal } = useConfirm();
+const { waiting } = useWait();
+
+const { content, channels } = props.blog;
+
+const selectedId = computed(() => content.selectedId.value);
+const selectedContent = computed<ContentMeta>(() => defaultTo(content.selectedItem.value, {} as ContentMeta));
+const metaBuffer = reactiveComputed<ContentMeta>(() => ({ ...selectedContent.value}));
+const selectedChannelName = computed(() => defaultTo(channels.selectedItem.value?.name, 'No channel selected'));
+
+const v$ = useVuelidate({
+ name: {
+ required,
+ maxLen:maxLength(50),
+ reg: helpers.withMessage('The file name contains invalid characters', helpers.regex(/^[a-zA-Z0-9 \-\.]*$/))
+ },
+}, metaBuffer)
+
+const { validate } = useVuelidateWrapper(v$);
+
+const file = ref<File | undefined>();
+const { files, open, reset, onChange } = useFileDialog({ accept: '*' })
+//update the file buffer when a user selects a file to upload
+onChange(() => {
+ file.value = first(files.value)
+ v$.value.name.$model = file.value?.name;
+})
+
+const editFile = computed<ContentMeta | undefined>(() => selectedContent.value);
+const uploadedFile = computed<File>(() => defaultTo(file.value, {} as File));
+
+const getFileName = (file : File | ContentMeta) => truncate(file.name, { length: 20 });
+const getFileSize = (file : File) => {
+ const size = round(file.size > 1024 ? file.size / 1024 : file.size, 2);
+ return `${size} ${file.size > 1024 ? 'KB' : 'B'}`;
+}
+const getContentType = (file : File) => file.type;
+const getSizeinKb = (value : number | undefined) => {
+ value = defaultTo(value, 0);
+ const size = round(value > 1024 ? value / 1024 : value, 2);
+ return `${size} ${value > 1024 ? 'KB' : 'B'}`;
+}
+
+const onSubmit = async () => {
+
+ const { error } = useFormToaster()
+ const hasFile = !isEmpty(file.value?.name);
+
+ //Validate the form
+ if(!await validate()){
+ return;
+ }
+
+ //Check if in edit mode
+ if(selectedId.value === 'new'){
+ //New file upload
+ if(!hasFile){
+ error({ title: 'No file selected' })
+ return;
+ }
+ }
+ //Edit mode
+ else{
+ //If a new file has been attached, then we should prompt for an overwrite
+ if(hasFile){
+ //Confirm overwrite
+ const { isCanceled } = await reveal({
+ title: 'Overwrite file?',
+ text: 'Are you sure you want to overwrite the file? This action cannot be undone.',
+ })
+ if (isCanceled) {
+ return;
+ }
+ }
+ }
+ emit('submit', { item: metaBuffer, file: file.value });
+}
+
+const onClose = () => emit('close');
+
+const onDelete = async () => {
+ //Show confirm
+ const { isCanceled } = await reveal({
+ title: 'Delete File?',
+ text: 'Are you sure you want to delete this file? This action cannot be undone.',
+ })
+ if (isCanceled) {
+ return;
+ }
+
+ if (!confirm('Are you sure you want to delete this file forever?')) {
+ return;
+ }
+
+ //Emit the delete event with the original post
+ emit('delete', metaBuffer)
+}
+
+const removeNewFile = () =>{
+ file.value = undefined;
+ v$.value.name.$model = editFile.value?.name ?? '';
+ reset();
+}
+
+</script>
+
+<style lang="scss">
+#content-upload-form{
+ input.primary.invalid{
+ @apply border-red-500;
+ }
+}
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Content/ContentTable.vue b/front-end/src/views/Blog/components/Content/ContentTable.vue
new file mode 100644
index 0000000..c47a063
--- /dev/null
+++ b/front-end/src/views/Blog/components/Content/ContentTable.vue
@@ -0,0 +1,75 @@
+<template>
+ <thead>
+ <tr>
+ <th>File Name</th>
+ <th>Id</th>
+ <th>Date</th>
+ <th>Content Type</th>
+ <th>Length</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="item in content" :key="item.id" class="table-row">
+ <td>
+ {{ getItemName(item) }}
+ </td>
+ <td>
+ {{ getItemId(item) }}
+ </td>
+ <td>
+ {{ getDateString(item.date) }}
+ </td>
+ <td>
+ {{ item.content_type }}
+ </td>
+ <td>
+ {{ getItemLength(item) }}
+ </td>
+ <td class="w-24">
+ <fieldset :disabled="waiting">
+ <button class="btn xs no-border" @click="copyLink(item)">
+ <fa-icon icon="link" />
+ </button>
+ <button class="btn xs no-border" @click="copy(item.id)">
+ <fa-icon icon="copy" />
+ </button>
+ <button class="btn xs no-border" @click="openEdit(item)">
+ <fa-icon icon="pencil" />
+ </button>
+ </fieldset>
+ </td>
+ </tr>
+ </tbody>
+</template>
+
+<script setup lang="ts">
+import { toRefs } from 'vue';
+import { filter as _filter, truncate } from 'lodash';
+import { useClipboard } from '@vueuse/core';
+import { useWait } from '@vnuge/vnlib.browser';
+import { ContentMeta } from '@vnuge/cmnext-admin';
+
+const emit = defineEmits(['open-edit', 'copy-link'])
+
+const props = defineProps<{
+ content: ContentMeta[]
+}>()
+
+const { content } = toRefs(props)
+
+const { waiting } = useWait()
+const { copy } = useClipboard()
+
+const getDateString = (time?: number) => new Date((time || 0) * 1000).toLocaleString();
+const getItemLength = (item: ContentMeta) : string =>{
+ const length = item.length || 0;
+ return length > 1024 ? `${(length / 1024).toFixed(2)} KB` : `${length} B`
+}
+const getItemId = (item: ContentMeta) => truncate(item.id || '', { length: 20 })
+const getItemName = (item : ContentMeta) => truncate(item.name || '', { length: 30 })
+
+const openEdit = async (item: ContentMeta) => emit('open-edit', item)
+const copyLink = (item : ContentMeta) => emit('copy-link', item)
+
+</script> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/ContentSearch.vue b/front-end/src/views/Blog/components/ContentSearch.vue
new file mode 100644
index 0000000..37fd438
--- /dev/null
+++ b/front-end/src/views/Blog/components/ContentSearch.vue
@@ -0,0 +1,115 @@
+<template>
+ <div id="content-search" class="my-4">
+ <div class="">
+ <div class="">
+ <input class="w-full input primary" placeholder="Search..." v-model="search" />
+ </div>
+ </div>
+ <div class="search-results">
+ <div v-if="searchResults.length == 0" class="result">
+ No results found.
+ </div>
+ <div v-else v-for="result in searchResults" :key="result.id" @click.prevent="onSelected(result)" class="result">
+ <div class="flex-auto result name">
+ {{ result.shortName }}
+ </div>
+ <div class="result id">
+ {{ result.shortId }}
+ </div>
+ <div class="rseult controls">
+ <div v-if="waiting">
+ <fa-icon icon="spinner" spin />
+ </div>
+ <div v-else-if="result.copied.value" class="text-sm text-amber-500">
+ copied
+ </div>
+ <div v-else class="">
+ <button class="btn secondary sm borderless" @click="result.copyLink()">
+ <fa-icon icon="link" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useClipboard } from '@vueuse/core';
+import { apiCall, useWait } from '@vnuge/vnlib.browser';
+import { computed, Ref, ref } from 'vue';
+import { map, slice, truncate } from 'lodash';
+import { ContentMeta } from '@vnuge/cmnext-admin';
+import { BlogState } from '../blog-api';
+
+const emit = defineEmits(['selected'])
+
+const props = defineProps<{
+ blog: BlogState,
+}>()
+
+const { createReactiveSearch, getPublicUrl } = props.blog.content
+const { waiting } = useWait()
+
+const search = ref('')
+const searcher = createReactiveSearch(search);
+
+interface ContentResult extends ContentMeta {
+ readonly shortId: string,
+ readonly shortName: string,
+ readonly copied: Ref<boolean>,
+ copyLink(): void
+}
+
+const searchResults = computed<ContentResult[]>(() => {
+ const current = slice(searcher.value, 0, 5);
+
+ //Copies the link to the clipboard from the server to insert into the editor
+ const copyLink = (result : ContentMeta, copy : (text: string) => Promise<void> ) => {
+ apiCall(async () =>{
+ const link = await getPublicUrl(result);
+ await copy(link);
+ })
+ }
+
+ //Formats the result for display
+ return map(current, content => {
+ //scoped clipboard for copy link
+ const { copied, copy } = useClipboard();
+ return {
+ ...content,
+ //truncate the id and name for display
+ shortId: truncate(content.id, { length: 15 }),
+ shortName: truncate(content.name, { length: 24 }),
+ copyLink: () => copyLink(content, copy),
+ copied
+ }
+ })
+})
+
+const onSelected = (result: ContentResult) => {
+ emit('selected', result)
+}
+
+</script>
+
+<style lang="scss">
+
+ .search-results{
+ @apply mt-3;
+ }
+
+ .result{
+ @apply flex flex-row items-center justify-between;
+ @apply p-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-600;
+
+ .id{
+ @apply text-sm;
+ }
+
+ .controls{
+ @apply min-w-[4rem] text-center;
+ }
+ }
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/EditorTable.vue b/front-end/src/views/Blog/components/EditorTable.vue
new file mode 100644
index 0000000..4ec5a33
--- /dev/null
+++ b/front-end/src/views/Blog/components/EditorTable.vue
@@ -0,0 +1,96 @@
+<template>
+ <slot class="flex flex-row">
+ <div class="flex-1 px-4 mt-3">
+ <div v-if="!showEdit" class="">
+ <div class="flex justify-between p-4 pt-0">
+ <div class="w-[20rem]">
+ <h4>{{ $props.title }}</h4>
+ </div>
+ <div class="h-full">
+ <div :class="{'opacity-100':waiting}" class="opacity-0">
+ <fa-icon icon="spinner" class="animate-spin" />
+ </div>
+ </div>
+ <div class="mt-auto">
+ <div class="flex justify-center">
+ <nav aria-label="Pagination">
+ <ul class="inline-flex items-center space-x-1 text-sm rounded-md">
+ <li>
+ <button :disabled="isFirstPage" class="page-button" @click="prev">
+ <fa-icon icon="chevron-left" />
+ </button>
+ </li>
+ <li>
+ <span class="inline-flex items-center px-4 py-2 space-x-1">
+ Page
+ <b class="mx-1">
+ {{ currentPage }}
+ </b>
+ of
+ <b class="ml-1">
+ {{ pageCount }}
+ </b>
+ </span>
+ </li>
+ <li>
+ <button :disabled="isLastPage" class="page-button" @click="next">
+ <fa-icon icon="chevron-right" />
+ </button>
+ </li>
+ </ul>
+ </nav>
+ </div>
+ </div>
+
+ <div class="h-fit">
+ <button class="rounded btn primary sm" @click="openNew">
+ <fa-icon :icon="['fas', 'plus']" class="mr-2" />
+ New
+ </button>
+ </div>
+ </div>
+ <table class="edit-table">
+ <slot name="table" />
+ </table>
+ </div>
+ <div v-else class="">
+ <slot name="editor" />
+ </div>
+ </div>
+ </slot>
+</template>
+
+<script setup lang="ts">
+import { toRefs } from 'vue';
+import { useWait } from '@vnuge/vnlib.browser';
+import { UseOffsetPaginationReturn } from '@vueuse/core';
+
+const emit = defineEmits(['open-new'])
+const props = defineProps<{
+ title: string,
+ showEdit: boolean,
+ pagination: UseOffsetPaginationReturn
+}>()
+
+const { showEdit } = toRefs(props)
+
+const { waiting } = useWait()
+
+//Get pagination
+const { pageCount, next, prev, isLastPage, isFirstPage, currentPage } = props.pagination
+
+const openNew = () => {
+ emit('open-new')
+}
+
+</script>
+
+<style lang="scss">
+
+button.page-button{
+ @apply inline-flex items-center px-2 py-1.5 space-x-2 font-medium;
+ @apply text-gray-500 bg-white border border-gray-300 rounded-full hover:bg-gray-50;
+ @apply dark:border-dark-300 dark:bg-transparent dark:text-gray-300 hover:dark:bg-dark-700;
+}
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/FeedFields.vue b/front-end/src/views/Blog/components/FeedFields.vue
new file mode 100644
index 0000000..90e1454
--- /dev/null
+++ b/front-end/src/views/Blog/components/FeedFields.vue
@@ -0,0 +1,140 @@
+<template>
+ <div id="feed-custom-fields">
+ <div class="my-3 text-center">
+ <h4>Feed custom fields</h4>
+ </div>
+
+ <div v-if="cleanXml" class="w-full max-w-2xl mx-auto">
+ <pre class="xml">
+{{ cleanXml }}
+ </pre>
+ </div>
+
+
+ <div class="my-2 ml-auto w-fit">
+ <div v-if="!editMode" class="button-group">
+ <button class="btn" @click="edit">Edit</button>
+ </div>
+ <div v-else class="button-group">
+ <button class="btn primary" @click="save" >Update</button>
+ <button class="btn" @click="cancel">Cancel</button>
+ </div>
+ </div>
+
+
+ <div v-if="editMode" class="flex flex-col">
+ <div v-if="$props.blog" class="mb-2">
+ <EpAdder :blog="$props.blog" @submit="onAddEnclosure" />
+ </div>
+
+ <div class="">
+ <JsonEditorVue :ask-to-format="true" class="json" v-model="jsonFeedData"/>
+ </div>
+ </div>
+
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { FeedProperty, UseXmlProperties } from '@vnuge/cmnext-admin';
+import { BlogState } from '../blog-api';
+import JsonEditorVue from 'json-editor-vue'
+import EpAdder from './podcast-helpers/EpisodeAdder.vue';
+
+const props = defineProps<{
+ properties: UseXmlProperties,
+ blog?: BlogState
+}>()
+
+const { getXml, saveJson, getModel, addProperties } = props.properties
+
+const jsonFeedData = ref()
+const editMode = ref(false)
+const xmlData = ref<string | undefined>(getXml())
+
+const cleanXml = computed(() => {
+
+ const formatXml = (xml : string) => { // tab = optional indent value, default is tab (\t)
+ var formatted = '', indent = '';
+ xml.split(/>\s*</).forEach(function (node) {
+ if (node.match(/^\/\w/)){
+ indent = indent.substring(1); // decrease indent by one 'tab'
+ }
+ formatted += indent + '<' + node + '>\r\n';
+ if (node.match(/^<?\w[^>]*[^\/]$/)){
+ indent += '\t'; // increase indent
+ }
+ });
+
+ return formatted.substring(1, formatted.length - 3);
+ }
+ return formatXml(xmlData.value || '')
+})
+
+const edit = () => {
+ jsonFeedData.value = getModel()
+ editMode.value = true
+}
+
+const save = () : void => {
+ //Only close editor if the json is valid
+ if(saveJson(jsonFeedData.value)){
+ editMode.value = false
+ //update xml
+ xmlData.value = getXml()
+ }
+}
+
+const cancel = () : void => {
+ editMode.value = false
+ xmlData.value = getXml()
+}
+
+const onAddEnclosure = (props: FeedProperty[]) =>{
+ addProperties(props);
+ //update xml
+ xmlData.value = getXml()
+ //update json editor
+ jsonFeedData.value = getModel()
+}
+
+</script>
+
+<style lang="scss">
+
+#feed-custom-fields{
+
+ @apply w-full max-w-[80%] mx-auto py-4 my-5;
+
+ .json > .jse-main{
+ @apply w-full min-h-[40rem] rounded bg-transparent mx-auto;
+ }
+
+ .feed-fields{
+ @apply mx-auto gap-4 flex flex-row justify-center my-6;
+
+ input.primary{
+ @apply w-full;
+ }
+
+ textarea.primary{
+ @apply w-full h-full tracking-wider font-mono p-3 text-sm;
+ }
+
+ textarea.invalid{
+ @apply border-red-500;
+ }
+ }
+
+ .xml{
+ @apply tracking-wider font-mono p-3 text-sm border dark:border-dark-500 rounded whitespace-pre-wrap;
+ }
+
+ .xml.invalid{
+ @apply border-red-500;
+ }
+
+}
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Posts.vue b/front-end/src/views/Blog/components/Posts.vue
new file mode 100644
index 0000000..5ebeeac
--- /dev/null
+++ b/front-end/src/views/Blog/components/Posts.vue
@@ -0,0 +1,113 @@
+<template>
+ <div id="post-editor" class="">
+ <EditorTable title="Manage posts" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
+ <template v-slot:table>
+ <PostTable
+ :posts="items"
+ @open-edit="openEdit"
+ />
+ </template>
+ <template #editor>
+ <PostEditor
+ :blog="$props.blog"
+ @submit="onSubmit"
+ @close="closeEdit"
+ @delete="onDelete"
+ />
+ </template>
+ </EditorTable>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { isEmpty } from 'lodash';
+import { PostMeta, useFilteredPages } from '@vnuge/cmnext-admin';
+import { apiCall, debugLog } from '@vnuge/vnlib.browser';
+import EditorTable from './EditorTable.vue';
+import PostEditor from './Posts/PostEdit.vue';
+import PostTable from './Posts/PostTable.vue';
+import { BlogState } from '../blog-api';
+
+const emit = defineEmits(['reload'])
+
+const props = defineProps<{
+ blog: BlogState
+}>()
+
+const { selectedId, publishPost, updatePost, deletePost } = props.blog.posts;
+const { updatePostContent } = props.blog.content;
+
+const showEdit = computed(() => !isEmpty(selectedId.value));
+
+//Init paginated items for the table and use filtered items
+const { pagination, items } = useFilteredPages(props.blog.posts, 15)
+
+//Open with the post id
+const openEdit = async (post: PostMeta) => selectedId.value = post.id;
+
+const closeEdit = (update?: boolean) => {
+ selectedId.value = ''
+ //reload channels
+ if (update) {
+ emit('reload')
+ }
+ //Reset page to top
+ window.scrollTo(0, 0)
+}
+
+const openNew = () => {
+ //Reset the edit post
+ selectedId.value = 'new'
+ //Reset page to top
+ window.scrollTo(0, 0)
+}
+
+const onSubmit = async ({post, content } : { post:PostMeta, content:string }) => {
+
+ debugLog('submitting', post, content);
+
+ //Check for new channel, or updating old channel
+ if (selectedId.value === 'new') {
+ //Exec create call
+ await apiCall(async () => {
+
+ //endpoint returns the content
+ const newMeta = await publishPost(post);
+
+ //Publish the content
+ await updatePostContent(newMeta, content)
+
+ //Close the edit panel
+ closeEdit(true);
+ })
+ }
+ else if (!isEmpty(selectedId.value)) {
+ //Exec update call
+ await apiCall(async () => {
+ await updatePost(post);
+
+ //Publish the content
+ await updatePostContent(post, content)
+
+ //Close the edit panel
+ closeEdit(true);
+ })
+ }
+ //Notify error state
+}
+
+const onDelete = async (post: PostMeta) => {
+ //Exec delete call
+ await apiCall(async () => {
+ await deletePost(post);
+ //Close the edit panel
+ closeEdit(true);
+ })
+}
+
+</script>
+
+<style lang="scss">
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Posts/PostEdit.vue b/front-end/src/views/Blog/components/Posts/PostEdit.vue
new file mode 100644
index 0000000..4f7b52b
--- /dev/null
+++ b/front-end/src/views/Blog/components/Posts/PostEdit.vue
@@ -0,0 +1,155 @@
+<template>
+ <div id="new-post-editor" class="flex flex-col w-full">
+ <div class="my-4 ml-auto">
+ <div class="button-group">
+ <!-- Submit the post form -->
+ <button class="btn primary" form="post-edit-form">Save</button>
+ <button class="btn" @click="onClose">Cancel</button>
+ </div>
+ </div>
+ <div class="mx-auto">
+ <h4 class="text-center">Edit Post</h4>
+ <p>
+ Add or edit a post to publish to your blog.
+ </p>
+ </div>
+ <div class="relative">
+ <div class="absolute top-2 right-10">
+ <button class="btn no-border" @click="setMeAsAuthor">@Me</button>
+ </div>
+ </div>
+ <dynamic-form
+ id="post-edit-form"
+ class="mx-auto"
+ :form="schema"
+ :disabled="false"
+ :validator="v$"
+ @submit="onSubmit"
+ />
+
+ <div id="post-content-editor" class="px-6" :class="{'invalid':v$.content.$invalid}">
+ <Editor @change="onContentChanged" :blog="$props.blog" @load="onEditorLoad" />
+ </div>
+
+ <FeedFields :properties="postProperties" :blog="$props.blog" />
+
+ <div class="mx-auto my-4">
+ <div class="button-group">
+ <!-- Submit the post form -->
+ <button class="btn primary" form="post-edit-form">Save</button>
+ <button class="btn" @click="onClose">Cancel</button>
+ <button v-if="!isNew" class="btn red" @click="onDelete">Delete Forever</button>
+ </div>
+ </div>
+ </div>
+</template>
+<script setup lang="ts">
+import { computed } from 'vue';
+import { BlogState } from '../../blog-api';
+import { reactiveComputed } from '@vueuse/core';
+import { isNil, isString, split } from 'lodash';
+import { PostMeta, useXmlProperties } from '@vnuge/cmnext-admin';
+import { apiCall, useConfirm, useUser } from '@vnuge/vnlib.browser';
+import { getPostForm } from '../../form-helpers';
+import Editor from '../../ckeditor/Editor.vue';
+import FeedFields from '../FeedFields.vue';
+
+const emit = defineEmits(['close', 'submit', 'delete']);
+const props = defineProps<{
+ blog: BlogState
+}>()
+
+const { reveal } = useConfirm();
+const { getProfile } = useUser();
+const { schema, getValidator } = getPostForm();
+
+const { posts, content } = props.blog;
+
+const isNew = computed(() => isNil(posts.selectedItem.value));
+
+/* Post meta may load delayed from the api so it must be computed
+and reactive, it may also be empty when a new post is created */
+const postBuffer = reactiveComputed<PostMeta>(() => {
+ return {
+ ...posts.selectedItem.value,
+ content: ''
+ } as PostMeta
+});
+
+const { v$, validate } = getValidator(postBuffer);
+
+//Wrap the post properties in an xml feed editor
+const postProperties = useXmlProperties(posts.selectedItem);
+
+const onSubmit = async () =>{
+ if(!await validate()){
+ return;
+ }
+
+ //get all properties
+ const p = postProperties.getCurrentProperties();
+
+ const post = {
+ ...postBuffer,
+ properties: p,
+ content: undefined
+ }
+
+ //Remove the content from the post object
+ delete post.content;
+
+ //Convert the tags string to an array of strings
+ post.tags = isString(post.tags) ? split(post.tags, ',') : post.tags;
+
+ emit('submit', { post, content: v$.value.content.$model});
+}
+
+const onClose = () => emit('close');
+
+const onContentChanged = (content: string) => {
+ //Set the validator content string
+ v$.value.content.$model = content;
+}
+
+const onDelete = async () => {
+ //Show confirm
+ const { isCanceled } = await reveal({
+ title: 'Delete Post?',
+ text: 'Are you sure you want to delete this post? This action cannot be undone.',
+ })
+ if (isCanceled) {
+ return;
+ }
+
+ if (!confirm('Are you sure you want to delete this post forever?')) {
+ return;
+ }
+
+ //Emit the delete event with the original post
+ emit('delete', posts.selectedItem.value)
+}
+
+const setMeAsAuthor = () => {
+ apiCall(async () => {
+ const { first, last } = await getProfile<{first?:string, last?:string, email:string}>();
+ v$.value.author.$model = `${first} ${last}`
+ })
+}
+
+const onEditorLoad = async (editor : any) =>{
+
+ //Get the initial content
+ const postContent = await content.getSelectedPostContent();
+
+ //Set the initial content
+ if(!isNil(postContent)){
+ onContentChanged(postContent);
+ editor.setData(postContent);
+ }
+}
+
+</script>
+
+<style lang="scss">
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Posts/PostTable.vue b/front-end/src/views/Blog/components/Posts/PostTable.vue
new file mode 100644
index 0000000..e5e45f2
--- /dev/null
+++ b/front-end/src/views/Blog/components/Posts/PostTable.vue
@@ -0,0 +1,62 @@
+<template>
+ <thead>
+ <tr>
+ <th>Title</th>
+ <th>Id</th>
+ <th>Date</th>
+ <th>Author</th>
+ <th>Summary</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="post in posts" :key="post.id" class="table-row">
+ <td>
+ {{ post.title }}
+ </td>
+ <td>
+ {{ getPostId(post) }}
+ </td>
+ <td>
+ {{ getDateString(post.date) }}
+ </td>
+ <td>
+ {{ post.author }}
+ </td>
+ <td>
+ {{ getSummaryString(post.summary) }}
+ </td>
+ <td class="w-20">
+ <button class="btn xs no-border" @click="copy(post.id)">
+ <fa-icon icon="copy" />
+ </button>
+ <button class="btn xs no-border" @click="openEdit(post)">
+ <fa-icon icon="pencil" />
+ </button>
+ </td>
+ </tr>
+ </tbody>
+</template>
+
+<script setup lang="ts">
+import { toRefs } from 'vue';
+import { filter as _filter, truncate } from 'lodash';
+import { useClipboard } from '@vueuse/core';
+import { PostMeta } from '@vnuge/cmnext-admin';
+
+const emit = defineEmits(['reload', 'open-edit'])
+
+const props = defineProps<{
+ posts: PostMeta[],
+}>()
+
+const { posts } = toRefs(props)
+
+const { copy } = useClipboard()
+
+const openEdit = async (post: PostMeta) => emit('open-edit', post)
+
+const getDateString = (time?: number) => new Date((time || 0) * 1000).toLocaleString();
+const getSummaryString = (summary?: string) => truncate(summary || '', { length: 40 })
+const getPostId = (post: PostMeta) => truncate(post.id || '', { length: 20 })
+</script> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue b/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue
new file mode 100644
index 0000000..79b21cf
--- /dev/null
+++ b/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue
@@ -0,0 +1,164 @@
+<template>
+ <div id="podcast-upload-form">
+
+ <div class="ml-auto w-fit">
+ <div class="">
+ <button class="btn sm" @click="setIsOpen(true)">Add enclosure</button>
+ </div>
+ </div>
+
+ <Dialog id="enclosure-dialog" :open="isOpen" @close="setIsOpen" class="relative z-50">
+ <div class="fixed inset-0 bg-black/30" aria-hidden="true" />
+
+ <div class="fixed inset-0 flex justify-center pt-[8rem]">
+ <DialogPanel class="dialog">
+ <div class="">
+ <DialogTitle>Set feed enclosure</DialogTitle>
+ <DialogDescription>
+ You may set a podcast episode or other rss enclosure from
+ content stored in the cms.
+ </DialogDescription>
+ <div class="my-3 ml-auto w-fit">
+ <Popover class="relative">
+ <PopoverButton class="btn">
+ Add media
+ <fa-icon class="ml-2" icon="photo-film" />
+ </PopoverButton>
+ <PopoverPanel class="absolute right-0 z-10 top-10">
+ <div class="md-pannel">
+ <div class="">
+ Search for content by its id or file name.
+ </div>
+ <ContentSearch :blog="$props.blog" @selected="onContentSelected"/>
+ </div>
+ </PopoverPanel>
+ </Popover>
+ </div>
+ <dynamic-form
+ class=""
+ id="enclosure-form"
+ :form="schema"
+ :validator="v$"
+ @submit="onFormSubmit"
+ @cancel="onCancel"
+ />
+ <div class="mt-4 ml-auto w-fit">
+ <div class="button-group">
+ <button class="btn sm primary" @click="onFormSubmit">Submit</button>
+ <button class="btn sm" @click="onCancel">Cancel</button>
+ </div>
+ </div>
+ </div>
+ </DialogPanel>
+ </div>
+ </Dialog>
+ </div>
+</template>
+<script setup lang="ts">
+
+import { ref, reactive } from 'vue';
+import { BlogState } from '../../blog-api';
+import { PodcastEntity, getPodcastForm } from './podcast-form'
+import {
+ Dialog,
+ DialogPanel,
+ DialogTitle,
+ DialogDescription,
+ PopoverButton,
+ PopoverPanel,
+ Popover,
+} from '@headlessui/vue'
+import ContentSearch from '../ContentSearch.vue'
+import { apiCall, debugLog } from '@vnuge/vnlib.browser';
+import { ContentMeta } from '@vnuge/cmnext-admin';
+
+const emit = defineEmits(['submit'])
+
+const props = defineProps<{
+ blog: BlogState,
+}>()
+
+const isOpen = ref(false)
+const { getPublicUrl } = props.blog.content;
+const { schema, setEnclosureContent, getValidator, exportProperties } = getPodcastForm()
+
+const buffer = reactive<PodcastEntity>({} as PodcastEntity)
+
+const { v$, validate } = getValidator(buffer)
+
+const setIsOpen = (value: boolean) => isOpen.value = value
+
+const onFormSubmit = async () =>{
+ //Validate the form
+ if(! await validate()){
+ return
+ }
+
+ //get the enclosure properties to add to the xml
+ const props = exportProperties(buffer)
+ debugLog(props);
+ emit('submit', props)
+ setIsOpen(false)
+}
+
+const onCancel = () =>{
+ setIsOpen(false)
+}
+
+const onContentSelected = (content: ContentMeta) =>{
+ apiCall(async () =>{
+ //Get the content link from the server
+ const url = await getPublicUrl(content)
+
+ //set the form content
+ setEnclosureContent(buffer, content, `/${url}`)
+ })
+}
+
+</script>
+
+<style lang="scss">
+
+#enclosure-dialog{
+
+ .dialog{
+ @apply w-full max-w-3xl px-8 pb-8 pt-4 mx-auto mb-auto border rounded shadow-md;
+ @apply bg-white dark:bg-dark-700 dark:text-gray-300 dark:border-dark-500;
+ }
+
+ .dynamic-form.input-group{
+ @apply grid grid-cols-2 gap-4;
+ }
+
+ .dynamic-form.input-container{
+ @apply flex flex-col;
+ }
+
+ .dynamic-form.field-description{
+ @apply text-sm text-gray-500 dark:text-gray-400 px-2;
+ }
+
+ .dynamic-form.input-label{
+ @apply text-sm font-semibold text-gray-700 dark:text-gray-100 ml-1 mb-1;
+ }
+
+ .dynamic-form.dynamic-input.input{
+ @apply py-1.5 bg-transparent;
+
+ &:disabled{
+ @apply bg-gray-100 dark:bg-transparent dark:border-transparent;
+ }
+
+ &:focus{
+ @apply border-primary-500;
+ }
+ }
+
+ .dirty.dynamic-form.input-container{
+ &.data-invalid .dynamic-form.dynamic-input.input{
+ @apply border-red-500;
+ }
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts b/front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts
new file mode 100644
index 0000000..ab8ad8a
--- /dev/null
+++ b/front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts
@@ -0,0 +1,174 @@
+// Copyright (C) 2023 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
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { computed, Ref } from 'vue';
+import { helpers, required, maxLength, alphaNum, numeric } from "@vuelidate/validators"
+import useVuelidate from "@vuelidate/core"
+import { MaybeRef } from '@vueuse/core';
+import { useVuelidateWrapper } from '@vnuge/vnlib.browser';
+import { ContentMeta, FeedProperty } from '@vnuge/cmnext-admin';
+
+export interface EnclosureEntity{
+ fileId: string;
+ contentUrl: string;
+ contentLength: number;
+ contentType: string;
+}
+
+export interface PodcastEntity extends EnclosureEntity{
+ episodeType: string;
+ duration: number;
+}
+
+export const getPodcastForm = (editMode?: Ref<boolean>) => {
+ const schema = computed(() => {
+ return {
+ fields: [
+ {
+ id: 'episode-type',
+ type: 'text',
+ label: 'Episode Type',
+ name: 'episodeType',
+ placeholder: '',
+ description: 'The itunes episode type, typically "full" or "trailer"',
+ },
+ {
+ id: 'episode-duration',
+ type: 'text',
+ label: 'Duration',
+ name: 'duration',
+ placeholder: '',
+ description: 'The duration in seconds for the episode',
+ },
+ {
+ id: 'ep-content-id',
+ type: 'text',
+ label: 'File Id',
+ name: 'fileId',
+ placeholder: '',
+ description: 'The file id of the episode already in the channel',
+ disabled: true,
+ },
+ {
+ id: 'content-url',
+ type: 'text',
+ label: 'Content url',
+ name: 'contentUrl',
+ placeholder: '',
+ description: 'This the relative url to the episode content file',
+ disabled: true,
+ },
+ {
+ id: 'content-length',
+ type: 'text',
+ label: 'Content length',
+ name: 'contentLength',
+ placeholder: '',
+ description: 'This the length in bytes of the episode content file',
+ disabled: true,
+ },
+ {
+ id: 'content-type',
+ type: 'text',
+ label: 'The MIME content type',
+ name: 'contentType',
+ placeholder: '',
+ description: 'The MIME content type for the episode content file',
+ disabled: true,
+ }
+ ]
+ }
+ });
+
+
+ const alphaNumSlash = helpers.regex(/^[a-zA-Z0-9\/]*$/);
+
+ const rules = {
+ fileId: {
+ required:helpers.withMessage('The file id is required', required),
+ maxLength: helpers.withMessage('The file id must be less than 64 characters', maxLength(64)),
+ alphaNumeric: helpers.withMessage('The file id must be alpha numeric', alphaNum)
+ },
+ episodeType: {
+ required: helpers.withMessage('The episode type is required', required),
+ maxLength: helpers.withMessage('The episode type must be less than 64 characters', maxLength(64)),
+ alphaNumeric: helpers.withMessage('The episode type must be alpha numeric', alphaNum)
+ },
+ duration: {
+ required: helpers.withMessage('The duration is required', required),
+ numeric: helpers.withMessage('The duration must be a number', numeric)
+ },
+ contentUrl: {
+ required: helpers.withMessage('The content url is required', required),
+ maxLength: helpers.withMessage('The content url must be less than 256 characters', maxLength(256))
+ },
+ contentLength: {
+ required: helpers.withMessage('The content length is required', required),
+ numeric: helpers.withMessage('The content length must be a number', numeric)
+ },
+ contentType: {
+ required: helpers.withMessage('The content type is required', required),
+ maxLength: helpers.withMessage('The content type must be less than 64 characters', maxLength(64)),
+ alphaNumeric: helpers.withMessage('The content type must be in MIME format', alphaNumSlash)
+ }
+ }
+
+ const getValidator = <T extends PodcastEntity>(buffer: MaybeRef<T>) => {
+ const v$ = useVuelidate(rules, buffer, { $lazy: true, $autoDirty: true });
+ const { validate } = useVuelidateWrapper(v$);
+
+ return { v$, validate, reset: v$.value.$reset };
+ }
+
+ const setEnclosureContent = (enclosure: EnclosureEntity, content: ContentMeta, url: string) => {
+ enclosure.fileId = content.id;
+ enclosure.contentLength = content.length
+ enclosure.contentType = content.content_type;
+ enclosure.contentUrl = url;
+ }
+
+ const exportProperties = (podcast: PodcastEntity) : FeedProperty[] => {
+ return [
+ {
+ name: 'episodeType',
+ namespace: 'itunes',
+ value: podcast.episodeType
+ },
+ {
+ name: 'duration',
+ namespace: 'itunes',
+ value: podcast.duration?.toString()
+ },
+ //Setup the enclosure
+ {
+ name:"enclosure",
+ attributes:{
+ url: podcast.contentUrl,
+ length: podcast.contentLength?.toString(),
+ type: podcast.contentType
+ },
+ }
+ ]
+ }
+
+ return {
+ schema,
+ rules,
+ getValidator,
+ setEnclosureContent,
+ exportProperties
+ };
+}
+
diff --git a/front-end/src/views/Blog/form-helpers/channels.ts b/front-end/src/views/Blog/form-helpers/channels.ts
new file mode 100644
index 0000000..cd33a20
--- /dev/null
+++ b/front-end/src/views/Blog/form-helpers/channels.ts
@@ -0,0 +1,227 @@
+// Copyright (C) 2023 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
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { MaybeRef, computed, watch, Ref } from 'vue'
+import { helpers, required, maxLength, numeric } from "@vuelidate/validators"
+import { useVuelidateWrapper } from '@vnuge/vnlib.browser';
+import { BlogChannel, ChannelFeed } from '@vnuge/cmnext-admin';
+import useVuelidate from "@vuelidate/core"
+
+export const getChannelForm = (editMode?: Ref<boolean>) => {
+ const channelSchema = computed(() => {
+ return {
+ fields: [
+ {
+ id: 'channel-name',
+ type: 'text',
+ label: 'Channel Name',
+ name: 'name',
+ placeholder: 'Enter the name of the channel',
+ description: 'A simple human readable name for the channel'
+ },
+ {
+ id: 'channel-path',
+ type: 'text',
+ label: 'Root Path',
+ name: 'path',
+ placeholder: 'Enter the root path to the channel',
+ description: editMode?.value ? 'You may not edit the channel directory' : 'The path in your bucket to the working directory for the channel',
+ disabled: editMode?.value
+ },
+ {
+ id: 'channel-index',
+ type: 'text',
+ label: 'Index File',
+ name: 'index',
+ placeholder: 'Enter the index file for the channel',
+ description: editMode?.value ?
+ 'You may not edit the index file path'
+ : 'The name or path of the post index file, stored under the root directory of the channel',
+ disabled: editMode?.value
+ },
+ {
+ id: 'channel-content-dir',
+ type: 'text',
+ label: 'Content Directory',
+ name: 'content',
+ placeholder: 'Enter the content directory for the channel',
+ description: editMode?.value ?
+ 'You may not edit the content directory path'
+ : 'The name or path of the content directory, stored under the root directory of the channel',
+ disabled: editMode?.value
+ },
+ {
+ id: 'index-file-example',
+ type: 'text',
+ label: 'Index Path',
+ name: 'example',
+ placeholder: 'Your index file path',
+ description: 'This is the location within your bucket where the index file will be stored',
+ disabled: true,
+ }
+ ]
+ }
+ });
+
+ const feedSchema = {
+ fields: [
+ {
+ id: 'channel-feed-url',
+ type: 'text',
+ label: 'Publish Url',
+ name: 'url',
+ placeholder: 'Enter the feed url for the channel',
+ description: 'The rss syndication url for your blog channel, the http url your blog resides at.'
+ },
+ {
+ id: 'channel-feed-path',
+ type: 'text',
+ label: 'Feed File',
+ name: 'path',
+ placeholder: 'feed.xml',
+ description: 'The path to the feed xml file within the channel directory'
+ },
+ {
+ id: 'channel-feed-image',
+ type: 'text',
+ label: 'Image Url',
+ name: 'image',
+ placeholder: 'Enter the url for the default feed image',
+ description: 'The full http url to the default feed image'
+ },
+ {
+ id: 'channel-feed-author',
+ type: 'text',
+ label: 'Feed Author',
+ name: 'author',
+ placeholder: 'Your name',
+ description: 'The author name for the feed'
+ },
+ {
+ id: 'channel-feed-contact',
+ type: 'text',
+ label: 'Feed Contact',
+ name: 'contact',
+ placeholder: 'Your contact email address',
+ description: 'The webmaster contact email address'
+ },
+ {
+ id: 'channel-feed-max-items',
+ type: 'number',
+ label: 'Feed Max Items',
+ name: 'maxItems',
+ placeholder: 'Enter the feed max items for the channel',
+ description: 'The maximum number of posts to publish in the feed'
+ },
+ {
+ id: 'channel-feed-description',
+ type: 'textarea',
+ label: 'Feed Description',
+ name: 'description',
+ placeholder: 'Enter the feed description for the channel',
+ }
+ ]
+ }
+
+ const alphaNumSpace = helpers.regex(/^[a-zA-Z0-9 ]*$/);
+ const httpUrl = helpers.regex(/^(http|https):\/\/[^ "]+$/);
+
+ const channelRules = {
+ name: {
+ required: helpers.withMessage('Channel name is required', required),
+ maxlength: helpers.withMessage('Channel name must be less than 50 characters', maxLength(50)),
+ alphaNumSpace: helpers.withMessage('Channel name must be alphanumeric', alphaNumSpace),
+ },
+ path: {
+ required: helpers.withMessage('Channel path is required', required),
+ maxlength: helpers.withMessage('Channel path must be less than 50 characters', maxLength(50)),
+ },
+ index: {
+ required: helpers.withMessage('Channel index is required', required),
+ maxlength: helpers.withMessage('Channel index must be less than 50 characters', maxLength(50)),
+ },
+ content: {
+ required: helpers.withMessage('Channel content directory is required', required),
+ maxlength: helpers.withMessage('Channel content directory must be less than 50 characters', maxLength(50)),
+ },
+ example: {}
+ }
+
+ const feedRules = {
+ url: {
+ required: helpers.withMessage('Channel feed url is required', required),
+ maxlength: helpers.withMessage('Channel feed url must be less than 100 characters', maxLength(100)),
+ url: helpers.withMessage('Channel feed url must be a valid url', httpUrl),
+ },
+ path: {
+ required: helpers.withMessage('Channel feed path is required', required),
+ maxlength: helpers.withMessage('Channel feed path must be less than 50 characters', maxLength(50)),
+ },
+ image: {
+ maxlength: helpers.withMessage('Channel feed image must be less than 200 characters', maxLength(200)),
+ },
+ contact: {
+ maxlength: helpers.withMessage('Channel feed contact must be less than 50 characters', maxLength(50)),
+ },
+ description: {
+ alphaNumSpace: helpers.withMessage('Channel feed description must be alphanumeric', alphaNumSpace),
+ maxlength: helpers.withMessage('Channel feed description must be less than 50 characters', maxLength(200)),
+ },
+ maxItems: {
+ numeric: helpers.withMessage('Channel feed max items must be a number', numeric),
+ },
+ author: {
+ alphaNumSpace: helpers.withMessage('Channel feed author must be alphanumeric', alphaNumSpace),
+ maxlength: helpers.withMessage('Channel feed author must be less than 50 characters', maxLength(50)),
+ }
+ }
+
+ const getChannelValidator = <T extends BlogChannel>(buffer: MaybeRef<T | undefined>) => {
+
+ const v$ = useVuelidate(channelRules, buffer, { $lazy: true, $autoDirty: true });
+
+ const updateExample = () => {
+ if (!v$.value.path.$model || !v$.value.index.$model) {
+ v$.value.example.$model = '';
+ return;
+ }
+ //Update the example path
+ v$.value.example.$model = `${v$.value.path.$model}/${v$.value.index.$model}`;
+ }
+
+ watch(v$, updateExample);
+
+ updateExample();
+
+ const { validate } = useVuelidateWrapper(v$);
+ return { v$, validate, reset: v$.value.$reset };
+ }
+
+ const getFeedValidator = <T extends ChannelFeed>(buffer: MaybeRef<T | undefined>) => {
+ const v$ = useVuelidate(feedRules, buffer, { $lazy: true, $autoDirty: true });
+ const { validate } = useVuelidateWrapper(v$);
+ return { v$, validate, reset: v$.value.$reset };
+ }
+
+ return {
+ channelSchema,
+ feedSchema,
+ channelRules,
+ feedRules,
+ getChannelValidator,
+ getFeedValidator
+ };
+}
+
diff --git a/front-end/src/views/Blog/form-helpers/index.ts b/front-end/src/views/Blog/form-helpers/index.ts
new file mode 100644
index 0000000..d3df0f7
--- /dev/null
+++ b/front-end/src/views/Blog/form-helpers/index.ts
@@ -0,0 +1,17 @@
+// Copyright (C) 2023 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
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+export * from './channels'
+export * from './posts' \ No newline at end of file
diff --git a/front-end/src/views/Blog/form-helpers/posts.ts b/front-end/src/views/Blog/form-helpers/posts.ts
new file mode 100644
index 0000000..da805e7
--- /dev/null
+++ b/front-end/src/views/Blog/form-helpers/posts.ts
@@ -0,0 +1,116 @@
+// Copyright (C) 2023 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
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { MaybeRef, computed } from "vue";
+import { useVuelidateWrapper } from "@vnuge/vnlib.browser"
+import { PostMeta } from '@vnuge/cmnext-admin'
+import { helpers, required, maxLength } from "@vuelidate/validators"
+import useVuelidate from "@vuelidate/core"
+
+export const getPostForm = () => {
+
+ const schema = computed(() => {
+ return {
+ fields: [
+ {
+ id: 'post-title',
+ type: 'text',
+ label: 'Post Title',
+ name: 'title',
+ placeholder: 'Enter the title of the post',
+ description: 'A simple human readable title for the post'
+ },
+ {
+ id: 'post-author',
+ type: 'text',
+ label: 'Post Author',
+ name: 'author',
+ placeholder: 'Enter the author of the post',
+ description: 'The author of the post'
+ },
+ {
+ id: 'post-tags',
+ type: 'text',
+ label: 'Post Tags',
+ name: 'tags',
+ placeholder: 'Enter the tags for the post',
+ description: 'A comma separated list of tags for the post'
+ },
+ {
+ id: 'post-image',
+ type: 'text',
+ label: 'Post Image',
+ name: 'image',
+ placeholder: 'Enter the image url for the post',
+ description: 'The full http url to the post image'
+ },
+ {
+ id: 'post-summary',
+ type: 'textarea',
+ label: 'Post Summary',
+ name: 'summary',
+ placeholder: 'Enter the summary of the post',
+ description: 'A short summary of the post, also the description for the rss feed'
+ },
+ {
+ id: 'existing-post-id',
+ type: 'text',
+ label: 'Post Id',
+ name: 'id',
+ placeholder: '',
+ description: 'The id of the post, this cannot be changed',
+ disabled: true,
+ }
+ ]
+ }
+ });
+
+ const alphaNumSpace = helpers.regex(/^[a-zA-Z0-9 ]*$/);
+ const httpUrl = helpers.regex(/^(http|https):\/\/[^ "]+$/);
+
+ const rules = {
+ title: {
+ required: helpers.withMessage('Post title is required', required),
+ maxlength: helpers.withMessage('Post title must be less than 50 characters', maxLength(50)),
+ alphaNumSpace: helpers.withMessage('Post title must be alphanumeric', alphaNumSpace),
+ },
+ summary: {
+ required: helpers.withMessage('Post summary is required', required),
+ maxlength: helpers.withMessage('Post summary must be less than 50 characters', maxLength(200)),
+ },
+ author: {
+ required: helpers.withMessage('Post author is required', required),
+ maxlength: helpers.withMessage('Post author must be less than 50 characters', maxLength(50)),
+ },
+ tags: {},
+ image: {
+ maxlength: helpers.withMessage('Post image must be less than 200 characters', maxLength(200)),
+ httpUrl: helpers.withMessage('Post image must be a valid http url', httpUrl),
+ },
+ content: {
+ required: helpers.withMessage('Post content is required', required),
+ maxLength: maxLength(50000),
+ },
+ id: {}
+ }
+
+ const getValidator = <T extends PostMeta>(buffer: MaybeRef<T | undefined>) => {
+ const v$ = useVuelidate(rules, buffer, { $lazy: true, $autoDirty: true });
+ const { validate } = useVuelidateWrapper(v$);
+ return { v$, validate, reset: v$.value.$reset };
+ }
+
+ return { schema, rules, getValidator };
+} \ No newline at end of file
diff --git a/front-end/src/views/Blog/index.vue b/front-end/src/views/Blog/index.vue
new file mode 100644
index 0000000..6bfcb6e
--- /dev/null
+++ b/front-end/src/views/Blog/index.vue
@@ -0,0 +1,324 @@
+<template>
+ <div class="container mx-auto mt-10 mb-[10rem]">
+ <div id="blog-admin-template" class="">
+
+ <TabGroup vertical :selected-index="tabId" @change="onTabChange">
+ <div class="menu">
+ <TabList>
+ <div class="inline-flex items-center justify-center w-16 h-16">
+ <span class="username-box">
+ {{ firstLetter }}
+ </span>
+ </div>
+
+ <div class="border-t border-gray-100 dark:border-dark-500">
+ <div class="px-2">
+
+ <Tab v-slot="{ selected }" as="div" class="py-4">
+ <div class="t group menu-item" :class="{'active':selected}">
+
+ <fa-icon icon="bullhorn" size="lg" />
+
+ <span class="opacity-0 tooltip group-hover:opacity-100">
+ Channel
+ </span>
+ </div>
+ </Tab>
+
+ <ul class="flex flex-col pt-4 space-y-1 border-t border-gray-100 dark:border-dark-500">
+ <Tab v-slot="{ selected }" as="li">
+ <div class="group menu-item" :class="{'active':selected}">
+
+ <fa-icon icon="comment" size="xl" />
+
+ <span class="opacity-0 tooltip group-hover:opacity-100">
+ Posts
+ </span>
+ </div>
+ </Tab>
+ <Tab v-slot="{ selected }" as="li">
+ <div class="group menu-item" :class="{'active':selected}">
+
+ <fa-icon icon="folder-open" size="lg" />
+
+ <span class="opacity-0 tooltip group-hover:opacity-100">
+ Content
+ </span>
+ </div>
+ </Tab>
+
+ </ul>
+ </div>
+ </div>
+ </TabList>
+ </div>
+
+ <TabPanels class="tab-container">
+ <div class="flex flex-row h-12 px-4 pb-2">
+
+ <div class="inline-flex flex-row gap-3">
+ <div class="my-auto">
+ <fa-icon icon="bullhorn" />
+ </div>
+
+ <select id="channel-select" class="" v-model="channel">
+ <option value="">Select Channel</option>
+ <option v-for="c in channels.items.value" :value="c.id">
+ {{ c.name }}
+ </option>
+ </select>
+ </div>
+
+ <div class="flex flex-row w-full max-w-md gap-4 ml-auto mr-4 filter">
+ <div class="my-auto">Filter</div>
+ <input class="w-full rounded input primary" v-model="search"/>
+ </div>
+
+ <div class="flex flex-row py-2 mr-auto">
+ <Switch v-model="lastModified"
+ :class="lastModified ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-500'"
+ class="relative inline-flex items-center w-10 h-5 my-auto duration-75 rounded-full">
+ <span class="sr-only">Last modified</span>
+ <span :class="lastModified ? 'translate-x-6' : 'translate-x-1'"
+ class="inline-block w-3 h-3 transition transform bg-white rounded-full" />
+ </Switch>
+ <div class="my-auto ml-3">
+ Last Modifed
+ </div>
+
+ </div>
+ </div>
+
+ <TabPanel>
+ <Channels :blog="blogState" />
+ </TabPanel>
+
+ <TabPanel>
+ <Posts :blog="blogState" />
+ </TabPanel>
+
+ <TabPanel>
+ <Content :blog="blogState" />
+ </TabPanel>
+
+ </TabPanels>
+ </TabGroup>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useScriptTag } from '@vueuse/core';
+import { useRouteQuery } from '@vueuse/router';
+import { TabGroup, TabList, Tab, TabPanels, TabPanel, Switch } from '@headlessui/vue'
+import { first } from 'lodash';
+import { usePageGuard, useUser, useTitle } from '@vnuge/vnlib.browser';
+import { createBlogContext, useComputedChannels, useComputedPosts, useComputedContent, SortType } from '@vnuge/cmnext-admin';
+import { BlogState } from './blog-api';
+import Channels from './components/Channels.vue';
+import Posts from './components/Posts.vue';
+import Content from './components/Content.vue';
+
+//Protect page
+usePageGuard();
+useTitle('CMNext Admin')
+
+//Load scripts
+const ckEditorTag = useScriptTag("https://cdn.ckeditor.com/ckeditor5/35.4.0/super-build/ckeditor.js")
+//Store the wait result on the window for the editor script to wait
+window.editorLoadResult = ckEditorTag.load(true);
+
+const { userName, getProfile } = useUser()
+
+//Load user profile and forget if not set
+if(!userName.value){
+ getProfile()
+}
+
+const firstLetter = computed(() => first(userName.value))
+
+const tabIdQ = useRouteQuery<string>('tabid', '', { mode: 'push' })
+
+const context = createBlogContext({
+ channelUrl: '/blog/channels',
+ postUrl: '/blog/posts',
+ contentUrl: '/blog/content'
+})
+
+const { search, sort, channel } = context.getQuery();
+
+const channels = useComputedChannels(context)
+const posts = useComputedPosts(context)
+const content = useComputedContent(context)
+
+const blogState = { channels, posts, content } as BlogState
+
+//Map queries to their respective computed values
+const tabId = computed(() => tabIdQ.value ? parseInt(tabIdQ.value) : 0);
+const lastModified = computed({
+ get :() => sort.value === SortType.ModifiedTime,
+ set: (value:boolean) => {
+ sort.value = value ? SortType.ModifiedTime : SortType.CreatedTime
+ }
+})
+
+const onTabChange = (id:number) => tabIdQ.value = id.toString(10)
+
+</script>
+
+<style lang="scss">
+
+#blog-admin-template{
+ @apply flex flex-row flex-auto min-h-[50rem] border rounded-sm max-w-[82rem] mx-auto;
+ @apply dark:border-dark-600 dark:text-gray-300 border-gray-200;
+
+ .username-box{
+ @apply grid w-10 h-10 text-sm rounded-lg place-content-center;
+ @apply text-gray-600 bg-gray-100 dark:text-gray-300 dark:bg-dark-600;
+ }
+
+ .menu-item{
+ @apply relative flex justify-center rounded px-2 py-2 cursor-pointer;
+ @apply text-gray-500 hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-dark-700 dark:hover:text-gray-300;
+
+ &.active{
+ @apply text-primary-600;
+ }
+
+ .tooltip{
+ @apply absolute start-full -translate-y-1/2 top-1/2 ms-4 rounded px-2 py-1.5 text-xs font-medium;
+ @apply text-white bg-gray-900 dark:bg-dark-600;
+ }
+ }
+
+ .menu{
+ @apply flex flex-col justify-between w-16 border-e;
+ @apply bg-white dark:bg-dark-800 dark:border-dark-500;
+ }
+
+ #channel-select{
+ @apply w-full p-1 px-2 border rounded-sm sm:text-sm min-w-[13rem];
+ @apply border-gray-300 text-gray-700 bg-white;
+ @apply dark:bg-dark-800 dark:border-dark-500 focus:dark:border-dark-400 hover:dark:border-dark-400 dark:text-inherit;
+
+ option{
+ @apply text-base;
+ }
+ }
+
+ .tab-container{
+ @apply flex-1 py-4 rounded-r-sm dark:bg-dark-800 bg-white text-gray-700 dark:text-inherit;
+ }
+
+ // Rules for dynamic forms in edit panes
+ .dynamic-form.form{
+ @apply w-full mt-4 md:px-12;
+
+ .dynamic-form.input-group{
+ @apply grid grid-flow-row grid-cols-2;
+ }
+
+ .dynamic-form.input-group{
+ @apply gap-x-16;
+
+ .dynamic-form.input-container{
+
+ .dynamic-form.dynamic-input{
+ @apply border rounded-sm p-2 bg-transparent w-full dark:border-dark-600;
+ @apply dark:bg-dark-800 focus:border-primary-500;
+
+ &.input-textarea{
+ @apply h-40 outline-none;
+ }
+
+ &::placeholder{
+ @apply dark:text-gray-500;
+ }
+
+ &:disabled{
+ @apply text-rose-400 border-transparent;
+ }
+ }
+
+ &.dirty.data-invalid .dynamic-form.dynamic-input{
+ @apply border-red-500 focus:border-red-500;
+ }
+
+ .dynamic-form.field-description{
+ @apply pt-1 p-2 pb-4 text-sm;
+ }
+
+ }
+
+ .dynamic-form.input-label{
+ @apply col-span-2 text-right m-auto mr-2;
+ }
+ }
+ }
+
+ table.edit-table {
+ @apply w-full divide-y-2 divide-gray-200 bg-white text-sm dark:divide-dark-500 dark:bg-dark-800;
+
+ thead{
+ @apply text-left text-lg;
+ }
+
+ tbody{
+ @apply divide-y divide-gray-200 dark:divide-dark-500;
+ }
+
+ thead th,
+ tr td{
+ @apply whitespace-nowrap px-4 py-2 font-medium;
+ }
+ }
+
+ .ck.ck-editor{
+ @apply border dark:border-coolGray-600;
+ }
+
+ .ck-editor .ck-content,
+ .ck-editor .ck-source-editing-area{
+ @apply min-h-[32rem] resize-y dark:bg-dark-800;
+
+ a {
+ @apply text-blue-500;
+ }
+
+ p{
+ @apply my-2;
+ }
+
+ pre{
+ @apply p-2 dark:text-gray-200;
+ }
+
+ h1, h2{
+ @apply border-b pb-3 mb-4;
+ }
+ }
+
+ .ck-source-editing-area textarea{
+ @apply dark:bg-transparent;
+ }
+
+ .ck.ck-toolbar,
+ .ck.ck-reset
+ {
+ @apply dark:bg-dark-800 dark:text-gray-300;
+
+ .ck-button,
+ .ck-dropdown
+ {
+ @apply dark:text-gray-300;
+
+ &:hover,
+ &.ck-on
+ {
+ @apply dark:bg-dark-600;
+ }
+ }
+ }
+}
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Login/components/Social.vue b/front-end/src/views/Login/components/Social.vue
new file mode 100644
index 0000000..34c5a1e
--- /dev/null
+++ b/front-end/src/views/Login/components/Social.vue
@@ -0,0 +1,57 @@
+<template>
+
+ <form class="w-full" @submit.prevent="SocalLogin('/login/social/github')">
+ <button type="submit" class="btn social-button" :disabled="waiting">
+ <fa-icon :icon="['fab','github']" size="xl" />
+ Login with Github
+ </button>
+ </form>
+
+ <form class="mt-4" @submit.prevent="SocalLogin('/login/social/discord')">
+ <button type="submit" class="btn social-button" :disabled="waiting">
+ <fa-icon :icon="['fab','discord']" size="xl" />
+ Login with Discord
+ </button>
+ </form>
+
+ <form v-if="auth0Enabled" class="mt-4" @submit.prevent="SocalLogin('/login/social/auth0')">
+ <button type="submit" class="btn social-button" :disabled="waiting">
+ <fa-icon :icon="['fa','key']" size="xl" />
+ Login with Auth0
+ </button>
+ </form>
+
+</template>
+
+<script setup lang="ts">
+import { apiCall, useWait, useSession, useSessionUtils, WebMessage } from '@vnuge/vnlib.browser'
+
+//auth0 enabled flag from env
+const auth0Enabled = import.meta.env.VITE_ENABLE_AUTH0 == 'true';
+
+const { waiting } = useWait()
+const { browserId, publicKey } = useSession()
+const { KeyStore } = useSessionUtils()
+
+const SocalLogin = async (url:string) => {
+ await apiCall(async ({ axios }) => {
+ const { data } = await axios.put<WebMessage<string>>(url, {
+ browser_id: browserId.value,
+ public_key: publicKey.value
+ })
+
+ const encDat = data.getResultOrThrow()
+ // Decrypt the result which should be a redirect url
+ const result = await KeyStore.decryptDataAsync(encDat)
+ // get utf8 text
+ const text = new TextDecoder('utf-8').decode(result)
+ // Recover url
+ const redirect = new URL(text)
+ // Force https
+ redirect.protocol = 'https:'
+ // redirect to the url
+ window.location.href = redirect.href
+ })
+}
+
+</script> \ No newline at end of file
diff --git a/front-end/src/views/Login/components/Totp.vue b/front-end/src/views/Login/components/Totp.vue
new file mode 100644
index 0000000..50a5be3
--- /dev/null
+++ b/front-end/src/views/Login/components/Totp.vue
@@ -0,0 +1,65 @@
+<template>
+ <div id="totp-login-form">
+ <h5>Enter your TOTP code</h5>
+ <div class="flex flex-col h-32">
+ <div class="h-8 mx-auto">
+ <fa-icon v-if="waiting" class="animate-spin" size="xl" icon="spinner"/>
+ </div>
+ <div class="mx-auto mt-4">
+ <VOtpInput
+ class="otp-input"
+ input-type="letter-numeric"
+ :is-disabled="waiting"
+ separator=""
+ input-classes="primary input rounded"
+ :num-inputs="6"
+ value=""
+ @on-change="onInput"
+ @on-complete="SubimitTotp"
+ />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useMessage, useWait } from '@vnuge/vnlib.browser';
+import { toSafeInteger } from 'lodash';
+import VOtpInput from "vue3-otp-input";
+
+const emit = defineEmits(['submit'])
+
+const { waiting } = useWait();
+const { onInput } = useMessage();
+
+const SubimitTotp = async (code : string) => {
+
+ //If a request is still pending, do nothing
+ if (waiting.value) {
+ return
+ }
+
+ //Submit a mfa upgrade result
+ emit('submit', {
+ code: toSafeInteger(code)
+ })
+}
+
+
+</script>
+
+<style lang="scss">
+
+#totp-login-form {
+ .otp-input {
+ @apply rounded-sm gap-2;
+
+ input {
+ @apply w-12 h-12 p-3 text-center text-2xl;
+ appearance: none;
+ -webkit-appearance: none;
+ }
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/front-end/src/views/Login/components/UserPass.vue b/front-end/src/views/Login/components/UserPass.vue
new file mode 100644
index 0000000..e218cb8
--- /dev/null
+++ b/front-end/src/views/Login/components/UserPass.vue
@@ -0,0 +1,92 @@
+<template>
+ <div class="">
+ <h3>Login</h3>
+ <form id="user-pass-submit-form" method="post" action="/login" @submit.prevent="SubmitLogin">
+ <fieldset class="" :disabled="waiting" >
+ <div>
+ <div class="float-label">
+ <input
+ id="username"
+ v-model="v$.username.$model"
+ type="email"
+ class="w-full primary input"
+ placeholder="Email"
+ :class="{ 'data-invalid': v$.username.$invalid }"
+ @input="onInput"
+ >
+ <label for="username">Email</label>
+ </div>
+ </div>
+ <div class="py-3">
+ <div class="mb-2 float-label">
+ <input
+ id="password"
+ v-model="v$.password.$model"
+ type="password"
+ class="w-full primary input"
+ placeholder="Password"
+ :class="{ 'data-invalid': v$.password.$invalid }"
+ @input="onInput"
+ >
+ <label for="password">Password</label>
+ </div>
+ </div>
+ </fieldset>
+ <button type="submit" form="user-pass-submit-form" class="btn primary" :disabled="waiting">
+ <!-- Display spinner if waiting, otherwise the sign-in icon -->
+ <fa-icon :class="{'animate-spin':waiting}" :icon="waiting ? 'spinner' : 'sign-in-alt'"/>
+ Log-in
+ </button>
+ </form>
+ <div class="flex flex-row justify-between gap-3 pt-3 pb-2 form-links">
+ <router-link to="/pwreset">
+ Forgot password
+ </router-link>
+ <router-link to="/register">
+ Register a new account
+ </router-link>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { reactive } from 'vue'
+import useVuelidate from '@vuelidate/core'
+import { required, maxLength, minLength, email, helpers } from '@vuelidate/validators'
+import { useMessage, useVuelidateWrapper, useWait } from '@vnuge/vnlib.browser'
+
+const emit = defineEmits(['login'])
+
+const { onInput } = useMessage();
+const { waiting } = useWait();
+
+const vState = reactive({ username: '', password: '' })
+
+const rules = {
+ username: {
+ required: helpers.withMessage('Email cannot be empty', required),
+ email: helpers.withMessage('Your email address is not valid', email),
+ maxLength: helpers.withMessage('Email address must be less than 50 characters', maxLength(50))
+ },
+ password: {
+ required: helpers.withMessage('Password cannot be empty', required),
+ minLength: helpers.withMessage('Password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage('Password must have less than 128 characters', maxLength(128))
+ }
+}
+
+const v$ = useVuelidate(rules, vState)
+const { validate } = useVuelidateWrapper(v$);
+
+const SubmitLogin = async () => {
+
+ // If the form is not valid set the error message
+ if (!await validate()) {
+ return
+ }
+
+ //Emit login and pass the username and password
+ emit('login', { username: v$.value.username.$model, password: v$.value.password.$model });
+}
+
+</script> \ No newline at end of file
diff --git a/front-end/src/views/Login/index.vue b/front-end/src/views/Login/index.vue
new file mode 100644
index 0000000..f3a3f59
--- /dev/null
+++ b/front-end/src/views/Login/index.vue
@@ -0,0 +1,182 @@
+<template>
+ <div id="login-template" class="app-component-entry">
+ <div class="login-container">
+
+ <div v-if="showTotp">
+ <Totp @submit="totpSubmit" />
+ </div>
+
+ <div v-else-if="!loggedIn">
+ <UserPass @login="submitLogin" />
+ </div>
+
+ <div v-else>
+ <h3>Logout</h3>
+ <p class="mt-3 mb-5 text-lg">
+ You are currently logged-in.
+ </p>
+ <div class="">
+ <button form="user-pass-submit-form" class="btn primary" @click="submitLogout" :disabled="waiting">
+ <!-- Display spinner if waiting, otherwise the sign-in icon -->
+ <fa-icon :class="{'animate-spin':waiting}" :icon="waiting ? 'spinner' : 'sign-in-alt'"/>
+ Log-out
+ </button>
+ </div>
+ </div>
+
+ <div v-if="!(loggedIn || showTotp)" class="w-full mt-6">
+
+ <Social />
+
+ <!-- pki button, forward to the pki route -->
+ <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" />
+ Login with PKI Credential
+ </button>
+ </router-link>
+ </div>
+ </div>
+
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import Totp from './components/Totp.vue'
+import UserPass from './components/UserPass.vue'
+import Social from './components/Social.vue'
+import { apiCall, useMessage, useWait, useUser, useSession, useLastPage, useTitle, debugLog } from '@vnuge/vnlib.browser'
+import { useMfaLogin, totpMfaProcessor, IMfaFlowContinuiation, MfaMethod } from '@vnuge/vnlib.browser/dist/mfa'
+import { useTimeoutFn } from '@vueuse/shared'
+import { isNil } from 'lodash'
+
+useTitle('Login')
+
+//pki enabled flag from env
+const pkiEnabled = !isNil(import.meta.env.VITE_PKI_ENDPOINT);
+
+const { waiting } = useWait()
+const { setMessage } = useMessage()
+const { logout } = useUser();
+const { loggedIn } = useSession()
+
+//Setup mfa login
+const { login } = useMfaLogin([
+ totpMfaProcessor()
+])
+
+//If logged in re-route to the last page the user
+//was on but delayed to the session has time to be set
+const { gotoLastPage } = useLastPage()
+useTimeoutFn(() => loggedIn.value ? gotoLastPage() : null, 500)
+
+const mfaUpgrade = ref<IMfaFlowContinuiation>();
+const mfaTimer = ref<{stop:() => void}>();
+const showTotp = computed(() => mfaUpgrade.value?.type === MfaMethod.TOTP)
+
+const submitLogout = async () => {
+ //Submit logout request
+ await apiCall(async ({ toaster }) => {
+ // Attempt to login
+ await logout()
+ // Push a new toast message
+ toaster.general.success({
+ id: 'logout-success',
+ title: 'Success',
+ text: 'You have been logged out',
+ duration: 5000
+ })
+ })
+}
+
+const submitLogin = async ({username, password} : { username: string, password:string }) => {
+ // Run login in an apicall wrapper
+ await apiCall(async ({ toaster }) => {
+ // Attempt to login
+ const response = await login(username, password)
+
+ debugLog('Mfa-login',response)
+
+ //Try to get response as a flow continuation
+ const mfa = response as IMfaFlowContinuiation
+
+ // Response is a totp upgrade request
+ if (mfa.type === MfaMethod.TOTP) {
+
+ //Store the upgrade message
+ mfaUpgrade.value = mfa;
+
+ // Set timeout to reset the form when totp expires
+ mfaTimer.value = useTimeoutFn(() => {
+
+ //Clear upgrade message
+ mfaUpgrade.value = undefined;
+
+ setMessage('Your TOTP request has expired')
+
+ }, mfa.expires! * 1000)
+ }
+ //If login without mfa was successful
+ else if (response.success) {
+ // Push a new toast message
+ toaster.general.success({
+ title: 'Success',
+ text: 'You have been logged in',
+ })
+
+ return;
+ }
+ })
+}
+
+const totpSubmit = ({ code } : {code:number}) =>{
+ apiCall(async ({ toaster }) =>{
+
+ if (!mfaUpgrade.value)
+ return;
+
+ //Submit totp code
+ const res = await mfaUpgrade.value.submit({ code })
+ res.getResultOrThrow()
+
+ //Clear timer
+ mfaTimer.value?.stop()
+
+ //Clear upgrade message
+ mfaUpgrade.value = undefined;
+
+ // Push a new toast message
+ toaster.general.success({
+ title: 'Success',
+ text: 'You have been logged in',
+ })
+ })
+}
+
+</script>
+
+<style lang="scss">
+#login-template {
+ .login-container{
+ @apply container max-w-sm w-full sm:mt-2 mt-8 mb-16 mx-auto lg:mt-16 px-6 py-4 flex flex-col;
+ @apply ease-linear duration-150 text-center;
+ @apply rounded-sm sm:bg-white sm:border shadow-sm border-gray-200 sm:dark:bg-dark-800 dark:border-dark-500;
+ }
+
+ .login-container button{
+ @apply w-full border py-2.5;
+ }
+
+ button.social-button {
+ @apply flex flex-row justify-center gap-3 items-center;
+ }
+
+ a {
+ @apply ease-in-out duration-100;
+ @apply hover:text-primary-600 dark:hover:text-primary-500;
+ }
+}
+</style>
diff --git a/front-end/src/views/Login/pki/index.vue b/front-end/src/views/Login/pki/index.vue
new file mode 100644
index 0000000..ae1a4a8
--- /dev/null
+++ b/front-end/src/views/Login/pki/index.vue
@@ -0,0 +1,80 @@
+<template>
+ <div id="pki-login-template" class="app-component-entry">
+ <div class="container max-w-lg mx-auto mt-6 lg:mt-20">
+ <div class="p-2 text-center bg-white border rounded shadow-md dark:border-dark-500 dark:bg-dark-800">
+
+ <h4>Enter your PKI-OTP</h4>
+
+ <div class="p-3">
+ <div class="">
+ <textarea v-model="otp" class="w-full p-1 border rounded-sm input primary" rows="5"></textarea>
+ </div>
+
+ <div class="flex justify-between mt-4">
+ <div class="text-sm">
+ <a class="link" target="_blank" href="https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/VNLib.Plugins.Essentials.Accounts">
+ Goto OTP spec
+ <fa-icon icon="arrow-right" class="ml-1" />
+ </a>
+ </div>
+ <div class="button-group">
+ <RouterLink to="/login">
+ <button class="btn">Back</button>
+ </RouterLink>
+ <button class="btn primary" @click.prevent="submit">Login</button>
+ </div>
+ </div>
+
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { isEmpty } from 'lodash';
+import { apiCall, debugLog, useUser, useMessage } from '@vnuge/vnlib.browser';
+import { ITokenResponse } from '@vnuge/vnlib.browser/dist/session';
+import { ref } from 'vue'
+import { decodeJwt } from 'jose'
+import { useRouter } from 'vue-router';
+
+const otp = ref('')
+
+const pkiEndpoint = import.meta.env.VITE_PKI_ENDPOINT
+
+const { prepareLogin } = useUser()
+const { setMessage } = useMessage()
+const { push } = useRouter()
+
+const submit = () =>{
+
+ apiCall(async ({ axios }) =>{
+ 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)
+
+ //Prepare a login message
+ const loginMessage = prepareLogin()
+
+ //Set the 'login' field to the otp
+ loginMessage.login = otp.value
+
+ const { data } = await axios.post<ITokenResponse>(pkiEndpoint, loginMessage)
+
+ data.getResultOrThrow()
+
+ //Finalize the login
+ await loginMessage.finalize(data);
+
+ //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
new file mode 100644
index 0000000..5a803bd
--- /dev/null
+++ b/front-end/src/views/Login/social/[type].vue
@@ -0,0 +1,127 @@
+<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 { isEqual } from 'lodash'
+import { useRouteParams, useRouteQuery } from '@vueuse/router'
+import { useSession, useWait, useUser, useTitle, configureApiCall } from '@vnuge/vnlib.browser'
+import { useRouter } from 'vue-router';
+import { ref } from 'vue'
+
+useTitle('Social Login')
+
+const { loggedIn } = useSession()
+const { prepareLogin } = useUser()
+const { waiting } = useWait()
+
+const type = useRouteParams('type')
+const result = useRouteQuery('result', '');
+const nonce = useRouteQuery('nonce', '');
+const router = useRouter()
+
+const message = ref('')
+
+//Override the message handler to capture the error message and display it
+const { apiCall } = configureApiCall(m => message.value = m)
+
+//If logged-in redirect to login page
+if (loggedIn.value) {
+ router.push({ name: 'Login' })
+}
+
+
+const run = async () => {
+ if (isEqual(result.value, 'authorized')) {
+
+ let loginUrl : string = ''
+
+ switch (type.value) {
+ case 'github':
+ loginUrl = '/login/social/github';
+ break;
+ case 'discord':
+ loginUrl = '/login/social/discord';
+ break;
+ case 'auth0':
+ loginUrl = '/login/social/auth0';
+ break;
+ default:
+ router.push('/login')
+ break;
+ }
+
+ // If nonce is set, then we can proceed with finalization
+ await apiCall(async ({ axios }) => {
+ const preppedLogin = prepareLogin()
+ // Send the login request
+ const response = await axios.post(loginUrl, { nonce: nonce.value })
+ if (response.data.success === true) {
+ // Finalize the login
+ await preppedLogin.finalize(response)
+ // If the login was successful, then we can redirect to the login page
+ router.push({ name: 'Login' })
+ return
+ }
+ // Otherwise, we can show an error
+ throw { response }
+ })
+ } else {
+ switch (result.value) {
+ case 'invalid':
+ message.value = 'The request was invalid, and you could not be logged in. Please try again.'
+ break
+ case 'expired':
+ message.value = 'The request has expired. Please try again.'
+ break
+ default:
+ message.value = 'There was an error processing the request. Please try again.'
+ break
+ }
+ }
+}
+
+//Run without awaiting
+run()
+
+</script>
+
+<style lang="scss">
+
+#social-login-template{
+ .entry-container{
+ @apply w-full max-w-[28rem] p-6 text-center sm:border rounded-sm sm:shadow-sm;
+ @apply sm:bg-white bg-transparent sm:dark:bg-dark-700 dark:border-dark-400;
+ }
+}
+
+</style>
diff --git a/front-end/src/views/Register/components/CompleteReg.vue b/front-end/src/views/Register/components/CompleteReg.vue
new file mode 100644
index 0000000..b8f8ef0
--- /dev/null
+++ b/front-end/src/views/Register/components/CompleteReg.vue
@@ -0,0 +1,116 @@
+<template>
+ <div id="reg-submit-template">
+ <form
+ id="complete-registration"
+ method="POST"
+ action="#"
+ :disabled="waiting"
+ @submit.prevent="onSubmit"
+ >
+ <fieldset class="input-group">
+ <div class="input-container">
+ <label for="reg-password" class="pl-1">
+ Password
+ </label>
+ <input
+ id="reg-password"
+ v-model="v$.password.$model"
+ type="password"
+ class="input-field primary input"
+ @input="onInput"
+ >
+ </div>
+ <div class="input-container">
+ <label for="reg-repeat-pass" class="pl-1">
+ Repeat Password
+ </label>
+ <input
+ id="reg-repeat-pass"
+ v-model="v$.repeatPass.$model"
+ type="password"
+ class="input-field primary input"
+ @input="onInput"
+ >
+ </div>
+ <div class="flex flex-row justify-end gap-4 pt-4">
+ <button form="complete-registration" type="submit" class="btn primary">
+ Submit
+ </button>
+ <button class="text-red-500 cursor-pointer" @click.prevent="cancel">
+ Cancel
+ </button>
+ </div>
+ </fieldset>
+ </form>
+ </div>
+</template>
+
+<script setup lang="ts">
+
+import { isEqual } from 'lodash'
+import useVuelidate from '@vuelidate/core'
+import { required, minLength, maxLength, helpers } from '@vuelidate/validators'
+import { apiCall, useMessage, useWait, useVuelidateWrapper, WebMessage } from '@vnuge/vnlib.browser'
+import { computed, reactive, toRefs } from 'vue'
+
+const emit = defineEmits(['complete', 'cancel'])
+
+const props = defineProps<{
+ token: string
+ regPath: string
+}>()
+
+const { token, regPath } = toRefs(props)
+const { onInput } = useMessage()
+const { waiting } = useWait()
+
+const vState = reactive({ password: '', repeatPass: ''})
+
+const rules = computed(() => {
+ return {
+ password: {
+ required: helpers.withMessage('Password cannot be empty', required),
+ minLength: helpers.withMessage('Password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage(' must have less than 128 characters', maxLength(128))
+ },
+ repeatPass: {
+ sameAs: helpers.withMessage('Your passwords do not match', (value : string) => isEqual(value, v$.value.password.$model)),
+ required: helpers.withMessage('Repeat password cannot be empty', required),
+ minLength: helpers.withMessage('Repeat password must be at least 8 characters', minLength(8)),
+ maxLength: helpers.withMessage('Repeat password must have less than 128 characters', maxLength(128))
+ },
+ }
+})
+
+const v$ = useVuelidate(rules, vState, { $lazy: true })
+
+const { validate } = useVuelidateWrapper(v$)
+
+const onSubmit = async function () {
+
+ if (!await validate()) {
+ return
+ }
+
+ await apiCall(async ({ axios }) => {
+ // finalize by passing the token and the new password
+ const { data } = await axios.post<WebMessage>(regPath.value, {
+ token: token.value,
+ password: v$.value.password.$model
+ })
+
+ //Throw if not successful
+ data.getResultOrThrow()
+ v$.value.$reset()
+ emit('complete')
+
+ })
+}
+
+const cancel = () => emit('cancel')
+
+</script>
+
+<style>
+
+</style>
diff --git a/front-end/src/views/Register/index.vue b/front-end/src/views/Register/index.vue
new file mode 100644
index 0000000..92a5992
--- /dev/null
+++ b/front-end/src/views/Register/index.vue
@@ -0,0 +1,161 @@
+<template>
+ <div id="reg-template" class="app-component-entry">
+ <div class="container flex flex-col m-auto my-2 duration-150 ease-linear lg:mt-16">
+ <div class="text-center">
+ <h2>Sign Up</h2>
+ </div>
+ <div class="mt-4 content-container">
+ <form v-if="formState === 0" @submit.prevent="OnSubmit" :disabled="waiting">
+ <fieldset class="input-group">
+ <div class="input-container">
+ <label for="reg-email" class="pl-1 text-sm">Email Address</label>
+ <input
+ id="reg-email"
+ v-model="v$.emailAddress.$model"
+ type="email"
+ placeholder="user@example.com"
+ required
+ class="input-field primary"
+ @input="onInput"
+ >
+ </div>
+ </fieldset>
+ <fieldset class="flex flex-row justify-between mt-6">
+ <div>
+ <label class="checkbox primary">
+ <input v-model="acceptedTerms" type="checkbox">
+ <span class="check" />
+ <span class="mx-2 text-sm">
+ I agree to the
+ <a class="link" href="#">Terms of Service</a>
+ </span>
+ </label>
+ </div>
+ <div>
+ <button type="submit" :disabled="!acceptedTerms || waiting" class="btn primary">
+ Submit
+ </button>
+ </div>
+ </fieldset>
+ </form>
+ <complete-reg
+ v-else-if="formState === 1"
+ :reg-path="regPath"
+ :token="token"
+ @cancel="formState = 0"
+ @complete="formState = 2"
+ />
+ <div v-else>
+ <div class="text-center">
+ <h3>Success</h3>
+ <fa-icon
+ :icon="['fa','check-circle']"
+ class="text-primary-500 dark:text-primary-600"
+ size="3x"
+ />
+ <p class="mt-4">
+ You may log in now.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { isNil } from 'lodash';
+import useVuelidate from '@vuelidate/core'
+import { required, maxLength, email, helpers } from '@vuelidate/validators'
+import { ref, reactive, watch } from 'vue'
+import { useSession, apiCall, useMessage, useWait, useTitle, useVuelidateWrapper } from '@vnuge/vnlib.browser'
+import CompleteReg from './components/CompleteReg.vue'
+import { useRouter } from 'vue-router';
+import { useRouteQuery } from '@vueuse/router';
+
+const regPath = "/account/registration"
+
+useTitle('Registration')
+
+const { setMessage, onInput } = useMessage()
+const { waiting } = useWait()
+const { browserId } = useSession()
+const router = useRouter();
+
+//Token is the t query argument
+const token = useRouteQuery('t', null)
+
+const acceptedTerms = ref(false)
+const formState = ref(isNil(token.value) ? 0 : 1)
+
+const vState = reactive({
+ emailAddress: ''
+})
+
+const rules = {
+ emailAddress: {
+ required: helpers.withMessage('Email cannot be empty', required),
+ email: helpers.withMessage('Your email address is not valid', email),
+ maxLength: helpers.withMessage('Email address must be less than 50 characters', maxLength(50))
+ }
+}
+const v$ = useVuelidate(rules, vState, { $lazy: true })
+const { validate } = useVuelidateWrapper(v$)
+
+const OnSubmit = async function () {
+ if (!acceptedTerms.value) {
+ setMessage('You must accept the terms of service to continue.')
+ return
+ }
+ if (!await validate()) {
+ return
+ }
+ await apiCall(async ({ axios, toaster }) => {
+ const response = await axios.put(regPath, {
+ username: v$.value.emailAddress.$model,
+ clientid: browserId.value,
+ localtime: new Date().toISOString()
+ })
+ if (response.data.success) {
+ toaster.form.success({
+ id: 'logout-success',
+ title: 'Success',
+ text: response.data.result,
+ duration: 5000
+ }
+ )
+
+ acceptedTerms.value = false
+ v$.value.emailAddress.$model = '';
+ //Clear form
+ v$.value.$reset()
+ } else {
+ setMessage(response.data.result)
+ }
+ })
+}
+
+watch(formState, () => {
+ //Clear token if formState is not 1
+ v$.value.$reset()
+ acceptedTerms.value = false
+ v$.value.emailAddress.$model = '';
+ router.push({ query: {} })
+})
+
+</script>
+
+<style>
+#reg-template .content-container{
+ @apply mx-auto p-4 bg-white dark:bg-dark-700 border border-gray-200 dark:border-dark-500;
+ @apply sm:p-6 sm:rounded-md sm:shadow-sm w-full max-w-sm;
+}
+
+#reg-template input.input-field {
+ @apply block w-full p-2 border-b-2 my-2;
+}
+
+.content-container fieldset.input-group {
+ @apply mx-auto;
+}
+</style>
diff --git a/front-end/src/views/[...all].vue b/front-end/src/views/[...all].vue
new file mode 100644
index 0000000..1f439fc
--- /dev/null
+++ b/front-end/src/views/[...all].vue
@@ -0,0 +1,24 @@
+<template>
+ <div id="default-template" class="flex px-4 py-24 app-component-entry">
+ <div class="mx-auto">
+ <h2 class="text-center">404 - Resource not found</h2>
+ <p>
+ The resource you are looking for could not be found on the server. Please check the URL and try again,
+ or go back <router-link class="link" to="/">home</router-link>
+ </p>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+
+import { useTitle } from '@vnuge/vnlib.browser'
+useTitle('404 - Resource not found')
+
+</script>
+
+<style lang="scss">
+#default-template{
+
+}
+</style> \ No newline at end of file
diff --git a/front-end/src/views/index.vue b/front-end/src/views/index.vue
new file mode 100644
index 0000000..07b0a09
--- /dev/null
+++ b/front-end/src/views/index.vue
@@ -0,0 +1,18 @@
+<template>
+ <div id="home-page-entry" class="app-component-entry">
+
+ </div>
+</template>
+
+<script setup lang="ts">
+
+import { useRouter } from 'vue-router';
+
+const { push } = useRouter();
+push('/blog')
+
+</script>
+
+<style lang="scss">
+
+</style> \ No newline at end of file
diff --git a/front-end/src/vite-env.d.ts b/front-end/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/front-end/src/vite-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="vite/client" />