feat: Self-Service-Kontenverwaltung + Organisationsdaten unter /organisation
Neue Rolle ist_organisationsadmin (auth.py), bewusst getrennt von is_superuser: is_superuser bleibt exklusiv internes, organisationsuebergreifendes Tuxflotte-Personal mit Zugriff auf /admin/* - ist_organisationsadmin ist auf die eigene Organisation beschränkt und kann nie über Self-Service gesetzt werden (organization_id/is_superuser kommen bei POST /organisation/konten nie aus dem Formular, sondern immer aus der Session). /organisation zeigt jetzt Kontakt-/Stammdaten (bearbeitbar) und die Kolleg:innen-Liste der eigenen Organisation (für alle sichtbar, Verwaltung nur für Org-Admins/internes Personal via require_org_admin). Selbstlöschung des eigenen Kontos wird verweigert. fetch_benutzer_fuer_organisation() von admin_kunden.py nach auth.py verschoben (jetzt von beiden Routern importiert, kein Duplikat) - liefert zusätzlich id + ist_organisationsadmin. admin_kunden.py bekommt eine neue Organisationsdaten-Sektion (nutzt denselben PATCH-Endpoint) und die neue Checkbox beim Konto-Anlegen, um den ersten Org-Admin einer neuen Organisation zu bootstrappen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
36d0346172
commit
ec11de1ee9
65
auth.py
65
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()
|
||||
|
||||
|
||||
|
||||
11
migrations/0005_organisationsadmin_rolle.sql
Normal file
11
migrations/0005_organisationsadmin_rolle.sql
Normal file
@ -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;
|
||||
@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -4,6 +4,31 @@
|
||||
<p><a href="/admin/kunden">← Kunden</a></p>
|
||||
<h2>{{ organization.name if organization else organization_id }}</h2>
|
||||
|
||||
<section>
|
||||
<h3>Organisationsdaten</h3>
|
||||
<form method="post" action="/admin/kunden/{{ organization_id }}/organisationsdaten">
|
||||
<label for="org_name">Name</label>
|
||||
<input type="text" id="org_name" name="name" value="{{ organization.name if organization else '' }}" required>
|
||||
|
||||
<label for="org_kontakt_name">Ansprechpartner:in</label>
|
||||
<input type="text" id="org_kontakt_name" name="kontakt_name" value="{{ organization.kontakt_name or '' if organization else '' }}">
|
||||
|
||||
<label for="org_adresse">Adresse</label>
|
||||
<input type="text" id="org_adresse" name="adresse" value="{{ organization.adresse or '' if organization else '' }}">
|
||||
|
||||
<label for="org_telefon">Telefon</label>
|
||||
<input type="text" id="org_telefon" name="telefon" value="{{ organization.telefon or '' if organization else '' }}">
|
||||
|
||||
<label for="org_kontakt_email">Kontakt-E-Mail</label>
|
||||
<input type="email" id="org_kontakt_email" name="kontakt_email" value="{{ organization.kontakt_email or '' if organization else '' }}">
|
||||
|
||||
<label for="org_notizen">Notizen</label>
|
||||
<textarea id="org_notizen" name="notizen" rows="3">{{ organization.notizen or '' if organization else '' }}</textarea>
|
||||
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Aktivierungscodes</h3>
|
||||
<figure>
|
||||
@ -90,12 +115,13 @@
|
||||
<h3>Kundenkonten</h3>
|
||||
<figure>
|
||||
<table>
|
||||
<thead><tr><th>E-Mail</th><th>Admin</th><th>Angelegt</th></tr></thead>
|
||||
<thead><tr><th>E-Mail</th><th>Tuxflotte-Admin</th><th>Org-Admin</th><th>Angelegt</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in benutzer %}
|
||||
<tr>
|
||||
<td>{{ b.email }}</td>
|
||||
<td>{{ "ja" if b.is_superuser else "nein" }}</td>
|
||||
<td>{{ "ja" if b.ist_organisationsadmin else "nein" }}</td>
|
||||
<td>{{ b.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -111,7 +137,11 @@
|
||||
<input type="password" id="password" name="password" required>
|
||||
<label>
|
||||
<input type="checkbox" name="is_superuser">
|
||||
Admin-Zugriff (organisationsübergreifend)
|
||||
Admin-Zugriff (organisationsübergreifend, internes Tuxflotte-Personal)
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="ist_organisationsadmin">
|
||||
Admin dieser Organisation (darf eigene Kolleg:innen-Konten verwalten)
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
|
||||
@ -6,6 +6,70 @@
|
||||
<form method="post" action="/organisation">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ organization.name }}" required>
|
||||
|
||||
<label for="kontakt_name">Ansprechpartner:in</label>
|
||||
<input type="text" id="kontakt_name" name="kontakt_name" value="{{ organization.kontakt_name or '' }}">
|
||||
|
||||
<label for="adresse">Adresse</label>
|
||||
<input type="text" id="adresse" name="adresse" value="{{ organization.adresse or '' }}">
|
||||
|
||||
<label for="telefon">Telefon</label>
|
||||
<input type="text" id="telefon" name="telefon" value="{{ organization.telefon or '' }}">
|
||||
|
||||
<label for="kontakt_email">Kontakt-E-Mail</label>
|
||||
<input type="email" id="kontakt_email" name="kontakt_email" value="{{ organization.kontakt_email or '' }}">
|
||||
|
||||
<label for="notizen">Notizen</label>
|
||||
<textarea id="notizen" name="notizen" rows="3">{{ organization.notizen or '' }}</textarea>
|
||||
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
|
||||
<section>
|
||||
<h3>Kolleg:innen</h3>
|
||||
{% if not benutzer %}
|
||||
<p>Keine Konten gefunden.</p>
|
||||
{% else %}
|
||||
<figure>
|
||||
<table>
|
||||
<thead><tr><th>E-Mail</th><th>Org-Admin</th><th>Angelegt</th>{% if user.is_superuser or user.ist_organisationsadmin %}<th></th>{% endif %}</tr></thead>
|
||||
<tbody>
|
||||
{% for b in benutzer %}
|
||||
<tr>
|
||||
<td>{{ b.email }}</td>
|
||||
<td>{{ "ja" if b.ist_organisationsadmin else "nein" }}</td>
|
||||
<td>{{ b.created_at }}</td>
|
||||
{% if user.is_superuser or user.ist_organisationsadmin %}
|
||||
<td>
|
||||
{% if b.id != user.id|string %}
|
||||
<form method="post" action="/organisation/konten/{{ b.id }}/loeschen">
|
||||
<button type="submit" class="outline secondary">Löschen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_superuser or user.ist_organisationsadmin %}
|
||||
<details>
|
||||
<summary role="button" class="secondary outline">Neues Konto anlegen</summary>
|
||||
<form method="post" action="/organisation/konten">
|
||||
<label for="konto_email">E-Mail</label>
|
||||
<input type="email" id="konto_email" name="email" required>
|
||||
<label for="konto_password">Passwort</label>
|
||||
<input type="password" id="konto_password" name="password" required>
|
||||
<label>
|
||||
<input type="checkbox" name="ist_organisationsadmin">
|
||||
Admin dieser Organisation (darf Kolleg:innen-Konten verwalten)
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user