From 101376540aec3b6963eeef8c262ce794577deb1a Mon Sep 17 00:00:00 2001
From: Lars
Date: Sat, 4 Jul 2026 20:19:02 +0200
Subject: [PATCH] Erstregistrierung als Systemadmin plus Login/Register-Dialog
im Frontend.
Co-authored-by: Cursor
---
README.md | 2 +
backend/bootstrap.py | 76 ++-------
backend/routers/auth.py | 31 +++-
backend/services/registration.py | 152 ++++++++++++++++++
backend/tests/test_registration.py | 69 ++++++++
frontend/src/App.jsx | 247 +++++++++++++++++++++++------
frontend/src/app.css | 37 +++++
7 files changed, 502 insertions(+), 112 deletions(-)
create mode 100644 backend/services/registration.py
create mode 100644 backend/tests/test_registration.py
diff --git a/README.md b/README.md
index 0bfc5e0..1fe1550 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/backend/bootstrap.py b/backend/bootstrap.py
index 270fbc9..d8d1c9e 100644
--- a/backend/bootstrap.py
+++ b/backend/bootstrap.py
@@ -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")
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
index da2eacc..920c8f1 100644
--- a/backend/routers/auth.py
+++ b/backend/routers/auth.py
@@ -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)
diff --git a/backend/services/registration.py b/backend/services/registration.py
new file mode 100644
index 0000000..0363dae
--- /dev/null
+++ b/backend/services/registration.py
@@ -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",
+ )
diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py
new file mode 100644
index 0000000..cebd176
--- /dev/null
+++ b/backend/tests/test_registration.py
@@ -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
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 13dd341..ae4f62a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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 (
-
- API Health
- {healthError && Fehler: {healthError}
}
- {!healthError && !health && Lade …
}
- {health && {JSON.stringify(health, null, 2)}}
-
+
+ {token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}
+
+ {!token && registrationOpen && (
+
+ Noch kein Systemadministrator. Der erste Account wird automatisch{' '}
+ Portal-Admin und erhält einen Default-Tenant.
+
+ )}
-
- Session
{!token && (
-
+ <>
+
+
+
+
+
+ {authMode === 'login' && (
+
+ )}
+
+ {authMode === 'register' && registrationOpen && (
+
+ )}
+
+ {authMode === 'register' && !registrationOpen && (
+
+ Registrierung ist geschlossen. Bitte melden Sie sich an oder wenden Sie sich an
+ den Administrator.
+
+ )}
+ >
)}
{token && me && (
@@ -151,8 +301,8 @@ export default function App() {
Eingeloggt als {me.display_name} ({me.email})
- 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}`}