Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Failing after 28s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 7s
Test Suite / compose-smoke (push) Has been skipped
390 lines
12 KiB
JavaScript
390 lines
12 KiB
JavaScript
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
|
|
}
|
|
|
|
function parseError(body, fallback) {
|
|
if (!body) return fallback
|
|
if (typeof body.detail === 'string') return body.detail
|
|
if (Array.isArray(body.detail)) {
|
|
return body.detail.map((d) => d.msg || JSON.stringify(d)).join(', ')
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
export default function App() {
|
|
const [health, setHealth] = useState(null)
|
|
const [healthError, setHealthError] = useState(null)
|
|
const [setup, setSetup] = useState(null)
|
|
const [setupLoading, setSetupLoading] = useState(true)
|
|
const [setupError, setSetupError] = useState(null)
|
|
const [authMode, setAuthMode] = useState('login')
|
|
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [displayName, setDisplayName] = useState('')
|
|
const [organizationName, setOrganizationName] = useState('')
|
|
const [passwordConfirm, setPasswordConfirm] = 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')
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return res.json()
|
|
})
|
|
.then(setHealth)
|
|
.catch((err) => setHealthError(err.message))
|
|
|
|
async function loadSetupStatus() {
|
|
setSetupLoading(true)
|
|
setSetupError(null)
|
|
try {
|
|
const res = await fetch('/api/auth/setup-status')
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
res.status === 404
|
|
? 'Endpoint /api/auth/setup-status nicht gefunden — Backend neu deployen?'
|
|
: parseError(body, `Setup-Status fehlgeschlagen (HTTP ${res.status})`),
|
|
)
|
|
}
|
|
if (typeof body.registration_open !== 'boolean') {
|
|
throw new Error('Ungültige Antwort vom Setup-Status — Backend-Version prüfen')
|
|
}
|
|
setSetup(body)
|
|
if (body.registration_open) setAuthMode('register')
|
|
} catch (err) {
|
|
setSetup(null)
|
|
setSetupError(err.message)
|
|
} finally {
|
|
setSetupLoading(false)
|
|
}
|
|
}
|
|
|
|
loadSetupStatus()
|
|
}, [])
|
|
|
|
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])
|
|
|
|
function applyLogin(data) {
|
|
localStorage.setItem(TOKEN_KEY, data.token)
|
|
setToken(data.token)
|
|
setPassword('')
|
|
setPasswordConfirm('')
|
|
setSetup({ registration_open: false, has_users: true, user_count: 1 })
|
|
setAuthMode('login')
|
|
}
|
|
|
|
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 }),
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok) throw new Error(parseError(body, `Login fehlgeschlagen (${res.status})`))
|
|
applyLogin(body)
|
|
} catch (err) {
|
|
setAuthError(err.message)
|
|
} finally {
|
|
setAuthBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleRegister(event) {
|
|
event.preventDefault()
|
|
if (password !== passwordConfirm) {
|
|
setAuthError('Passwörter stimmen nicht überein')
|
|
return
|
|
}
|
|
setAuthBusy(true)
|
|
setAuthError(null)
|
|
try {
|
|
const res = await fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: apiHeaders(),
|
|
body: JSON.stringify({
|
|
email,
|
|
password,
|
|
display_name: displayName,
|
|
organization_name: organizationName || undefined,
|
|
}),
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok) throw new Error(parseError(body, `Registrierung fehlgeschlagen (${res.status})`))
|
|
applyLogin(body)
|
|
} 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 {
|
|
/* lokal entfernen */
|
|
} finally {
|
|
localStorage.removeItem(TOKEN_KEY)
|
|
setToken('')
|
|
setMe(null)
|
|
setContext(null)
|
|
setAuthBusy(false)
|
|
}
|
|
}
|
|
|
|
const registrationOpen = setup?.registration_open === true
|
|
const registrationClosedBecauseUsersExist = setup?.has_users === true && !registrationOpen
|
|
|
|
return (
|
|
<main className="shell">
|
|
<header className="hero">
|
|
<h1>Jinkendo Kairo</h1>
|
|
<p>Operativer Program Director — Sprint 0 / AP0.5</p>
|
|
<p className="muted">Capability Registry & Entitlements</p>
|
|
</header>
|
|
|
|
<section className="card auth-card">
|
|
<h2>{token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}</h2>
|
|
|
|
{!token && registrationOpen && (
|
|
<p className="setup-hint">
|
|
Noch kein Systemadministrator. Der erste Account wird automatisch{' '}
|
|
<strong>Portal-Admin</strong> und erhält einen Default-Tenant.
|
|
</p>
|
|
)}
|
|
|
|
{!token && setupLoading && (
|
|
<p className="muted">Prüfe Ersteinrichtung …</p>
|
|
)}
|
|
|
|
{!token && setupError && (
|
|
<p className="error">{setupError}</p>
|
|
)}
|
|
|
|
{!token && registrationClosedBecauseUsersExist && (
|
|
<p className="setup-hint setup-hint-muted">
|
|
Es existiert bereits mindestens ein Benutzer ({setup.user_count ?? '≥1'}).
|
|
Registrierung ist nur für die Ersteinrichtung vorgesehen — bitte anmelden.
|
|
Falls der Admin per <code>KAIRO_BOOTSTRAP_*</code> in der <code>.env</code> angelegt
|
|
wurde, nutzen Sie diese Zugangsdaten.
|
|
</p>
|
|
)}
|
|
|
|
{!token && !setupLoading && !setupError && (
|
|
<>
|
|
<div className="tabs" role="tablist">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
className={authMode === 'login' ? 'tab active' : 'tab'}
|
|
aria-selected={authMode === 'login'}
|
|
onClick={() => { setAuthMode('login'); setAuthError(null) }}
|
|
>
|
|
Anmelden
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
className={authMode === 'register' ? 'tab active' : 'tab'}
|
|
aria-selected={authMode === 'register'}
|
|
disabled={!registrationOpen}
|
|
onClick={() => { setAuthMode('register'); setAuthError(null) }}
|
|
>
|
|
Registrieren
|
|
</button>
|
|
</div>
|
|
|
|
{authMode === 'login' && (
|
|
<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>
|
|
)}
|
|
|
|
{authMode === 'register' && registrationOpen && (
|
|
<form className="form" onSubmit={handleRegister}>
|
|
<label>
|
|
Anzeigename
|
|
<input
|
|
type="text"
|
|
autoComplete="name"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
Organisation / Tenant (optional)
|
|
<input
|
|
type="text"
|
|
value={organizationName}
|
|
onChange={(e) => setOrganizationName(e.target.value)}
|
|
placeholder="z. B. Mein Programm-Team"
|
|
/>
|
|
</label>
|
|
<label>
|
|
E-Mail
|
|
<input
|
|
type="email"
|
|
autoComplete="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
Passwort
|
|
<input
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
minLength={8}
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
Passwort bestätigen
|
|
<input
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={passwordConfirm}
|
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
|
minLength={8}
|
|
required
|
|
/>
|
|
</label>
|
|
<button type="submit" disabled={authBusy}>
|
|
{authBusy ? 'Registrieren …' : 'Systemadmin anlegen'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{authMode === 'register' && !registrationOpen && (
|
|
<p className="muted">
|
|
Registrierung ist geschlossen. Bitte melden Sie sich an oder wenden Sie sich an
|
|
den Administrator.
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{token && me && (
|
|
<div className="session">
|
|
<p>
|
|
Eingeloggt als <strong>{me.display_name}</strong> ({me.email})
|
|
</p>
|
|
<p className="muted">
|
|
{me.portal_role === 'admin' ? 'Portal-Systemadmin' : `Portal-Rolle: ${me.portal_role}`}
|
|
{context?.tenant?.name && ` · ${context.tenant.name}`}
|
|
</p>
|
|
<button type="button" className="secondary" onClick={handleLogout} disabled={authBusy}>
|
|
Abmelden
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{authError && <p className="error">{authError}</p>}
|
|
</section>
|
|
|
|
<section className="card">
|
|
<h2>Ersteinrichtung</h2>
|
|
{setupLoading && <p className="muted">Lade …</p>}
|
|
{setupError && <p className="error">{setupError}</p>}
|
|
{setup && <pre>{JSON.stringify(setup, null, 2)}</pre>}
|
|
{!setupLoading && !setup && !setupError && (
|
|
<p className="muted">Kein Setup-Status verfügbar.</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="card">
|
|
<h2>API Health</h2>
|
|
{healthError && <p className="error">Fehler: {healthError}</p>}
|
|
{!healthError && !health && <p>Lade …</p>}
|
|
{health && <pre>{JSON.stringify(health, null, 2)}</pre>}
|
|
</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>
|
|
)
|
|
}
|