Frontend: AP0.2 Login, /api/me und TenantContext-Anzeige.
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 8s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 8s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
25d3976226
commit
45a108b4a0
|
|
@ -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() {
|
export default function App() {
|
||||||
const [health, setHealth] = useState(null)
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/health')
|
fetch('/api/health')
|
||||||
|
|
@ -11,21 +26,157 @@ export default function App() {
|
||||||
return res.json()
|
return res.json()
|
||||||
})
|
})
|
||||||
.then(setHealth)
|
.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 (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
|
<header className="hero">
|
||||||
<h1>Jinkendo Kairo</h1>
|
<h1>Jinkendo Kairo</h1>
|
||||||
<p>Operativer Program Director — Sprint 0 / AP0.1</p>
|
<p>Operativer Program Director — Sprint 0 / AP0.2</p>
|
||||||
|
<p className="muted">Auth, Tenant & Actor Foundation</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>API Health</h2>
|
<h2>API Health</h2>
|
||||||
{error && <p className="error">Fehler: {error}</p>}
|
{healthError && <p className="error">Fehler: {healthError}</p>}
|
||||||
{!error && !health && <p>Lade …</p>}
|
{!healthError && !health && <p>Lade …</p>}
|
||||||
{health && (
|
{health && <pre>{JSON.stringify(health, null, 2)}</pre>}
|
||||||
<pre>{JSON.stringify(health, null, 2)}</pre>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Session</h2>
|
||||||
|
{!token && (
|
||||||
|
<form className="form" onSubmit={handleLogin}>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={authBusy}>
|
||||||
|
{authBusy ? 'Anmelden …' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{token && me && (
|
||||||
|
<div className="session">
|
||||||
|
<p>
|
||||||
|
Eingeloggt als <strong>{me.display_name}</strong> ({me.email})
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
Portal-Rolle: {me.portal_role}
|
||||||
|
{me.active_tenant_id && ` · Tenant: ${me.active_tenant_id}`}
|
||||||
|
</p>
|
||||||
|
<button type="button" className="secondary" onClick={handleLogout} disabled={authBusy}>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authError && <p className="error">{authError}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{token && me && (
|
||||||
|
<>
|
||||||
|
<section className="card">
|
||||||
|
<h2>/api/me</h2>
|
||||||
|
<pre>{JSON.stringify(me, null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
{context && (
|
||||||
|
<section className="card">
|
||||||
|
<h2>/api/me/context</h2>
|
||||||
|
<pre>{JSON.stringify(context, null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,16 @@ body {
|
||||||
.shell {
|
.shell {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|
@ -19,6 +28,12 @@ body {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|
@ -27,4 +42,49 @@ body {
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
overflow: auto;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user