Add frontend auth, dashboard & router guards
Introduce a complete frontend auth flow and protected dashboard. - Add auth session module (GUI/src/services/authSession.ts) with fetchCurrentUser, login, logout, caching and structured errors. - Add Dashboard page (GUI/src/routes/dashboard/Dashboard.vue) and a protected Dashboard route (meta.requiresAuth) at '/'. - Move public landing page to /welcome and mark it Visibility.Unauthenticated; update 404 and Impressum links. - Implement router guard (GUI/src/router/index.ts) to redirect unauthenticated users to Login and prevent logged-in users from accessing guest-only pages. - Update routes layout (GUI/src/plugins/routesLayout.ts) to include authenticated/unauthenticated visibility and dashboard entry. - Update Layout.vue to track current user, show username/menu, conditionally render sidebar items, add logout flow and error snackbar, and insert visual divider before auth-only items. - Convert Login.vue into a working login form with loading state, error handling and redirect after success. - Update codexInfo.md to document the new auth features and related UI/route changes.
This commit is contained in:
+178
-30
@@ -5,6 +5,12 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import iconImage from '@/assets/images/icon.svg'
|
||||
import { Visibility, routes } from '@/plugins/routesLayout'
|
||||
import {
|
||||
AuthRequestError,
|
||||
fetchCurrentUser,
|
||||
logout,
|
||||
type CurrentUser,
|
||||
} from '@/services/authSession'
|
||||
|
||||
const display = useDisplay()
|
||||
const theme = useTheme()
|
||||
@@ -13,6 +19,9 @@ const router = useRouter()
|
||||
const showDrawer = ref(true)
|
||||
const currentYear = new Date().getFullYear()
|
||||
const themeStorageKey = 'theme'
|
||||
const currentUser = ref<CurrentUser | null>(null)
|
||||
const isLoggingOut = ref(false)
|
||||
const authMessage = ref('')
|
||||
|
||||
function normalizeRoutePath(path: string) {
|
||||
if (!path || path === '/') {
|
||||
@@ -55,6 +64,14 @@ const sidebarRoutes = computed(() =>
|
||||
return true
|
||||
}
|
||||
|
||||
if (item.visible === Visibility.Authenticated) {
|
||||
return currentUser.value !== null
|
||||
}
|
||||
|
||||
if (item.visible === Visibility.Unauthenticated) {
|
||||
return currentUser.value === null
|
||||
}
|
||||
|
||||
if (item.visible !== Visibility.Route) {
|
||||
return false
|
||||
}
|
||||
@@ -64,6 +81,9 @@ const sidebarRoutes = computed(() =>
|
||||
)
|
||||
}),
|
||||
)
|
||||
const firstAuthenticatedSidebarIndex = computed(() =>
|
||||
sidebarRoutes.value.findIndex((item) => item.visible === Visibility.Authenticated),
|
||||
)
|
||||
const footerRoutes = computed(() => routes.filter((x) => x.visible === Visibility.Footer))
|
||||
|
||||
const activeRoute = computed(() => {
|
||||
@@ -85,6 +105,16 @@ const themeIcon = computed(() => (isDarkTheme.value ? 'mdi-white-balance-sunny'
|
||||
const themeLabel = computed(() =>
|
||||
isDarkTheme.value ? 'Hellen Modus aktivieren' : 'Dunklen Modus aktivieren',
|
||||
)
|
||||
const isAuthenticated = computed(() => currentUser.value !== null)
|
||||
const userLabel = computed(() => currentUser.value?.userName ?? 'Konto')
|
||||
const showAuthMessage = computed({
|
||||
get: () => authMessage.value.length > 0,
|
||||
set: (nextValue: boolean) => {
|
||||
if (!nextValue) {
|
||||
authMessage.value = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function applyTheme(nextTheme: 'light' | 'dark', persist = true) {
|
||||
theme.global.name.value = nextTheme
|
||||
@@ -103,6 +133,38 @@ function toggleTheme() {
|
||||
applyTheme(isDarkTheme.value ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
async function refreshAuthState(options: { force?: boolean } = {}) {
|
||||
try {
|
||||
currentUser.value = await fetchCurrentUser({ force: options.force === true })
|
||||
} catch {
|
||||
currentUser.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (isLoggingOut.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoggingOut.value = true
|
||||
authMessage.value = ''
|
||||
|
||||
try {
|
||||
await logout()
|
||||
currentUser.value = null
|
||||
await router.replace({ name: 'Login' })
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequestError) {
|
||||
authMessage.value = error.message
|
||||
} else {
|
||||
authMessage.value = 'Abmeldung fehlgeschlagen. Bitte versuche es erneut.'
|
||||
}
|
||||
} finally {
|
||||
isLoggingOut.value = false
|
||||
void refreshAuthState()
|
||||
}
|
||||
}
|
||||
|
||||
function changeWebsiteTitle() {
|
||||
if (activeRoute.value) {
|
||||
document.title = `Hoard | ${activeRoute.value.name}`
|
||||
@@ -119,12 +181,14 @@ onMounted(() => {
|
||||
|
||||
const storedDrawer = localStorage.getItem('drawer')
|
||||
showDrawer.value = storedDrawer ? storedDrawer.startsWith('Y') : !display.mobile.value
|
||||
void refreshAuthState({ force: true })
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
changeWebsiteTitle()
|
||||
void refreshAuthState()
|
||||
if (display.mobile.value) {
|
||||
showDrawer.value = false
|
||||
}
|
||||
@@ -177,23 +241,83 @@ watch(
|
||||
{{ themeLabel }}
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-if="!display.smAndDown.value" location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn variant="outlined" prepend-icon="mdi-account-circle-outline" to="/login" v-bind="props">
|
||||
Konto
|
||||
</v-btn>
|
||||
</template>
|
||||
Zum Login
|
||||
</v-tooltip>
|
||||
<template v-if="isAuthenticated">
|
||||
<v-menu
|
||||
v-if="!display.smAndDown.value"
|
||||
open-on-hover
|
||||
:open-on-click="false"
|
||||
location="bottom end"
|
||||
offset="8"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
class="account-button"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-account-check-outline"
|
||||
:to="{ name: 'Dashboard' }"
|
||||
v-bind="props"
|
||||
>
|
||||
{{ userLabel }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-tooltip v-else location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon to="/login" v-bind="props" aria-label="Zum Login">
|
||||
<v-icon>mdi-account-circle-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
Zum Login
|
||||
</v-tooltip>
|
||||
<v-list density="compact" class="account-menu-list">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-logout"
|
||||
:title="isLoggingOut ? 'Abmelden...' : 'Abmelden'"
|
||||
:disabled="isLoggingOut"
|
||||
@click="handleLogout"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-menu
|
||||
v-else
|
||||
location="bottom end"
|
||||
offset="8"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon v-bind="props" :aria-label="`Angemeldet als ${userLabel}`">
|
||||
<v-icon>mdi-account-check-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list density="compact" class="account-menu-list">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-view-dashboard-outline"
|
||||
title="Zum Dash"
|
||||
:to="{ name: 'Dashboard' }"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
prepend-icon="mdi-logout"
|
||||
:title="isLoggingOut ? 'Abmelden...' : 'Abmelden'"
|
||||
:disabled="isLoggingOut"
|
||||
@click="handleLogout"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-tooltip v-if="!display.smAndDown.value" location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn variant="outlined" prepend-icon="mdi-account-circle-outline" to="/login" v-bind="props">
|
||||
Konto
|
||||
</v-btn>
|
||||
</template>
|
||||
Zum Login
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-else location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon to="/login" v-bind="props" aria-label="Zum Login">
|
||||
<v-icon>mdi-account-circle-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
Zum Login
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
|
||||
@@ -211,22 +335,27 @@ watch(
|
||||
</div>
|
||||
|
||||
<v-list nav :density="display.mobile.value ? 'default' : 'comfortable'" class="px-1">
|
||||
<v-list-item
|
||||
v-for="item in sidebarRoutes"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:active="route.path === item.path"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
class="hoard-nav-item"
|
||||
rounded="lg"
|
||||
link
|
||||
/>
|
||||
<template v-for="(item, index) in sidebarRoutes" :key="item.path">
|
||||
<v-divider
|
||||
v-if="firstAuthenticatedSidebarIndex > 0 && index === firstAuthenticatedSidebarIndex"
|
||||
class="my-2 mx-2"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
:to="item.path"
|
||||
:active="route.path === item.path"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.name"
|
||||
class="hoard-nav-item"
|
||||
rounded="lg"
|
||||
link
|
||||
/>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
<template #append>
|
||||
<div class="drawer-bottom">
|
||||
<v-btn variant="text" prepend-icon="mdi-home" to="/" :block="display.mobile.value">
|
||||
<div v-if="!isAuthenticated" class="drawer-bottom">
|
||||
<v-btn variant="text" prepend-icon="mdi-home" to="/welcome" :block="display.mobile.value">
|
||||
Zur Startseite
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -255,6 +384,15 @@ watch(
|
||||
<p class="footer-copy">{{ currentYear }} - <strong>Hoard</strong></p>
|
||||
</v-footer>
|
||||
</v-main>
|
||||
|
||||
<v-snackbar
|
||||
v-model="showAuthMessage"
|
||||
color="error"
|
||||
location="bottom right"
|
||||
timeout="4500"
|
||||
>
|
||||
{{ authMessage }}
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
@@ -323,7 +461,17 @@ watch(
|
||||
.topbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.account-button {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
:deep(.account-button .v-btn__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-name,
|
||||
|
||||
Reference in New Issue
Block a user