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:
Jonas
2026-04-18 22:42:17 +02:00
parent 38ec3741ab
commit 86ed227566
9 changed files with 759 additions and 53 deletions
+178 -30
View File
@@ -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,