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:
+153
-5
@@ -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,6 +241,65 @@ watch(
|
||||
{{ themeLabel }}
|
||||
</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-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">
|
||||
@@ -194,6 +317,7 @@ watch(
|
||||
</template>
|
||||
Zum Login
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
|
||||
@@ -211,9 +335,13 @@ watch(
|
||||
</div>
|
||||
|
||||
<v-list nav :density="display.mobile.value ? 'default' : 'comfortable'" class="px-1">
|
||||
<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
|
||||
v-for="item in sidebarRoutes"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:active="route.path === item.path"
|
||||
:prepend-icon="item.icon"
|
||||
@@ -222,11 +350,12 @@ watch(
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user