Backend: - User-Modell (sqlmodel) mit bcrypt-Passwort-Hash und is_admin-Flag - Job.owner_id (FK auf user.id), per Migration nachgerüstet (SQLite ALTER) - Starlette SessionMiddleware mit signiertem Cookie (SESSION_SECRET) - /api/auth: login / logout / me / needs-setup / setup (Initial-Admin) - /api/admin: User-CRUD + Passwort-Reset (Admin-only); letzter Admin / eigener Account vor Löschung geschützt - /api/jobs: Owner-Filter (Admin sieht alle inkl. owner_username), 404 statt 403 bei fremden Jobs (kein Existenz-Leak) Frontend: - Auth-Overlay mit Login bzw. Erst-Setup-Modus (auto-Detection) - Header zeigt User + Logout, Admin-Badge bei Admin-Konten - Admin-Karte: User anlegen, Passwort zurücksetzen, Löschen (mit Confirm) - 401 → Login-Overlay (auch bei XHR-Upload) Deploy: - install.sh generiert SESSION_SECRET einmalig per python-secrets - .env wird auf chmod 600 gesetzt - Erstaufruf zeigt automatisch das Setup-Formular; bestehende Jobs werden dem ersten Admin zugeordnet
87 lines
3.1 KiB
Python
87 lines
3.1 KiB
Python
"""Admin-Endpoints: User-CRUD. Nur für Admins."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlmodel import Session, select
|
|
|
|
from app.core.auth import admin_required, get_user_by_username, hash_password
|
|
from app.core.db import get_session
|
|
from app.models.user import User
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(admin_required)])
|
|
log = logging.getLogger("voice-agent.admin")
|
|
|
|
|
|
class CreateUserIn(BaseModel):
|
|
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
|
|
password: str = Field(min_length=8, max_length=200)
|
|
is_admin: bool = False
|
|
|
|
|
|
class ResetPasswordIn(BaseModel):
|
|
password: str = Field(min_length=8, max_length=200)
|
|
|
|
|
|
def _user_dict(u: User) -> dict:
|
|
return {
|
|
"id": u.id,
|
|
"username": u.username,
|
|
"is_admin": u.is_admin,
|
|
"created_at": u.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
@router.get("/users")
|
|
def list_users(session: Session = Depends(get_session)):
|
|
users = session.exec(select(User).order_by(User.created_at)).all()
|
|
return [_user_dict(u) for u in users]
|
|
|
|
|
|
@router.post("/users", status_code=status.HTTP_201_CREATED)
|
|
def create_user(payload: CreateUserIn, session: Session = Depends(get_session)):
|
|
if get_user_by_username(session, payload.username.strip()):
|
|
raise HTTPException(status.HTTP_409_CONFLICT, f"Benutzer '{payload.username}' existiert bereits")
|
|
user = User(
|
|
username=payload.username.strip(),
|
|
password_hash=hash_password(payload.password),
|
|
is_admin=payload.is_admin,
|
|
)
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
log.info("User angelegt: %s admin=%s", user.username, user.is_admin)
|
|
return _user_dict(user)
|
|
|
|
|
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_user(user_id: int, session: Session = Depends(get_session), me: User = Depends(admin_required)):
|
|
if user_id == me.id:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Eigenen Account kann man nicht löschen")
|
|
user = session.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
|
# Sicherheit: mind. ein Admin muss bleiben
|
|
if user.is_admin:
|
|
admins = session.exec(select(User).where(User.is_admin == True)).all() # noqa: E712
|
|
if len(admins) <= 1:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Letzten Admin kann man nicht löschen")
|
|
session.delete(user)
|
|
session.commit()
|
|
log.info("User gelöscht: id=%d username=%s", user_id, user.username)
|
|
return None
|
|
|
|
|
|
@router.post("/users/{user_id}/password")
|
|
def reset_password(user_id: int, payload: ResetPasswordIn, session: Session = Depends(get_session)):
|
|
user = session.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
|
user.password_hash = hash_password(payload.password)
|
|
session.add(user)
|
|
session.commit()
|
|
log.info("Passwort zurückgesetzt: user=%s", user.username)
|
|
return {"ok": True}
|