238 lines
8.5 KiB
Python
238 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from account_access import (
|
|
admin_reset_recipients,
|
|
apply_identifier_edits,
|
|
assert_address_free,
|
|
assert_username_free,
|
|
deliver_jobs,
|
|
issue_password_reset,
|
|
issue_verification,
|
|
load_profile,
|
|
normalize_email,
|
|
normalize_username,
|
|
profile_view,
|
|
reset_message,
|
|
verify_message,
|
|
)
|
|
from auth import hash_password, require_admin_dep, require_auth
|
|
from db import get_db
|
|
from mailer import MailNotConfigured
|
|
|
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
|
|
|
|
|
class CreateUserRequest(BaseModel):
|
|
email: str
|
|
name: str = Field(min_length=1)
|
|
password: str = Field(min_length=4)
|
|
role: str = "user"
|
|
username: str | None = None
|
|
|
|
|
|
class PatchUserRequest(BaseModel):
|
|
name: str | None = None
|
|
role: str | None = None
|
|
status: str | None = None
|
|
password: str | None = None
|
|
username: str | None = None
|
|
email: str | None = None
|
|
secondary_email: str | None = None
|
|
|
|
|
|
class SelfPatchRequest(BaseModel):
|
|
username: str | None = None
|
|
email: str | None = None
|
|
secondary_email: str | None = None
|
|
password: str | None = None
|
|
|
|
|
|
def _active_admin_count(conn, exclude_id: str | None = None) -> int:
|
|
if exclude_id:
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) AS n FROM profiles WHERE role = 'admin' AND status = 'active' AND id != ?",
|
|
(exclude_id,),
|
|
).fetchone()
|
|
else:
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) AS n FROM profiles WHERE role = 'admin' AND status = 'active'"
|
|
).fetchone()
|
|
return int(row["n"])
|
|
|
|
|
|
def _send_jobs(jobs: list[tuple[list[str], str, str]]) -> bool:
|
|
if not jobs:
|
|
return True
|
|
try:
|
|
deliver_jobs(jobs)
|
|
except MailNotConfigured:
|
|
return False
|
|
return True
|
|
|
|
|
|
@router.get("")
|
|
def list_users(session: dict = Depends(require_admin_dep)):
|
|
with get_db() as conn:
|
|
rows = conn.execute("SELECT * FROM profiles ORDER BY created").fetchall()
|
|
return [profile_view(dict(row)) for row in rows]
|
|
|
|
|
|
@router.post("")
|
|
def create_user(req: CreateUserRequest, session: dict = Depends(require_admin_dep)):
|
|
if req.role not in {"user", "admin"}:
|
|
raise HTTPException(400, "Rolle muss user oder admin sein")
|
|
email = normalize_email(req.email)
|
|
username = normalize_username(req.username) if req.username and req.username.strip() else None
|
|
profile_id = str(uuid.uuid4())
|
|
jobs: list[tuple[list[str], str, str]] = []
|
|
try:
|
|
with get_db() as conn:
|
|
assert_address_free(conn, email, None)
|
|
if username:
|
|
assert_username_free(conn, username, None)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO profiles (id, email, name, password_hash, role, status, tier_id, username)
|
|
VALUES (?, ?, ?, ?, ?, 'active', 'local', ?)
|
|
""",
|
|
(
|
|
profile_id,
|
|
email,
|
|
req.name.strip(),
|
|
hash_password(req.password),
|
|
req.role,
|
|
username,
|
|
),
|
|
)
|
|
raw = issue_verification(conn, profile_id, "primary", email)
|
|
if raw:
|
|
subject, body = verify_message(raw)
|
|
jobs.append(([email], subject, body))
|
|
prof = profile_view(load_profile(conn, profile_id))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
if "UNIQUE" in str(exc).upper():
|
|
raise HTTPException(409, "E-Mail ist bereits vergeben") from exc
|
|
raise
|
|
if not _send_jobs(jobs):
|
|
prof = dict(prof or {})
|
|
prof["mail_sent"] = False
|
|
return prof
|
|
|
|
|
|
@router.patch("/me")
|
|
def patch_me(req: SelfPatchRequest, session: dict = Depends(require_auth)):
|
|
provided = req.model_fields_set
|
|
jobs: list[tuple[list[str], str, str]] = []
|
|
with get_db() as conn:
|
|
current = load_profile(conn, session["profile_id"])
|
|
if not current:
|
|
raise HTTPException(401, "Profil nicht gefunden")
|
|
if req.password:
|
|
if len(req.password) < 4:
|
|
raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben")
|
|
conn.execute(
|
|
"UPDATE profiles SET password_hash = ? WHERE id = ?",
|
|
(hash_password(req.password), current["id"]),
|
|
)
|
|
jobs = apply_identifier_edits(
|
|
conn,
|
|
current,
|
|
username=req.username,
|
|
email=req.email,
|
|
secondary_email=req.secondary_email,
|
|
username_set="username" in provided,
|
|
email_set="email" in provided,
|
|
secondary_set="secondary_email" in provided,
|
|
)
|
|
prof = profile_view(load_profile(conn, current["id"]))
|
|
if not _send_jobs(jobs):
|
|
raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.")
|
|
return prof
|
|
|
|
|
|
@router.patch("/{profile_id}")
|
|
def patch_user(profile_id: str, req: PatchUserRequest, session: dict = Depends(require_admin_dep)):
|
|
provided = req.model_fields_set
|
|
jobs: list[tuple[list[str], str, str]] = []
|
|
with get_db() as conn:
|
|
current = load_profile(conn, profile_id)
|
|
if not current:
|
|
raise HTTPException(404, "Nutzer nicht gefunden")
|
|
name = req.name.strip() if req.name is not None else current["name"]
|
|
role = req.role if req.role is not None else current["role"]
|
|
status = req.status if req.status is not None else current["status"]
|
|
if role not in {"user", "admin"}:
|
|
raise HTTPException(400, "Rolle muss user oder admin sein")
|
|
if status not in {"active", "disabled"}:
|
|
raise HTTPException(400, "Status muss active oder disabled sein")
|
|
becomes_non_admin = current["role"] == "admin" and (role != "admin" or status == "disabled")
|
|
if becomes_non_admin and _active_admin_count(conn, exclude_id=profile_id) < 1:
|
|
raise HTTPException(400, "Der letzte aktive Admin kann nicht entfernt werden")
|
|
password_hash = current["password_hash"]
|
|
if req.password:
|
|
if len(req.password) < 4:
|
|
raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben")
|
|
password_hash = hash_password(req.password)
|
|
conn.execute(
|
|
"""
|
|
UPDATE profiles SET name = ?, role = ?, status = ?, password_hash = ?
|
|
WHERE id = ?
|
|
""",
|
|
(name, role, status, password_hash, profile_id),
|
|
)
|
|
if status == "disabled":
|
|
conn.execute("DELETE FROM sessions WHERE profile_id = ?", (profile_id,))
|
|
current = load_profile(conn, profile_id) or current
|
|
jobs = apply_identifier_edits(
|
|
conn,
|
|
current,
|
|
username=req.username,
|
|
email=req.email,
|
|
secondary_email=req.secondary_email,
|
|
username_set="username" in provided,
|
|
email_set="email" in provided,
|
|
secondary_set="secondary_email" in provided,
|
|
)
|
|
prof = profile_view(load_profile(conn, profile_id))
|
|
if not _send_jobs(jobs):
|
|
raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.")
|
|
return prof
|
|
|
|
|
|
@router.post("/{profile_id}/password-reset")
|
|
def admin_password_reset(profile_id: str, session: dict = Depends(require_admin_dep)):
|
|
if profile_id == session["profile_id"]:
|
|
raise HTTPException(400, "Den eigenen Reset startest du über die Anmeldung")
|
|
jobs: list[tuple[list[str], str, str]] = []
|
|
with get_db() as conn:
|
|
profile = load_profile(conn, profile_id)
|
|
if not profile:
|
|
raise HTTPException(404, "Nutzer nicht gefunden")
|
|
recipients = admin_reset_recipients(profile)
|
|
if not recipients:
|
|
raise HTTPException(400, "Für dieses Konto ist keine E-Mail hinterlegt")
|
|
raw = issue_password_reset(conn, profile_id)
|
|
if not raw:
|
|
raise HTTPException(429, "Zu viele Reset-Links. Bitte später erneut versuchen.")
|
|
subject, body = reset_message(raw)
|
|
jobs.append((recipients, subject, body))
|
|
if not _send_jobs(jobs):
|
|
raise HTTPException(503, "Mailversand ist nicht konfiguriert")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/me")
|
|
def my_account(session: dict = Depends(require_auth)):
|
|
with get_db() as conn:
|
|
prof = profile_view(load_profile(conn, session["profile_id"]))
|
|
if not prof:
|
|
raise HTTPException(401, "Profil nicht gefunden")
|
|
return prof
|