diff options
Diffstat (limited to 'front-end/src')
32 files changed, 1645 insertions, 964 deletions
diff --git a/front-end/src/bootstrap/Environment.vue b/front-end/src/bootstrap/Environment.vue index 618ee62..c14b7b6 100644 --- a/front-end/src/bootstrap/Environment.vue +++ b/front-end/src/bootstrap/Environment.vue @@ -1,3 +1,42 @@ +<script setup lang="ts"> + +import { computed, defineAsyncComponent } from 'vue' +import { RouteLocation, useRouter } from 'vue-router' +import { filter, map, without, find, includes } from 'lodash-es' +import { storeToRefs } from 'pinia' +import { useEnvSize } from '@vnuge/vnlib.browser' +import { useStore } from '../store' +import siteHeader from './components/Header.vue' +import siteFooter from './components/Footer.vue' +const ConfirmPrompt = defineAsyncComponent(() => import('./components/ConfirmPrompt.vue')); +const CookieWarning = defineAsyncComponent(() => import('./components/CookieWarning.vue')); +const PasswordPrompt = defineAsyncComponent(() => import('./components/PasswordPrompt.vue')); + +const emit = defineEmits(['logout']) +const store = useStore() +const { showCookieWarning, currentRoutes } = storeToRefs(store) +const { getRoutes } = useRouter(); + +//Use the env size to calculate the header and footer heights for us +const { header, footer, content, headerHeight, footerHeight } = useEnvSize(true) + +const routes = computed<RouteLocation[]>(() => { + // Get routes that are defined above but only if they are defined in the router + // This is a computed property because loggedin is a reactive property + + const routes = filter(getRoutes(), (pageName) => includes(currentRoutes.value, pageName.name)) + + const activeRoutes = map(currentRoutes.value, route => find(routes, { name: route })) + + return without<RouteLocation>(activeRoutes, undefined) +}) + +//Forces the page content to be exactly the height of the viewport - header and footer sizes +const bodyStyle = computed(() => ({ 'min-height': `calc(100vh - ${headerHeight.value + footerHeight.value}px)` })) +const generalToastStyle = computed(() => ({ top: `${headerHeight.value + 5}px` })) +const formToastStyle = computed(() => ({ top: `${headerHeight.value}px` })) + +</script> <template> <div id="env-entry" ref="content" class="absolute top-0 left-0 w-full min-h-screen env-bg"> <div class="absolute flex w-full"> @@ -44,43 +83,3 @@ <ConfirmPrompt /> </div> </template> - -<script setup lang="ts"> - -import { computed } from 'vue' -import { RouteLocation, useRouter } from 'vue-router' -import { filter, map, without, find, includes } from 'lodash-es' -import { storeToRefs } from 'pinia' -import { useEnvSize } from '@vnuge/vnlib.browser' -import { useStore } from '../store' -import CookieWarning from './components/CookieWarning.vue' -import PasswordPrompt from './components/PasswordPrompt.vue' -import siteHeader from './components/Header.vue' -import siteFooter from './components/Footer.vue' -import ConfirmPrompt from './components/ConfirmPrompt.vue' - -const emit = defineEmits(['logout']) -const store = useStore() -const { showCookieWarning, currentRoutes } = storeToRefs(store) -const { getRoutes } = useRouter(); - -//Use the env size to calculate the header and footer heights for us -const { header, footer, content, headerHeight, footerHeight } = useEnvSize(true) - -const routes = computed<RouteLocation[]>(() => { - // Get routes that are defined above but only if they are defined in the router - // This is a computed property because loggedin is a reactive property - - const routes = filter(getRoutes(), (pageName) => includes(currentRoutes.value, pageName.name)) - - const activeRoutes = map(currentRoutes.value, route => find(routes, { name: route })) - - return without<RouteLocation>(activeRoutes, undefined) -}) - -//Forces the page content to be exactly the height of the viewport - header and footer sizes -const bodyStyle = computed(() => ({ 'min-height': `calc(100vh - ${headerHeight.value + footerHeight.value}px)` })) -const generalToastStyle = computed(() => ({ top: `${headerHeight.value + 5}px` })) -const formToastStyle = computed(() => ({ top: `${headerHeight.value}px` })) - -</script> diff --git a/front-end/src/bootstrap/components/ConfirmPrompt.vue b/front-end/src/bootstrap/components/ConfirmPrompt.vue index c67bcfc..3994672 100644 --- a/front-end/src/bootstrap/components/ConfirmPrompt.vue +++ b/front-end/src/bootstrap/components/ConfirmPrompt.vue @@ -1,3 +1,37 @@ +<script setup lang="ts"> +import { defaultTo, noop } from 'lodash-es' +import { computed, ref } from 'vue' +import { Dialog, DialogPanel, DialogTitle, DialogDescription } from '@headlessui/vue' +import { onClickOutside } from '@vueuse/core' +import { useConfirm, useEnvSize } from '@vnuge/vnlib.browser' + +export interface ConfirmMessage { + title: string + text: string + subtext?: string +} + +const { headerHeight } = useEnvSize() +//Use component side of confirm +const { isRevealed, confirm, cancel, onReveal } = useConfirm() + +const dialog = ref(null) +const message = ref<ConfirmMessage>() + +//Cancel prompt when user clicks outside of dialog, only when its open +onClickOutside(dialog, () => isRevealed.value ? cancel() : noop()) + +//Set message on reveal +onReveal(m => message.value = defaultTo(m, {})); + +const style = computed(() => { + return { + 'height': `calc(100vh - ${headerHeight.value}px)`, + 'top': `${headerHeight.value}px` + } +}) + +</script> <template> <div id="confirm-prompt"> @@ -5,15 +39,15 @@ <div class="modal-content-container"> <DialogPanel> <DialogTitle class="modal-title"> - {{ message.title ?? 'Confirm' }} + {{ message?.title ?? 'Confirm' }} </DialogTitle> <DialogDescription class="modal-description"> - {{ message.text }} + {{ message?.text }} </DialogDescription> <p class="modal-text-secondary"> - {{ message.subtext }} + {{ message?.subtext }} </p> <div class="modal-button-container"> @@ -29,39 +63,3 @@ </Dialog> </div> </template> - -<script setup lang="ts"> -import { defaultTo } from 'lodash-es' -import { computed, ref } from 'vue' - -import { - Dialog, - DialogPanel, - DialogTitle, - DialogDescription, -} from '@headlessui/vue' - -import { onClickOutside } from '@vueuse/core' -import { useConfirm, useEnvSize } from '@vnuge/vnlib.browser' - -const { headerHeight } = useEnvSize() -//Use component side of confirm -const { isRevealed, confirm, cancel, onReveal } = useConfirm() - -const dialog = ref(null) -const message = ref({}) - -//Cancel prompt when user clicks outside of dialog, only when its open -onClickOutside(dialog, () => isRevealed.value ? cancel() : null) - -//Set message on reveal -onReveal(m => message.value = defaultTo(m, {})); - -const style = computed(() => { - return { - 'height': `calc(100vh - ${headerHeight.value}px)`, - 'top': `${headerHeight.value}px` - } -}) - -</script>
\ No newline at end of file diff --git a/front-end/src/bootstrap/components/CookieWarning.vue b/front-end/src/bootstrap/components/CookieWarning.vue index b5239f5..2651cd1 100644 --- a/front-end/src/bootstrap/components/CookieWarning.vue +++ b/front-end/src/bootstrap/components/CookieWarning.vue @@ -1,15 +1,4 @@ -<template> - <div v-if="show" class="fixed top-0 left-0 z-10 w-full" :style="style"> - <div class="flex w-full p-2 text-center text-white bg-blue-600"> - <div class="m-auto text-sm font-semibold md:text-base"> - You must have cookies enabled for this site to work properly - </div> - </div> - </div> -</template> - <script setup lang="ts"> - import { computed, toRefs } from 'vue' import { useEnvSize } from '@vnuge/vnlib.browser' @@ -18,19 +7,20 @@ const props = defineProps<{ }>() const { hidden } = toRefs(props) - const { headerHeight } = useEnvSize() const show = computed(() => (!window.navigator.cookieEnabled) && !hidden.value) - -const style = computed(() => { - return { - top: headerHeight.value + 'px' - } -}) +const style = computed(() => ({ top: headerHeight.value + 'px' })) </script> +<template> + <div v-if="show" class="fixed top-0 left-0 z-10 w-full" :style="style"> + <div class="flex w-full p-2 text-center text-white bg-blue-600"> + <div class="m-auto text-sm font-semibold md:text-base"> + You must have cookies enabled for this site to work properly + </div> + </div> + </div> +</template> -<style> -</style> diff --git a/front-end/src/bootstrap/components/Footer.vue b/front-end/src/bootstrap/components/Footer.vue index 7c306c9..bdd0c92 100644 --- a/front-end/src/bootstrap/components/Footer.vue +++ b/front-end/src/bootstrap/components/Footer.vue @@ -1,9 +1,19 @@ +<script setup lang="ts"> +import { useDark } from '@vueuse/core' +import { debounce } from 'lodash-es' + +const isDark = useDark() + +const Dark = debounce(() => isDark.value = true, 50) +const Light = debounce(() => isDark.value = false, 50) +</script> + <template> <footer id="vn-footer" class="bottom-0 left-0 z-10 w-full"> <div id="footer-content" class="footer-content" > <div class="footer-main-container"> <div id="footer-text-container" class="col-span-4 sm:col-span-6 lg:col-span-3"> - <p class="my-4 text-sm leading-normal"> + <p class="my-4 text-xs leading-normal"> CMNext ia a AGPL3 licensed free and open source content management system </p> </div> @@ -50,19 +60,9 @@ </p> </div> <div class="mb-6 text-left md:mb-0"> - Copyright © 2023 Vaughn Nugent. All Rights Reserved. + Copyright © 2024 Vaughn Nugent. </div> </div> </div> </footer> </template> - -<script setup lang="ts"> -import { useDark } from '@vueuse/core' -import { debounce } from 'lodash-es' - -const isDark = useDark() - -const Dark = debounce(() => isDark.value = true, 50) -const Light = debounce(() => isDark.value = false, 50) -</script> diff --git a/front-end/src/bootstrap/components/Header.vue b/front-end/src/bootstrap/components/Header.vue index 43a805b..6093fdc 100644 --- a/front-end/src/bootstrap/components/Header.vue +++ b/front-end/src/bootstrap/components/Header.vue @@ -1,78 +1,4 @@ <!-- eslint-disable vue/max-attributes-per-line --> -<template> - <header class="sticky top-0 left-0 z-40"> - <div class="flex header-container"> - <div id="header-mobile-menu" ref="sideMenu" class="side-menu" :style="sideMenuStyle"> - <div class="pt-4 pl-4 pr-6"> - <nav id="header-mobile-nav" class="relative flex flex-col pr-3"> - <div v-for="route in routes" :key="route.path" class="m-auto ml-0"> - <div class="my-1" @click="closeSideMenu"> - <router-link :to="route"> - {{ route.name }} - </router-link> - </div> - </div> - </nav> - </div> - </div> - <div class="flex flex-row w-full md:mx-3"> - <div class="hidden w-4 lg:block" /> - <div class="flex px-4 my-auto text-xl md:hidden"> - <div v-if="!sideMenuActive" class="w-7" @click.prevent="openSideMenu"> - <fa-icon icon="bars" /> - </div> - <div v-else class="text-2xl w-7"> - <fa-icon icon="times" /> - </div> - </div> - <div id="site-title-container" class="flex m-0 mr-3"> - <div class="inline-block px-1"> - <slot name="site_logo" /> - </div> - <div id="site-title" class="inline-block m-auto mx-1"> - <router-link to="/"> - <h3>{{ siteTitle }}</h3> - </router-link> - </div> - </div> - <div class="hidden w-4 lg:block" /> - <nav id="header-desktop-nav" class="flex-row hidden mr-2 md:flex"> - <span v-for="route in routes" :key="route.fullPath" class="flex px-1 lg:px-3"> - <div v-if="!route.hide" class="m-auto"> - <router-link :to="route" class="flex-auto"> - {{ route.name }} - </router-link> - </div> - </span> - </nav> - <div id="user-menu" ref="userMenu" class="drop-controller" :class="{ 'hovered': userMenuHovered }"> - <div class="user-menu"> - Hello <span class="font-semibold">{{ uname }}</span> - </div> - <div ref="userDrop" class="absolute top-0 right-0 duration-100 ease-in-out" style="z-index:-1" :style="dropStyle"> - <div class="drop-menu" @click.prevent="userMenuHovered = false"> - <span class="space-x-2" /> - <a v-if="!loggedIn" href="#" data-header-dropdown="register" @click="gotoRoute('/register')"> - Register - </a> - <a v-else href="#" data-header-dropdown="account" @click="gotoRoute('/account')"> - Account - </a> - <a v-if="!loggedIn" href="#" data-header-dropdown="login" @click="gotoRoute('/login')"> - Login - </a> - <a v-else href="#" data-header-dropdown="logout" @click.prevent="OnLogout"> - Logout - </a> - </div> - </div> - </div> - <div class="hidden space-x-4 lg:block" /> - </div> - </div> - </header> -</template> - <script setup lang="ts"> import { debounce, find } from 'lodash-es' @@ -80,7 +6,7 @@ import { useElementSize, onClickOutside, useElementHover } from '@vueuse/core' import { computed, ref, toRefs } from 'vue' import { useEnvSize } from '@vnuge/vnlib.browser' import { RouteLocation, useRouter } from 'vue-router'; -import { storeToRefs } from 'pinia'; +import { storeToRefs } from 'pinia'; import { useStore } from '../../store'; const emit = defineEmits(['logout']) @@ -92,7 +18,6 @@ const { routes } = toRefs(props) const store = useStore(); const { loggedIn, siteTitle } = storeToRefs(store); - const { headerHeight } = useEnvSize() //Get the router for navigation @@ -132,7 +57,7 @@ const openSideMenu = debounce(() => sideMenuActive.value = true, 50) onClickOutside(sideMenu, closeSideMenu) //Redirect to the route when clicking on it -const gotoRoute = (route : string) =>{ +const gotoRoute = (route: string) => { //Get all routes from the router const allRoutes = router.getRoutes(); @@ -140,19 +65,90 @@ const gotoRoute = (route : string) =>{ //Try to find the route by its path const goto = find(allRoutes, { path: route }); - if(goto){ + if (goto) { //navigate to the route manually router.push(goto); } - else{ + else { //Fallback to full navigation window.location.assign(route); } } -const OnLogout = () =>{ - //Emit logout event - emit('logout') -} +//Emit logout event +const OnLogout = () => emit('logout') -</script>
\ No newline at end of file +</script> +<template> + <header class="sticky top-0 left-0 z-40"> + <div class="flex header-container"> + <div id="header-mobile-menu" ref="sideMenu" class="side-menu" :style="sideMenuStyle"> + <div class="pt-4 pl-4 pr-6"> + <nav id="header-mobile-nav" class="relative flex flex-col pr-3"> + <div v-for="route in routes" :key="route.path" class="m-auto ml-0"> + <div class="my-1" @click="closeSideMenu"> + <router-link :to="route"> + {{ route.name }} + </router-link> + </div> + </div> + </nav> + </div> + </div> + <div class="flex flex-row w-full md:mx-3"> + <div class="hidden w-4 lg:block" /> + <div class="flex px-4 my-auto text-xl md:hidden"> + <div v-if="!sideMenuActive" class="w-7" @click.prevent="openSideMenu"> + <fa-icon icon="bars" /> + </div> + <div v-else class="text-2xl w-7"> + <fa-icon icon="times" /> + </div> + </div> + <div id="site-title-container" class="flex m-0 mr-3"> + <div class="inline-block px-1"> + <slot name="site_logo" /> + </div> + <div id="site-title" class="inline-block m-auto mx-1"> + <router-link to="/"> + <h3>{{ siteTitle }}</h3> + </router-link> + </div> + </div> + <div class="hidden w-4 lg:block" /> + <nav id="header-desktop-nav" class="flex-row hidden mr-2 md:flex"> + <span v-for="route in routes" :key="route.fullPath" class="flex px-1 lg:px-3"> + <div v-if="!route.hide" class="m-auto"> + <router-link :to="route" class="flex-auto"> + {{ route.name }} + </router-link> + </div> + </span> + </nav> + <div id="user-menu" ref="userMenu" class="drop-controller" :class="{ 'hovered': userMenuHovered }"> + <div class="user-menu"> + Hello <span class="font-semibold">{{ uname }}</span> + </div> + <div ref="userDrop" class="absolute top-0 right-0 duration-100 ease-in-out" style="z-index:-1" :style="dropStyle"> + <div class="drop-menu" @click.prevent="userMenuHovered = false"> + <span class="space-x-2" /> + <a v-if="!loggedIn" href="#" data-header-dropdown="register" @click="gotoRoute('/register')"> + Register + </a> + <a v-else href="#" data-header-dropdown="account" @click="gotoRoute('/account')"> + Account + </a> + <a v-if="!loggedIn" href="#" data-header-dropdown="login" @click="gotoRoute('/login')"> + Login + </a> + <a v-else href="#" data-header-dropdown="logout" @click.prevent="OnLogout"> + Logout + </a> + </div> + </div> + </div> + <div class="hidden space-x-4 lg:block" /> + </div> + </div> + </header> +</template> diff --git a/front-end/src/main.ts b/front-end/src/main.ts index 4d62df4..3cc3bfa 100644 --- a/front-end/src/main.ts +++ b/front-end/src/main.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2023 Vaughn Nugent +// Copyright (C) 2024 Vaughn Nugent // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as @@ -35,7 +35,7 @@ import { faGithub, faDiscord, faMarkdown } from '@fortawesome/free-brands-svg-ic library.add(faSignInAlt, faGithub, faDiscord, faSpinner, faCertificate, faKey, faSync, faPlus, faMinusCircle, faUser, faCheck, faTrash, faCopy, faPencil, faLink, faPhotoFilm, faRotateLeft, faMarkdown, faBullhorn, faFolderOpen, faComment, faChevronLeft, faChevronRight, faFileDownload, faCode, faFile, faVideo, faImage, faHeadphones, faFileZipper - ); +); //Add icons to library import router from './router' @@ -47,6 +47,7 @@ import SiteLogo from './components/Site-Logo.vue' import DynamicFormVue from './components/DynamicForm.vue' import { globalStatePlugin } from './store/globalState' +import { oauth2AppsPlugin } from './store/oauthAppsPlugin' import { profilePlugin } from './store/userProfile' import { mfaSettingsPlugin } from './store/mfaSettingsPlugin' import { pageProtectionPlugin } from './store/pageProtectionPlugin' @@ -90,16 +91,18 @@ createVnApp({ app.use(router) store.use(globalStatePlugin) + //Add page protection plugin + .use(pageProtectionPlugin(router)) //User-profile plugin .use(profilePlugin('/account/profile')) - //setup page protection plugin with the router - .use(pageProtectionPlugin(router)) //Enable mfa with totp settings plugin (optional pki config) .use(mfaSettingsPlugin('/account/mfa', '/account/pki')) - //Setup social mfa plugin - .use(socialMfaPlugin()) + //Setup social oauth + .use(socialMfaPlugin("/login/social/portals")) //Setup blog state .use(cmnextAdminPlugin(router, 'https://cdn.ckeditor.com/ckeditor5/40.0.0/super-build/ckeditor.js', 15)) + //Use the oauth2 plugin store (disabled for now) + //.use(oauth2AppsPlugin('/oauth/apps', '/oauth/scopes')) //Add the home-page component router.addRoute({ diff --git a/front-end/src/store/index.ts b/front-end/src/store/index.ts index 1b2d7ee..936dddf 100644 --- a/front-end/src/store/index.ts +++ b/front-end/src/store/index.ts @@ -16,10 +16,12 @@ import { useSession } from "@vnuge/vnlib.browser"; import { set } from "@vueuse/core"; import { defineStore } from "pinia"; -import { computed, shallowRef } from "vue"; +import { computed, shallowRef, type UnwrapNestedRefs } from "vue"; export { SortType, QueryType } from './sharedTypes' +export const storeExport = <T>(val: T): UnwrapNestedRefs<T> => val as UnwrapNestedRefs<T>; + /** * Loads the main store for the application */ diff --git a/front-end/src/store/mfaSettingsPlugin.ts b/front-end/src/store/mfaSettingsPlugin.ts index dffafce..b801f32 100644 --- a/front-end/src/store/mfaSettingsPlugin.ts +++ b/front-end/src/store/mfaSettingsPlugin.ts @@ -1,62 +1,99 @@ import 'pinia' -import { MaybeRef, shallowRef, watch } from 'vue'; -import { MfaMethod, PkiPublicKey, apiCall, useMfaConfig, usePkiConfig, usePkiAuth } from '@vnuge/vnlib.browser'; -import { useToggle, get } from '@vueuse/core'; +import { MaybeRef, ref, shallowRef, watch } from 'vue'; +import { MfaMethod, PkiPublicKey, apiCall, useMfaConfig, usePkiConfig, usePkiAuth, MfaApi } from '@vnuge/vnlib.browser'; +import { useToggle, get, set } from '@vueuse/core'; import { PiniaPluginContext, PiniaPlugin, storeToRefs } from 'pinia' import { includes } from 'lodash-es'; +import { storeExport, } from './index'; + +interface PkiStore { + publicKeys: PkiPublicKey[] + pkiConfig: ReturnType<typeof usePkiConfig> + pkiAuth: ReturnType<typeof usePkiAuth> + refresh: () => void +} + +export interface MfaSettingsStore{ + mfa:{ + enabledMethods: MfaMethod[] + refresh: () => void + } & MfaApi + pki?: PkiStore +} declare module 'pinia' { - export interface PiniaCustomProperties { - mfaEndabledMethods: MfaMethod[] - mfaConfig: ReturnType<typeof useMfaConfig> - pkiConfig: ReturnType<typeof usePkiConfig> - pkiAuth: ReturnType<typeof usePkiAuth> - pkiPublicKeys: PkiPublicKey[] - mfaRefreshMethods: () => void + export interface PiniaCustomProperties extends MfaSettingsStore { + } } export const mfaSettingsPlugin = (mfaEndpoint: MaybeRef<string>, pkiEndpoint?:MaybeRef<string>): PiniaPlugin => { - return ({ store }: PiniaPluginContext) => { + return ({ store }: PiniaPluginContext): MfaSettingsStore => { const { loggedIn } = storeToRefs(store) const mfaConfig = useMfaConfig(mfaEndpoint) - const pkiConfig = usePkiConfig(pkiEndpoint || '/') - const pkiAuth = usePkiAuth(pkiEndpoint || '/') - const [onRefresh, mfaRefreshMethods] = useToggle() + + const [onRefresh, refresh] = useToggle() + + const enabledMethods = ref<MfaMethod[]>([]) + + const usePki = () => { + + const publicKeys = shallowRef<PkiPublicKey[]>([]) + + const pkiConfig = usePkiConfig(pkiEndpoint || '/') + const pkiAuth = usePkiAuth(pkiEndpoint || '/') + + //Watch for changes to mfa methods (refresh) and update the pki keys + watch([enabledMethods], ([methods]) => { + if (!includes(methods, 'pki' as MfaMethod) || !get(pkiEndpoint)) { + set(publicKeys, []) + return + } - const mfaEndabledMethods = shallowRef<MfaMethod[]>([]) - const pkiPublicKeys = shallowRef<PkiPublicKey[]>([]) + //load the pki keys if pki is enabled + apiCall(async () => publicKeys.value = await pkiConfig.getAllKeys()) + }) + + return{ + publicKeys, + pkiConfig, + pkiAuth, + refresh + } + } watch([loggedIn, onRefresh], ([ li ]) => { if(!li){ - mfaEndabledMethods.value = [] + set(enabledMethods, []) return } //load the mfa methods if the user is logged in - apiCall(async () => mfaEndabledMethods.value = await mfaConfig.getMethods()) - }) - - //Watch for changes to mfa methods (refresh) and update the pki keys - watch([mfaEndabledMethods], ([ methods ]) => { - if(!includes(methods, 'pki' as MfaMethod) || !get(pkiEndpoint)){ - pkiPublicKeys.value = [] - return - } - - //load the pki keys if pki is enabled - apiCall(async () => pkiPublicKeys.value = await pkiConfig.getAllKeys()) + apiCall(async () => enabledMethods.value = await mfaConfig.getMethods()) }) - return{ - mfaRefreshMethods, - mfaEndabledMethods, - mfaConfig, - pkiConfig, - pkiAuth, - pkiPublicKeys + //Only return the pki store if pki is enabled + if(get(pkiEndpoint)){ + return storeExport({ + mfa:{ + enabledMethods, + refresh, + ...mfaConfig + }, + pki: usePki() + }) + } + else{ + return storeExport({ + mfa:{ + enabledMethods, + refresh, + ...mfaConfig + }, + }) + } } }
\ No newline at end of file diff --git a/front-end/src/store/oauthAppsPlugin.ts b/front-end/src/store/oauthAppsPlugin.ts new file mode 100644 index 0000000..7a76992 --- /dev/null +++ b/front-end/src/store/oauthAppsPlugin.ts @@ -0,0 +1,154 @@ +import 'pinia' +import { MaybeRef, computed, ref, shallowRef, watch } from 'vue'; +import { apiCall, useAxios } from '@vnuge/vnlib.browser'; +import { get, set, useToggle } from '@vueuse/core'; +import { PiniaPlugin, PiniaPluginContext, storeToRefs } from 'pinia' +import { map, sortBy, isArray } from 'lodash-es'; +import { storeExport } from '.'; + +export interface OAuth2Application { + readonly Id: string, + readonly name: string, + readonly description: string, + readonly permissions: string[], + readonly client_id: string, + Created: Date, + readonly LastModified: Date, +} + +export interface NewAppResponse { + readonly secret: string + readonly app: OAuth2Application +} + +export interface Oauth2Store{ + oauth2: { + apps: OAuth2Application[], + scopes: string[], + getApps(): Promise<OAuth2Application[]> + createApp(app: OAuth2Application): Promise<NewAppResponse> + updateAppSecret(app: OAuth2Application, password: string): Promise<string> + updateAppMeta(app: OAuth2Application): Promise<void> + deleteApp(app: OAuth2Application, password: string): Promise<void> + refresh(): void + } +} + +declare module 'pinia' { + export interface PiniaCustomProperties extends Oauth2Store{ + + } +} + +export const oauth2AppsPlugin = (o2EndpointUrl: MaybeRef<string>, scopeEndpoint: MaybeRef<string>): PiniaPlugin =>{ + + return ({ store }: PiniaPluginContext): Oauth2Store => { + + const axios = useAxios(null); + const { loggedIn } = storeToRefs(store) + + const [onRefresh, refresh] = useToggle() + + const _oauth2Apps = shallowRef<OAuth2Application[]>([]) + const scopes = ref<string[]>([]) + + /** + * Updates an Oauth2 application's metadata + */ + const updateAppMeta = async (app: OAuth2Application): Promise<void> => { + //Update the app metadata + await axios.put(get(o2EndpointUrl), app) + } + + /** + * Gets all of the user's oauth2 applications from the server + * @returns The user's oauth2 applications + */ + const getApps = async () => { + // Get all apps + const { data } = await axios.get<OAuth2Application[]>(get(o2EndpointUrl)); + + if(!isArray(data)){ + throw new Error("Invalid response from server") + } + + return map(data, (appData) => { + //Store the created time as a date object + appData.Created = new Date(appData?.Created ?? 0) + //create a new state manager for the user's profile + return appData; + }) + } + + /** + * Creates a new application from the given data + * @param param0 The application server buffer + * @returns The newly created application + */ + const createApp = async ({ name, description, permissions }: OAuth2Application): Promise<NewAppResponse> => { + + // make the post request, response is the new app data with a secret + const { data } = await axios.post<OAuth2Application & { raw_secret: string }>(`${get(o2EndpointUrl)}?action=create`, { name, description, permissions }) + + // Store secret + const secret = data.raw_secret + + // remove secre tfrom the response + delete (data as any).raw_secret + + return { secret, app: data } + } + + /** + * Requets a new secret for an application from the server + * @param app The app to request a new secret for + * @param password The user's password + * @returns The new secret + */ + const updateAppSecret = async (app: OAuth2Application, password: string): Promise<string> => { + const { data } = await axios.post(`${o2EndpointUrl}?action=secret`, { Id: app.Id, password }) + return data.raw_secret + } + + /** + * Deletes an application from the server + * @param app The application to delete + * @param password The user's password + * @returns The response from the server + */ + const deleteApp = async ({ Id }: OAuth2Application, password: string): Promise<void> => { + await axios.post(`${o2EndpointUrl}?action=delete`, { password, Id }); + } + + const apps = computed(() => sortBy(_oauth2Apps.value, a => a.Created)) + + watch([loggedIn, onRefresh], async ([li]) => { + if (!li){ + set(_oauth2Apps, []) + return; + } + + //Load the user's oauth2 apps + apiCall(async () => { + _oauth2Apps.value = await getApps() + + //Load the oauth2 scopes + const { data } = await axios.get<string[]>(get(scopeEndpoint)) + set(scopes, data) + }) + }) + + return storeExport({ + oauth2:{ + apps, + scopes, + getApps, + createApp, + updateAppMeta, + updateAppSecret, + deleteApp, + refresh + } + }) + } +}
\ No newline at end of file diff --git a/front-end/src/store/pageProtectionPlugin.ts b/front-end/src/store/pageProtectionPlugin.ts index 9831dad..a747e49 100644 --- a/front-end/src/store/pageProtectionPlugin.ts +++ b/front-end/src/store/pageProtectionPlugin.ts @@ -60,14 +60,12 @@ export const pageProtectionPlugin = (router: ReturnType<typeof useRouter>): Pini return true; }) - router.afterEach(() => { - //scroll window back to top - window.scrollTo(0, 0) - }) + //scroll window back to top + router.afterEach(() => window.scrollTo(0, 0)) - watch(loggedIn, (loggedIn) => { + watch(loggedIn, (li) => { //If the user gets logged out, redirect to login - if(loggedIn === false && router.currentRoute.value.name !== 'Login'){ + if(li === false && router.currentRoute.value.name !== 'Login'){ router.push({ name: 'Login' }) } }) diff --git a/front-end/src/store/socialMfaPlugin.ts b/front-end/src/store/socialMfaPlugin.ts index b9bce27..3968cf1 100644 --- a/front-end/src/store/socialMfaPlugin.ts +++ b/front-end/src/store/socialMfaPlugin.ts @@ -1,3 +1,4 @@ + import 'pinia' import { MaybeRef } from 'vue'; import { useSocialOauthLogin, useUser, SocialOAuthPortal, fromPortals, useAxios } from '@vnuge/vnlib.browser' @@ -34,30 +35,42 @@ export const socialMfaPlugin = (portalEndpoint?: MaybeRef<string>): PiniaPlugin } } - const _loadPromise = new Promise<SocialMfaPlugin>((resolve, reject) => { + const _loadPromise = new Promise<SocialMfaPlugin>((resolve, _) => { - if(get(portalEndpoint) == null) { + if (get(portalEndpoint) == null) { const socialOauth = useSocialOauthLogin([]) setLogoutMethod(socialOauth) return resolve(socialOauth) } + /* + Try to load social methods from server, if it fails, then we will + fall back to default + */ + defer(async () => { + + let portals: SocialOAuthPortal[] = [] + try { //Get axios instance const axios = useAxios(null) //Get all enabled portals - const { data } = await axios.get<SocialOAuthPortal[]>(get(portalEndpoint)); - //Setup social providers from server portals - const socialOauth = useSocialOauthLogin(fromPortals(data)); - setLogoutMethod(socialOauth); - - resolve(socialOauth) + const { data, headers } = await axios.get<SocialOAuthPortal[]>(get(portalEndpoint)!); + + if(headers['content-type'] === 'application/json') { + portals = data + } } catch (error) { - reject(error) + //Let failure fall back to default } + + //Create social login from available portals + const socialOauth = useSocialOauthLogin(fromPortals(portals)); + setLogoutMethod(socialOauth); + resolve(socialOauth) }) }) diff --git a/front-end/src/store/userProfile.ts b/front-end/src/store/userProfile.ts index a4ea469..0320ace 100644 --- a/front-end/src/store/userProfile.ts +++ b/front-end/src/store/userProfile.ts @@ -3,7 +3,8 @@ import { MaybeRef, watch } from 'vue'; import { ServerDataBuffer, ServerObjectBuffer, UserProfile, WebMessage, apiCall, useAxios, useDataBuffer, useUser } from '@vnuge/vnlib.browser'; import { get, useToggle } from '@vueuse/core'; import { PiniaPlugin, PiniaPluginContext, storeToRefs } from 'pinia' -import { defer } from 'lodash-es'; +import { defer, noop } from 'lodash-es'; +import { storeExport } from './index'; export interface OAuth2Application { readonly Id: string, @@ -24,17 +25,21 @@ interface ExUserProfile extends UserProfile { created: string | Date } +export interface UserProfileStore{ + userProfile: ServerDataBuffer<ExUserProfile, WebMessage<string>> + userName: string | undefined + refreshProfile(): void; +} + declare module 'pinia' { - export interface PiniaCustomProperties { - userProfile: ServerDataBuffer<ExUserProfile, WebMessage<string>> - userName: string | undefined - refreshProfile(): void; + export interface PiniaCustomProperties extends UserProfileStore { + } } export const profilePlugin = (accountsUrl:MaybeRef<string>) :PiniaPlugin => { - return ({ store }: PiniaPluginContext) => { + return ({ store }: PiniaPluginContext): UserProfileStore => { const { loggedIn } = storeToRefs(store) const { getProfile, userName } = useUser() @@ -64,19 +69,16 @@ export const profilePlugin = (accountsUrl:MaybeRef<string>) :PiniaPlugin => { userProfile.apply(profile) } - watch([loggedIn, onRefresh], ([li]) => { - //If the user is logged in, load the profile buffer - if (li) { - apiCall(loadProfile) - } - }) + //If the user is logged in, load the profile buffer + watch([loggedIn, onRefresh], ([li]) => li ? apiCall(loadProfile) : noop()) + //Defer intiial profile load defer(refreshProfile); - return { + return storeExport({ userProfile, refreshProfile, userName - } + }) } }
\ No newline at end of file diff --git a/front-end/src/views/Account/[comp].vue b/front-end/src/views/Account/[comp].vue index d4f1c4d..713a6fe 100644 --- a/front-end/src/views/Account/[comp].vue +++ b/front-end/src/views/Account/[comp].vue @@ -1,3 +1,51 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { get, set } from '@vueuse/core' +import { useRouteParams } from '@vueuse/router' +import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue' +import { useStore } from '../../store' +import Settings from './components/settings/Settings.vue' +import Profile from './components/profile/Profile.vue' +import OauthApps from './components/oauth/Oauth.vue' + +const store = useStore() +store.setPageTitle('Account') + +const oauthEnabled = computed(() => store.oauth2?.apps); + +type ComponentType = 'profile' | 'oauth' | 'settings' | '' + +const comp = useRouteParams<ComponentType>('comp', '') + +const tabId = computed<number>(() => { + switch (comp.value) { + case 'oauth': + //If oauth is not enabled, redirect to profile + return get(oauthEnabled) ? 2 : 0 + case 'settings': + return 1 + case 'profile': + default: + return 0 + } +}) + +const onTabChange = (tabid: number) => { + switch (tabid) { + case 1: + set(comp, 'settings') + break + case 2: + set(comp, 'oauth') + break + case 0: + default: + set(comp, 'profile') + break + } +} + +</script> <template> <div id="account-template" class="app-component-entry"> <TabGroup :selectedIndex="tabId" @change="onTabChange" as="div" class="container h-full m-auto mt-0 mb-10 duration-150 ease-linear text-color-foreground"> @@ -18,6 +66,12 @@ </span> </tab> + <Tab v-if="oauthEnabled" v-slot="{ selected }" > + <span class="page-link" :class="{ 'active': selected }"> + OAuth + </span> + </tab> + </TabList> </div> @@ -31,54 +85,16 @@ <Settings /> </TabPanel> + <TabPanel v-if="oauthEnabled" :unmount="false"> + <OauthApps /> + </TabPanel> + </TabPanels> </TabGroup> </div> </template> -<script setup lang="ts"> -import { computed } from 'vue' -import { useRouteParams } from '@vueuse/router' -import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue' -import { useStore } from '../../store' -import Settings from './components/settings/Settings.vue' -import Profile from './components/profile/Profile.vue' - -const { setPageTitle } = useStore() -setPageTitle('Account') - -enum ComponentType{ - Profile = 'profile', - Settings = 'settings' -} - -const comp = useRouteParams<ComponentType>('comp', '') - -const tabId = computed<number>(() => { - switch (comp.value) { - case ComponentType.Settings: - return 1 - case ComponentType.Profile: - default: - return 0 - } -}) - -const onTabChange = (tabid : number) =>{ - switch (tabid) { - case 1: - comp.value = ComponentType.Settings - break - case 0: - default: - comp.value = ComponentType.Profile - break - } -} - -</script> - <style lang="scss"> #account-template{ diff --git a/front-end/src/views/Account/components/oauth/CreateApp.vue b/front-end/src/views/Account/components/oauth/CreateApp.vue new file mode 100644 index 0000000..038c43f --- /dev/null +++ b/front-end/src/views/Account/components/oauth/CreateApp.vue @@ -0,0 +1,182 @@ +<script setup lang="ts"> +import { indexOf, pull } from 'lodash-es' +import { Ref, ref, toRefs } from 'vue'; +import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue' +import { set, useClipboard } from '@vueuse/core' +import { apiCall } from '@vnuge/vnlib.browser' +import { getAppValidator } from './o2AppValidation' +import { useStore } from '../../../../store'; +import { OAuth2Application } from '../../../../store/oauthAppsPlugin'; + +const emit = defineEmits(['close']) +const props = defineProps<{ + isOpen: boolean +}>() + +const { isOpen } = toRefs(props); + +//Init the oauth2 app api +const store = useStore() + +const { copied, copy } = useClipboard(); + +const newAppBuffer = ref<Partial<OAuth2Application& { secret: string }>>(); +const newAppPermissions = ref<string[]>([]); + +const { v$, validate, reset } = getAppValidator(newAppBuffer as Ref<OAuth2Application>); + +const close = () => { + set(newAppBuffer, {}); + reset() + emit('close') +} + +const onFormSubmit = async () => { + + // Validate the new app form + if (!await validate()) { + return + } + + // Create the new app + await apiCall(async () => { + + const { secret } = await store.oauth2.createApp(newAppBuffer.value as OAuth2Application) + + // Reset the new app buffer and pass the secret value + set(newAppBuffer, { secret }) + }) + + // reset the validator + v$.value.$reset() +} + +const permissionChanged = (e: any) => { + if (e.target.checked) { + // Make sure the permission is not already in the list + if (indexOf(newAppPermissions.value, e.target.name) > -1) { + return + } + // Add the permission to the list + newAppPermissions.value.push(e.target.name as string) + } else { + // Remove the permission from the list + pull(newAppPermissions.value, e.target.name) + } + // Update the permissions model + v$.value.permissions.$model = newAppPermissions.value.join(',') +} + +</script> +<template> + <Dialog :open="isOpen" @close="close" class="relative z-10"> + <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> + <div class="fixed inset-0 flex justify-center top-20"> + <DialogPanel class="new-o2-app-dialog"> + <DialogTitle>Create app</DialogTitle> + <div class="flex"> + <div class="m-auto mb-3 text-sm"> + <p class="my-1"> + Step 1: Enter a name for your app. + </p> + <p class="my-1"> + Step 2: Submit the form. + </p> + <p class="my-1 text-red-500"> + Step 3: Save your Client ID and Secret somewhere safe. + </p> + </div> + </div> + <!-- If secret is set, show the scret window --> + <div v-if="newAppBuffer?.secret" class="mt-2"> + <div class="block mx-1 sm:inline"> + Secret: + </div> + <div class="px-1 py-4 my-2 break-all border-2 border-gray-300 rounded-lg"> + <div class="text-center secret"> + <span class="block mx-1 sm:inline"> + {{ newAppBuffer.secret }} + </span> + </div> + </div> + <div class="text-sm"> + <p class="p-2"> + This secret will only be displayed <strong>once</strong>, and you cannot request it again. + If you lose it, you will need to update the secret from the app edit pane. + </p> + <p class="p-2"> + Please remember to keep this secret somewhere safe. If an attacker gains + access to it, they will be able to access any APIs on your behalf! + </p> + </div> + <div class="flex justify-end"> + <button v-if="!copied" class="btn primary" @click="copy(newAppBuffer.secret)"> + Copy + </button> + <button v-else class="btn primary" @click="close"> + Done + </button> + </div> + </div> + <div v-else> + <form id="o2-app-creation" class="" @submit.prevent="onFormSubmit"> + <fieldset class="flex flex-col gap-4"> + <div class="input-container"> + <label>App Name</label> + <input + class="w-full mt-1 input primary" + :class="{'invalid':v$.name.$invalid, 'dirty': v$.name.$dirty}" + name="name" + type="text" + v-model="v$.name.$model" + /> + </div> + <div class="input-container"> + <label>Description</label> + <textarea + class="w-full mt-1 input primary" + :class="{ 'invalid': v$.description.$invalid, 'dirty': v$.name.$dirty }" + name="description" + v-model="v$.description.$model" + rows="3" + /> + </div> + <div class="input-container"> + <label>Permissions</label> + <ul class="text-sm"> + <li v-for="scope in store.oauth2.scopes" :key="scope" class="my-1.5"> + <label class="flex cursor-pointer"> + <input class="w-3.5 cursor-pointer" type="checkbox" :name="`02scope-${scope}`" @change="permissionChanged"> + <span class="my-auto ml-1.5">{{ scope }}</span> + </label> + </li> + </ul> + </div> + </fieldset> + <div class="flex justify-end mt-4"> + <div class="button-group"> + <button type="submit" form="o2-app-creation" class="btn primary">Submit</button> + <button class="btn" @click.prevent="close">Cancel</button> + </div> + </div> + </form> + </div> + </DialogPanel> + </div> + </Dialog> +</template> +<style lang="scss"> + +.new-o2-app-dialog{ + @apply w-full max-w-lg p-8 pt-4 m-auto mt-0 shadow-md sm:rounded border dark:border-dark-500; + @apply bg-white dark:bg-dark-700 dark:text-gray-200; + + #o2-app-creation{ + input.dirty.invalid, + textarea.dirty.invalid{ + @apply border-red-500 focus:border-red-500; + } + } +} + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Account/components/oauth/Oauth.vue b/front-end/src/views/Account/components/oauth/Oauth.vue new file mode 100644 index 0000000..d269689 --- /dev/null +++ b/front-end/src/views/Account/components/oauth/Oauth.vue @@ -0,0 +1,78 @@ +<script setup lang="ts"> +import { defineAsyncComponent } from 'vue' +import { storeToRefs } from 'pinia' +import { useStore } from '../../../../store' +const CreateApp = defineAsyncComponent(() => import('./CreateApp.vue')) + +import SingleApplication from './SingleApplication.vue' +import { useToggle } from '@vueuse/core'; + +const store = useStore() +const { isLocalAccount } = storeToRefs(store) + +const [editNew, toggleEdit] = useToggle() + +const newAppClose = () => { + toggleEdit(false); + //Reload apps on close + store.oauth2.refresh(); +} + +//Load apps +store.oauth2.refresh(); + +</script> +<template> + <div id="oauth-apps" class="acnt-content-container"> + <div class="app-container panel-container"> + <div class="mb-6 panel-header"> + <div class="flex ml-0 mr-auto"> + <div class="my-auto panel-title"> + <h4>Your applications</h4> + </div> + </div> + <div class="ml-auto mr-0"> + <div class="button-container"> + <button class="btn primary sm" :disabled="!isLocalAccount" @click="toggleEdit(true)"> + Create App + </button> + </div> + </div> + </div> + <div v-if="store.oauth2.apps.length == 0" class="no-apps-container"> + <div class="m-auto"> + You dont have any OAuth2 client applications yet. + </div> + </div> + <div v-else> + <div v-for="app in store.oauth2.apps" :key="app.Id" class="panel-content"> + <SingleApplication :application="app" :allow-edit="isLocalAccount" /> + </div> + </div> + </div> + <div class="px-2 my-10"> + <div class="m-auto text-sm"> + OAuth2 applications allow you grant api access to OAuth2 clients using the Client Credentials grant type. + <a class="link" href="https://oauth.net" target="_blank"> + Learn more + </a> + </div> + <div v-show="!isLocalAccount" class="mt-3 text-center text-red-500"> + You may not create or edit applications if you are using external authentication. + </div> + </div> + <CreateApp :is-open="editNew" @close="newAppClose" /> + </div> +</template> + +<style> + +#oauth-apps { + @apply m-auto max-w-3xl; +} + +#oauth-apps .app-container .no-apps-container { + @apply w-full flex h-36 sm:border sm:rounded-md mt-4 mb-20 dark:border-dark-500 border-gray-300; +} + +</style> diff --git a/front-end/src/views/Account/components/oauth/SingleApplication.vue b/front-end/src/views/Account/components/oauth/SingleApplication.vue new file mode 100644 index 0000000..60bad68 --- /dev/null +++ b/front-end/src/views/Account/components/oauth/SingleApplication.vue @@ -0,0 +1,198 @@ +<script setup lang="ts"> +import { toUpper } from 'lodash-es' +import { apiCall, useWait, useConfirm, usePassConfirm, useDataBuffer } from '@vnuge/vnlib.browser' +import { ref, computed, toRefs, watch } from 'vue' +import { get, set, useClipboard, useTimeAgo, useToggle } from '@vueuse/core' +import { getAppValidator } from './o2AppValidation' +import { OAuth2Application } from '../../../../store/oauthAppsPlugin' +import { useStore } from '../../../../store' + +const props = defineProps<{ + application: OAuth2Application + allowEdit: boolean +}>() + +const store = useStore() +const { application, allowEdit } = toRefs(props) + +//Init data buffer around application +const { data, revert, modified, buffer, update, apply } = useDataBuffer(get(application), + async (adb) => { + await store.oauth2.updateAppMeta(adb.buffer); + store.oauth2.refresh(); + }) + +//Watch for store app changes and apply them to the buffer +watch(application, apply) + +const { waiting } = useWait() +const { reveal } = useConfirm() +const { elevatedApiCall } = usePassConfirm() +const { copied, copy } = useClipboard() + +const { v$, validate, reset } = getAppValidator(buffer) + +const [showEdit, toggleEdit] = useToggle() +const newSecret = ref<string | null>(null); + +const name = computed(() => data.name) +const clientId = computed(() => toUpper(data.client_id)) +const createdTime = useTimeAgo(data.Created); +const formId = computed(() => `app-form-${data.client_id}`) + +const onCancel = function () { + revert() + reset() + toggleEdit(false) +} + +const onSubmit = async function () { + // Validate the new app form + if (!await validate()) { + return + } + // Create the new app + await apiCall(async ({ toaster }) => { + // Update does not return anything, if successful + await update() + + toaster.general.success({ + text: 'Application successfully updated', + title: 'Success' + }) + reset() + toggleEdit(false) + }) +} + +const updateSecret = async function () { + // Show a confrimation prompt + const { isCanceled } = await reveal({ + title: 'Update Secret?', + text: `Are you sure you want to update the secret? Any active sessions will be invalidated, and the old secret will be invalidated.` + }) + if (isCanceled) { + return + } + await elevatedApiCall(async ({ password }) => { + // Submit the secret update with the new challenge + newSecret.value = await store.oauth2.updateAppSecret(data, password) + store.oauth2.refresh() + }) +} + +const onDelete = async function () { + // Show a confirmation prompt + const { isCanceled } = await reveal({ + title: 'Delete?', + text: 'You are about to permanently delete this application. This will invalidate any active sessions.', + subtext: '' + }) + if (isCanceled) { + return + } + await elevatedApiCall(async ({ password, toaster }) => { + await store.oauth2.deleteApp(data, password) + toaster.general.success({ + text: 'Application deleted successfully', + title: 'Success' + }) + store.oauth2.refresh() + }) +} + +const closeNewSecret = () => set(newSecret, null); + +</script> + +<template> + <div :id="data.Id"> + <div class="flex flex-row"> + <div class="flex ml-0 mr-auto"> + <div class="flex w-8 h-8 rounded-full bg-primary-500"> + <div class="m-auto text-white dark:text-dark-500"> + <fa-icon icon="key"></fa-icon> + </div> + </div> + <div class="inline my-auto ml-2"> + <h5 class="m-0">{{ name }}</h5> + </div> + </div> + <div v-if="allowEdit && showEdit" class="button-group"> + <button class="btn primary xs" :disabled="!modified" @click="onSubmit">Update</button> + <button class="btn xs" @click="onCancel">Cancel</button> + </div> + <div v-else class=""> + <button class="btn no-border xs" @click="toggleEdit(true)">Edit</button> + </div> + </div> + <div class="px-3 py-1 text-color-background"> + <div class="my-1"> + <span> Client ID: </span> + <span class="font-mono text-color-foreground">{{ clientId }}</span> + </div> + <div class="text-sm"> + <span> Created: </span> + <span>{{ createdTime }}</span> + </div> + <div v-if="!showEdit" class="text-sm"> + <span>{{ data.description }}</span> + </div> + </div> + <div v-if="newSecret" class="flex"> + <div class="py-4 mx-auto"> + <div class="pl-1 mb-2"> + New secret + </div> + <div class="p-4 text-sm break-all border-2 rounded dark:border-dark-500 dark:bg-dark-700"> + {{ newSecret }} + </div> + <div class="flex justify-end my-3"> + <button v-if="!copied" class="rounded btn" @click="copy(newSecret)"> + Copy + </button> + <button v-else class="rounded btn" @click="closeNewSecret"> + Done + </button> + </div> + </div> + </div> + <div v-else-if="showEdit" class="app-form-container"> + <div class="py-4"> + <form :id="formId" class="max-w-md mx-auto"> + <fieldset :disabled="waiting" class=""> + <div class="input-container"> + <div class="pl-1 mb-1"> + App name + </div> + <input class="w-full input primary" :class="{ 'invalid': v$.name.$invalid }" v-model="v$.name.$model" type="text" name="name" /> + </div> + <div class="mt-3 input-container"> + <div class="pl-1 mb-1"> + App description + <span class="text-sm">(optional)</span> + </div> + <textarea class="w-full input primary" :class="{ 'invalid': v$.description.$invalid }" v-model="v$.description.$model" name="description" rows="3" /> + </div> + </fieldset> + </form> + </div> + <div class="mt-3"> + + <div class="mx-auto w-fit"> + <div class="button-group"> + <button class="btn xs" @click.prevent="updateSecret"> + <fa-icon icon="sync" /> + <span class="pl-2">New Secret</span> + </button> + <button class="btn red xs" @click.prevent="onDelete"> + <fa-icon icon="minus-circle" /> + <span class="pl-2">Delete</span> + </button> + </div> + </div> + + </div> + </div> + </div> +</template> diff --git a/front-end/src/views/Account/components/oauth/o2AppValidation.ts b/front-end/src/views/Account/components/oauth/o2AppValidation.ts new file mode 100644 index 0000000..0596f1e --- /dev/null +++ b/front-end/src/views/Account/components/oauth/o2AppValidation.ts @@ -0,0 +1,43 @@ +import { MaybeRef } from 'vue' +import { useVuelidate } from '@vuelidate/core' +import { maxLength, helpers, required } from '@vuelidate/validators' +import { useVuelidateWrapper } from '@vnuge/vnlib.browser' +import { OAuth2Application } from '../../../../store/oauthAppsPlugin' + +//Custom alpha numeric regex +const alphaNumRegex = helpers.regex(/^[a-zA-Z0-9_,\s]*$/) + +const rules = { + name: { + alphaNumSpace: helpers.withMessage("Name contains invalid characters", alphaNumRegex), + maxLength: helpers.withMessage('App name must be less than 50 characters', maxLength(50)), + required: helpers.withMessage('Oauth Application name is required', required) + }, + description: { + alphaNumSpace: helpers.withMessage("Description contains invalid characters", alphaNumRegex), + maxLength: helpers.withMessage('Description must be less than 50 characters', maxLength(50)) + }, + permissions: { + alphaNumSpace: helpers.regex(/^[a-zA-Z0-9_,:\s]*$/), + maxLength: helpers.withMessage('Permissions must be less than 64 characters', maxLength(64)) + } +} + +export interface AppValidator { + readonly v$: ReturnType<typeof useVuelidate> + readonly validate: () => Promise<boolean> + readonly reset: () => void +} + +/** + * Gets the validator for the given application (or new appication) buffer + * @param buffer The app buffer to validate + * @returns The validator instance, validate function, and reset function + */ +export const getAppValidator = (buffer: MaybeRef<OAuth2Application>) : AppValidator => { + //App validator + const v$ = useVuelidate(rules, buffer) + //validate wrapper function + const { validate } = useVuelidateWrapper(v$); + return { v$, validate, reset: v$.value.$reset }; +}
\ No newline at end of file diff --git a/front-end/src/views/Account/components/profile/Profile.vue b/front-end/src/views/Account/components/profile/Profile.vue index 0db7192..106c8b9 100644 --- a/front-end/src/views/Account/components/profile/Profile.vue +++ b/front-end/src/views/Account/components/profile/Profile.vue @@ -1,3 +1,62 @@ + +<script setup lang="ts"> +import { defaultTo } from 'lodash-es' +import { useVuelidate } from '@vuelidate/core' +import { ref, computed, watch, type Ref } from 'vue' +import { Rules, FormSchema } from './profile-schema.ts' +import { apiCall, useMessage, useWait, useVuelidateWrapper, type VuelidateInstance } from '@vnuge/vnlib.browser' +import { useStore } from '../../../../store' + +const { waiting } = useWait() +const { onInput, clearMessage } = useMessage() + +const store = useStore() +const editMode = ref(false) + +// Create validator based on the profile buffer as a data model +const v$ = useVuelidate(Rules, store.userProfile.buffer as any, { $lazy: true }) + +// Setup the validator wrapper +const { validate } = useVuelidateWrapper(v$ as Ref<VuelidateInstance>); + +//const modified = computed(() => profile.value.Modified) +const createdTime = computed(() => defaultTo(store.userProfile.data.created?.toLocaleString(), '')) + +const revertProfile = () => { + //Revert the buffer + store.userProfile.revert() + clearMessage() + editMode.value = false +} + +const onSubmit = async () => { + if (waiting.value) { + return; + } + // Validate the form + if (!await validate()) { + return + } + // Init the api call + await apiCall(async ({ toaster }) => { + const res = await store.userProfile.update(); + + const successm = res.getResultOrThrow(); + + //No longer in edit mode + editMode.value = false + + //Show success message + toaster.general.success({ + title: 'Update successful', + text: successm, + }) + }) +} + +watch(editMode, () => v$.value.$reset()) + +</script> <template> <div id="account-profile" class="acnt-content-container panel-container"> <div class="acnt-content profile-container panel-content"> @@ -57,66 +116,6 @@ </div> </template> -<script setup lang="ts"> -import { defaultTo } from 'lodash-es' -import useVuelidate from '@vuelidate/core' -import { ref, computed, watch } from 'vue' -import { Rules, FormSchema } from './profile-schema.ts' -import { apiCall, useMessage, useWait, useVuelidateWrapper, WebMessage } from '@vnuge/vnlib.browser' -import { useStore } from '../../../../store' - -const { waiting } = useWait() -const { onInput, clearMessage } = useMessage() - -const store = useStore() - -const editMode = ref(false) - -// Create validator based on the profile buffer as a data model -const v$ = useVuelidate(Rules, store.userProfile.buffer, { $lazy:true }) - -// Setup the validator wrapper -const { validate } = useVuelidateWrapper(v$); - -//const modified = computed(() => profile.value.Modified) -const createdTime = computed(() => defaultTo(store.userProfile.data.created?.toLocaleString(), '')) - -const revertProfile = () => { - //Revert the buffer - store.userProfile.revert() - clearMessage() - editMode.value = false -} - -const onSubmit = async () => { - if(waiting.value){ - return; - } - // Validate the form - if (!await validate()) { - return - } - // Init the api call - await apiCall(async ({ toaster }) => { - const res = await store.userProfile.update(); - - const successm = (res as WebMessage<string>).getResultOrThrow(); - - //No longer in edit mode - editMode.value = false - - //Show success message - toaster.general.success({ - title: 'Update successful', - text: successm, - }) - }) -} - -watch(editMode, () => v$.value.$reset()) - - -</script> <style lang="scss"> diff --git a/front-end/src/views/Account/components/settings/Fido.vue b/front-end/src/views/Account/components/settings/Fido.vue index d453378..9303541 100644 --- a/front-end/src/views/Account/components/settings/Fido.vue +++ b/front-end/src/views/Account/components/settings/Fido.vue @@ -1,3 +1,19 @@ +<script setup lang="ts"> +import { useSession } from '@vnuge/vnlib.browser' +import { toRefs } from 'vue'; + +const props = defineProps<{ + fidoEnabled?: boolean +}>() + +const { fidoEnabled } = toRefs(props) +const { isLocalAccount } = useSession() + +const Disable = () => { } +const Setup = () => { } + +</script> + <template> <div id="account-fido-settings"> <div v-if="!isLocalAccount" class="flex flex-row justify-between"> @@ -30,24 +46,3 @@ </div> </div> </template> - -<script setup lang="ts"> -import { useSession } from '@vnuge/vnlib.browser' -import { toRefs } from 'vue'; - -const props = defineProps<{ - fidoEnabled?: boolean -}>() - -const { fidoEnabled } = toRefs(props) - -const { isLocalAccount } = useSession() - -const Disable = () => {} -const Setup = () => {} - -</script> - -<style> - -</style> diff --git a/front-end/src/views/Account/components/settings/PasswordReset.vue b/front-end/src/views/Account/components/settings/PasswordReset.vue index 24dced6..61fda7d 100644 --- a/front-end/src/views/Account/components/settings/PasswordReset.vue +++ b/front-end/src/views/Account/components/settings/PasswordReset.vue @@ -1,68 +1,9 @@ -<template> - <div id="pwreset-settings" class="container"> - <div class="panel-content"> - - <div v-if="!pwResetShow" class=""> - <div class="flex flex-wrap items-center justify-between"> - - <div class=""> - <h5>Password Reset</h5> - </div> - - <div class="flex justify-end"> - <button class="btn xs" @click="showForm"> - <fa-icon icon="sync" /> - <span class="pl-2">Reset Password</span> - </button> - </div> - </div> - - <p class="mt-3 text-sm text-color-background"> - You may only reset your password if you have an internal user account. If you exclusivly use an external - authentication provider (like GitHub or Discord), you will need to reset your password externally. - </p> - </div> - - <div v-else class="px-2 my-2"> - - <p class="my-3 text-center"> - Enter your current password, new password, and confirm the new password. - </p> - - <dynamic-form - id="password-reset-form" - class="pwreset-form primary" - :form="formSchema" - :disabled="waiting" - :validator="v$" - @submit="onSubmit" - @input="onInput" - /> - - <div class="flex flex-row justify-end my-2"> - <div class="button-group"> - <button type="submit" form="password-reset-form" class="btn primary sm" :disabled="waiting"> - <fa-icon v-if="!waiting" icon="check" /> - <fa-icon v-else class="animate-spin" icon="spinner" /> - Update - </button> - <button class="btn sm cancel-btn" :disabled="waiting" @click="resetForm"> - Cancel - </button> - </div> - </div> - - </div> - </div> - </div> -</template> - <script setup lang="ts"> import { isEmpty, toSafeInteger } from 'lodash-es'; import { useVuelidate } from '@vuelidate/core' import { required, maxLength, minLength, helpers } from '@vuelidate/validators' -import { useUser, apiCall, useMessage, useWait, useConfirm, useVuelidateWrapper } from '@vnuge/vnlib.browser' -import { computed, reactive, ref, toRefs, watch } from 'vue' +import { useUser, apiCall, useMessage, useWait, useConfirm, useVuelidateWrapper, VuelidateInstance } from '@vnuge/vnlib.browser' +import { MaybeRef, computed, reactive, ref, toRefs, watch } from 'vue' const props = defineProps<{ totpEnabled: boolean, @@ -136,7 +77,7 @@ const rules = computed(() =>{ }) const v$ = useVuelidate(rules, vState, { $lazy: true }) -const { validate } = useVuelidateWrapper(v$) +const { validate } = useVuelidateWrapper(v$ as MaybeRef<VuelidateInstance>) const showTotpCode = computed(() => totpEnabled.value && !fidoEnabled.value) @@ -208,6 +149,58 @@ const resetForm = () => { </script> +<template> + <div id="pwreset-settings" class="container"> + <div class="panel-content"> + + <div v-if="!pwResetShow" class=""> + <div class="flex flex-wrap items-center justify-between"> + + <div class=""> + <h5>Password Reset</h5> + </div> + + <div class="flex justify-end"> + <button class="btn xs" @click="showForm"> + <fa-icon icon="sync" /> + <span class="pl-2">Reset Password</span> + </button> + </div> + </div> + + <p class="mt-3 text-sm text-color-background"> + You may only reset your password if you have an internal user account. If you exclusivly use an external + authentication provider (like GitHub or Discord), you will need to reset your password externally. + </p> + </div> + + <div v-else class="px-2 my-2"> + + <p class="my-3 text-center"> + Enter your current password, new password, and confirm the new password. + </p> + + <dynamic-form id="password-reset-form" class="pwreset-form primary" :form="formSchema" :disabled="waiting" + :validator="v$" @submit="onSubmit" @input="onInput" /> + + <div class="flex flex-row justify-end my-2"> + <div class="button-group"> + <button type="submit" form="password-reset-form" class="btn primary sm" :disabled="waiting"> + <fa-icon v-if="!waiting" icon="check" /> + <fa-icon v-else class="animate-spin" icon="spinner" /> + Update + </button> + <button class="btn sm cancel-btn" :disabled="waiting" @click="resetForm"> + Cancel + </button> + </div> + </div> + + </div> + </div> + </div> +</template> + <style lang="scss"> #password-reset-form{ diff --git a/front-end/src/views/Account/components/settings/Pki.vue b/front-end/src/views/Account/components/settings/Pki.vue index afe606f..957a188 100644 --- a/front-end/src/views/Account/components/settings/Pki.vue +++ b/front-end/src/views/Account/components/settings/Pki.vue @@ -1,128 +1,35 @@ -<template> - <div id="pki-settings" class="container"> - <div class="panel-content"> - - <div class="flex flex-row flex-wrap justify-between"> - <h5>PKI Authentication</h5> - <div class=""> - <div v-if="pkiEnabled" class="button-group"> - <button class="btn xs" @click.prevent="setIsOpen(true)"> - <fa-icon icon="plus" /> - <span class="pl-2">Add Key</span> - </button> - <button class="btn red xs" @click.prevent="onDisable"> - <fa-icon icon="minus-circle" /> - <span class="pl-2">Disable</span> - </button> - </div> - <div v-else class=""> - <button class="btn primary xs" @click.prevent="setIsOpen(true)"> - <fa-icon icon="plus" /> - <span class="pl-2">Add Key</span> - </button> - </div> - </div> - - <div v-if="store.pkiPublicKeys && store.pkiPublicKeys.length > 0" class="w-full mt-4"> - <table class="min-w-full text-sm divide-y-2 divide-gray-200 dark:divide-dark-500"> - <thead class="text-left"> - <tr> - <th class="p-2 font-medium whitespace-nowrap dark:text-white" > - KeyID - </th> - <th class="p-2 font-medium whitespace-nowrap dark:text-white"> - Algorithm - </th> - <th class="p-2 font-medium whitespace-nowrap dark:text-white"> - Curve - </th> - <th class="p-2"></th> - </tr> - </thead> - - <tbody class="divide-y divide-gray-200 dark:divide-dark-500"> - <tr v-for="key in store.pkiPublicKeys"> - <td class="p-2 t font-medium truncate max-w-[8rem] whitespace-nowrap dark:text-white"> - {{ key.kid }} - </td> - <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> - {{ key.alg }} - </td> - <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> - {{ key.crv }} - </td> - <td class="p-2 text-right whitespace-nowrap"> - <button class="rounded btn red xs borderless" @click="onRemoveKey(key)"> - <span class="hidden sm:inline">Remove</span> - <fa-icon icon="trash-can" class="inline sm:hidden" /> - </button> - </td> - </tr> - </tbody> - </table> - </div> - - <p v-else class="p-1 pt-3 text-sm text-color-background"> - PKI authentication is a method of authenticating your user account with signed messages and a shared public key. This method implementation - uses client signed Json Web Tokens to authenticate user generated outside this website as a One Time Password (OTP). This allows for you to - use your favorite hardware or software tools, to generate said OTPs to authenticate your user. - </p> - </div> - </div> - </div> - <Dialog :open="isOpen" @close="setIsOpen" class="relative z-30"> - <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> - - <div class="fixed inset-0 flex justify-center"> - <DialogPanel class="w-full max-w-lg p-4 m-auto mt-24 bg-white rounded dark:bg-dark-700 dark:text-gray-300"> - <h4>Configure your authentication key</h4> - <p class="mt-2 text-sm"> - Please paste your authenticator's public key as a Json Web Key (JWK) object. Your JWK must include a kid (key id) and a kty (key type) field. - </p> - <div class="p-2 mt-3"> - <textarea class="w-full p-1 text-sm border dark:bg-dark-700 ring-0 dark:border-dark-400" rows="10" v-model="keyData" /> - </div> - <div class="flex justify-end gap-2 mt-4"> - <button class="rounded btn sm primary" @click.prevent="onSubmitKeys">Submit</button> - <button class="rounded btn sm" @click.prevent="setIsOpen(false)">Cancel</button> - </div> - </DialogPanel> - </div> - </Dialog> -</template> - <script setup lang="ts"> import { includes, isEmpty } from 'lodash-es' -import { apiCall, useConfirm, useSession, debugLog, useFormToaster, PkiPublicKey } from '@vnuge/vnlib.browser' +import { apiCall, useConfirm, useSession, debugLog, useFormToaster, type PkiPublicKey, MfaMethod } from '@vnuge/vnlib.browser' import { computed, ref, watch } from 'vue' import { Dialog, DialogPanel } from '@headlessui/vue' import { useStore } from '../../../../store' -import { } from 'pinia' +import { useToggle } from '@vueuse/core' const store = useStore() const { reveal } = useConfirm() const { isLocalAccount } = useSession() const { error } = useFormToaster() +const { refresh, pkiConfig } = store.pki! -const pkiEnabled = computed(() => isLocalAccount.value && includes(store.mfaEndabledMethods, "pki") && window.crypto.subtle) +const pkiEnabled = computed(() => isLocalAccount.value && includes(store.mfa.enabledMethods, "pki" as MfaMethod) && window.crypto.subtle) +const pkiPublicKeys = computed(() => store.pki!.publicKeys) -const isOpen = ref(false) +const [isOpen, toggleOpen] = useToggle() const keyData = ref('') const pemFormat = ref(false) const explicitCurve = ref("") -watch(isOpen, () =>{ +watch(isOpen, () => { keyData.value = '' pemFormat.value = false explicitCurve.value = "" //Reload status - store.mfaRefreshMethods() + refresh() }) -const setIsOpen = (value : boolean) => isOpen.value = value - -const onRemoveKey = async (single: PkiPublicKey) =>{ - const { isCanceled } = await reveal({ +const onRemoveKey = async (single: PkiPublicKey) => { + const { isCanceled } = await reveal({ title: 'Are you sure?', text: `This will remove key ${single.kid} from your account.` }) @@ -130,11 +37,11 @@ const onRemoveKey = async (single: PkiPublicKey) =>{ return; } - //Delete pki + //Delete pki await apiCall(async ({ toaster }) => { - + //TODO: require password or some upgrade to disable - const { success } = await store.pkiConfig.removeKey(single.kid); + const { success } = await pkiConfig.removeKey(single.kid); if (success) { toaster.general.success({ @@ -150,33 +57,33 @@ const onRemoveKey = async (single: PkiPublicKey) =>{ } //Refresh the status - store.mfaRefreshMethods() + refresh() }); } const onDisable = async () => { - const { isCanceled } = await reveal({ + const { isCanceled } = await reveal({ title: 'Are you sure?', text: 'This will disable PKI authentication for your account.' }) if (isCanceled) { - return; + return; } //Delete pki - await apiCall(async ({ toaster }) =>{ + await apiCall(async ({ toaster }) => { //Disable pki //TODO: require password or some upgrade to disable - const { success } = await store.pkiConfig.disable(); - - if(success){ + const { success } = await pkiConfig.disable(); + + if (success) { toaster.general.success({ title: 'Success', text: 'PKI authentication has been disabled.' }) } - else{ + else { toaster.general.error({ title: 'Error', text: 'PKI authentication could not be disabled.' @@ -184,40 +91,40 @@ const onDisable = async () => { } //Refresh the status - store.mfaRefreshMethods() + refresh() }); } -const onSubmitKeys = async () =>{ - - if(window.crypto.subtle == null){ +const onSubmitKeys = async () => { + + if (window.crypto.subtle == null) { error({ title: "Your browser does not support PKI authentication." }) return; } - + //Validate key data - if(isEmpty(keyData.value)){ + if (isEmpty(keyData.value)) { error({ title: "Please enter key data" }) return; } - let jwk : PkiPublicKey & JsonWebKey; + let jwk: PkiPublicKey & JsonWebKey; try { //Try to parse as jwk jwk = JSON.parse(keyData.value) - if(isEmpty(jwk.use) - || isEmpty(jwk.kty) - || isEmpty(jwk.alg) - || isEmpty(jwk.kid) - || isEmpty(jwk.x) - || isEmpty(jwk.y)){ + if (isEmpty(jwk.use) + || isEmpty(jwk.kty) + || isEmpty(jwk.alg) + || isEmpty(jwk.kid) + || isEmpty(jwk.x) + || isEmpty(jwk.y)) { throw new Error("Invalid JWK"); } } catch (e) { //Write error to debug log debugLog(e) - error({ title:"The key is not a valid Json Web Key (JWK)"}) + error({ title: "The key is not a valid Json Web Key (JWK)" }) return; } @@ -226,7 +133,7 @@ const onSubmitKeys = async () =>{ //init/update the key //TODO: require password or some upgrade to disable - const { getResultOrThrow } = await store.pkiConfig.addOrUpdate(jwk); + const { getResultOrThrow } = await pkiConfig.addOrUpdate(jwk); const result = getResultOrThrow(); @@ -234,12 +141,103 @@ const onSubmitKeys = async () =>{ title: 'Success', text: result }) - setIsOpen(false) + toggleOpen(false) }) } </script> -<style> +<template> + <div id="pki-settings" class="container"> + <div class="panel-content"> + + <div class="flex flex-row flex-wrap justify-between"> + <h5>PKI Authentication</h5> + <div class=""> + <div v-if="pkiEnabled" class="button-group"> + <button class="btn xs" @click.prevent="toggleOpen(true)"> + <fa-icon icon="plus" /> + <span class="pl-2">Add Key</span> + </button> + <button class="btn red xs" @click.prevent="onDisable"> + <fa-icon icon="minus-circle" /> + <span class="pl-2">Disable</span> + </button> + </div> + <div v-else class=""> + <button class="btn primary xs" @click.prevent="toggleOpen(true)"> + <fa-icon icon="plus" /> + <span class="pl-2">Add Key</span> + </button> + </div> + </div> + + <div v-if="pkiPublicKeys && pkiPublicKeys.length > 0" class="w-full mt-4"> + <table class="min-w-full text-sm divide-y-2 divide-gray-200 dark:divide-dark-500"> + <thead class="text-left"> + <tr> + <th class="p-2 font-medium whitespace-nowrap dark:text-white" > + KeyID + </th> + <th class="p-2 font-medium whitespace-nowrap dark:text-white"> + Algorithm + </th> + <th class="p-2 font-medium whitespace-nowrap dark:text-white"> + Curve + </th> + <th class="p-2"></th> + </tr> + </thead> + + <tbody class="divide-y divide-gray-200 dark:divide-dark-500"> + <tr v-for="key in pkiPublicKeys"> + <td class="p-2 t font-medium truncate max-w-[8rem] whitespace-nowrap dark:text-white"> + {{ key.kid }} + </td> + <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> + {{ key.alg }} + </td> + <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> + {{ key.crv }} + </td> + <td class="p-2 text-right whitespace-nowrap"> + <button class="rounded btn red xs borderless" @click="onRemoveKey(key)"> + <span class="hidden sm:inline">Remove</span> + <fa-icon icon="trash-can" class="inline sm:hidden" /> + </button> + </td> + </tr> + </tbody> + </table> + </div> + + <p v-else class="p-1 pt-3 text-sm text-color-background"> + PKI authentication is a method of authenticating your user account with signed messages and a shared public key. This method implementation + uses client signed Json Web Tokens to authenticate user generated outside this website as a One Time Password (OTP). This allows for you to + use your favorite hardware or software tools, to generate said OTPs to authenticate your user. + </p> + </div> + </div> + </div> + <Dialog :open="isOpen" @close="toggleOpen(false)" class="relative z-30"> + <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> + + <div class="fixed inset-0 flex justify-center"> + <DialogPanel class="w-full max-w-lg p-4 m-auto mt-24 bg-white rounded dark:bg-dark-700 dark:text-gray-300"> + <h4>Configure your authentication key</h4> + <p class="mt-2 text-sm"> + Please paste your authenticator's public key as a Json Web Key (JWK) object. Your JWK must include a kid (key id) and a kty (key type) field. + </p> + <div class="p-2 mt-3"> + <textarea class="w-full p-1 text-sm border dark:bg-dark-700 ring-0 dark:border-dark-400" rows="10" v-model="keyData" /> + </div> + <div class="flex justify-end gap-2 mt-4"> + <button class="rounded btn sm primary" @click.prevent="onSubmitKeys">Submit</button> + <button class="rounded btn sm" @click.prevent="toggleOpen(false)">Cancel</button> + </div> + </DialogPanel> + </div> + </Dialog> +</template> + -</style> diff --git a/front-end/src/views/Account/components/settings/Security.vue b/front-end/src/views/Account/components/settings/Security.vue index e6075f9..ae0d143 100644 --- a/front-end/src/views/Account/components/settings/Security.vue +++ b/front-end/src/views/Account/components/settings/Security.vue @@ -1,3 +1,25 @@ +<script setup lang="ts"> +import { MfaMethod } from '@vnuge/vnlib.browser' +import { computed } from 'vue' +import { Switch } from '@headlessui/vue' +import { includes, isNil } from 'lodash-es' +import { useStore } from '../../../../store' +import Fido from './Fido.vue' +import Pki from './Pki.vue' +import TotpSettings from './TotpSettings.vue' +import PasswordReset from './PasswordReset.vue' + +const store = useStore(); + +//Load mfa methods +store.mfa.refresh(); + +const fidoEnabled = computed(() => includes(store.mfa.enabledMethods, 'fido' as MfaMethod)) +const totpEnabled = computed(() => includes(store.mfa.enabledMethods, MfaMethod.TOTP)) +const pkiEnabled = computed(() => !isNil(store.pki)) + +</script> + <template> <div id="account-security-settings"> <div class="panel-container"> @@ -20,20 +42,20 @@ </div> </div> - <Pki /> + <Pki v-if="pkiEnabled" /> <div id="browser-poll-settings" class="panel-content" > <div class="flex justify-between"> <h5>Keep me logged in</h5> <div class="pl-1"> <Switch - v-model="autoHeartbeat" - :class="autoHeartbeat ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-500'" + v-model="store.autoHeartbeat" + :class="store.autoHeartbeat ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-500'" class="relative inline-flex items-center h-6 rounded-full w-11" > <span class="sr-only">Enable auto heartbeat</span> <span - :class="autoHeartbeat ? 'translate-x-6' : 'translate-x-1'" + :class="store.autoHeartbeat ? 'translate-x-6' : 'translate-x-1'" class="inline-block w-4 h-4 transition transform bg-white rounded-full" /> </Switch> @@ -51,29 +73,6 @@ </div> </template> -<script setup lang="ts"> -import { MfaMethod } from '@vnuge/vnlib.browser' -import { computed } from 'vue' -import { Switch } from '@headlessui/vue' -import { includes } from 'lodash-es' -import { storeToRefs } from 'pinia' -import { useStore } from '../../../../store' -import Fido from './Fido.vue' -import Pki from './Pki.vue' -import TotpSettings from './TotpSettings.vue' -import PasswordReset from './PasswordReset.vue' - -const store = useStore(); -const { autoHeartbeat } = storeToRefs(store); - -//Load mfa methods -store.mfaRefreshMethods(); - -const fidoEnabled = computed(() => includes(store.mfaEndabledMethods, 'fido' as MfaMethod)) -const totpEnabled = computed(() => includes(store.mfaEndabledMethods, MfaMethod.TOTP)) - -</script> - <style> #account-security-settings .modal-body{ diff --git a/front-end/src/views/Account/components/settings/Settings.vue b/front-end/src/views/Account/components/settings/Settings.vue index fb86951..0580b58 100644 --- a/front-end/src/views/Account/components/settings/Settings.vue +++ b/front-end/src/views/Account/components/settings/Settings.vue @@ -1,3 +1,7 @@ +<script setup lang="ts"> +import Security from './Security.vue' +</script> + <template> <div id="account-settings" class="container"> <div class="acnt-content-container"> @@ -5,12 +9,3 @@ </div> </div> </template> - -<script setup lang="ts"> -import Security from './Security.vue' - -</script> - -<style> - -</style> diff --git a/front-end/src/views/Account/components/settings/TotpSettings.vue b/front-end/src/views/Account/components/settings/TotpSettings.vue index 0fcfe31..04a261b 100644 --- a/front-end/src/views/Account/components/settings/TotpSettings.vue +++ b/front-end/src/views/Account/components/settings/TotpSettings.vue @@ -1,99 +1,11 @@ -<template> - <div id="totp-settings"> - - <div v-if="!isLocalAccount" class="flex flex-row justify-between"> - <h6 class="block"> - TOTP Authenticator App - </h6> - <div class="text-red-500"> - Unavailable for external auth - </div> - </div> - - <div v-else-if="showTotpCode" class="w-full py-2 text-center"> - <h5 class="text-center" /> - <p class="py-2"> - Scan the QR code with your TOTP authenticator app. - </p> - - <div class="flex"> - <VueQrcode class="m-auto" :value="qrCode" /> - </div> - - <p class="py-2"> - Your secret, if your application requires it. - </p> - - <p class="flex flex-row flex-wrap justify-center p-2 bg-gray-200 border border-gray-300 dark:bg-dark-800 dark:border-dark-500"> - <span v-for="code in secretSegments" :key="code" class="px-2 font-mono tracking-wider" > - {{ code }} - </span> - </p> - - <p class="py-2 text-color-background"> - Please enter your code from your authenticator app to continue. - </p> - - <div class="m-auto w-min"> - <VOtpInput - class="otp-input" - input-type="letter-numeric" - separator="" - :is-disabled="showSubmitButton" - input-classes="primary input rounded" - :num-inputs="6" - @on-change="onInput" - @on-complete="VerifyTotp" - /> - </div> - - <div v-if="showSubmitButton" class="flex flex-row justify-end my-2"> - <button class="btn primary" @click.prevent="CloseQrWindow"> - Complete - </button> - </div> - </div> - - <div v-else class="flex flex-row flex-wrap justify-between"> - <h6>TOTP Authenticator App</h6> - - <div v-if="totpEnabled" class="button-group"> - <button class="btn xs" @click.prevent="regenTotp"> - <fa-icon icon="sync" /> - <span class="pl-2">Regenerate</span> - </button> - <button class="btn red xs" @click.prevent="disable"> - <fa-icon icon="minus-circle" /> - <span class="pl-2">Disable</span> - </button> - </div> - - <div v-else> - <button class="btn primary xs" @click.prevent="configTotp"> - <fa-icon icon="plus" /> - <span class="pl-2">Setup</span> - </button> - </div> - <p class="p-1 pt-3 text-sm text-color-background"> - TOTP is a time based one time password. You can use it as a form of Multi Factor Authentication when - using another device such as a smart phone or TOTP hardware device. You can use TOTP with your smart - phone - using apps like Google Authenticator, Authy, or Duo. Read more on - <a class="link" href="https://en.wikipedia.org/wiki/Time-based_one-time_password" target="_blank"> - Wikipedia. - </a> - </p> - </div> - - </div> -</template> - <script setup lang="ts"> import { isNil, chunk, defaultTo, includes, map, join } from 'lodash-es' import { TOTP } from 'otpauth' -import { computed, ref, defineAsyncComponent } from 'vue' import base32Encode from 'base32-encode' -import { +import QrCodeVue from 'qrcode.vue' +import VOtpInput from "vue3-otp-input"; +import { computed, ref } from 'vue' +import { useSession, useMessage, useConfirm, @@ -104,26 +16,23 @@ import { import { useStore } from '../../../../store'; import { storeToRefs } from 'pinia'; -const VueQrcode = defineAsyncComponent(() => import('@chenfengyuan/vue-qrcode')) -const VOtpInput = defineAsyncComponent(() => import('vue3-otp-input')); - -interface TotpConfig{ - secret: string; - readonly issuer: string; - readonly algorithm: string; - readonly digits?: number; - readonly period?: number; +interface TotpConfig { + secret: string; + readonly issuer: string; + readonly algorithm: string; + readonly digits?: number; + readonly period?: number; } const store = useStore(); -const { userName, isLocalAccount, mfaEndabledMethods } = storeToRefs(store); +const { isLocalAccount } = storeToRefs(store); const { KeyStore } = useSession() const { reveal } = useConfirm() const { elevatedApiCall } = usePassConfirm() const { onInput, setMessage } = useMessage() -const totpEnabled = computed(() => includes(mfaEndabledMethods.value, MfaMethod.TOTP)) +const totpEnabled = computed(() => includes(store.mfa.enabledMethods, MfaMethod.TOTP)) const totpMessage = ref<TotpConfig>() const showSubmitButton = ref(false) @@ -152,7 +61,7 @@ const qrCode = computed(() => { params.append('algorithm', m.algorithm) params.append('digits', defaultTo(m.digits, 6).toString()) params.append('period', defaultTo(m.period, 30).toString()) - const url = `otpauth://totp/${m.issuer}:${userName.value}?${params.toString()}` + const url = `otpauth://totp/${m.issuer}:${store.userName}?${params.toString()}` return url }) @@ -160,7 +69,7 @@ const ProcessAddOrUpdate = async () => { await elevatedApiCall(async ({ password }) => { // Init or update the totp method and get the encrypted totp message - const res = await store.mfaConfig.initOrUpdateMethod<TotpConfig>(MfaMethod.TOTP, password); + const res = await store.mfa.initOrUpdateMethod<TotpConfig>(MfaMethod.TOTP, password); //Get the encrypted totp message const totp = res.getResultOrThrow() @@ -181,7 +90,7 @@ const configTotp = async () => { text: 'Are you sure you understand TOTP multi factor and wish to enable it?', }) - if(!isCanceled){ + if (!isCanceled) { ProcessAddOrUpdate() } } @@ -197,7 +106,7 @@ const regenTotp = async () => { text: 'If you continue your previous TOTP authenticator and recovery codes will no longer be valid.' }) - if(!isCanceled){ + if (!isCanceled) { ProcessAddOrUpdate() } } @@ -214,16 +123,15 @@ const disable = async () => { } await elevatedApiCall(async ({ password }) => { - // Disable the totp method - const res = await store.mfaConfig.disableMethod(MfaMethod.TOTP, password) + const res = await store.mfa.disableMethod(MfaMethod.TOTP, password) res.getResultOrThrow() - - store.mfaRefreshMethods() + + store.mfa.refresh() }) } -const VerifyTotp = async (code : string) => { +const VerifyTotp = async (code: string) => { // Create a new TOTP instance from the current message const totp = new TOTP(totpMessage.value) @@ -231,10 +139,11 @@ const VerifyTotp = async (code : string) => { const valid = totp.validate({ token: code, window: 4 }) if (valid) { - showSubmitButton.value = true + showSubmitButton.value = true; + toaster.success({ title: 'Success', - text: 'Your TOTP code is valid and your account is now verified.' + text: 'Your TOTP code is valid and is now enabled' }) } else { setMessage('Your TOTP code is not valid.') @@ -244,12 +153,103 @@ const VerifyTotp = async (code : string) => { const CloseQrWindow = () => { showSubmitButton.value = false totpMessage.value = undefined - + //Fresh methods - store.mfaRefreshMethods() + store.mfa.refresh() } </script> +<template> + <div id="totp-settings"> + + <div v-if="!isLocalAccount" class="flex flex-row justify-between"> + <h6 class="block"> + TOTP Authenticator App + </h6> + <div class="text-red-500"> + Unavailable for external auth + </div> + </div> + + <div v-else-if="showTotpCode" class="w-full py-2 text-center"> + <h5 class="text-center" /> + <p class="py-2"> + Scan the QR code with your TOTP authenticator app. + </p> + + <div class="flex"> + <QrCodeVue class="m-auto" :size="180" render-as="svg" level="Q" :value="qrCode" /> + </div> + + <p class="py-2"> + Your secret, if your application requires it. + </p> + + <p class="flex flex-row flex-wrap justify-center p-2 bg-gray-200 border border-gray-300 dark:bg-dark-800 dark:border-dark-500"> + <span v-for="code in secretSegments" :key="code" class="px-2 font-mono tracking-wider" > + {{ code }} + </span> + </p> + + <p class="py-2 text-color-background"> + Please enter your code from your authenticator app to continue. + </p> + + <div class="m-auto w-min"> + <VOtpInput + class="otp-input" + input-type="letter-numeric" + separator="" + value="" + :is-disabled="showSubmitButton" + input-classes="primary input rounded" + :num-inputs="6" + @on-change="onInput" + @on-complete="VerifyTotp" + /> + </div> + + <div v-if="showSubmitButton" class="flex flex-row justify-end my-2"> + <button class="btn primary" @click.prevent="CloseQrWindow"> + Complete + </button> + </div> + </div> + + <div v-else class="flex flex-row flex-wrap justify-between"> + <h6>TOTP Authenticator App</h6> + + <div v-if="totpEnabled" class="button-group"> + <button class="btn xs" @click.prevent="regenTotp"> + <fa-icon icon="sync" /> + <span class="pl-2">Regenerate</span> + </button> + <button class="btn red xs" @click.prevent="disable"> + <fa-icon icon="minus-circle" /> + <span class="pl-2">Disable</span> + </button> + </div> + + <div v-else> + <button class="btn primary xs" @click.prevent="configTotp"> + <fa-icon icon="plus" /> + <span class="pl-2">Setup</span> + </button> + </div> + <p class="p-1 pt-3 text-sm text-color-background"> + TOTP is a time based one time password. You can use it as a form of Multi Factor Authentication when + using another device such as a smart phone or TOTP hardware device. You can use TOTP with your smart + phone + using apps like Google Authenticator, Authy, or Duo. Read more on + <a class="link" href="https://en.wikipedia.org/wiki/Time-based_one-time_password" target="_blank"> + Wikipedia. + </a> + </p> + </div> + + </div> +</template> + <style> diff --git a/front-end/src/views/Blog/components/Posts.vue b/front-end/src/views/Blog/components/Posts.vue index 647e093..b89d263 100644 --- a/front-end/src/views/Blog/components/Posts.vue +++ b/front-end/src/views/Blog/components/Posts.vue @@ -1,24 +1,3 @@ -<template> - <div id="post-editor" class=""> - <EditorTable title="Manage posts" :show-edit="showEdit" :pagination="pagination" @open-new="openNew"> - <template #table> - <PostTable - :items="items" - @open-edit="openEdit" - @delete="onDelete" - /> - </template> - <template #editor> - <PostEditor - @submit="onSubmit" - @close="closeEdit(true)" - @delete="onDelete" - /> - </template> - </EditorTable> - </div> -</template> - <script setup lang="ts"> import { computed, defineAsyncComponent } from 'vue'; import { isEmpty } from 'lodash-es'; @@ -49,7 +28,7 @@ const closeEdit = (update?: boolean) => { //reload channels if (update) { //must refresh content and posts when a post is updated - refresh(); + refresh(); } //Reset page to top window.scrollTo(0, 0); @@ -62,14 +41,14 @@ const openNew = () => { window.scrollTo(0, 0) } -const onSubmit = async ({post, content } : { post: PostMeta, content: string }) => { +const onSubmit = async ({ post, content }: { post: PostMeta, content: string }) => { debugLog('submitting', post, content); //Check for new channel, or updating old channel if (store.posts.selectedId === 'new') { //Exec create call - await apiCall(async ({toaster}) => { + await apiCall(async ({ toaster }) => { //endpoint returns the content const newMeta = await store.posts.add(post); @@ -86,9 +65,9 @@ const onSubmit = async ({post, content } : { post: PostMeta, content: string }) } else if (!isEmpty(store.posts.selectedId)) { //Exec update call - await apiCall(async ( {toaster} ) => { + await apiCall(async ({ toaster }) => { await store.posts.update(post); - + //Publish the content await store.content.updatePostContent(post, content) @@ -129,6 +108,26 @@ const onDelete = async (post: PostMeta) => { } </script> +<template> + <div id="post-editor" class=""> + <EditorTable title="Manage posts" :show-edit="showEdit" :pagination="pagination" @open-new="openNew"> + <template #table> + <PostTable + :items="items" + @open-edit="openEdit" + @delete="onDelete" + /> + </template> + <template #editor> + <PostEditor + @submit="onSubmit" + @close="closeEdit(true)" + @delete="onDelete" + /> + </template> + </EditorTable> + </div> +</template> <style lang="scss"> diff --git a/front-end/src/views/Blog/components/image-preview-dialog.vue b/front-end/src/views/Blog/components/image-preview-dialog.vue index 5cfe552..b134d19 100644 --- a/front-end/src/views/Blog/components/image-preview-dialog.vue +++ b/front-end/src/views/Blog/components/image-preview-dialog.vue @@ -1,34 +1,14 @@ -<template> - <div class=""> - <Dialog :open="isOpen" @close="onClose" class="relative z-50"> - <!-- The backdrop, rendered as a fixed sibling to the panel container --> - <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> - - <!-- Full-screen container to center the panel --> - <div class="fixed inset-0 flex items-center justify-center w-screen p-4"> - <!-- The actual dialog panel --> - <DialogPanel class="p-2 bg-white rounded dark:bg-dark-700" ref="dialog"> - <DialogDescription> - <img class="preview-image" :src="imgUrl" alt="preview" /> - </DialogDescription> - <!-- ... --> - </DialogPanel> - </div> - </Dialog> - </div> -</template> - <script setup lang="ts"> import { ref, computed, watch, toRefs } from 'vue' import { Dialog, DialogDescription } from '@headlessui/vue' -import { onClickOutside } from '@vueuse/core'; +import { onClickOutside, set } from '@vueuse/core'; import { useStore } from '../../../store'; import { ContentMeta } from '../../../../../lib/admin/dist'; import { isNil } from 'lodash-es'; import { apiCall } from '@vnuge/vnlib.browser'; const emit = defineEmits(['close']) -const props = defineProps<{ +const props = defineProps<{ item: ContentMeta | undefined, }>() @@ -52,19 +32,33 @@ const downloadImage = (item: ContentMeta) => { }) } -//load the image when open -watch(item, (item) => { - if (isNil(item)) { - imgUrl.value = undefined - } else { - downloadImage(item) - } -}) +//load the image when open or remove it if the item is undefined +watch(item, (item) => isNil(item) ? set(imgUrl, undefined) : downloadImage(item)) //Close dialog when clicking outside onClickOutside(dialog, onClose) </script> + +<template> + <div class=""> + <Dialog :open="isOpen" @close="onClose" class="relative z-50"> + <!-- The backdrop, rendered as a fixed sibling to the panel container --> + <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> + + <!-- Full-screen container to center the panel --> + <div class="fixed inset-0 flex items-center justify-center w-screen p-4"> + <!-- The actual dialog panel --> + <DialogPanel class="p-2 bg-white rounded dark:bg-dark-700" ref="dialog"> + <DialogDescription> + <img class="preview-image" :src="imgUrl" alt="preview" /> + </DialogDescription> + <!-- ... --> + </DialogPanel> + </div> + </Dialog> + </div> +</template> <style lang="scss"> .preview-image { diff --git a/front-end/src/views/Blog/index.vue b/front-end/src/views/Blog/index.vue index de435ec..af6ab88 100644 --- a/front-end/src/views/Blog/index.vue +++ b/front-end/src/views/Blog/index.vue @@ -1,3 +1,35 @@ +<script setup lang="ts"> +import { computed } from 'vue'; +import { useRouteQuery } from '@vueuse/router'; +import { TabGroup, TabList, Tab, TabPanels, TabPanel, Switch } from '@headlessui/vue' +import { defer, first } from 'lodash-es'; +import { useStore, SortType } from '../../store'; +import Channels from './components/Channels.vue'; +import Posts from './components/Posts.vue'; +import Content from './components/Content.vue'; + +//Protect page +const store = useStore() +store.setPageTitle('Blog Admin') + +const firstLetter = computed(() => first(store.userName)) +const tabIdQ = useRouteQuery<string>('tabid', '', { mode: 'push' }) + +//Map queries to their respective computed values +const tabId = computed(() => tabIdQ.value ? parseInt(tabIdQ.value) : 0); +const lastModified = computed({ + get: () => store.queryState.sort === SortType.ModifiedTime, + set: (value: boolean) => { + store.queryState.sort = value ? SortType.ModifiedTime : SortType.CreatedTime + } +}) + +const onTabChange = (id: number) => tabIdQ.value = id.toString(10) + +//Load channels on page load +defer(() => store.channels.refresh()); + +</script> <template> <div class="container mx-auto mt-10 mb-[10rem]"> <div id="blog-admin-template" class=""> @@ -107,40 +139,6 @@ </div> </template> -<script setup lang="ts"> -import { computed } from 'vue'; -import { useRouteQuery } from '@vueuse/router'; -import { TabGroup, TabList, Tab, TabPanels, TabPanel, Switch } from '@headlessui/vue' -import { defer, first } from 'lodash-es'; -import { useStore, SortType } from '../../store'; -import Channels from './components/Channels.vue'; -import Posts from './components/Posts.vue'; -import Content from './components/Content.vue'; - - -//Protect page -const store = useStore() -store.setPageTitle('Blog Admin') - -const firstLetter = computed(() => first(store.userName)) -const tabIdQ = useRouteQuery<string>('tabid', '', { mode: 'push' }) - -//Map queries to their respective computed values -const tabId = computed(() => tabIdQ.value ? parseInt(tabIdQ.value) : 0); -const lastModified = computed({ - get :() => store.queryState.sort === SortType.ModifiedTime, - set: (value:boolean) => { - store.queryState.sort = value ? SortType.ModifiedTime : SortType.CreatedTime - } -}) - -const onTabChange = (id:number) => tabIdQ.value = id.toString(10) - -//Load channels on page load -defer(() => store.channels.refresh()); - -</script> - <style lang="scss"> #blog-admin-template{ diff --git a/front-end/src/views/Login/components/Social.vue b/front-end/src/views/Login/components/Social.vue index 4d22074..7e92d99 100644 --- a/front-end/src/views/Login/components/Social.vue +++ b/front-end/src/views/Login/components/Social.vue @@ -1,19 +1,3 @@ -<template> - <div class="flex flex-col gap-3"> - <div v-for="method in methods" :key="method.Id" class=""> - <button - type="submit" - class="btn social-button" - :disabled="waiting" - @click.prevent="submitLogin(method)" - > - <fa-icon :icon="getIcon(method)" size="xl" /> - Login with {{ capitalize(method.Id) }} - </button> - </div> - </div> -</template> - <script setup lang="ts"> import { shallowRef } from 'vue' import { apiCall, useWait, type OAuthMethod } from '@vnuge/vnlib.browser' @@ -43,4 +27,22 @@ const getIcon = (method: OAuthMethod): string[] => { //Load methods once the fetch completes store.socialOauth().then(m => methods.value = m.methods); -</script>
\ No newline at end of file +</script> + +<template> + + <div class="flex flex-col gap-3"> + <div v-for="method in methods" :key="method.Id" class=""> + <button + type="submit" + class="btn social-button" + :disabled="waiting" + @click.prevent="submitLogin(method)" + > + <fa-icon :icon="getIcon(method)" size="xl" /> + Login with {{ capitalize(method.Id) }} + </button> + </div> + </div> + +</template> diff --git a/front-end/src/views/Login/components/UserPass.vue b/front-end/src/views/Login/components/UserPass.vue index 442abb1..bc9d8d1 100644 --- a/front-end/src/views/Login/components/UserPass.vue +++ b/front-end/src/views/Login/components/UserPass.vue @@ -55,14 +55,15 @@ </template> <script setup lang="ts"> -import { ref, shallowRef, reactive, defineAsyncComponent, type Ref } from 'vue' +import { ref, shallowRef, reactive, defineAsyncComponent, Ref } from 'vue' import { useTimeoutFn, set } from '@vueuse/core' import { useVuelidate } from '@vuelidate/core' import { isEqual } from 'lodash-es' import { required, maxLength, minLength, email, helpers } from '@vuelidate/validators' import { useVuelidateWrapper, useMfaLogin, totpMfaProcessor, IMfaFlowContinuiation, MfaMethod, - apiCall, useMessage, useWait, debugLog, WebMessage + apiCall, useMessage, useWait, debugLog, WebMessage, + type VuelidateInstance } from '@vnuge/vnlib.browser' const Totp = defineAsyncComponent(() => import('./Totp.vue')) @@ -97,7 +98,7 @@ const rules = { } const v$ = useVuelidate(rules, vState) -const { validate } = useVuelidateWrapper(v$); +const { validate } = useVuelidateWrapper(v$ as Ref<VuelidateInstance>); const SubmitLogin = async () => { diff --git a/front-end/src/views/Login/index.vue b/front-end/src/views/Login/index.vue index 6a55aeb..476ebf4 100644 --- a/front-end/src/views/Login/index.vue +++ b/front-end/src/views/Login/index.vue @@ -1,3 +1,38 @@ +<script setup lang="ts"> +import { computed } from 'vue' +import { apiCall, useWait } from '@vnuge/vnlib.browser' +import { isNil } from 'lodash-es' +import { useStore } from '../../store' +import { storeToRefs } from 'pinia' +import UserPass from './components/UserPass.vue' +import Social from './components/Social.vue' + +const store = useStore(); +const { loggedIn } = storeToRefs(store) +const pkiEnabled = computed(() => !isNil(store.pki?.pkiAuth)) + +store.setPageTitle('Login') + +const { waiting } = useWait() + +const submitLogout = async () => { + //Submit logout request + await apiCall(async ({ toaster }) => { + const { logout } = await store.socialOauth() + // Attempt to logout + await logout() + // Push a new toast message + toaster.general.success({ + id: 'logout-success', + title: 'Success', + text: 'You have been logged out', + duration: 5000 + }) + }) +} + +</script> + <template> <div id="login-template" class="app-component-entry"> <div class="login-container"> @@ -25,7 +60,7 @@ <Social /> <!-- pki button, forward to the pki route --> - <div v-if="pkiEnabled" class="mt-3"> + <div v-if="pkiEnabled" class="mt-4"> <router-link to="/login/pki"> <button type="submit" class="btn red social-button" :disabled="waiting"> <fa-icon :icon="['fa','certificate']" size="xl" /> @@ -39,44 +74,6 @@ </div> </template> -<script setup lang="ts"> -import { } from 'vue' -import { apiCall, useWait } from '@vnuge/vnlib.browser' -import { isNil } from 'lodash-es' -import { useStore } from '../../store' -import { storeToRefs } from 'pinia' -import UserPass from './components/UserPass.vue' -import Social from './components/Social.vue' - -//pki enabled flag from env -const pkiEnabled = !isNil(import.meta.env.VITE_PKI_ENABLED); - -const store = useStore(); -const { loggedIn } = storeToRefs(store) - -store.setPageTitle('Login') - -const { waiting } = useWait() - -const submitLogout = async () => { - //Submit logout request - await apiCall(async ({ toaster }) => { - // Attempt to logout - const { logout } = await store.socialOauth() - await logout() - - // Push a new toast message - toaster.general.success({ - id: 'logout-success', - title: 'Success', - text: 'You have been logged out', - duration: 5000 - }) - }) -} - -</script> - <style lang="scss"> #login-template { .login-container{ diff --git a/front-end/src/views/Login/pki/index.vue b/front-end/src/views/Login/pki/index.vue index 585942a..8edd063 100644 --- a/front-end/src/views/Login/pki/index.vue +++ b/front-end/src/views/Login/pki/index.vue @@ -1,3 +1,38 @@ +<script setup lang="ts"> +import { isEmpty } from 'lodash-es'; +import { apiCall, debugLog, useMessage } from '@vnuge/vnlib.browser'; +import { ref } from 'vue' +import { decodeJwt } from 'jose' +import { useRouter } from 'vue-router'; +import { useStore } from '../../../store'; + +const { setMessage } = useMessage() +const { push } = useRouter() +const store = useStore() + +const otp = ref('') + +const submit = () => { + + apiCall(async () => { + if (isEmpty(otp.value)) { + setMessage('Please enter your OTP') + return + } + + //try to decode the jwt to confirm its form is valid + const jwt = decodeJwt(otp.value) + debugLog(jwt) + + await store.pki!.pkiAuth.login(otp.value) + + //Go back to login page + push({ name: 'Login' }) + }) +} + +</script> + <template> <div id="pki-login-template" class="app-component-entry"> <div class="container max-w-lg mx-auto mt-6 lg:mt-20"> @@ -30,38 +65,3 @@ </div> </div> </template> - -<script setup lang="ts"> -import { isEmpty } from 'lodash-es'; -import { apiCall, debugLog, useMessage } from '@vnuge/vnlib.browser'; -import { ref } from 'vue' -import { decodeJwt } from 'jose' -import { useRouter } from 'vue-router'; -import { useStore } from '../../../store'; - -const { setMessage } = useMessage() -const { push } = useRouter() -const store = useStore() - -const otp = ref('') - -const submit = () =>{ - - apiCall(async () =>{ - if(isEmpty(otp.value)){ - setMessage('Please enter your OTP') - return - } - - //try to decode the jwt to confirm its form is valid - const jwt = decodeJwt(otp.value) - debugLog(jwt) - - await store.pkiAuth.login(otp.value) - - //Go back to login page - push({ name: 'Login' }) - }) -} - -</script>
\ No newline at end of file diff --git a/front-end/src/views/Login/social/[type].vue b/front-end/src/views/Login/social/[type].vue index 51da94f..f011f9c 100644 --- a/front-end/src/views/Login/social/[type].vue +++ b/front-end/src/views/Login/social/[type].vue @@ -1,35 +1,3 @@ -<template> - <div id="social-login-template" class="app-component-entry"> - <div class="container flex flex-col m-auto my-16"> - <div id="social-final-template" class="flex justify-center"> - <div class="entry-container"> - <h3>Finalizing login</h3> - <div class="mt-6 mb-4"> - <div v-if="message?.length > 0" class="text-lg text-red-500 dark:text-rose-500"> - <p>{{ message }}</p> - <div class="flex justify-center mt-5"> - <router-link to="/login"> - <button type="submit" class="btn primary" :disabled="waiting"> - <fa-icon icon="sign-in-alt" /> - Try again - </button> - </router-link> - </div> - </div> - <div v-else> - <div class="flex justify-center"> - <div class="m-auto"> - <fa-icon class="animate-spin" icon="spinner" size="2x"/> - </div> - </div> - <p>Please wait while we log you in.</p> - </div> - </div> - </div> - </div> - </div> - </div> -</template> <script setup lang="ts"> import { defer } from 'lodash-es' @@ -62,19 +30,20 @@ tryOnMounted(() => defer(() => { //try to complete an oauth login apiCall(async ({ toaster }) => { - try{ - //Complete the login - const { completeLogin } = await store.socialOauth(); - await completeLogin() - - toaster.general.success({ - title:'Login Successful', - text: 'You have successfully logged in.' - }) - - router.push({ name: 'Login' }) + try { + const { completeLogin } = await store.socialOauth(); + + //Complete the login + await completeLogin(); + + toaster.general.success({ + title: 'Login Successful', + text: 'You have successfully logged in.' + }) + + router.push({ name: 'Login' }) } - catch(err: any){ + catch (err: any) { set(message, err.message) } }) @@ -82,6 +51,39 @@ tryOnMounted(() => defer(() => { </script> +<template> + <div id="social-login-template" class="app-component-entry"> + <div class="container flex flex-col m-auto my-16"> + <div id="social-final-template" class="flex justify-center"> + <div class="entry-container"> + <h3>Finalizing login</h3> + <div class="mt-6 mb-4"> + <div v-if="message?.length > 0" class="text-lg text-red-500 dark:text-rose-500"> + <p>{{ message }}</p> + <div class="flex justify-center mt-5"> + <router-link to="/login"> + <button type="submit" class="btn primary" :disabled="waiting"> + <fa-icon icon="sign-in-alt" /> + Try again + </button> + </router-link> + </div> + </div> + <div v-else> + <div class="flex justify-center"> + <div class="m-auto"> + <fa-icon class="animate-spin" icon="spinner" size="2x"/> + </div> + </div> + <p>Please wait while we log you in.</p> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + <style lang="scss"> #social-login-template{ |