master user anlegen #2
|
|
@ -113,6 +113,8 @@ KAIRO_BOOTSTRAP_TENANT_SLUG=default
|
|||
|
||||
| Endpoint | Methode | Auth | Beschreibung |
|
||||
|----------|---------|------|--------------|
|
||||
| `/api/auth/setup-status` | GET | — | Ob Erstregistrierung offen ist |
|
||||
| `/api/auth/register` | POST | — | Erster User → Portal-Admin + Tenant (nur wenn noch kein User) |
|
||||
| `/api/auth/login` | POST | — | E-Mail + Passwort → Session-Token |
|
||||
| `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen |
|
||||
| `/api/me` | GET | `X-Auth-Token` | Aktueller User + Tenant-Liste |
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
"""Bootstrap first admin user, tenant, membership and human actor from environment."""
|
||||
"""Bootstrap first admin user from environment (optional alternative to UI registration)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from auth import create_session, hash_password
|
||||
from db import get_connection
|
||||
from services.actors import create_actor
|
||||
from services.audit import log_audit
|
||||
from services.registration import provision_system_admin, user_count
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
|
|
@ -25,64 +20,19 @@ def bootstrap_admin_if_needed() -> None:
|
|||
if not email or not password:
|
||||
return
|
||||
|
||||
if user_count() > 0:
|
||||
return
|
||||
|
||||
tenant_slug = _env("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default"
|
||||
tenant_name = _env("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant"
|
||||
display_name = _env("KAIRO_BOOTSTRAP_ADMIN_NAME") or "Kairo Admin"
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT COUNT(*) AS n FROM users")
|
||||
if cur.fetchone()["n"] > 0:
|
||||
return
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenants (slug, name)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id
|
||||
""",
|
||||
(tenant_slug, tenant_name),
|
||||
)
|
||||
tenant_row = cur.fetchone()
|
||||
if not tenant_row:
|
||||
cur.execute("SELECT id FROM tenants WHERE slug = %s", (tenant_slug,))
|
||||
tenant_row = cur.fetchone()
|
||||
tenant_id = str(tenant_row["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash, display_name, portal_role)
|
||||
VALUES (%s, %s, %s, 'admin')
|
||||
RETURNING id
|
||||
""",
|
||||
(email.lower(), hash_password(password), display_name),
|
||||
)
|
||||
user_id = str(cur.fetchone()["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
|
||||
VALUES (%s, %s, 'owner')
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING
|
||||
""",
|
||||
(tenant_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
create_actor(
|
||||
tenant_id=tenant_id,
|
||||
actor_type="human",
|
||||
name=display_name,
|
||||
user_id=user_id,
|
||||
provision_system_admin(
|
||||
email=email,
|
||||
password=password,
|
||||
display_name=display_name,
|
||||
tenant_slug=tenant_slug,
|
||||
tenant_name=tenant_name,
|
||||
source="env_bootstrap",
|
||||
)
|
||||
log_audit(
|
||||
"bootstrap.admin_created",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"email": email.lower(), "tenant_slug": tenant_slug},
|
||||
)
|
||||
print(f"[bootstrap] Admin user and tenant '{tenant_slug}' created")
|
||||
print(f"[bootstrap] Systemadmin und Tenant '{tenant_slug}' angelegt")
|
||||
|
|
|
|||
|
|
@ -1,27 +1,50 @@
|
|||
"""Auth endpoints: login, logout."""
|
||||
"""Auth endpoints: login, logout, registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from auth import login, logout, require_auth
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from services.registration import register_system_admin, setup_status
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
# str statt EmailStr: Self-Hosted/Dev nutzt oft .local-Domains (admin@kairo.local)
|
||||
email: str = Field(min_length=3, max_length=255)
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=255)
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
display_name: str = Field(min_length=1, max_length=255)
|
||||
organization_name: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
@router.get("/setup-status")
|
||||
def auth_setup_status():
|
||||
return setup_status()
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def auth_register(body: RegisterRequest):
|
||||
"""Erster User wird Portal-Systemadmin inkl. Default-Tenant."""
|
||||
return register_system_admin(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
display_name=body.display_name,
|
||||
organization_name=body.organization_name,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def auth_login(body: LoginRequest):
|
||||
return login(body.email, body.password)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def auth_logout(response: Response, session: dict = Depends(require_auth)):
|
||||
def auth_logout(session: dict = Depends(require_auth)):
|
||||
token = session.get("token")
|
||||
if token:
|
||||
logout(token)
|
||||
|
|
|
|||
152
backend/services/registration.py
Normal file
152
backend/services/registration.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""First-user / system-admin provisioning (bootstrap + registration)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from auth import create_session, get_user_by_email, hash_password
|
||||
from db import get_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from fastapi import HTTPException
|
||||
from services.actors import create_actor
|
||||
from services.audit import log_audit
|
||||
|
||||
|
||||
def user_count() -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) FROM users")
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def registration_open() -> bool:
|
||||
return user_count() == 0
|
||||
|
||||
|
||||
def setup_status() -> dict[str, Any]:
|
||||
count = user_count()
|
||||
return {
|
||||
"has_users": count > 0,
|
||||
"registration_open": count == 0,
|
||||
"user_count": count,
|
||||
}
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return (slug[:48] or "default")
|
||||
|
||||
|
||||
def provision_system_admin(
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
tenant_slug: str | None = None,
|
||||
tenant_name: str | None = None,
|
||||
source: str = "bootstrap",
|
||||
) -> dict[str, Any]:
|
||||
"""Create portal admin, default tenant, owner membership and human actor."""
|
||||
if user_count() > 0:
|
||||
raise ValueError("users_already_exist")
|
||||
|
||||
normalized_email = email.strip().lower()
|
||||
slug = tenant_slug or os.getenv("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default"
|
||||
name = tenant_name or os.getenv("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant"
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenants (slug, name)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id, slug, name
|
||||
""",
|
||||
(slug, name),
|
||||
)
|
||||
tenant = dict(cur.fetchone())
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash, display_name, portal_role)
|
||||
VALUES (%s, %s, %s, 'admin')
|
||||
RETURNING id, email, display_name, portal_role
|
||||
""",
|
||||
(normalized_email, hash_password(password), display_name.strip()),
|
||||
)
|
||||
user = dict(cur.fetchone())
|
||||
user_id = str(user["id"])
|
||||
tenant_id = str(tenant["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
|
||||
VALUES (%s, %s, 'owner')
|
||||
""",
|
||||
(tenant_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
create_actor(
|
||||
tenant_id=tenant_id,
|
||||
actor_type="human",
|
||||
name=display_name.strip(),
|
||||
user_id=user_id,
|
||||
)
|
||||
log_audit(
|
||||
"auth.register_system_admin",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"email": normalized_email, "source": source, "tenant_slug": slug},
|
||||
)
|
||||
|
||||
session = create_session(user_id, tenant_id)
|
||||
return {
|
||||
"token": session["token"],
|
||||
"expires_at": session["expires_at"].isoformat(),
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"email": user["email"],
|
||||
"display_name": user["display_name"],
|
||||
"portal_role": user["portal_role"],
|
||||
},
|
||||
"tenant": {"id": tenant_id, "slug": tenant["slug"], "name": tenant["name"]},
|
||||
}
|
||||
|
||||
|
||||
def register_system_admin(
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
organization_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if get_user_by_email(email):
|
||||
raise HTTPException(status_code=409, detail="E-Mail bereits registriert")
|
||||
|
||||
if not registration_open():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Registrierung geschlossen — Systemadministrator ist bereits eingerichtet",
|
||||
)
|
||||
|
||||
org = (organization_name or display_name or "Default Tenant").strip()
|
||||
slug = _slugify(org)
|
||||
|
||||
return provision_system_admin(
|
||||
email=email,
|
||||
password=password,
|
||||
display_name=display_name.strip(),
|
||||
tenant_slug=slug,
|
||||
tenant_name=org,
|
||||
source="registration",
|
||||
)
|
||||
69
backend/tests/test_registration.py
Normal file
69
backend/tests/test_registration.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Registration and first system-admin tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from auth import AUTH_HEADER, get_session
|
||||
from db import get_connection
|
||||
from services.registration import user_count
|
||||
|
||||
|
||||
def test_setup_status_open_without_users(client):
|
||||
if user_count() > 0:
|
||||
return
|
||||
res = client.get("/api/auth/setup-status")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["registration_open"] is True
|
||||
assert body["has_users"] is False
|
||||
|
||||
|
||||
def test_register_first_user_becomes_portal_admin(client):
|
||||
if user_count() > 0:
|
||||
return
|
||||
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
email = f"admin-{suffix}@example.com"
|
||||
res = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"password": "secure-pass-123",
|
||||
"display_name": "System Admin",
|
||||
"organization_name": "Kairo Org",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["user"]["portal_role"] == "admin"
|
||||
assert body["token"]
|
||||
assert get_session(body["token"]) is not None
|
||||
|
||||
me = client.get("/api/me", headers={AUTH_HEADER: body["token"]})
|
||||
assert me.status_code == 200
|
||||
assert me.json()["portal_role"] == "admin"
|
||||
|
||||
ctx = client.get("/api/me/context", headers={AUTH_HEADER: body["token"]})
|
||||
assert ctx.status_code == 200
|
||||
assert ctx.json()["tenant"]["role"] == "owner"
|
||||
assert ctx.json()["actor"]["type"] == "human"
|
||||
|
||||
# cleanup: allow re-run in isolated env — skip delete in shared CI DB
|
||||
closed = client.get("/api/auth/setup-status")
|
||||
assert closed.json()["registration_open"] is False
|
||||
|
||||
|
||||
def test_register_closed_when_users_exist(client):
|
||||
if user_count() == 0:
|
||||
return
|
||||
|
||||
res = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": f"other-{uuid.uuid4().hex[:8]}@example.com",
|
||||
"password": "secure-pass-123",
|
||||
"display_name": "Other User",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
|
@ -8,12 +8,26 @@ function apiHeaders(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 [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)
|
||||
|
|
@ -27,6 +41,14 @@ export default function App() {
|
|||
})
|
||||
.then(setHealth)
|
||||
.catch((err) => setHealthError(err.message))
|
||||
|
||||
fetch('/api/auth/setup-status')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setSetup(data)
|
||||
if (data.registration_open) setAuthMode('register')
|
||||
})
|
||||
.catch(() => setSetup({ registration_open: false, has_users: true }))
|
||||
}, [])
|
||||
|
||||
const loadSession = useCallback(async (activeToken) => {
|
||||
|
|
@ -53,6 +75,15 @@ export default function App() {
|
|||
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)
|
||||
|
|
@ -63,14 +94,38 @@ export default function App() {
|
|||
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('')
|
||||
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 {
|
||||
|
|
@ -89,7 +144,7 @@ export default function App() {
|
|||
})
|
||||
}
|
||||
} catch {
|
||||
/* Session trotzdem lokal entfernen */
|
||||
/* lokal entfernen */
|
||||
} finally {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
setToken('')
|
||||
|
|
@ -99,6 +154,8 @@ export default function App() {
|
|||
}
|
||||
}
|
||||
|
||||
const registrationOpen = setup?.registration_open === true
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<header className="hero">
|
||||
|
|
@ -107,42 +164,135 @@ export default function App() {
|
|||
<p className="muted">Auth, Tenant & Actor Foundation</p>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<>
|
||||
<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 && (
|
||||
|
|
@ -151,8 +301,8 @@ export default function App() {
|
|||
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}`}
|
||||
{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
|
||||
|
|
@ -163,6 +313,13 @@ export default function App() {
|
|||
{authError && <p className="error">{authError}</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">
|
||||
|
|
|
|||
|
|
@ -36,6 +36,43 @@ body {
|
|||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
background: #eef4fc;
|
||||
border: 1px solid #c5d9f0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.92rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: #eee;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.45rem 0.9rem;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #1a4d8f;
|
||||
color: #fff;
|
||||
border-color: #1a4d8f;
|
||||
}
|
||||
|
||||
.tab:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b00020;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user