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,
+22 -3
View File
@@ -1,6 +1,7 @@
import type { RouteRecordRaw } from 'vue-router'
import Home from '@/routes/Home.vue'
import Dashboard from '@/routes/dashboard/Dashboard.vue'
import NotFound from '@/routes/404NotFound.vue'
import Login from '@/routes/authentication/Login.vue'
import Impressum from '@/routes/Impressum.vue'
@@ -38,17 +39,32 @@ export interface LayoutRoute {
*/
export const routes: LayoutRoute[] = [
{
path: '/',
path: '/welcome',
name: 'Startseite',
description: 'Self-hosted Datei-Workspace für Hoard',
icon: 'mdi-home',
visible: Visibility.Public,
visible: Visibility.Unauthenticated,
meta: {
name: 'Home',
path: '/',
path: '/welcome',
component: Home,
},
},
{
path: '/',
name: 'Dash',
description: 'Geschützter Bereich für dein Konto',
icon: 'mdi-view-dashboard-outline',
visible: Visibility.Authenticated,
meta: {
name: 'Dashboard',
path: '/',
component: Dashboard,
meta: {
requiresAuth: true,
},
},
},
{
path: '/login',
name: 'Login',
@@ -59,6 +75,9 @@ export const routes: LayoutRoute[] = [
path: '/login',
name: 'Login',
component: Login,
meta: {
guestOnly: true,
},
},
},
{
+49
View File
@@ -1,9 +1,58 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { routes } from '@/plugins/routesLayout'
import { fetchCurrentUser } from '@/services/authSession'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes.filter((x) => x.meta !== undefined).map((x) => x.meta) as RouteRecordRaw[],
})
router.beforeEach(async (to) => {
const requiresAuth = to.meta.requiresAuth === true
const guestOnly = to.meta.guestOnly === true
if (!requiresAuth && !guestOnly) {
return true
}
let currentUser = null
try {
currentUser = await fetchCurrentUser()
} catch {
if (requiresAuth) {
const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
return {
name: 'Login',
query,
replace: true,
}
}
return true
}
const isAuthenticated = currentUser !== null
if (requiresAuth && !isAuthenticated) {
const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
return {
name: 'Login',
query,
replace: true,
}
}
if (guestOnly && isAuthenticated) {
return {
name: 'Dashboard',
replace: true,
}
}
return true
})
export default router
+2 -2
View File
@@ -11,7 +11,7 @@ function navigateBack() {
return
}
router.push('/')
router.push('/welcome')
}
</script>
@@ -33,7 +33,7 @@ function navigateBack() {
</p>
<div class="not-found-actions hoard-action-row">
<v-btn color="primary" prepend-icon="mdi-home" to="/">Zur Startseite</v-btn>
<v-btn color="primary" prepend-icon="mdi-home" to="/welcome">Zur Startseite</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="navigateBack">Zurück</v-btn>
</div>
</div>
+1 -1
View File
@@ -55,7 +55,7 @@ const legalNotes = [
</div>
<div class="hero-actions hoard-action-row">
<v-btn color="primary" prepend-icon="mdi-home" to="/">Zur Startseite</v-btn>
<v-btn color="primary" prepend-icon="mdi-home" to="/welcome">Zur Startseite</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-login" to="/login">Zum Login</v-btn>
</div>
</section>
+89 -17
View File
@@ -1,7 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { AuthRequestError, login } from '@/services/authSession'
const showPassword = ref(false)
const userName = ref('')
const password = ref('')
const isSubmitting = ref(false)
const errorMessage = ref('')
const route = useRoute()
const router = useRouter()
const submitDisabled = computed(
() => isSubmitting.value || userName.value.trim().length === 0 || password.value.length === 0,
)
const redirectPath = computed(() => {
const redirectQuery = route.query.redirect
if (typeof redirectQuery === 'string' && redirectQuery.startsWith('/')) {
return redirectQuery
}
return '/'
})
async function handleSubmit() {
if (submitDisabled.value) {
errorMessage.value = 'Bitte Benutzername und Passwort eingeben.'
return
}
isSubmitting.value = true
errorMessage.value = ''
try {
await login({
userName: userName.value.trim(),
password: password.value,
})
await router.replace(redirectPath.value)
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
} else {
errorMessage.value = 'Anmeldung fehlgeschlagen. Bitte versuche es erneut.'
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
@@ -30,22 +79,35 @@ const showPassword = ref(false)
</ul>
</aside>
<v-form class="login-form hoard-panel" @submit.prevent>
<v-form class="login-form hoard-panel" @submit.prevent="handleSubmit">
<div class="form-head">
<h2>Login</h2>
<p>Melde dich mit deinem bestehenden Konto an.</p>
</div>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="comfortable"
border="start"
>
{{ errorMessage }}
</v-alert>
<v-text-field
label="E-Mail"
type="email"
v-model="userName"
label="Benutzername"
type="text"
variant="outlined"
prepend-inner-icon="mdi-email-outline"
autocomplete="email"
prepend-inner-icon="mdi-account-outline"
autocomplete="username"
required
:disabled="isSubmitting"
/>
<v-text-field
v-model="password"
label="Passwort"
:type="showPassword ? 'text' : 'password'"
variant="outlined"
@@ -53,17 +115,27 @@ const showPassword = ref(false)
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
required
:disabled="isSubmitting"
@click:append-inner="showPassword = !showPassword"
/>
<div class="form-meta">
<v-checkbox hide-details color="primary" density="compact" label="Angemeldet bleiben" />
<v-btn variant="text" size="small">Passwort vergessen?</v-btn>
<p>Anmeldung erfolgt per sicherem Session-Cookie.</p>
</div>
<v-btn type="submit" color="primary" block size="large" prepend-icon="mdi-login">Anmelden</v-btn>
<v-btn
type="submit"
color="primary"
block
size="large"
prepend-icon="mdi-login"
:loading="isSubmitting"
:disabled="submitDisabled"
>
Anmelden
</v-btn>
<v-btn variant="outlined" block to="/" prepend-icon="mdi-home">Zur Startseite</v-btn>
<v-btn variant="outlined" block to="/welcome" prepend-icon="mdi-home">Zur Startseite</v-btn>
</v-form>
</section>
</v-container>
@@ -132,8 +204,13 @@ h1 {
.form-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
gap: var(--space-2);
}
.form-meta p {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
@media (width <= 960px) {
@@ -179,11 +256,6 @@ h1 {
gap: var(--space-2);
}
:deep(.form-meta .v-btn) {
width: 100%;
min-height: 44px;
}
:deep(.login-form .v-btn) {
min-height: 44px;
}
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AuthRequestError, fetchCurrentUser, type CurrentUser } from '@/services/authSession'
const router = useRouter()
const isLoading = ref(true)
const errorMessage = ref('')
const user = ref<CurrentUser | null>(null)
const prettyUser = computed(() => {
if (!user.value) {
return ''
}
return JSON.stringify(user.value, null, 2)
})
async function loadCurrentUser() {
isLoading.value = true
errorMessage.value = ''
try {
const currentUser = await fetchCurrentUser({ force: true })
if (!currentUser) {
await router.replace({ name: 'Login', query: { redirect: '/' } })
return
}
user.value = currentUser
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
} else {
errorMessage.value = 'Benutzerdaten konnten nicht geladen werden.'
}
} finally {
isLoading.value = false
}
}
onMounted(() => {
void loadCurrentUser()
})
</script>
<template>
<v-container fluid class="dashboard-page hoard-page">
<section class="dashboard-shell hoard-panel">
<header class="dashboard-head">
<p class="hoard-kicker">Geschützter Bereich</p>
<h1>Dashboard</h1>
<p>Hier siehst du aktuell direkt die Antwort von <code>/auth/me</code>.</p>
</header>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="comfortable"
border="start"
>
{{ errorMessage }}
</v-alert>
<article v-else class="dashboard-user hoard-panel">
<template v-if="isLoading">
<p class="dashboard-loading">Benutzerdaten werden geladen...</p>
</template>
<template v-else>
<p class="dashboard-label">Antwort von <code>GET /auth/me</code>:</p>
<pre class="dashboard-json">{{ prettyUser }}</pre>
</template>
</article>
<div class="dashboard-actions">
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@click="loadCurrentUser"
>
Neu laden
</v-btn>
</div>
</section>
</v-container>
</template>
<style scoped>
.dashboard-page {
--hoard-page-width: 980px;
}
.dashboard-shell {
display: grid;
gap: var(--space-4);
padding: var(--space-6);
}
.dashboard-head h1,
.dashboard-head p,
.dashboard-label,
.dashboard-loading {
margin: 0;
}
.dashboard-head h1 {
margin-top: var(--space-2);
margin-bottom: var(--space-2);
}
.dashboard-head p {
color: var(--color-text-secondary);
}
.dashboard-user {
padding: var(--space-4);
}
.dashboard-label {
margin-bottom: var(--space-2);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.dashboard-loading {
color: var(--color-text-secondary);
}
.dashboard-json {
margin: 0;
overflow: auto;
padding: var(--space-4);
border-radius: var(--radius-md);
color: var(--color-text);
background-color: var(--color-surface-alt);
font-size: 0.88rem;
line-height: 1.5;
}
.dashboard-actions {
display: flex;
justify-content: flex-end;
}
@media (width <= 600px) {
.dashboard-shell {
padding: var(--space-4);
}
.dashboard-actions {
justify-content: stretch;
}
:deep(.dashboard-actions .v-btn) {
width: 100%;
min-height: 44px;
}
}
</style>
+240
View File
@@ -0,0 +1,240 @@
export interface CurrentUser {
id: string
userName: string
isAdmin: boolean
isActive: boolean
mustChangePassword: boolean
}
interface ApiMessageResponse {
message?: unknown
}
interface LoginPayload {
userName: string
password: string
}
export class AuthRequestError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'AuthRequestError'
this.status = status
}
}
let cachedUser: CurrentUser | null = null
let sessionResolved = false
let pendingSessionRequest: Promise<CurrentUser | null> | null = null
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function normalizeCurrentUser(value: unknown): CurrentUser | null {
if (!isObject(value)) {
return null
}
const { id, userName, isAdmin, isActive, mustChangePassword } = value
if (typeof id !== 'string' || typeof userName !== 'string') {
return null
}
return {
id,
userName,
isAdmin: Boolean(isAdmin),
isActive: Boolean(isActive),
mustChangePassword: Boolean(mustChangePassword),
}
}
function isUnauthenticatedResponse(response: Response) {
if (response.status === 401 || response.status === 403) {
return true
}
if (!response.redirected) {
return false
}
try {
const url = new URL(response.url)
return url.pathname === '/auth/login'
} catch {
return false
}
}
async function readApiMessage(response: Response): Promise<string | null> {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
return null
}
try {
const body = (await response.json()) as ApiMessageResponse
if (typeof body.message === 'string' && body.message.trim().length > 0) {
return body.message
}
} catch {
return null
}
return null
}
function toAuthError(error: unknown, fallbackMessage: string): AuthRequestError {
if (error instanceof AuthRequestError) {
return error
}
return new AuthRequestError(fallbackMessage, 0)
}
export function clearAuthSession() {
cachedUser = null
sessionResolved = false
pendingSessionRequest = null
}
export async function fetchCurrentUser(options: { force?: boolean } = {}): Promise<CurrentUser | null> {
const shouldForce = options.force === true
if (!shouldForce && sessionResolved) {
return cachedUser
}
if (!shouldForce && pendingSessionRequest) {
return pendingSessionRequest
}
const request = (async () => {
let response: Response
try {
response = await fetch('/auth/me', {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} catch (error) {
throw toAuthError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (isUnauthenticatedResponse(response)) {
cachedUser = null
sessionResolved = true
return null
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Benutzerdaten konnten nicht geladen werden.',
response.status,
)
}
const rawPayload: unknown = await response.json()
const user = normalizeCurrentUser(rawPayload)
if (!user) {
throw new AuthRequestError('Antwortformat von /auth/me ist ungültig.', response.status)
}
cachedUser = user
sessionResolved = true
return user
})().finally(() => {
pendingSessionRequest = null
})
pendingSessionRequest = request
return request
}
export async function login(payload: LoginPayload): Promise<void> {
let response: Response
try {
response = await fetch('/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
userName: payload.userName,
password: payload.password,
}),
})
} catch (error) {
throw toAuthError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
if (response.status === 400) {
throw new AuthRequestError(
apiMessage ?? 'Bitte Benutzername und Passwort eingeben.',
response.status,
)
}
if (response.status === 401) {
throw new AuthRequestError(apiMessage ?? 'Ungültige Anmeldedaten.', response.status)
}
if (response.status === 403) {
throw new AuthRequestError('Dieses Konto ist nicht aktiv.', response.status)
}
throw new AuthRequestError(
apiMessage ?? 'Anmeldung fehlgeschlagen. Bitte versuche es erneut.',
response.status,
)
}
sessionResolved = false
await fetchCurrentUser({ force: true })
}
export async function logout(): Promise<void> {
let response: Response
try {
response = await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} catch (error) {
throw toAuthError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401 || response.status === 403) {
clearAuthSession()
return
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Abmeldung fehlgeschlagen. Bitte versuche es erneut.',
response.status,
)
}
clearAuthSession()
}
+16
View File
@@ -101,6 +101,12 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für
- `API/appsettings.custom.json` ist in `.gitignore` hinterlegt, damit lokale Konfigurationswerte nicht versehentlich committed werden.
- Backend-Logging ist aktiviert mit strukturierter Console-Ausgabe (inkl. Zeitstempel) sowie HTTP-Request-Logging.
- Beim Identity-Seeding wird explizit geloggt, wenn ein Admin-Account neu angelegt wurde.
- Frontend-Auth ist jetzt aktiv: Login-Form (`GUI/src/routes/authentication/Login.vue`) sendet an `POST /auth/login` und zeigt API-Fehler als sichtbare Meldung.
- Dashboard ist als geschützte Route auf `/` umgesetzt (`GUI/src/routes/dashboard/Dashboard.vue`) und zeigt die Antwort von `GET /auth/me`.
- Router-Guards in `GUI/src/router/index.ts` leiten nicht eingeloggte Nutzer von geschützten Routen auf `/login` um und leiten eingeloggte Nutzer von `/login` zurück aufs Dashboard.
- Öffentliche Landingpage wurde auf `/welcome` verschoben; `404`- und Impressum-Links zur Startseite zeigen entsprechend auf `/welcome`.
- Sidebar-Navigation berücksichtigt Auth-Status differenziert: `Startseite` wird nur unangemeldet angezeigt (inkl. unterem Drawer-Link), `Dash` nur angemeldet.
- Topbar zeigt bei aktiver Anmeldung den Benutzernamen; die Abmelden-Aktion erscheint im Hover-Menü unter dem Benutzernamen (Desktop) bzw. im Account-Menü (Mobile).
## Änderungen durch Codex
- Grundlegender UI-Neuaufbau der App-Shell (`GUI/src/Layout.vue`) inklusive Navigation, Footer und Seitenkontext.
@@ -139,3 +145,13 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für
- `API/Program.cs` um Identity-Service-Registrierung, Cookie-Konfiguration (`hoard.auth`), `UseAuthentication`/`UseAuthorization` und Startup-Seeding erweitert.
- Backend-Logging in `API/Program.cs` ergänzt: `AddSimpleConsole` (Zeitstempel, Single-Line), `AddDebug` und `AddHttpLogging`/`UseHttpLogging` für Request-Logs.
- `API/Services/IdentitySeedService.cs` neu ergänzt: erstellt beim ersten Start einen initialen Admin aus `SeedAdmin:*` und loggt Erfolg/Fehler nachvollziehbar.
- Neues Frontend-Auth-Modul `GUI/src/services/authSession.ts` ergänzt (Login-Request, Session-Abfrage via `/auth/me`, Fehler-Mapping und Session-Cache).
- Routing für Auth ergänzt: neue geschützte Dashboard-Route (`/`) mit `meta.requiresAuth`, Login als `guestOnly` und globaler Guard in `GUI/src/router/index.ts`.
- Neue Seite `GUI/src/routes/dashboard/Dashboard.vue` ergänzt, die die `/auth/me`-Nutzerdaten anzeigt.
- `GUI/src/routes/authentication/Login.vue` von statischer Maske auf echten Login-Flow mit Loading-State, Fehlermeldung und Redirect nach erfolgreicher Anmeldung umgestellt.
- Öffentliche Home/Landing-Route auf `/welcome` verschoben und Navigation in `Layout.vue`, `404NotFound.vue` und `Impressum.vue` entsprechend angepasst.
- `GUI/src/services/authSession.ts` um `logout()` für `POST /auth/logout` ergänzt; Session-Cache wird beim Abmelden zuverlässig geleert.
- `GUI/src/plugins/routesLayout.ts` angepasst: Dashboard-Navigationseintrag als `Dash` mit `Visibility.Authenticated` und Reihenfolge hinter `Startseite`.
- `GUI/src/Layout.vue` erweitert: Sidebar filtert jetzt auch `Visibility.Authenticated`/`Visibility.Unauthenticated`, zeigt Trennlinie vor Auth-Einträgen, Topbar zeigt Benutzernamen bei Login und bietet Abmelden (Desktop + Mobile) inkl. Fehler-Snackbar.
- `GUI/src/plugins/routesLayout.ts` weiter angepasst: `Startseite` nutzt jetzt `Visibility.Unauthenticated`, damit sie in der Sidebar nach Login nicht mehr auswählbar ist.
- `GUI/src/Layout.vue` Topbar angepasst: separater Logout-Button entfernt; Logout ist jetzt als Menüpunkt unter dem Benutzernamen verfügbar (`open-on-hover` auf Desktop, Menü auf Mobile).