feat(users): add user administration and password management

This commit is contained in:
Schubert Ferenc 2026-07-11 14:04:30 +02:00
parent 47f54d3461
commit b584e60273
16 changed files with 720 additions and 22 deletions

View file

@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_session
from app.models.user import User
from app.models.user import User, UserRole
bearer = HTTPBearer()
@ -30,3 +30,17 @@ def current_user(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
return user
def require_role(*roles: UserRole):
def dependency(user: User = Depends(current_user)) -> User:
if user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return user
return dependency
def current_admin(user: User = Depends(current_user)) -> User:
if user.role != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
return user

View file

@ -1,12 +1,15 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
from app.db.session import get_session
from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse, UserRead
from app.core.security import hash_password, verify_password
from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserRead
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@ -22,3 +25,24 @@ def login(payload: LoginRequest, session: Session = Depends(get_session)) -> Tok
def me(user: User = Depends(current_user)) -> User:
return user
@router.post("/change-password")
def change_password(
payload: ChangePasswordRequest,
user: User = Depends(current_user),
session: Session = Depends(get_session),
) -> dict[str, str]:
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status_code=422, detail="Das aktuelle Passwort ist falsch.")
if payload.new_password != payload.new_password_confirmation:
raise HTTPException(status_code=422, detail="Die neuen Passwoerter stimmen nicht ueberein.")
if len(payload.new_password) < 12:
raise HTTPException(status_code=422, detail="Das neue Passwort muss mindestens 12 Zeichen haben.")
if verify_password(payload.new_password, user.password_hash):
raise HTTPException(status_code=422, detail="Das neue Passwort darf nicht identisch zum alten sein.")
user.password_hash = hash_password(payload.new_password)
user.must_change_password = False
user.password_changed_at = datetime.now(UTC)
session.commit()
return {"status": "ok"}

View file

@ -9,18 +9,20 @@ import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
from app.api.dependencies import current_admin, current_user
from app.db.session import get_session
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.user import User, UserRole
from app.models.validation import Validation
from app.modules.helios.service import HeliosImportService
from app.modules.orion.service import OrionReportService
@ -50,6 +52,10 @@ from app.schemas.domain import (
ValidationRead,
ValidationReview,
ValidationUpdate,
UserCreate,
UserRead,
UserUpdate,
UserPasswordResetRequest,
)
from app.services.domain_service import CrudService, DomainServices
from app.services.validation_workflow import ValidationWorkflowService
@ -546,3 +552,155 @@ def validation_report_pdf(item_id: str, session: Session = Depends(get_session))
media_type="application/pdf",
filename=report_path.name,
)
def _user_payload(user: User) -> dict:
return {
"id": user.id,
"created_at": user.created_at,
"updated_at": user.updated_at,
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role,
"is_active": user.is_active,
"must_change_password": user.must_change_password,
"last_login_at": user.last_login_at,
"password_changed_at": user.password_changed_at,
}
@router.get("/users", response_model=PaginatedResponse[UserRead])
def list_users(
session: Session = Depends(get_session),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str | None = Query(default=None),
role: str | None = Query(default=None),
active: bool | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_order: str = Query(default="desc"),
_: User = Depends(current_admin),
):
query = select(User)
if search:
term = f"%{search.strip()}%"
query = query.where(
(User.first_name.ilike(term))
| (User.last_name.ilike(term))
| (User.email.ilike(term))
)
if role:
query = query.where(User.role == role)
if active is not None:
query = query.where(User.is_active.is_(active))
sort_columns = {
"name": User.last_name,
"email": User.email,
"role": User.role,
"is_active": User.is_active,
"last_login_at": User.last_login_at,
"password_changed_at": User.password_changed_at,
"created_at": User.created_at,
"updated_at": User.updated_at,
}
sort_column = sort_columns.get(sort_by, User.created_at)
query = query.order_by(sort_column.asc() if sort_order.lower() == "asc" else sort_column.desc())
total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.post("/users", status_code=201)
def create_user(payload: UserCreate, session: Session = Depends(get_session), _: User = Depends(current_admin)):
from app.core.security import hash_password
import secrets
from app.models.user import User
temporary_password = payload.temporary_password or secrets.token_urlsafe(12)
user = User(
email=payload.email.lower(),
first_name=payload.first_name,
last_name=payload.last_name,
role=payload.role.value,
password_hash=hash_password(temporary_password),
is_active=payload.is_active,
must_change_password=payload.must_change_password,
)
session.add(user)
session.commit()
session.refresh(user)
return JSONResponse(content={"temporary_password": temporary_password, "user": jsonable_encoder(user)}, status_code=201)
@router.get("/users/{item_id}", response_model=UserRead)
def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
return user
@router.put("/users/{item_id}", response_model=UserRead)
def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id and not payload.is_active:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
active_admins = session.scalar(
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1 and (not payload.is_active or payload.role != UserRole.ADMIN.value):
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert oder herabgestuft werden.")
user.first_name = payload.first_name
user.last_name = payload.last_name
user.email = payload.email.lower()
user.role = payload.role.value
user.is_active = payload.is_active
user.must_change_password = payload.must_change_password
session.commit()
session.refresh(user)
return user
@router.post("/users/{item_id}/deactivate", response_model=UserRead)
def deactivate_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
active_admins = session.scalar(
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1:
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert werden.")
user.is_active = False
session.commit()
session.refresh(user)
return user
@router.post("/users/{item_id}/activate", response_model=UserRead)
def activate_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
user.is_active = True
session.commit()
session.refresh(user)
return user
@router.post("/users/{item_id}/reset-password")
def reset_user_password(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
import secrets
from app.core.security import hash_password
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
temporary_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(temporary_password)
user.must_change_password = True
session.commit()
return JSONResponse(content={"temporary_password": temporary_password})