root 7679efa7e4 feat(settings): eigene Unterseite für User-Einstellungen + Passwort-Wechsel
Header: Profil- und Modell-Dropdowns rausgenommen, dafür ein
"⚙ Einstellungen"-Link. Klick öffnet eine eigene Settings-View
(toggled die main-View aus) mit drei Sektionen:

- Standard-Profil (sofort speichernd)
- Standard-LLM-Modell (sofort speichernd, leer = Worker-Default)
- Passwort ändern (aktuelles + neues + Bestätigung)

Neuer Endpoint POST /api/auth/me/password — verlangt zwingend das
aktuelle Passwort (verhindert Übernahme via offene Browser-Session).
Admin-Reset über /api/admin/users/<id>/password bleibt unberührt.

Layout: Settings-View ist eine Card mit max-width 640px, jede
Sektion durch Trennlinie abgegrenzt. Inline-Status-Meldungen
("Standard-Profil ist jetzt: …") direkt unter den Controls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:40:28 +00:00

149 lines
5.1 KiB
Python

"""Auth-Endpoints: Login, Logout, Me, Erst-Setup (Initial-Admin)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlmodel import Session
from app.core import migrate
from app.core.auth import (
current_user,
get_user_by_username,
has_any_user,
hash_password,
verify_password,
)
from app.core.db import get_session
from app.models.user import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
log = logging.getLogger("voice-agent.auth")
class LoginIn(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=200)
class SetupIn(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)
def _user_dict(u: User) -> dict:
return {
"id": u.id,
"username": u.username,
"is_admin": u.is_admin,
"default_profile": u.default_profile or "meeting",
# Leerer String = User hat keine Modell-Wahl getroffen → Mac-Worker
# nimmt seinen .env-Default. UI zeigt das als „Standard (Worker)".
"default_model": u.default_model or "",
}
class UpdateMeIn(BaseModel):
default_profile: str | None = Field(default=None, max_length=64)
# Leerer String bedeutet bewusst „kein Override" — wird gespeichert.
default_model: str | None = Field(default=None, max_length=128)
@router.get("/me")
def me(user: User = Depends(current_user)):
return _user_dict(user)
@router.patch("/me")
def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session: Session = Depends(get_session)):
changed = False
if payload.default_profile is not None:
user.default_profile = payload.default_profile.strip() or "meeting"
changed = True
if payload.default_model is not None:
user.default_model = payload.default_model.strip()
changed = True
if changed:
session.add(user)
session.commit()
session.refresh(user)
return _user_dict(user)
class ChangePasswordIn(BaseModel):
current_password: str = Field(min_length=1, max_length=200)
new_password: str = Field(min_length=8, max_length=200)
@router.post("/me/password")
def change_password(
payload: ChangePasswordIn,
user: User = Depends(current_user),
session: Session = Depends(get_session),
):
"""User ändert eigenes Passwort.
Aktuelles Passwort muss korrekt sein — verhindert Übernahme, wenn jemand
eine offene Browser-Session findet. Admin-Reset läuft weiter über
/api/admin/users/<id>/password (ohne current_password).
"""
if not verify_password(payload.current_password, user.password_hash):
log.warning("Password-Change abgelehnt für user=%s (aktuelles Passwort falsch)", user.username)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort falsch")
user.password_hash = hash_password(payload.new_password)
session.add(user)
session.commit()
log.info("Password geändert für user=%s (id=%d)", user.username, user.id)
return {"ok": True}
@router.get("/needs-setup")
def needs_setup(session: Session = Depends(get_session)):
"""Vor Login prüfen ob noch kein User existiert (erstes Deployment)."""
return {"needs_setup": not has_any_user(session)}
@router.post("/login")
def login(payload: LoginIn, request: Request, session: Session = Depends(get_session)):
username = payload.username.strip()
user = get_user_by_username(session, username)
if not user:
log.warning("Login fehlgeschlagen: User '%s' nicht gefunden", username)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
if not verify_password(payload.password, user.password_hash):
log.warning(
"Login fehlgeschlagen: Passwort falsch für user=%s (hash_len=%d, prefix=%s)",
user.username, len(user.password_hash), user.password_hash[:7],
)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
request.session["user_id"] = user.id
log.info("Login OK: user=%s id=%d admin=%s", user.username, user.id, user.is_admin)
return _user_dict(user)
@router.post("/logout")
def logout(request: Request):
request.session.clear()
return {"ok": True}
@router.post("/setup")
def setup(payload: SetupIn, request: Request, session: Session = Depends(get_session)):
"""Erstellt den ersten Admin und ordnet alle Bestand-Jobs ihm zu.
Nur möglich, solange kein User existiert."""
if has_any_user(session):
raise HTTPException(status.HTTP_409_CONFLICT, "Setup bereits abgeschlossen")
user = User(
username=payload.username.strip(),
password_hash=hash_password(payload.password),
is_admin=True,
)
session.add(user)
session.commit()
session.refresh(user)
log.info("Initial-Admin angelegt: %s (id=%d)", user.username, user.id)
n = migrate.assign_orphan_jobs_to(user.id)
request.session["user_id"] = user.id
return {**_user_dict(user), "migrated_jobs": n}