Files
cluedo-hp-webapp/backend/app/routes/admin.py
T
nessi 565d538c35 Add permanent user deletion endpoint with cascade cleanup and trash icon button in admin panel
Implemented DELETE `/admin/users/{user_id}/permanent` endpoint with safeguards preventing self-deletion and blocking deletion of game hosts. Added cascade deletion of user's game memberships, chips, sheet states, and invite tokens, with winner references nullified. Added trash icon button (🗑) to admin user rows with bilingual confirmation dialog and error handling for game ownership conflicts. Adjusted
2026-08-02 10:44:40 +02:00

241 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..db import get_db
from ..mailer import send_html_email, send_user_invite
from ..models import AppSettings, Game, GameChip, GameMember, InviteToken, SheetState, User, Role
from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"])
def require_admin(req: Request, db: Session) -> User:
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
user = db.query(User).filter(User.id == uid).first()
if not user or user.role != Role.admin.value:
raise HTTPException(status_code=403, detail="forbidden")
return user
@router.get("/users")
def list_users(req: Request, db: Session = Depends(get_db)):
require_admin(req, db)
users = db.query(User).order_by(User.created_at.desc()).all()
return [
{
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"role": u.role,
"disabled": u.disabled,
}
for u in users
]
@router.post("/users")
def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
email = (data.get("email") or "").lower().strip()
password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip()
if not email or not display_name:
raise HTTPException(400, "name/email required")
if db.query(User).filter(User.email == email).first():
raise HTTPException(409, "email exists")
role = data.get("role")
if not role:
raise HTTPException(400, "role required")
if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role")
settings = db.query(AppSettings).filter(AppSettings.id == "default").first() or AppSettings(id="default")
invite_sent = not password
raw_token = None
if invite_sent:
if not settings.smtp_host or not settings.smtp_from_email:
raise HTTPException(400, "configure SMTP settings before sending invites")
raw_token = secrets.token_urlsafe(32)
password = secrets.token_urlsafe(32)
u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name)
db.add(u)
db.flush()
if invite_sent:
invite = InviteToken(
user_id=u.id,
token_hash=hashlib.sha256(raw_token.encode()).hexdigest(),
expires_at=datetime.now(timezone.utc) + timedelta(hours=48),
)
db.add(invite)
invite_url = f"{(settings.app_base_url or 'http://localhost:8081').rstrip('/')}/invite/{raw_token}"
try:
send_user_invite(settings, u, invite_url)
except Exception as exc:
db.rollback()
raise HTTPException(502, f"invite email could not be sent: {exc}")
db.commit()
return {"ok": True, "id": u.id, "invite_sent": invite_sent}
@router.delete("/users/{user_id}/permanent")
def permanently_delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
admin = require_admin(req, db)
if admin.id == user_id:
raise HTTPException(400, "cannot delete yourself")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "not found")
if db.query(Game).filter(Game.host_user_id == user_id).first():
raise HTTPException(409, "cannot delete a user who owns games")
db.query(Game).filter(Game.winner_user_id == user_id).update({Game.winner_user_id: None}, synchronize_session=False)
db.query(GameMember).filter(GameMember.user_id == user_id).delete(synchronize_session=False)
db.query(GameChip).filter(GameChip.user_id == user_id).delete(synchronize_session=False)
db.query(SheetState).filter(SheetState.owner_user_id == user_id).delete(synchronize_session=False)
db.query(InviteToken).filter(InviteToken.user_id == user_id).delete(synchronize_session=False)
db.delete(user)
db.commit()
return {"ok": True, "deleted": True}
@router.delete("/users/{user_id}")
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
admin = require_admin(req, db)
if admin.id == user_id:
raise HTTPException(400, "cannot delete yourself")
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(404, "not found")
# soft delete
u.disabled = True
db.add(u)
db.commit()
return {"ok": True}
@router.patch("/users/{user_id}")
def update_user(req: Request, user_id: str, data: dict, db: Session = Depends(get_db)):
admin = require_admin(req, db)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "not found")
email = (data.get("email") or user.email).lower().strip()
display_name = (data.get("display_name") if "display_name" in data else user.display_name or "").strip()
role = data.get("role") or user.role
password = data.get("password") or ""
if not email or "@" not in email:
raise HTTPException(400, "valid email required")
if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role")
if password and len(password) < 8:
raise HTTPException(400, "password too short (min 8)")
if admin.id == user_id and role != Role.admin.value:
raise HTTPException(400, "cannot demote yourself")
if admin.id == user_id and data.get("disabled") is True:
raise HTTPException(400, "cannot disable yourself")
duplicate = db.query(User).filter(User.email == email, User.id != user_id).first()
if duplicate:
raise HTTPException(409, "email exists")
user.email = email
user.display_name = display_name
user.role = role
if password:
user.password_hash = hash_password(password)
if "disabled" in data:
user.disabled = bool(data["disabled"])
db.add(user)
db.commit()
return {"ok": True}
def get_settings(db: Session) -> AppSettings:
settings = db.query(AppSettings).filter(AppSettings.id == "default").first()
if not settings:
settings = AppSettings(id="default")
db.add(settings)
db.commit()
db.refresh(settings)
return settings
@router.get("/settings/smtp")
def read_smtp_settings(req: Request, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
return {
"smtp_host": settings.smtp_host,
"smtp_port": settings.smtp_port,
"smtp_username": settings.smtp_username,
"smtp_password_configured": bool(settings.smtp_password),
"smtp_from_email": settings.smtp_from_email,
"smtp_from_name": settings.smtp_from_name,
"smtp_security": getattr(settings, "smtp_security", "starttls") or ("starttls" if settings.smtp_use_tls else "none"),
"smtp_use_tls": settings.smtp_use_tls,
"app_base_url": settings.app_base_url,
}
@router.patch("/settings/smtp")
def update_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
settings.smtp_host = (data.get("smtp_host") or "").strip()
try:
settings.smtp_port = int(data.get("smtp_port") or 587)
except (TypeError, ValueError):
raise HTTPException(400, "SMTP port must be a number")
settings.smtp_username = (data.get("smtp_username") or "").strip()
settings.smtp_from_email = (data.get("smtp_from_email") or "").strip()
settings.smtp_from_name = (data.get("smtp_from_name") or "Cluedo HP").strip()
security = data.get("smtp_security") or ("starttls" if data.get("smtp_use_tls", True) else "none")
if security not in ("none", "starttls", "ssl"):
raise HTTPException(400, "invalid SMTP security mode")
settings.smtp_security = security
settings.smtp_use_tls = security == "starttls"
settings.app_base_url = (data.get("app_base_url") or "http://localhost:8081").strip().rstrip("/")
if "smtp_password" in data and data.get("smtp_password"):
settings.smtp_password = data["smtp_password"]
if settings.smtp_host and not settings.smtp_from_email:
raise HTTPException(400, "from email required when SMTP is configured")
db.add(settings)
db.commit()
return {"ok": True}
@router.post("/settings/smtp/test")
def test_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
recipient = (data.get("recipient") or "").strip()
if not recipient:
raise HTTPException(400, "recipient required")
try:
send_html_email(
settings,
recipient,
"SMTP-Test Cluedo HP",
"<div style='font-family:Georgia,serif;padding:24px;background:#17161b;color:#f5efdc'><h2 style='color:#e9d8a6'>SMTP funktioniert</h2><p>Der Einladungsversand ist bereit.</p></div>",
"SMTP funktioniert. Der Einladungsversand ist bereit.",
)
except Exception as exc:
raise HTTPException(502, f"SMTP test failed: {exc}")
return {"ok": True}