From 45a108b4a089d72ec14710c8080255fe649fa033 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 20:17:03 +0200 Subject: [PATCH] Frontend: AP0.2 Login, /api/me und TenantContext-Anzeige. Co-authored-by: Cursor --- frontend/src/App.jsx | 171 ++++++++++++++++++++++++++++++++++++++++--- frontend/src/app.css | 62 +++++++++++++++- 2 files changed, 222 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b612a68..13dd341 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,23 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' + +const TOKEN_KEY = 'kairo_auth_token' + +function apiHeaders(token) { + const headers = { 'Content-Type': 'application/json' } + if (token) headers['X-Auth-Token'] = token + return headers +} export default function App() { const [health, setHealth] = useState(null) - const [error, setError] = useState(null) + const [healthError, setHealthError] = useState(null) + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [authError, setAuthError] = useState(null) + const [authBusy, setAuthBusy] = useState(false) + const [me, setMe] = useState(null) + const [context, setContext] = useState(null) useEffect(() => { fetch('/api/health') @@ -11,21 +26,157 @@ export default function App() { return res.json() }) .then(setHealth) - .catch((err) => setError(err.message)) + .catch((err) => setHealthError(err.message)) }, []) + const loadSession = useCallback(async (activeToken) => { + if (!activeToken) { + setMe(null) + setContext(null) + return + } + const meRes = await fetch('/api/me', { headers: apiHeaders(activeToken) }) + if (!meRes.ok) { + localStorage.removeItem(TOKEN_KEY) + setToken('') + setMe(null) + setContext(null) + throw new Error(meRes.status === 401 ? 'Session abgelaufen' : `HTTP ${meRes.status}`) + } + setMe(await meRes.json()) + const ctxRes = await fetch('/api/me/context', { headers: apiHeaders(activeToken) }) + if (ctxRes.ok) setContext(await ctxRes.json()) + }, []) + + useEffect(() => { + if (!token) return + loadSession(token).catch((err) => setAuthError(err.message)) + }, [token, loadSession]) + + async function handleLogin(event) { + event.preventDefault() + setAuthBusy(true) + setAuthError(null) + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: apiHeaders(), + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.detail || `Login fehlgeschlagen (${res.status})`) + } + const data = await res.json() + localStorage.setItem(TOKEN_KEY, data.token) + setToken(data.token) + setPassword('') + } catch (err) { + setAuthError(err.message) + } finally { + setAuthBusy(false) + } + } + + async function handleLogout() { + setAuthBusy(true) + setAuthError(null) + try { + if (token) { + await fetch('/api/auth/logout', { + method: 'POST', + headers: apiHeaders(token), + }) + } + } catch { + /* Session trotzdem lokal entfernen */ + } finally { + localStorage.removeItem(TOKEN_KEY) + setToken('') + setMe(null) + setContext(null) + setAuthBusy(false) + } + } + return (
-

Jinkendo Kairo

-

Operativer Program Director — Sprint 0 / AP0.1

+
+

Jinkendo Kairo

+

Operativer Program Director — Sprint 0 / AP0.2

+

Auth, Tenant & Actor Foundation

+
+

API Health

- {error &&

Fehler: {error}

} - {!error && !health &&

Lade …

} - {health && ( -
{JSON.stringify(health, null, 2)}
- )} + {healthError &&

Fehler: {healthError}

} + {!healthError && !health &&

Lade …

} + {health &&
{JSON.stringify(health, null, 2)}
}
+ +
+

Session

+ {!token && ( +
+ + + +
+ )} + + {token && me && ( +
+

+ Eingeloggt als {me.display_name} ({me.email}) +

+

+ Portal-Rolle: {me.portal_role} + {me.active_tenant_id && ` · Tenant: ${me.active_tenant_id}`} +

+ +
+ )} + + {authError &&

{authError}

} +
+ + {token && me && ( + <> +
+

/api/me

+
{JSON.stringify(me, null, 2)}
+
+ {context && ( +
+

/api/me/context

+
{JSON.stringify(context, null, 2)}
+
+ )} + + )}
) } diff --git a/frontend/src/app.css b/frontend/src/app.css index 2a8997c..065425b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -11,7 +11,16 @@ body { .shell { max-width: 720px; margin: 2rem auto; - padding: 0 1rem; + padding: 0 1rem 3rem; +} + +.hero h1 { + margin-bottom: 0.25rem; +} + +.muted { + color: #555; + font-size: 0.95rem; } .card { @@ -19,6 +28,12 @@ body { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; + margin-top: 1rem; +} + +.card h2 { + margin-top: 0; + font-size: 1.1rem; } .error { @@ -27,4 +42,49 @@ body { pre { overflow: auto; + font-size: 0.85rem; +} + +.form { + display: grid; + gap: 0.75rem; +} + +.form label { + display: grid; + gap: 0.25rem; + font-size: 0.9rem; +} + +.form input { + padding: 0.5rem 0.6rem; + border: 1px solid #ccc; + border-radius: 6px; + font: inherit; +} + +button { + padding: 0.55rem 1rem; + border: none; + border-radius: 6px; + background: #1a4d8f; + color: #fff; + font: inherit; + cursor: pointer; + width: fit-content; +} + +button:disabled { + opacity: 0.6; + cursor: wait; +} + +button.secondary { + background: #eee; + color: #222; + border: 1px solid #ccc; +} + +.session p { + margin: 0.35rem 0; }