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 (
@@ -107,42 +164,135 @@ export default function App() {

Auth, Tenant & Actor Foundation

-
-

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}`}

+
+

API Health

+ {healthError &&

Fehler: {healthError}

} + {!healthError && !health &&

Lade …

} + {health &&
{JSON.stringify(health, null, 2)}
} +
+ {token && me && ( <>
diff --git a/frontend/src/app.css b/frontend/src/app.css index 065425b..f8895e8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; }