diff --git a/auth.py b/auth.py
index f604ba1..7e2a06e 100644
--- a/auth.py
+++ b/auth.py
@@ -10,7 +10,7 @@ from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, schemas
from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy
from fastapi_users.db import BaseUserDatabase
-from db import get_async_database_connection
+from db import get_async_database_connection, get_database_connection
from templating import templates
@@ -19,7 +19,10 @@ SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "")
COOKIE_NAME = "kundenplattform_session"
COOKIE_MAX_AGE = 14 * 24 * 60 * 60 # 14 Tage, wie zuvor Starlettes SessionMiddleware-Default
-BENUTZER_SPALTEN = "id, organization_id, email, hashed_password, is_active, is_superuser, is_verified"
+BENUTZER_SPALTEN = (
+ "id, organization_id, email, hashed_password, is_active, is_superuser, "
+ "ist_organisationsadmin, is_verified"
+)
@dataclass
@@ -30,16 +33,21 @@ class User:
organization_id: uuid.UUID
is_active: bool = True
is_superuser: bool = False
+ ist_organisationsadmin: bool = False
is_verified: bool = True
class UserCreate(schemas.BaseUserCreate):
organization_id: uuid.UUID
+ ist_organisationsadmin: Optional[bool] = False
is_verified: Optional[bool] = True
def _row_to_user(row) -> User:
- user_id, organization_id, email, hashed_password, is_active, is_superuser, is_verified = row
+ (
+ user_id, organization_id, email, hashed_password, is_active,
+ is_superuser, ist_organisationsadmin, is_verified,
+ ) = row
# psycopg liefert TEXT-Spalten hier teils als bytes zurück, nicht als str.
if isinstance(hashed_password, bytes):
@@ -54,6 +62,7 @@ def _row_to_user(row) -> User:
hashed_password=hashed_password,
is_active=is_active,
is_superuser=is_superuser,
+ ist_organisationsadmin=ist_organisationsadmin,
is_verified=is_verified,
)
@@ -86,8 +95,9 @@ class UserDatabase(BaseUserDatabase[User, uuid.UUID]):
await cur.execute(
"""
INSERT INTO benutzer
- (id, organization_id, email, hashed_password, is_active, is_superuser, is_verified)
- VALUES (%s, %s, %s, %s, %s, %s, %s)
+ (id, organization_id, email, hashed_password, is_active, is_superuser,
+ ist_organisationsadmin, is_verified)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
user_id,
@@ -96,6 +106,7 @@ class UserDatabase(BaseUserDatabase[User, uuid.UUID]):
create_dict["hashed_password"],
create_dict.get("is_active", True),
create_dict.get("is_superuser", False),
+ create_dict.get("ist_organisationsadmin", False),
create_dict.get("is_verified", True),
),
)
@@ -168,6 +179,50 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
return user
+async def require_org_admin(user: User = Depends(get_current_user)) -> User:
+ """
+ Für Self-Service-Kontenverwaltung (/organisation/konten): erlaubt
+ entweder is_superuser (internes Tuxflotte-Personal, soll nicht
+ ausgesperrt sein, falls Support nötig ist) oder ist_organisationsadmin
+ (die neue, auf die eigene Organisation beschränkte Rolle). Bewusst kein
+ Ersatz für require_admin - is_superuser bleibt exklusiv für /admin/*.
+ """
+
+ if not (user.is_superuser or user.ist_organisationsadmin):
+ raise HTTPException(status_code=403, detail="Kein Admin-Zugriff für diese Organisation.")
+
+ return user
+
+
+def fetch_benutzer_fuer_organisation(organization_id: str) -> list[dict]:
+ """
+ Kontenliste einer Organisation - gemeinsam genutzt vom internen
+ Admin-Bereich (admin_kunden.py) und der Self-Service-Seite
+ (routers/organisation.py), um die SQL-Abfrage nicht zu duplizieren
+ (siehe ADR-0017 fetch_gruppen()-Reuse-Pattern).
+ """
+
+ with get_database_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT id, email, is_superuser, ist_organisationsadmin, created_at
+ FROM benutzer WHERE organization_id = %s ORDER BY email
+ """,
+ (organization_id,),
+ )
+ return [
+ {
+ "id": str(benutzer_id),
+ "email": email,
+ "is_superuser": is_superuser,
+ "ist_organisationsadmin": ist_organisationsadmin,
+ "created_at": created_at,
+ }
+ for benutzer_id, email, is_superuser, ist_organisationsadmin, created_at in cur.fetchall()
+ ]
+
+
router = APIRouter()
diff --git a/migrations/0005_organisationsadmin_rolle.sql b/migrations/0005_organisationsadmin_rolle.sql
new file mode 100644
index 0000000..01c40a7
--- /dev/null
+++ b/migrations/0005_organisationsadmin_rolle.sql
@@ -0,0 +1,11 @@
+BEGIN;
+
+-- Organisationsgebundene Admin-Rolle fuer Self-Service-Kontenverwaltung
+-- (/organisation/konten) - bewusst getrennt von is_superuser, das
+-- weiterhin ausschliesslich organisationsuebergreifendes, internes
+-- Tuxflotte-Personal mit Zugriff auf /admin/* bedeutet. Eine Kundin darf
+-- sich selbst nie is_superuser setzen koennen, nur dieses neue,
+-- auf die eigene Organisation beschraenkte Flag.
+ALTER TABLE benutzer ADD COLUMN ist_organisationsadmin BOOLEAN NOT NULL DEFAULT FALSE;
+
+COMMIT;
diff --git a/routers/admin_kunden.py b/routers/admin_kunden.py
index fba182d..d47706a 100644
--- a/routers/admin_kunden.py
+++ b/routers/admin_kunden.py
@@ -2,27 +2,13 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from anode_client import anode_request
-from auth import UserCreate, UserManager, get_user_manager, require_admin
-from db import get_database_connection
+from auth import UserCreate, UserManager, fetch_benutzer_fuer_organisation, get_user_manager, require_admin
from templating import templates
router = APIRouter(prefix="/admin/kunden")
-def fetch_benutzer_fuer_organisation(organization_id: str) -> list[dict]:
- with get_database_connection() as conn:
- with conn.cursor() as cur:
- cur.execute(
- "SELECT email, is_superuser, created_at FROM benutzer WHERE organization_id = %s ORDER BY email",
- (organization_id,),
- )
- return [
- {"email": email, "is_superuser": is_superuser, "created_at": created_at}
- for email, is_superuser, created_at in cur.fetchall()
- ]
-
-
@router.get("")
def index(request: Request, user: dict = Depends(require_admin)):
result = anode_request("GET", "/api/v1/organizations")
@@ -80,6 +66,33 @@ def detail(request: Request, organization_id: str, user: dict = Depends(require_
)
+@router.post("/{organization_id}/organisationsdaten")
+def organisationsdaten_speichern(
+ organization_id: str,
+ name: str = Form(...),
+ kontakt_name: str = Form(default=""),
+ adresse: str = Form(default=""),
+ telefon: str = Form(default=""),
+ kontakt_email: str = Form(default=""),
+ notizen: str = Form(default=""),
+ user: dict = Depends(require_admin),
+):
+ anode_request(
+ "PATCH",
+ f"/api/v1/organizations/{organization_id}",
+ json={
+ "name": name,
+ "kontakt_name": kontakt_name or None,
+ "adresse": adresse or None,
+ "telefon": telefon or None,
+ "kontakt_email": kontakt_email or None,
+ "notizen": notizen or None,
+ },
+ )
+
+ return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303)
+
+
@router.post("/{organization_id}/gruppen")
def gruppe_anlegen(organization_id: str, name: str = Form(...), user: dict = Depends(require_admin)):
anode_request("POST", f"/api/v1/organizations/{organization_id}/gruppen", json={"name": name})
@@ -215,6 +228,7 @@ async def konto_anlegen(
email: str = Form(...),
password: str = Form(...),
is_superuser: str = Form(default=""),
+ ist_organisationsadmin: str = Form(default=""),
user: dict = Depends(require_admin),
user_manager: UserManager = Depends(get_user_manager),
):
@@ -224,6 +238,7 @@ async def konto_anlegen(
password=password,
organization_id=organization_id,
is_superuser=is_superuser == "on",
+ ist_organisationsadmin=ist_organisationsadmin == "on",
)
)
diff --git a/routers/organisation.py b/routers/organisation.py
index 927c214..af3439b 100644
--- a/routers/organisation.py
+++ b/routers/organisation.py
@@ -2,7 +2,14 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from anode_client import anode_request
-from auth import get_current_user
+from auth import (
+ UserCreate,
+ UserManager,
+ fetch_benutzer_fuer_organisation,
+ get_current_user,
+ get_user_manager,
+ require_org_admin,
+)
from templating import templates
@@ -16,13 +23,85 @@ def organisation_ansicht(request: Request, user=Depends(get_current_user)):
if not result.get("success"):
raise HTTPException(status_code=404, detail="Organisation wurde nicht gefunden.")
+ benutzer = fetch_benutzer_fuer_organisation(str(user.organization_id))
+
return templates.TemplateResponse(
- request, "organisation.html", {"user": user, "organization": result["organization"]}
+ request,
+ "organisation.html",
+ {"user": user, "organization": result["organization"], "benutzer": benutzer},
)
@router.post("/organisation")
-def organisation_speichern(name: str = Form(...), user=Depends(get_current_user)):
- anode_request("PATCH", f"/api/v1/organizations/{user.organization_id}", json={"name": name})
+def organisation_speichern(
+ name: str = Form(...),
+ kontakt_name: str = Form(default=""),
+ adresse: str = Form(default=""),
+ telefon: str = Form(default=""),
+ kontakt_email: str = Form(default=""),
+ notizen: str = Form(default=""),
+ user=Depends(get_current_user),
+):
+ anode_request(
+ "PATCH",
+ f"/api/v1/organizations/{user.organization_id}",
+ json={
+ "name": name,
+ "kontakt_name": kontakt_name or None,
+ "adresse": adresse or None,
+ "telefon": telefon or None,
+ "kontakt_email": kontakt_email or None,
+ "notizen": notizen or None,
+ },
+ )
+
+ return RedirectResponse("/organisation", status_code=303)
+
+
+@router.post("/organisation/konten")
+async def konto_anlegen(
+ email: str = Form(...),
+ password: str = Form(...),
+ ist_organisationsadmin: str = Form(default=""),
+ user=Depends(require_org_admin),
+ user_manager: UserManager = Depends(get_user_manager),
+):
+ # organization_id kommt bewusst nie aus dem Formular, sondern immer aus
+ # der Session des anfragenden Nutzers - verhindert, dass jemand per
+ # manipuliertem Request ein Konto in einer fremden Organisation anlegt.
+ # is_superuser wird beim Self-Service-Aufruf nie gesetzt (Default False)
+ # - das bleibt exklusiv dem internen Admin-Bereich vorbehalten (siehe
+ # ADR zu is_superuser vs. ist_organisationsadmin).
+ await user_manager.create(
+ UserCreate(
+ email=email,
+ password=password,
+ organization_id=user.organization_id,
+ ist_organisationsadmin=ist_organisationsadmin == "on",
+ )
+ )
+
+ return RedirectResponse("/organisation", status_code=303)
+
+
+@router.post("/organisation/konten/{konto_id}/loeschen")
+async def konto_loeschen(
+ konto_id: str,
+ user=Depends(require_org_admin),
+ user_manager: UserManager = Depends(get_user_manager),
+):
+ if str(user.id) == konto_id:
+ raise HTTPException(status_code=400, detail="Das eigene Konto kann nicht gelöscht werden.")
+
+ ziel = next(
+ (b for b in fetch_benutzer_fuer_organisation(str(user.organization_id)) if b["id"] == konto_id),
+ None,
+ )
+
+ if ziel is None:
+ raise HTTPException(status_code=404, detail="Konto wurde nicht gefunden.")
+
+ ziel_user = await user_manager.get_by_email(ziel["email"])
+ await user_manager.delete(ziel_user)
return RedirectResponse("/organisation", status_code=303)
diff --git a/templates/admin/kunden_detail.html b/templates/admin/kunden_detail.html
index 0975775..ee8ee4c 100644
--- a/templates/admin/kunden_detail.html
+++ b/templates/admin/kunden_detail.html
@@ -4,6 +4,31 @@
← Kunden
{{ organization.name if organization else organization_id }}
+
+
Aktivierungscodes
@@ -90,12 +115,13 @@
Kundenkonten