Neue Sektion 'Mein Konto' auf der bestehenden /organisation-Seite. Jeder eingeloggte Nutzer darf sein eigenes Passwort ändern (kein require_org_admin nötig, anders als bei /organisation/konten für fremde Konten). Prüft aktuelles Passwort per user_manager.authenticate() (gleiches Muster wie der Login), validiert das neue Passwort explizit gegen die bestehende Policy (>=12 Zeichen, nicht gleich der E-Mail) aus der Sicherheitshärtung vom 25.08.2026, zeigt Fehler inline auf derselben Seite statt als nackte HTTPException. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
from types import SimpleNamespace
|
|
|
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from fastapi_users import exceptions
|
|
|
|
from anode_client import anode_request
|
|
from auth import (
|
|
UserCreate,
|
|
UserManager,
|
|
fetch_benutzer_fuer_organisation,
|
|
get_current_user,
|
|
get_user_manager,
|
|
require_org_admin,
|
|
)
|
|
from templating import templates
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _organisation_kontext(user: dict) -> dict:
|
|
"""
|
|
Gemeinsamer Seitenkontext für /organisation - wird sowohl vom normalen
|
|
GET als auch von passwort_aendern() bei einem Fehler gebraucht (dort
|
|
muss die Seite mit allen bestehenden Daten neu gerendert werden, nicht
|
|
nur mit der Fehlermeldung).
|
|
"""
|
|
|
|
result = anode_request("GET", f"/api/v1/organizations/{user.organization_id}")
|
|
|
|
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 {"user": user, "organization": result["organization"], "benutzer": benutzer}
|
|
|
|
|
|
@router.get("/organisation")
|
|
def organisation_ansicht(request: Request, user=Depends(get_current_user)):
|
|
return templates.TemplateResponse(request, "organisation.html", _organisation_kontext(user))
|
|
|
|
|
|
@router.post("/organisation")
|
|
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).
|
|
try:
|
|
await user_manager.create(
|
|
UserCreate(
|
|
email=email,
|
|
password=password,
|
|
organization_id=user.organization_id,
|
|
ist_organisationsadmin=ist_organisationsadmin == "on",
|
|
)
|
|
)
|
|
except exceptions.InvalidPasswordException as fehler:
|
|
raise HTTPException(status_code=400, detail=fehler.reason)
|
|
except exceptions.UserAlreadyExists:
|
|
raise HTTPException(status_code=400, detail="Ein Konto mit dieser E-Mail-Adresse existiert bereits.")
|
|
|
|
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.")
|
|
|
|
# Über die ID statt per E-Mail nachladen: get_by_email() lief über den
|
|
# sync-Verbindungspfad von fetch_benutzer_fuer_organisation() zurück in
|
|
# eine async-Abfrage - psycopg liefert TEXT-Spalten dort teils als bytes
|
|
# zurück (dasselbe bekannte Verhalten wie bei _row_to_user()), was hier
|
|
# zu "operator does not exist: text = bytea" führte (live gefunden bei
|
|
# der Verifikation). user_manager.get(id) nutzt ausschließlich den
|
|
# async-Pfad (UserDatabase.get(), mit korrektem Decode in _row_to_user()).
|
|
ziel_user = await user_manager.get(user_manager.parse_id(konto_id))
|
|
await user_manager.delete(ziel_user)
|
|
|
|
return RedirectResponse("/organisation", status_code=303)
|
|
|
|
|
|
@router.post("/organisation/passwort")
|
|
async def passwort_aendern(
|
|
request: Request,
|
|
aktuelles_passwort: str = Form(...),
|
|
neues_passwort: str = Form(...),
|
|
neues_passwort_wiederholung: str = Form(...),
|
|
user=Depends(get_current_user),
|
|
user_manager: UserManager = Depends(get_user_manager),
|
|
):
|
|
"""
|
|
Self-Service-Passwortänderung für den eingeloggten Nutzer selbst -
|
|
anders als /organisation/konten (fremdes Konto anlegen/löschen, nur für
|
|
require_org_admin) braucht das hier keine besondere Rolle, jeder
|
|
eingeloggte Nutzer darf sein eigenes Passwort ändern.
|
|
"""
|
|
|
|
fehler = None
|
|
|
|
if neues_passwort != neues_passwort_wiederholung:
|
|
fehler = "Die Wiederholung stimmt nicht mit dem neuen Passwort überein."
|
|
else:
|
|
# Gleiches Authenticate()-Muster wie beim Login (auth.py) - prüft
|
|
# das aktuelle Passwort, ohne eine eigene Vergleichslogik zu bauen.
|
|
authentifiziert = await user_manager.authenticate(
|
|
SimpleNamespace(username=user.email, password=aktuelles_passwort)
|
|
)
|
|
|
|
if authentifiziert is None:
|
|
fehler = "Das aktuelle Passwort ist falsch."
|
|
else:
|
|
try:
|
|
await user_manager.validate_password(neues_passwort, user)
|
|
except exceptions.InvalidPasswordException as exc:
|
|
fehler = exc.reason
|
|
else:
|
|
await user_manager.user_db.update(
|
|
user, {"hashed_password": user_manager.password_helper.hash(neues_passwort)}
|
|
)
|
|
|
|
if fehler:
|
|
kontext = _organisation_kontext(user)
|
|
kontext["passwort_fehler"] = fehler
|
|
return templates.TemplateResponse(request, "organisation.html", kontext, status_code=400)
|
|
|
|
return RedirectResponse("/organisation", status_code=303)
|