aboutsummaryrefslogtreecommitdiff
path: root/front-end/src
diff options
context:
space:
mode:
Diffstat (limited to 'front-end/src')
-rw-r--r--front-end/src/App.vue4
-rw-r--r--front-end/src/assets/main.scss6
-rw-r--r--front-end/src/bootstrap/components/Header.vue10
-rw-r--r--front-end/src/bootstrap/style/footer.scss2
-rw-r--r--front-end/src/main.ts13
-rw-r--r--front-end/src/router/index.ts70
-rw-r--r--front-end/src/store/globalState.ts3
-rw-r--r--front-end/src/store/socialMfaPlugin.ts16
-rw-r--r--front-end/src/views/Account/[comp].vue4
-rw-r--r--front-end/src/views/Account/components/oauth/Oauth.vue2
-rw-r--r--front-end/src/views/Account/components/oauth/SingleApplication.vue2
-rw-r--r--front-end/src/views/Account/components/profile/Profile.vue2
-rw-r--r--front-end/src/views/Account/components/settings/Fido.vue2
-rw-r--r--front-end/src/views/Account/components/settings/PasswordReset.vue14
-rw-r--r--front-end/src/views/Account/components/settings/Pki.vue2
-rw-r--r--front-end/src/views/Account/components/settings/Security.vue2
-rw-r--r--front-end/src/views/Account/components/settings/TotpSettings.vue4
-rw-r--r--front-end/src/views/Blog/components/Channels.vue43
-rw-r--r--front-end/src/views/Blog/components/Content.vue84
-rw-r--r--front-end/src/views/Blog/components/Content/ContentTable.vue90
-rw-r--r--front-end/src/views/Blog/components/ContentSearch.vue72
-rw-r--r--front-end/src/views/Blog/components/EditorTable.vue69
-rw-r--r--front-end/src/views/Blog/components/FeedFields.vue75
-rw-r--r--front-end/src/views/Login/components/Social.vue52
-rw-r--r--front-end/src/views/Login/components/UserPass.vue123
-rw-r--r--front-end/src/views/Login/index.vue30
26 files changed, 422 insertions, 374 deletions
diff --git a/front-end/src/App.vue b/front-end/src/App.vue
index 2f29fde..ecd9d73 100644
--- a/front-end/src/App.vue
+++ b/front-end/src/App.vue
@@ -3,7 +3,7 @@
<title>{{ metaTile }}</title>
</head>
<!-- Import environment component top level as the entrypoint -->
- <Environment @logout="logout">
+ <Environment @logout="logout()">
<template #main>
<router-view />
</template>
@@ -14,8 +14,8 @@
import { computed } from 'vue';
import { useStore } from './store';
import { storeToRefs } from 'pinia';
-import Environment from './bootstrap/Environment.vue';
import { apiCall } from '@vnuge/vnlib.browser';
+import Environment from './bootstrap/Environment.vue';
const store = useStore()
const { siteTitle, pageTitle } = storeToRefs(store)
diff --git a/front-end/src/assets/main.scss b/front-end/src/assets/main.scss
index 168a034..d4a86e7 100644
--- a/front-end/src/assets/main.scss
+++ b/front-end/src/assets/main.scss
@@ -1,4 +1,4 @@
-#footer-content .footer-lower{
+#footer-content .footer-lower {
@apply hidden;
}
@@ -15,4 +15,8 @@
background: -moz-linear-gradient(bottom right, #98E4C8, #2C6BC3);
background: linear-gradient(to top left, #98E4C8, #2C6BC3);
@apply text-gray-700;
+}
+
+.text-bg{
+ @apply text-gray-700 dark:text-gray-400;
} \ No newline at end of file
diff --git a/front-end/src/bootstrap/components/Header.vue b/front-end/src/bootstrap/components/Header.vue
index 6093fdc..db004d8 100644
--- a/front-end/src/bootstrap/components/Header.vue
+++ b/front-end/src/bootstrap/components/Header.vue
@@ -1,8 +1,8 @@
<!-- eslint-disable vue/max-attributes-per-line -->
<script setup lang="ts">
-import { debounce, find } from 'lodash-es'
-import { useElementSize, onClickOutside, useElementHover } from '@vueuse/core'
+import { debounce, find, isEqual, toLower } from 'lodash-es'
+import { useElementSize, onClickOutside, useElementHover, get } from '@vueuse/core'
import { computed, ref, toRefs } from 'vue'
import { useEnvSize } from '@vnuge/vnlib.browser'
import { RouteLocation, useRouter } from 'vue-router';
@@ -35,6 +35,10 @@ const userMenuHovered = useElementHover(userMenu)
const uname = computed(() => (store as any).userName || 'Visitor')
const sideMenuStyle = computed(() => {
+
+ const { width } = sideMenuSize;
+ if(get(width) === 0) return { left: '-100vw' }
+
// Side menu should be the exact height of the page and under the header,
// So menu height is the height of the page minus the height of the header
return {
@@ -63,7 +67,7 @@ const gotoRoute = (route: string) => {
const allRoutes = router.getRoutes();
//Try to find the route by its path
- const goto = find(allRoutes, { path: route });
+ const goto = find(allRoutes, r => isEqual(toLower(r.path), toLower(route)));
if (goto) {
//navigate to the route manually
diff --git a/front-end/src/bootstrap/style/footer.scss b/front-end/src/bootstrap/style/footer.scss
index 4d05928..86b6e6b 100644
--- a/front-end/src/bootstrap/style/footer.scss
+++ b/front-end/src/bootstrap/style/footer.scss
@@ -1,5 +1,5 @@
footer{
- @apply text-center shadow-md bg-white dark:bg-dark-800 dark:text-gray-500;
+ @apply text-center shadow-md bg-white dark:bg-dark-800 dark:text-gray-400;
.footer-content{
@apply mx-auto max-w-7xl p-4;
diff --git a/front-end/src/main.ts b/front-end/src/main.ts
index 3cc3bfa..b3a5ad7 100644
--- a/front-end/src/main.ts
+++ b/front-end/src/main.ts
@@ -38,7 +38,7 @@ library.add(faSignInAlt, faGithub, faDiscord, faSpinner, faCertificate, faKey, f
);
//Add icons to library
-import router from './router'
+import router, { guardRoutes } from './router'
//Import nav components
import FooterNav1 from './components/FooterNav1.vue'
@@ -50,7 +50,6 @@ 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'
import { socialMfaPlugin } from './store/socialMfaPlugin'
import { cmnextAdminPlugin } from './store/cmnextAdminPlugin'
@@ -58,7 +57,7 @@ import { cmnextAdminPlugin } from './store/cmnextAdminPlugin'
configureApi({
session: {
//The identifier of the login cookie, see Essentials.Accounts docs
- loginCookieName: 'li',
+ loginCookieName: import.meta.env.VITE_LOGIN_COOKIE_ID,
browserIdSize: 32,
},
user: {
@@ -91,8 +90,6 @@ createVnApp({
app.use(router)
store.use(globalStatePlugin)
- //Add page protection plugin
- .use(pageProtectionPlugin(router))
//User-profile plugin
.use(profilePlugin('/account/profile'))
//Enable mfa with totp settings plugin (optional pki config)
@@ -117,6 +114,12 @@ createVnApp({
name: 'Account',
redirect: { path: '/account/profile' }
})
+
+ /**
+ * An array of named routes to protect from
+ * unauthenticated access.
+ */
+ guardRoutes(router, ['Account', 'account/:comp', 'Blog'])
//Add the footer nav components
app.component('FooterNav1', FooterNav1)
diff --git a/front-end/src/router/index.ts b/front-end/src/router/index.ts
index 538c132..4238621 100644
--- a/front-end/src/router/index.ts
+++ b/front-end/src/router/index.ts
@@ -1,5 +1,71 @@
+import { watch } from 'vue';
+import { useSession } from '@vnuge/vnlib.browser';
+import { useSessionStorage, get, set } from '@vueuse/core';
+import { includes, map, toLower } from 'lodash-es';
+import { type Router } from 'vue-router';
+
import { createRouter, createWebHistory } from 'vue-router/auto'
+import { routes } from 'vue-router/auto-routes'
export default createRouter({
- history: createWebHistory(import.meta.env.BASE_URL)
-}) \ No newline at end of file
+ history: createWebHistory(import.meta.env.BASE_URL),
+ routes
+})
+
+/**
+ * Enables page guards for protected routes and configures a
+ * last page store.
+ */
+export const guardRoutes = (router: Router, protectedRoutes: string[]) => {
+ const { loggedIn } = useSession()
+
+ const lastPageStore = useSessionStorage('lastPageStore', undefined)
+
+ const { beforeEach, currentRoute, afterEach, push } = router
+
+ //Convert routes to lowercase
+ protectedRoutes = map(protectedRoutes, toLower);
+
+ //Setup nav guards
+ beforeEach((to, from) => {
+ if (!to.name) {
+ return true;
+ }
+
+ if (!get(loggedIn)) {
+ if (includes(protectedRoutes, toLower(to.name as string))) {
+
+ //Set last page as from page
+ set(lastPageStore, from.fullPath)
+
+ return { name: 'Login' }
+ }
+ }
+ else {
+ /**
+ * If the user is going back to the login page, are logged in,
+ * and have a previous page to go back to, redirect to the last page
+ * instead of the login page
+ */
+ const lastPath = get(lastPageStore);
+
+ if (to.name === 'Login' && lastPath) {
+ set(lastPageStore, undefined) //Clear the last page
+ return lastPath ? { path: lastPath } : true;
+ }
+ }
+
+ //Allow
+ return true;
+ })
+
+ //scroll window back to top
+ afterEach(() => window.scrollTo(0, 0))
+
+ watch(loggedIn, (li) => {
+ //If the user gets logged out, redirect to login
+ if (li === false && currentRoute.value.name !== 'Login') {
+ push({ name: 'Login' })
+ }
+ })
+} \ No newline at end of file
diff --git a/front-end/src/store/globalState.ts b/front-end/src/store/globalState.ts
index 9e700eb..26001c7 100644
--- a/front-end/src/store/globalState.ts
+++ b/front-end/src/store/globalState.ts
@@ -1,5 +1,4 @@
import 'pinia'
-import { shallowRef } from 'vue';
import { useAutoHeartbeat } from '@vnuge/vnlib.browser';
import { toRefs, useLocalStorage } from '@vueuse/core';
import { PiniaPluginContext, PiniaPlugin } from 'pinia'
@@ -22,7 +21,7 @@ export const globalStatePlugin: PiniaPlugin = ({ store }: PiniaPluginContext) =>
const { ahEnabled } = toRefs(mainState)
//Setup heartbeat for 5 minutes
- useAutoHeartbeat(shallowRef(5 * 60 * 1000), ahEnabled)
+ useAutoHeartbeat(5 * 60 * 1000, ahEnabled)
return{
autoHeartbeat: ahEnabled,
diff --git a/front-end/src/store/socialMfaPlugin.ts b/front-end/src/store/socialMfaPlugin.ts
index 2f78f3a..79cb088 100644
--- a/front-end/src/store/socialMfaPlugin.ts
+++ b/front-end/src/store/socialMfaPlugin.ts
@@ -1,11 +1,10 @@
-
import 'pinia'
import { MaybeRef } from 'vue';
import {
useUser,
useOauthLogin,
- useSocialDefaultLogout,
- fetchSocialPortals,
+ useSocialDefaultLogout,
+ fetchSocialPortals,
fromSocialPortals,
fromSocialConnections,
} from '@vnuge/vnlib.browser'
@@ -41,25 +40,22 @@ export const socialMfaPlugin = (portalEndpoint?: MaybeRef<string>): PiniaPlugin
}
/*
- Try to load social methods from server, if it fails, then we will
- fall back to default
+ Try to load social methods from server, if it fails, then we will
+ fall back to default
*/
defer(async () => {
try {
-
+
const portals = await fetchSocialPortals(get(portalEndpoint)!);
const social = fromSocialPortals(portals);
const methods = fromSocialConnections(social);
//Create social login from available portals
const login = useOauthLogin(methods);
-
- const socialOauth = useSocialDefaultLogout(login, logout);
-
- console.log(login.methods)
+ const socialOauth = useSocialDefaultLogout(login, logout);
resolve(socialOauth)
} catch (error) {
diff --git a/front-end/src/views/Account/[comp].vue b/front-end/src/views/Account/[comp].vue
index 713a6fe..6c1fb7c 100644
--- a/front-end/src/views/Account/[comp].vue
+++ b/front-end/src/views/Account/[comp].vue
@@ -119,10 +119,6 @@ const onTabChange = (tabid: number) => {
@apply dark:text-white text-black;
}
- .text-color-background{
- @apply text-gray-500;
- }
-
.panel-container .panel-header{
@apply flex flex-row px-2;
}
diff --git a/front-end/src/views/Account/components/oauth/Oauth.vue b/front-end/src/views/Account/components/oauth/Oauth.vue
index d269689..2c49786 100644
--- a/front-end/src/views/Account/components/oauth/Oauth.vue
+++ b/front-end/src/views/Account/components/oauth/Oauth.vue
@@ -51,7 +51,7 @@ store.oauth2.refresh();
</div>
</div>
<div class="px-2 my-10">
- <div class="m-auto text-sm">
+ <div class="m-auto text-sm text-bg">
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
diff --git a/front-end/src/views/Account/components/oauth/SingleApplication.vue b/front-end/src/views/Account/components/oauth/SingleApplication.vue
index 60bad68..e2cf34c 100644
--- a/front-end/src/views/Account/components/oauth/SingleApplication.vue
+++ b/front-end/src/views/Account/components/oauth/SingleApplication.vue
@@ -126,7 +126,7 @@ const closeNewSecret = () => set(newSecret, null);
<button class="btn no-border xs" @click="toggleEdit(true)">Edit</button>
</div>
</div>
- <div class="px-3 py-1 text-color-background">
+ <div class="px-3 py-1 text-bg">
<div class="my-1">
<span> Client ID: </span>
<span class="font-mono text-color-foreground">{{ clientId }}</span>
diff --git a/front-end/src/views/Account/components/profile/Profile.vue b/front-end/src/views/Account/components/profile/Profile.vue
index 106c8b9..c3af26a 100644
--- a/front-end/src/views/Account/components/profile/Profile.vue
+++ b/front-end/src/views/Account/components/profile/Profile.vue
@@ -87,7 +87,7 @@ watch(editMode, () => v$.value.$reset())
<div>
- <p class="profile-text text-color-background">
+ <p class="profile-text text-bg">
You may set or change your profile information here. All fields are optional,
but some features may not work without some information.
</p>
diff --git a/front-end/src/views/Account/components/settings/Fido.vue b/front-end/src/views/Account/components/settings/Fido.vue
index 9303541..350764d 100644
--- a/front-end/src/views/Account/components/settings/Fido.vue
+++ b/front-end/src/views/Account/components/settings/Fido.vue
@@ -40,7 +40,7 @@ const Setup = () => { }
</button>
</div>
</div>
- <p class="p-1 pt-3 text-sm text-color-background">
+ <p class="p-1 pt-3 text-sm text-bg">
WebAuthN/FIDO is not yet supported, due to complexity and browser support.
</p>
</div>
diff --git a/front-end/src/views/Account/components/settings/PasswordReset.vue b/front-end/src/views/Account/components/settings/PasswordReset.vue
index 61fda7d..896e9f6 100644
--- a/front-end/src/views/Account/components/settings/PasswordReset.vue
+++ b/front-end/src/views/Account/components/settings/PasswordReset.vue
@@ -2,8 +2,9 @@
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, VuelidateInstance } from '@vnuge/vnlib.browser'
+import { useUser, apiCall, useMessage, useWait, useVuelidateWrapper, VuelidateInstance } from '@vnuge/vnlib.browser'
import { MaybeRef, computed, reactive, ref, toRefs, watch } from 'vue'
+import { set } from '@vueuse/core';
const props = defineProps<{
totpEnabled: boolean,
@@ -37,7 +38,6 @@ const formSchema = ref({
const { waiting } = useWait()
const { onInput } = useMessage()
-const { reveal } = useConfirm()
const { resetPassword } = useUser()
const pwResetShow = ref(false)
@@ -93,13 +93,7 @@ watch(showTotpCode, (val) => {
}
})
-const showForm = async function () {
- const { isCanceled } = await reveal({
- title: 'Reset Password',
- text: 'Are you sure you want to reset your password? This cannot be reversed.'
- })
- pwResetShow.value = !isCanceled
-}
+const showForm = () => set(pwResetShow, true)
const onSubmit = async () => {
@@ -168,7 +162,7 @@ const resetForm = () => {
</div>
</div>
- <p class="mt-3 text-sm text-color-background">
+ <p class="mt-3 text-sm text-bg">
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>
diff --git a/front-end/src/views/Account/components/settings/Pki.vue b/front-end/src/views/Account/components/settings/Pki.vue
index 957a188..0c49cf7 100644
--- a/front-end/src/views/Account/components/settings/Pki.vue
+++ b/front-end/src/views/Account/components/settings/Pki.vue
@@ -211,7 +211,7 @@ const onSubmitKeys = async () => {
</table>
</div>
- <p v-else class="p-1 pt-3 text-sm text-color-background">
+ <p v-else class="p-1 pt-3 text-sm bg">
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.
diff --git a/front-end/src/views/Account/components/settings/Security.vue b/front-end/src/views/Account/components/settings/Security.vue
index ae0d143..cbc07b6 100644
--- a/front-end/src/views/Account/components/settings/Security.vue
+++ b/front-end/src/views/Account/components/settings/Security.vue
@@ -62,7 +62,7 @@ const pkiEnabled = computed(() => !isNil(store.pki))
</div>
</div>
- <p class="p-1 text-sm text-color-background">
+ <p class="p-1 text-sm text-bg">
When enabled, continuously regenerates your login credentials to keep you logged in. The longer you are logged in,
the easier session fixation attacks become. If disabled, you will need to log when your credentials have expired.
It is recommneded that you leave this <strong>off</strong>.
diff --git a/front-end/src/views/Account/components/settings/TotpSettings.vue b/front-end/src/views/Account/components/settings/TotpSettings.vue
index 04a261b..7a93456 100644
--- a/front-end/src/views/Account/components/settings/TotpSettings.vue
+++ b/front-end/src/views/Account/components/settings/TotpSettings.vue
@@ -191,7 +191,7 @@ const CloseQrWindow = () => {
</span>
</p>
- <p class="py-2 text-color-background">
+ <p class="py-2 text-bg">
Please enter your code from your authenticator app to continue.
</p>
@@ -236,7 +236,7 @@ const CloseQrWindow = () => {
<span class="pl-2">Setup</span>
</button>
</div>
- <p class="p-1 pt-3 text-sm text-color-background">
+ <p class="p-1 pt-3 text-sm text-bg">
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
diff --git a/front-end/src/views/Blog/components/Channels.vue b/front-end/src/views/Blog/components/Channels.vue
index df71720..8de6527 100644
--- a/front-end/src/views/Blog/components/Channels.vue
+++ b/front-end/src/views/Blog/components/Channels.vue
@@ -1,22 +1,4 @@
-<template>
- <div id="channel-editor">
- <EditorTable title="Manage channels" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
- <template #table>
- <ChannelTable
- :items="items"
- @open-edit="openEdit"
- />
- </template>
- <template #editor>
- <ChannelEdit
- @close="closeEdit"
- @on-submit="onSubmit"
- @on-delete="onDelete"
- />
- </template>
- </EditorTable>
- </div>
-</template>
+
<script setup lang="ts">
import { computed } from 'vue';
@@ -33,7 +15,7 @@ const emit = defineEmits(['close'])
const store = useStore()
const { items, pagination } = store.channels.createPages()
-const showEdit = computed(() => !isEmpty(store.channels.editChannel))
+const showEdit = computed(() => !isEmpty(store.channels.editId))
const openEdit = (channel: BlogChannel) => store.channels.editId = channel.id;
@@ -44,13 +26,13 @@ const closeEdit = (update?:boolean) => {
store.channels.refresh()
}
//Reset page to top
- window.scrollTo(0, 0)
+ window.scrollTo({ top: 0, behavior: 'smooth' })
}
const openNew = () => {
- store.channels.editId = 'new'
+ store.channels.editId = 'new'
//Reset page to top
- window.scrollTo(0, 0)
+ window.scrollTo({ top: 0, behavior: 'smooth' })
}
const onSubmit = async ({channel, feed} : { channel:BlogChannel, feed? : ChannelFeed}) => {
@@ -86,6 +68,15 @@ const onDelete = async (channel : BlogChannel) => {
</script>
-<style lang="scss">
-
-</style> \ No newline at end of file
+<template>
+ <div id="channel-editor">
+ <EditorTable title="Manage channels" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
+ <template #table>
+ <ChannelTable :items="items" @open-edit="openEdit" />
+ </template>
+ <template #editor>
+ <ChannelEdit @close="closeEdit" @on-submit="onSubmit" @on-delete="onDelete" />
+ </template>
+ </EditorTable>
+ </div>
+</template>
diff --git a/front-end/src/views/Blog/components/Content.vue b/front-end/src/views/Blog/components/Content.vue
index 5e81629..568f2d1 100644
--- a/front-end/src/views/Blog/components/Content.vue
+++ b/front-end/src/views/Blog/components/Content.vue
@@ -1,43 +1,3 @@
-<template>
- <div id="content-editor" class="">
- <EditorTable title="Manage content" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
- <template #table>
- <ContentTable
- :items="items"
- @open-edit="openEdit"
- @copy-link="copyLink"
- @delete="onDelete"
- @download="onDownload"
- />
- </template>
- <template #editor>
- <div v-if="showProgress" class="max-w-xl mx-auto">
- <span id="ProgressLabel" class="sr-only">Loading</span>
-
- <span
- role="progressbar"
- aria-labelledby="ProgressLabel"
- :aria-valuenow="uploadProgress"
- class="relative block bg-gray-200 rounded-full dark:bg-dark-500"
- >
- <span class="absolute inset-0 flex items-center justify-center text-[10px]/4">
- <span class="font-bold text-white "> {{ loadingProgress }} </span>
- </span>
-
- <span class="block h-4 text-center rounded-full bg-primary-600" :style="progressWidth"></span>
- </span>
- </div>
- <ContentEditor
- @submit="onSubmit"
- @close="closeEdit"
- @delete="onDelete"
- />
- </template>
- </EditorTable>
- <a class="hidden" ref="downloadAnchor"></a>
- </div>
-</template>
-
<script setup lang="ts">
import { computed, shallowRef } from 'vue';
import { isEmpty } from 'lodash-es';
@@ -50,7 +10,6 @@ import EditorTable from './EditorTable.vue';
import ContentEditor from './Content/ContentEditor.vue';
import ContentTable from './Content/ContentTable.vue';
-
const store = useStore()
const { uploadProgress } = storeToRefs(store)
const { items, pagination } = store.content.createPages()
@@ -63,7 +22,6 @@ const loadingProgress = computed(() => `${uploadProgress.value}%`);
const progressWidth = computed(() => ({ width: `${uploadProgress.value}%` }));
const showProgress = computed(() => uploadProgress.value > 0 && uploadProgress.value < 100);
-
const openEdit = async (item: ContentMeta) => store.content.selectedId = item.id
const closeEdit = (update?: boolean) => {
@@ -173,4 +131,44 @@ const onDownload = async (item: ContentMeta) => {
})
}
-</script> \ No newline at end of file
+</script>
+
+<template>
+ <div id="content-editor" class="">
+ <EditorTable title="Manage content" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
+ <template #table>
+ <ContentTable
+ :items="items"
+ @open-edit="openEdit"
+ @copy-link="copyLink"
+ @delete="onDelete"
+ @download="onDownload"
+ />
+ </template>
+ <template #editor>
+ <div v-if="showProgress" class="max-w-xl mx-auto">
+ <span id="ProgressLabel" class="sr-only">Loading</span>
+
+ <span
+ role="progressbar"
+ aria-labelledby="ProgressLabel"
+ :aria-valuenow="uploadProgress"
+ class="relative block bg-gray-200 rounded-full dark:bg-dark-500"
+ >
+ <span class="absolute inset-0 flex items-center justify-center text-[10px]/4">
+ <span class="font-bold text-white "> {{ loadingProgress }} </span>
+ </span>
+
+ <span class="block h-4 text-center rounded-full bg-primary-600" :style="progressWidth"></span>
+ </span>
+ </div>
+ <ContentEditor
+ @submit="onSubmit"
+ @close="closeEdit"
+ @delete="onDelete"
+ />
+ </template>
+ </EditorTable>
+ <a class="hidden" ref="downloadAnchor"></a>
+ </div>
+</template> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Content/ContentTable.vue b/front-end/src/views/Blog/components/Content/ContentTable.vue
index 98a76a4..dab63bd 100644
--- a/front-end/src/views/Blog/components/Content/ContentTable.vue
+++ b/front-end/src/views/Blog/components/Content/ContentTable.vue
@@ -1,3 +1,48 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref } from 'vue';
+import { filter as _filter, defaultTo, includes, truncate } from 'lodash-es';
+import { useClipboard } from '@vueuse/core';
+import { useWait } from '@vnuge/vnlib.browser';
+import { ContentMeta } from '@vnuge/cmnext-admin';
+const ImgPreviewDialog = defineAsyncComponent(() => import('../image-preview-dialog.vue'))
+
+const emit = defineEmits(['open-edit', 'copy-link', 'delete', 'download'])
+defineProps<{ items: ContentMeta[] }>()
+
+const { waiting } = useWait()
+const { copy } = useClipboard()
+
+const previewItem = ref<ContentMeta | undefined>()
+
+const getDateString = (time?: number) => new Date((time || 0) * 1000).toLocaleString();
+const getItemLength = (item: ContentMeta) : string =>{
+ const length = item.length || 0;
+ return length > 1024 ? `${(length / 1024).toFixed(2)} KB` : `${length} B`
+}
+const getItemId = (item: ContentMeta) => truncate(item.id || '', { length: 20 })
+const getItemName = (item : ContentMeta) => truncate(item.name || '', { length: 30 })
+
+const getContentIconType = (item: ContentMeta) => {
+ const type = defaultTo(item.content_type, '')
+ if (includes(type, 'image')) return 'image'
+ if (includes(type, 'video')) return 'video'
+ if (includes(type, 'audio')) return 'headphones'
+ if (includes(type, 'html')) return 'code'
+ if (includes(type, 'zip')) return 'file-zipper'
+ return 'file'
+}
+
+const isImage = (item: ContentMeta) => includes(item.content_type, 'image')
+const openEdit = async (item: ContentMeta) => emit('open-edit', item)
+const copyLink = (item : ContentMeta) => emit('copy-link', item)
+const deleteItem = (item : ContentMeta) => emit('delete', item)
+const download = (item : ContentMeta) => emit('download', item)
+
+const onShowPreview = (item: ContentMeta) => previewItem.value = item
+const onClosePreview = () => previewItem.value = undefined
+
+</script>
+
<template>
<thead>
<tr>
@@ -63,48 +108,3 @@
<!-- Image preview dialog -->
<ImgPreviewDialog :item="previewItem" @close="onClosePreview()" />
</template>
-
-<script setup lang="ts">
-import { defineAsyncComponent, ref } from 'vue';
-import { filter as _filter, defaultTo, includes, truncate } from 'lodash-es';
-import { useClipboard } from '@vueuse/core';
-import { useWait } from '@vnuge/vnlib.browser';
-import { ContentMeta } from '@vnuge/cmnext-admin';
-const ImgPreviewDialog = defineAsyncComponent(() => import('../image-preview-dialog.vue'))
-
-const emit = defineEmits(['open-edit', 'copy-link', 'delete', 'download'])
-defineProps<{ items: ContentMeta[] }>()
-
-const { waiting } = useWait()
-const { copy } = useClipboard()
-
-const previewItem = ref<ContentMeta | undefined>()
-
-const getDateString = (time?: number) => new Date((time || 0) * 1000).toLocaleString();
-const getItemLength = (item: ContentMeta) : string =>{
- const length = item.length || 0;
- return length > 1024 ? `${(length / 1024).toFixed(2)} KB` : `${length} B`
-}
-const getItemId = (item: ContentMeta) => truncate(item.id || '', { length: 20 })
-const getItemName = (item : ContentMeta) => truncate(item.name || '', { length: 30 })
-
-const getContentIconType = (item: ContentMeta) => {
- const type = defaultTo(item.content_type, '')
- if (includes(type, 'image')) return 'image'
- if (includes(type, 'video')) return 'video'
- if (includes(type, 'audio')) return 'headphones'
- if (includes(type, 'html')) return 'code'
- if (includes(type, 'zip')) return 'file-zipper'
- return 'file'
-}
-
-const isImage = (item: ContentMeta) => includes(item.content_type, 'image')
-const openEdit = async (item: ContentMeta) => emit('open-edit', item)
-const copyLink = (item : ContentMeta) => emit('copy-link', item)
-const deleteItem = (item : ContentMeta) => emit('delete', item)
-const download = (item : ContentMeta) => emit('download', item)
-
-const onShowPreview = (item: ContentMeta) => previewItem.value = item
-const onClosePreview = () => previewItem.value = undefined
-
-</script>
diff --git a/front-end/src/views/Blog/components/ContentSearch.vue b/front-end/src/views/Blog/components/ContentSearch.vue
index 7871196..0970bf1 100644
--- a/front-end/src/views/Blog/components/ContentSearch.vue
+++ b/front-end/src/views/Blog/components/ContentSearch.vue
@@ -1,39 +1,3 @@
-<template>
- <div id="content-search" class="my-4">
- <div class="">
- <div class="">
- <input class="w-full input primary" placeholder="Search..." v-model="search" />
- </div>
- </div>
- <div class="search-results">
- <div v-if="searchResults.length == 0" class="result">
- No results found.
- </div>
- <div v-else v-for="result in searchResults" :key="result.id" @click.prevent="onSelected(result)" class="result">
- <div class="flex-auto result name">
- {{ result.shortName }}
- </div>
- <div class="result id">
- {{ result.shortId }}
- </div>
- <div class="rseult controls">
- <div v-if="waiting">
- <fa-icon icon="spinner" spin />
- </div>
- <div v-else-if="result.copied.value" class="text-sm text-amber-500">
- copied
- </div>
- <div v-else class="">
- <button class="btn secondary sm borderless" @click="result.copyLink()">
- <fa-icon icon="link" />
- </button>
- </div>
- </div>
- </div>
- </div>
- </div>
-</template>
-
<script setup lang="ts">
import { useClipboard } from '@vueuse/core';
import { apiCall, useWait } from '@vnuge/vnlib.browser';
@@ -89,6 +53,42 @@ const onSelected = (result: ContentResult) => {
</script>
+<template>
+ <div id="content-search" class="my-4">
+ <div class="">
+ <div class="">
+ <input class="w-full input primary" placeholder="Search..." v-model="search" />
+ </div>
+ </div>
+ <div class="search-results">
+ <div v-if="searchResults.length == 0" class="result">
+ No results found.
+ </div>
+ <div v-else v-for="result in searchResults" :key="result.id" @click.prevent="onSelected(result)" class="result">
+ <div class="flex-auto result name">
+ {{ result.shortName }}
+ </div>
+ <div class="result id">
+ {{ result.shortId }}
+ </div>
+ <div class="rseult controls">
+ <div v-if="waiting">
+ <fa-icon icon="spinner" spin />
+ </div>
+ <div v-else-if="result.copied.value" class="text-sm text-amber-500">
+ copied
+ </div>
+ <div v-else class="">
+ <button class="btn secondary sm borderless" @click="result.copyLink()">
+ <fa-icon icon="link" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
<style lang="scss">
.search-results{
diff --git a/front-end/src/views/Blog/components/EditorTable.vue b/front-end/src/views/Blog/components/EditorTable.vue
index 4ec5a33..a7f4f66 100644
--- a/front-end/src/views/Blog/components/EditorTable.vue
+++ b/front-end/src/views/Blog/components/EditorTable.vue
@@ -1,3 +1,25 @@
+<script setup lang="ts">
+import { toRefs } from 'vue';
+import { useWait } from '@vnuge/vnlib.browser';
+import { UseOffsetPaginationReturn } from '@vueuse/core';
+
+const emit = defineEmits(['open-new'])
+const props = defineProps<{
+ title: string,
+ showEdit: boolean,
+ pagination: UseOffsetPaginationReturn
+}>()
+
+const { showEdit } = toRefs(props)
+const { waiting } = useWait()
+
+//Get pagination
+const { pageCount, next, prev, isLastPage, isFirstPage, currentPage } = props.pagination
+
+const openNew = () => emit('open-new')
+
+</script>
+
<template>
<slot class="flex flex-row">
<div class="flex-1 px-4 mt-3">
@@ -6,12 +28,12 @@
<div class="w-[20rem]">
<h4>{{ $props.title }}</h4>
</div>
- <div class="h-full">
- <div :class="{'opacity-100':waiting}" class="opacity-0">
+ <div class="h-full">
+ <div :class="{ 'opacity-100': waiting }" class="opacity-0">
<fa-icon icon="spinner" class="animate-spin" />
</div>
</div>
- <div class="mt-auto">
+ <div class="mt-auto">
<div class="flex justify-center">
<nav aria-label="Pagination">
<ul class="inline-flex items-center space-x-1 text-sm rounded-md">
@@ -22,11 +44,11 @@
</li>
<li>
<span class="inline-flex items-center px-4 py-2 space-x-1">
- Page
+ Page
<b class="mx-1">
{{ currentPage }}
</b>
- of
+ of
<b class="ml-1">
{{ pageCount }}
</b>
@@ -41,16 +63,16 @@
</nav>
</div>
</div>
-
+
<div class="h-fit">
- <button class="rounded btn primary sm" @click="openNew">
+ <button class="rounded btn primary sm" id="new-btn" @click="openNew">
<fa-icon :icon="['fas', 'plus']" class="mr-2" />
New
</button>
</div>
</div>
<table class="edit-table">
- <slot name="table" />
+ <slot name="table" />
</table>
</div>
<div v-else class="">
@@ -60,37 +82,10 @@
</slot>
</template>
-<script setup lang="ts">
-import { toRefs } from 'vue';
-import { useWait } from '@vnuge/vnlib.browser';
-import { UseOffsetPaginationReturn } from '@vueuse/core';
-
-const emit = defineEmits(['open-new'])
-const props = defineProps<{
- title: string,
- showEdit: boolean,
- pagination: UseOffsetPaginationReturn
-}>()
-
-const { showEdit } = toRefs(props)
-
-const { waiting } = useWait()
-
-//Get pagination
-const { pageCount, next, prev, isLastPage, isFirstPage, currentPage } = props.pagination
-
-const openNew = () => {
- emit('open-new')
-}
-
-</script>
-
<style lang="scss">
-
-button.page-button{
+button.page-button {
@apply inline-flex items-center px-2 py-1.5 space-x-2 font-medium;
@apply text-gray-500 bg-white border border-gray-300 rounded-full hover:bg-gray-50;
- @apply dark:border-dark-300 dark:bg-transparent dark:text-gray-300 hover:dark:bg-dark-700;
+ @apply dark:border-dark-300 dark:bg-transparent dark:text-gray-300 hover:dark:bg-dark-700;
}
-
</style> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/FeedFields.vue b/front-end/src/views/Blog/components/FeedFields.vue
index e38c3d7..2d725f9 100644
--- a/front-end/src/views/Blog/components/FeedFields.vue
+++ b/front-end/src/views/Blog/components/FeedFields.vue
@@ -1,40 +1,3 @@
-<template>
- <div id="feed-custom-fields">
- <div class="my-3 text-center">
- <h4>Feed custom fields</h4>
- </div>
-
- <div v-if="cleanXml" class="w-full max-w-2xl mx-auto">
- <pre class="xml">
-{{ cleanXml }}
- </pre>
- </div>
-
-
- <div class="my-2 ml-auto w-fit">
- <div v-if="!editMode" class="button-group">
- <button class="btn" @click="edit">Edit</button>
- </div>
- <div v-else class="button-group">
- <button class="btn primary" @click="save" >Update</button>
- <button class="btn" @click="cancel">Cancel</button>
- </div>
- </div>
-
-
- <div v-if="editMode" class="flex flex-col">
- <div v-if="$props.showEpAdder" class="mb-2">
- <EpAdder @submit="onAddEnclosure" />
- </div>
-
- <div class="">
- <JsonEditorVue :ask-to-format="true" class="json" v-model="jsonFeedData"/>
- </div>
- </div>
-
- </div>
-</template>
-
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue';
import { FeedProperty, UseXmlProperties } from '@vnuge/cmnext-admin';
@@ -100,6 +63,44 @@ const onAddEnclosure = (props: FeedProperty[]) =>{
</script>
+<template>
+ <div id="feed-custom-fields">
+ <div class="my-3 text-center">
+ <h4>Feed custom fields</h4>
+ </div>
+
+ <div v-if="cleanXml" class="w-full max-w-2xl mx-auto">
+ <pre class="xml">
+{{ cleanXml }}
+ </pre>
+ </div>
+
+
+ <div class="my-2 ml-auto w-fit">
+ <div v-if="!editMode" class="button-group">
+ <button class="btn" @click="edit">Edit</button>
+ </div>
+ <div v-else class="button-group">
+ <button class="btn primary" @click="save" >Update</button>
+ <button class="btn" @click="cancel">Cancel</button>
+ </div>
+ </div>
+
+
+ <div v-if="editMode" class="flex flex-col">
+ <div v-if="$props.showEpAdder" class="mb-2">
+ <EpAdder @submit="onAddEnclosure" />
+ </div>
+
+ <div class="">
+ <JsonEditorVue :ask-to-format="true" class="json" v-model="jsonFeedData"/>
+ </div>
+ </div>
+
+ </div>
+</template>
+
+
<style lang="scss">
#feed-custom-fields{
diff --git a/front-end/src/views/Login/components/Social.vue b/front-end/src/views/Login/components/Social.vue
index 3c93d0e..2087524 100644
--- a/front-end/src/views/Login/components/Social.vue
+++ b/front-end/src/views/Login/components/Social.vue
@@ -1,13 +1,29 @@
<script setup lang="ts">
-import { shallowRef } from 'vue'
import { apiCall, useWait, type OAuthMethod } from '@vnuge/vnlib.browser'
-import { capitalize } from 'lodash-es';
+import { capitalize, map } from 'lodash-es';
import { useStore } from '../../../store';
+import { useAsyncState } from '@vueuse/core';
+import { shallowRef } from 'vue';
+import { Mutable } from '@vueuse/core';
const { waiting } = useWait()
const store = useStore()
+const buttonCont = shallowRef<HTMLDivElement | null>(null)
+
+const filterSvgIcon = (oauth: OAuthMethod[]) => {
+ return map(oauth, (method: Mutable<OAuthMethod>) => {
+ //parse the base64 icon as an svg
+ if (method.icon) {
+ return{
+ ...method,
+ icon: atob(method.icon).replace(/(width|height)="[^"]*"/g, '')
+ }
+ }
+ return method;
+ })
+}
-const methods = shallowRef<OAuthMethod[]>([])
+const { state: methods, isReady } = useAsyncState(store.socialOauth().then(p => filterSvgIcon(p.methods)), []);
//Invoke login wrapped in api call
const submitLogin = (method: OAuthMethod) => apiCall(async () => {
@@ -15,34 +31,24 @@ const submitLogin = (method: OAuthMethod) => apiCall(async () => {
await beginLoginFlow(method)
})
-const getIcon = (method: OAuthMethod): string[] => {
- switch (method.id) {
- case 'auth0':
- return ['fa', 'certificate']
- default:
- return ['fab', method.id]
- }
-}
-
-//Load methods once the fetch completes
-store.socialOauth().then(m => methods.value = m.methods);
-
</script>
<template>
- <div class="flex flex-col gap-3">
+ <div ref="buttonCont" v-if="isReady" 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" />
+ <button type="submit" class="btn social-button" :disabled="waiting" @click.prevent="submitLogin(method)">
+
+ <div v-html="method.icon" class="w-6 h-6" >
+ </div>
+
Login with {{ capitalize(method.id) }}
</button>
</div>
</div>
+ <div v-else class="my-8">
+ <fa-icon icon="spinner" size="2xl" spin />
+ </div>
+
</template>
diff --git a/front-end/src/views/Login/components/UserPass.vue b/front-end/src/views/Login/components/UserPass.vue
index bc9d8d1..16c8aab 100644
--- a/front-end/src/views/Login/components/UserPass.vue
+++ b/front-end/src/views/Login/components/UserPass.vue
@@ -1,67 +1,13 @@
-<template>
- <div class="">
- <h3>Login</h3>
- <div v-if="mfaUpgrade?.type === MfaMethod.TOTP">
- <Totp @clear="totpClear" :upgrade="mfaUpgrade" />
- </div>
-
- <form v-else id="user-pass-submit-form" method="post" action="/login" @submit.prevent="SubmitLogin">
- <fieldset class="" :disabled="waiting" >
- <div>
- <div class="float-label">
- <input
- id="username"
- v-model="v$.username.$model"
- type="email"
- class="w-full primary input"
- placeholder="Email"
- :class="{ 'data-invalid': v$.username.$invalid }"
- @input="onInput"
- >
- <label for="username">Email</label>
- </div>
- </div>
- <div class="py-3">
- <div class="mb-2 float-label">
- <input
- id="password"
- v-model="v$.password.$model"
- type="password"
- class="w-full primary input"
- placeholder="Password"
- :class="{ 'data-invalid': v$.password.$invalid }"
- @input="onInput"
- >
- <label for="password">Password</label>
- </div>
- </div>
- </fieldset>
- <button type="submit" form="user-pass-submit-form" class="btn primary" :disabled="waiting">
- <!-- Display spinner if waiting, otherwise the sign-in icon -->
- <fa-icon :class="{'animate-spin':waiting}" :icon="waiting ? 'spinner' : 'sign-in-alt'"/>
- Log-in
- </button>
- <div class="flex flex-row justify-between gap-3 pt-3 pb-2 form-links">
- <router-link to="/pwreset">
- Forgot password
- </router-link>
- <router-link to="/register">
- Register a new account
- </router-link>
- </div>
- </form>
- </div>
-</template>
<script setup lang="ts">
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 { isEmpty } from 'lodash-es'
import { required, maxLength, minLength, email, helpers } from '@vuelidate/validators'
import {
- useVuelidateWrapper, useMfaLogin, totpMfaProcessor, IMfaFlowContinuiation, MfaMethod,
+ useVuelidateWrapper, useMfaLogin, totpMfaProcessor, IMfaFlowContinuiation,
apiCall, useMessage, useWait, debugLog, WebMessage,
type VuelidateInstance
} from '@vnuge/vnlib.browser'
@@ -126,12 +72,21 @@ const SubmitLogin = async () => {
//Try to get response as a flow continuation
const mfa = response as IMfaFlowContinuiation
- // Response is a totp upgrade request
- if (isEqual(mfa.type, MfaMethod.TOTP)) {
- //Store the upgrade message
+ // Response is an mfa upgrade
+ if (!isEmpty(mfa.type)) {
+
+ /**
+ * If mfa has a type assicated, then we should have a handler matched
+ * with it to continue the flow
+ *
+ * All mfa upgrades will have a token expiration, and an assoicated
+ * type string name (string)
+ */
+
set(mfaUpgrade, mfa);
- //Setup timeout timer
+
set(mfaTimeout, mfa.expires! * 1000);
+
mfaTimer.start();
}
//If login without mfa was successful
@@ -145,11 +100,51 @@ const SubmitLogin = async () => {
})
}
-const totpClear = () => {
- //Clear timer
+const mfaClear = () => {
mfaTimer.stop();
- //Clear upgrade message
set(mfaUpgrade, undefined);
}
-</script> \ No newline at end of file
+</script>
+
+<template>
+ <div class="">
+ <h3>Login</h3>
+
+ <div v-if="mfaUpgrade?.type === 'totp'">
+ <Totp @clear="mfaClear()" :upgrade="mfaUpgrade" />
+ </div>
+
+ <form v-else id="user-pass-submit-form" method="post" action="/login" @submit.prevent="SubmitLogin">
+ <fieldset class="" :disabled="waiting">
+ <div>
+ <div class="float-label">
+ <input id="username" v-model="v$.username.$model" type="email" class="w-full primary input"
+ placeholder="Email" :class="{ 'data-invalid': v$.username.$invalid }" @input="onInput">
+ <label for="username">Email</label>
+ </div>
+ </div>
+ <div class="py-3">
+ <div class="mb-2 float-label">
+ <input id="password" v-model="v$.password.$model" type="password" class="w-full primary input"
+ placeholder="Password" :class="{ 'data-invalid': v$.password.$invalid }" @input="onInput">
+ <label for="password">Password</label>
+ </div>
+ </div>
+ </fieldset>
+ <button type="submit" form="user-pass-submit-form" class="btn primary" :disabled="waiting">
+ <!-- Display spinner if waiting, otherwise the sign-in icon -->
+ <fa-icon :class="{ 'animate-spin': waiting }" :icon="waiting ? 'spinner' : 'sign-in-alt'" />
+ Log-in
+ </button>
+ <div class="flex flex-row justify-between gap-3 pt-3 pb-2 form-links">
+ <router-link to="/pwreset">
+ Forgot password
+ </router-link>
+ <router-link to="/register">
+ Register a new account
+ </router-link>
+ </div>
+ </form>
+ </div>
+</template> \ No newline at end of file
diff --git a/front-end/src/views/Login/index.vue b/front-end/src/views/Login/index.vue
index 476ebf4..8532390 100644
--- a/front-end/src/views/Login/index.vue
+++ b/front-end/src/views/Login/index.vue
@@ -38,7 +38,7 @@ const submitLogout = async () => {
<div class="login-container">
<div v-if="!loggedIn">
- <UserPass/>
+ <UserPass />
</div>
<div v-else>
@@ -46,13 +46,13 @@ const submitLogout = async () => {
<p class="mt-3 mb-5 text-lg">
You are currently logged-in.
</p>
- <div class="">
- <button form="user-pass-submit-form" class="btn primary" @click="submitLogout" :disabled="waiting">
- <!-- Display spinner if waiting, otherwise the sign-in icon -->
- <fa-icon :class="{'animate-spin':waiting}" :icon="waiting ? 'spinner' : 'sign-in-alt'"/>
- Log-out
- </button>
- </div>
+ <div class="">
+ <button form="user-pass-submit-form" class="btn primary" @click="submitLogout" :disabled="waiting">
+ <!-- Display spinner if waiting, otherwise the sign-in icon -->
+ <fa-icon :class="{'animate-spin':waiting}" :icon="waiting ? 'spinner' : 'sign-in-alt'" />
+ Log-out
+ </button>
+ </div>
</div>
<div v-if="!loggedIn" class="w-full mt-6">
@@ -62,9 +62,13 @@ const submitLogout = async () => {
<!-- pki button, forward to the pki route -->
<div v-if="pkiEnabled" class="mt-4">
<router-link to="/login/pki">
- <button type="submit" class="btn red social-button" :disabled="waiting">
- <fa-icon :icon="['fa','certificate']" size="xl" />
- Login with PKI Credential
+ <button type="submit" class="btn social-button" :disabled="waiting">
+ <span>
+ <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 256 256">
+ <path fill="currentColor" d="M248 128a56 56 0 1 0-96 39.14V224a8 8 0 0 0 11.58 7.16L192 216.94l28.42 14.22A8 8 0 0 0 232 224v-56.86A55.81 55.81 0 0 0 248 128Zm-56-40a40 40 0 1 1-40 40a40 40 0 0 1 40-40Zm3.58 112.84a8 8 0 0 0-7.16 0L168 211.06v-32.47a55.94 55.94 0 0 0 48 0v32.47ZM136 192a8 8 0 0 1-8 8H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16a8 8 0 0 1-16 0H40v128h88a8 8 0 0 1 8 8Zm-16-56a8 8 0 0 1-8 8H72a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8Zm0-32a8 8 0 0 1-8 8H72a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8Z" />
+ </svg>
+ </span>
+ Login with OTP
</button>
</router-link>
</div>
@@ -90,9 +94,5 @@ const submitLogout = async () => {
@apply flex flex-row justify-center gap-3 items-center;
}
- a {
- @apply ease-in-out duration-100;
- @apply hover:text-primary-600 dark:hover:text-primary-500;
- }
}
</style>