From b584e602733aac723ef115a8f83884ddd9f70c47 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 11 Jul 2026 14:04:30 +0200 Subject: [PATCH] feat(users): add user administration and password management --- .../202607110004_users_password_management.py | 36 +++ .../backend/mercury/app/api/dependencies.py | 16 +- .../backend/mercury/app/api/v1/auth.py | 28 +- .../backend/mercury/app/api/v1/domain.py | 160 ++++++++++- .../backend/mercury/app/db/seed.py | 7 +- .../backend/mercury/app/models/user.py | 21 +- .../backend/mercury/app/schemas/auth.py | 13 +- .../backend/mercury/app/schemas/domain.py | 28 ++ .../mercury/app/services/auth_service.py | 7 +- .../mercury/app/services/domain_service.py | 3 + .../mercury/tests/test_validation_workflow.py | 42 +++ .../atlas/app/(app)/profile/security/page.tsx | 63 +++++ .../frontend/atlas/app/(app)/users/page.tsx | 251 ++++++++++++++++++ .../frontend/atlas/components/app-shell.tsx | 17 +- .../frontend/atlas/components/auth.tsx | 26 +- validation-suite/frontend/atlas/lib/api.ts | 24 +- 16 files changed, 720 insertions(+), 22 deletions(-) create mode 100644 validation-suite/backend/mercury/alembic/versions/202607110004_users_password_management.py create mode 100644 validation-suite/frontend/atlas/app/(app)/profile/security/page.tsx create mode 100644 validation-suite/frontend/atlas/app/(app)/users/page.tsx diff --git a/validation-suite/backend/mercury/alembic/versions/202607110004_users_password_management.py b/validation-suite/backend/mercury/alembic/versions/202607110004_users_password_management.py new file mode 100644 index 00000000..a9ad66b8 --- /dev/null +++ b/validation-suite/backend/mercury/alembic/versions/202607110004_users_password_management.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "202607110004" +down_revision = "202607110003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("first_name", sa.String(length=80), nullable=True)) + op.add_column("users", sa.Column("last_name", sa.String(length=80), nullable=True)) + op.add_column("users", sa.Column("must_change_password", sa.Boolean(), nullable=False, server_default=sa.true())) + op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("users", sa.Column("password_changed_at", sa.DateTime(timezone=True), nullable=True)) + + op.execute( + sa.text( + "UPDATE users SET first_name = COALESCE(first_name, split_part(full_name, ' ', 1)), " + "last_name = COALESCE(last_name, NULLIF(substr(full_name, length(split_part(full_name, ' ', 1)) + 2), ''))" + ) + ) + op.execute(sa.text("UPDATE users SET first_name = COALESCE(first_name, 'Validation'), last_name = COALESCE(last_name, 'Admin')")) + op.alter_column("users", "first_name", nullable=False) + op.alter_column("users", "last_name", nullable=False) + op.alter_column("users", "must_change_password", server_default=None) + + +def downgrade() -> None: + op.drop_column("users", "password_changed_at") + op.drop_column("users", "last_login_at") + op.drop_column("users", "must_change_password") + op.drop_column("users", "last_name") + op.drop_column("users", "first_name") diff --git a/validation-suite/backend/mercury/app/api/dependencies.py b/validation-suite/backend/mercury/app/api/dependencies.py index 8cb38b92..12e4735d 100644 --- a/validation-suite/backend/mercury/app/api/dependencies.py +++ b/validation-suite/backend/mercury/app/api/dependencies.py @@ -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 diff --git a/validation-suite/backend/mercury/app/api/v1/auth.py b/validation-suite/backend/mercury/app/api/v1/auth.py index a197ef5d..f066496b 100644 --- a/validation-suite/backend/mercury/app/api/v1/auth.py +++ b/validation-suite/backend/mercury/app/api/v1/auth.py @@ -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"} diff --git a/validation-suite/backend/mercury/app/api/v1/domain.py b/validation-suite/backend/mercury/app/api/v1/domain.py index 19f04f89..11ad978a 100644 --- a/validation-suite/backend/mercury/app/api/v1/domain.py +++ b/validation-suite/backend/mercury/app/api/v1/domain.py @@ -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}) diff --git a/validation-suite/backend/mercury/app/db/seed.py b/validation-suite/backend/mercury/app/db/seed.py index e16bc113..6837dcca 100644 --- a/validation-suite/backend/mercury/app/db/seed.py +++ b/validation-suite/backend/mercury/app/db/seed.py @@ -16,10 +16,12 @@ def seed_admin() -> None: session.add( User( email=settings.admin_email.lower(), - full_name="Validation Suite Administrator", - role=UserRole.admin, + first_name="Validation Suite", + last_name="Administrator", + role=UserRole.ADMIN.value, password_hash=hash_password(settings.admin_password), is_active=True, + must_change_password=False, ) ) session.commit() @@ -27,4 +29,3 @@ def seed_admin() -> None: if __name__ == "__main__": seed_admin() - diff --git a/validation-suite/backend/mercury/app/models/user.py b/validation-suite/backend/mercury/app/models/user.py index 3a921a82..e68750cc 100644 --- a/validation-suite/backend/mercury/app/models/user.py +++ b/validation-suite/backend/mercury/app/models/user.py @@ -1,25 +1,34 @@ from __future__ import annotations import enum +from datetime import datetime -from sqlalchemy import Boolean, Enum, String +from sqlalchemy import Boolean, DateTime, String from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, TimestampMixin, UUIDMixin class UserRole(str, enum.Enum): - admin = "admin" - employee = "employee" - auditor = "auditor" + ADMIN = "admin" + PRUEFER = "pruefer" + MITARBEITER = "mitarbeiter" + LESER = "leser" class User(Base, UUIDMixin, TimestampMixin): __tablename__ = "users" email: Mapped[str] = mapped_column(String(255), unique=True, index=True) - full_name: Mapped[str] = mapped_column(String(160)) - role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.employee) + first_name: Mapped[str] = mapped_column(String(80)) + last_name: Mapped[str] = mapped_column(String(80)) + role: Mapped[str] = mapped_column(String(40), default=UserRole.MITARBEITER.value) password_hash: Mapped[str] = mapped_column(String(255)) is_active: Mapped[bool] = mapped_column(Boolean, default=True) + must_change_password: Mapped[bool] = mapped_column(Boolean, default=True) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}".strip() diff --git a/validation-suite/backend/mercury/app/schemas/auth.py b/validation-suite/backend/mercury/app/schemas/auth.py index 85561f5c..c52824c7 100644 --- a/validation-suite/backend/mercury/app/schemas/auth.py +++ b/validation-suite/backend/mercury/app/schemas/auth.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime + from pydantic import BaseModel, EmailStr from app.models.user import UserRole @@ -18,7 +20,16 @@ class TokenResponse(BaseModel): class UserRead(EntityRead): email: EmailStr - full_name: str + first_name: str + last_name: str role: UserRole is_active: bool + must_change_password: bool + last_login_at: datetime | None = None + password_changed_at: datetime | None = None + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + new_password_confirmation: str diff --git a/validation-suite/backend/mercury/app/schemas/domain.py b/validation-suite/backend/mercury/app/schemas/domain.py index b91191d7..48921746 100644 --- a/validation-suite/backend/mercury/app/schemas/domain.py +++ b/validation-suite/backend/mercury/app/schemas/domain.py @@ -1,12 +1,14 @@ from __future__ import annotations from datetime import date +from datetime import datetime from uuid import UUID from pydantic import EmailStr, Field, field_validator from app.models.customer import CustomerType from app.models.equipment import EquipmentKind, EquipmentStatus +from app.models.user import UserRole from app.models.validation import ValidationStatus from app.schemas.common import EntityRead, ORMModel @@ -241,3 +243,29 @@ class MeasurementImportValueConfirm(ORMModel): class MeasurementImportConfirmRequest(ORMModel): values: list[MeasurementImportValueConfirm] + + +class UserBase(ORMModel): + first_name: str + last_name: str + email: EmailStr + role: UserRole + is_active: bool = True + must_change_password: bool = True + + +class UserCreate(UserBase): + temporary_password: str + + +class UserUpdate(UserBase): + pass + + +class UserRead(UserBase, EntityRead): + last_login_at: datetime | None = None + password_changed_at: datetime | None = None + + +class UserPasswordResetRequest(ORMModel): + temporary_password: str | None = None diff --git a/validation-suite/backend/mercury/app/services/auth_service.py b/validation-suite/backend/mercury/app/services/auth_service.py index 5a15d260..82724307 100644 --- a/validation-suite/backend/mercury/app/services/auth_service.py +++ b/validation-suite/backend/mercury/app/services/auth_service.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import UTC, datetime + from fastapi import HTTPException, status from sqlalchemy.orm import Session @@ -19,5 +21,6 @@ class AuthService: detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"}, ) - return create_access_token(user.id, user.role.value) - + user.last_login_at = datetime.now(UTC) + self.users.session.commit() + return create_access_token(user.id, user.role) diff --git a/validation-suite/backend/mercury/app/services/domain_service.py b/validation-suite/backend/mercury/app/services/domain_service.py index e2b1fb8c..3256fd37 100644 --- a/validation-suite/backend/mercury/app/services/domain_service.py +++ b/validation-suite/backend/mercury/app/services/domain_service.py @@ -12,6 +12,7 @@ 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.repositories.base import Repository from app.repositories.domain import ( @@ -20,6 +21,7 @@ from app.repositories.domain import ( DeviceRepository, EquipmentRepository, LocationRepository, + UserRepository, ValidationRepository, ) @@ -78,6 +80,7 @@ class CrudService: class DomainServices: def __init__(self, session: Session) -> None: + self.users: CrudService[User] = CrudService(UserRepository(session)) self.customers: CrudService[Customer] = CrudService(CustomerRepository(session)) self.locations: CrudService[Location] = CrudService(LocationRepository(session)) self.contacts: CrudService[Contact] = CrudService(ContactRepository(session)) diff --git a/validation-suite/backend/mercury/tests/test_validation_workflow.py b/validation-suite/backend/mercury/tests/test_validation_workflow.py index c7bbb6d2..3b6eedb7 100644 --- a/validation-suite/backend/mercury/tests/test_validation_workflow.py +++ b/validation-suite/backend/mercury/tests/test_validation_workflow.py @@ -11,9 +11,11 @@ from app.db.base import Base from app.models.customer import Customer, CustomerType from app.models.contact import Contact from app.models.device import Device +from app.models.user import User, UserRole from app.models.location import Location from app.models.validation import Validation, ValidationStatus from app.services.validation_workflow import ValidationWorkflowService +from app.services.auth_service import AuthService from app.schemas.domain import ValidationCreate from app.modules.orion.service import OrionReportService from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri @@ -187,6 +189,46 @@ def test_invalid_uuid_returns_validation_error(): raise AssertionError("Invalid UUID was accepted") +def test_user_model_supports_password_management_fields(): + db = session() + user = User( + email="user@schubamed.de", + first_name="Max", + last_name="Mustermann", + role=UserRole.PRUEFER.value, + password_hash="hash", + is_active=True, + must_change_password=True, + ) + db.add(user) + db.flush() + + assert user.full_name == "Max Mustermann" + assert user.must_change_password is True + assert user.role == UserRole.PRUEFER + + +def test_login_updates_last_login_at(): + from app.core.security import hash_password + + db = session() + user = User( + email="login@schubamed.de", + first_name="Login", + last_name="Test", + role=UserRole.MITARBEITER.value, + password_hash=hash_password("VerySecretPass123"), + is_active=True, + must_change_password=False, + ) + db.add(user) + db.flush() + + AuthService(db).login("login@schubamed.de", "VerySecretPass123") + + assert user.last_login_at is not None + + def test_revalidation_date_uses_calendar_months(): db = session() customer, location, device = seed(db) diff --git a/validation-suite/frontend/atlas/app/(app)/profile/security/page.tsx b/validation-suite/frontend/atlas/app/(app)/profile/security/page.tsx new file mode 100644 index 00000000..fe5d2735 --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/profile/security/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { KeyRound } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useAuth } from "@/components/auth"; +import { apiFetch } from "@/lib/api"; + +const schema = z.object({ + current_password: z.string().min(1, "Aktuelles Passwort erforderlich"), + new_password: z.string().min(12, "Mindestens 12 Zeichen"), + new_password_confirmation: z.string().min(12, "Mindestens 12 Zeichen") +}).refine((values) => values.new_password === values.new_password_confirmation, { + path: ["new_password_confirmation"], + message: "Die neuen Passwoerter stimmen nicht ueberein" +}); + +type SecurityForm = z.infer; + +export default function SecurityPage() { + const { token, refreshUser } = useAuth(); + const [message, setMessage] = useState(""); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { current_password: "", new_password: "", new_password_confirmation: "" } + }); + + async function onSubmit(values: SecurityForm) { + await apiFetch("/auth/change-password", token ?? "", { method: "POST", body: JSON.stringify(values) }); + setMessage("Passwort erfolgreich geaendert"); + form.reset(); + refreshUser(); + } + + return ( +
+
+

Sicherheit

+

Hier aenderst du dein Passwort.

+
+
+ + + + {message &&
{message}
} +
+ +
+
+
+ ); +} diff --git a/validation-suite/frontend/atlas/app/(app)/users/page.tsx b/validation-suite/frontend/atlas/app/(app)/users/page.tsx new file mode 100644 index 00000000..00f36109 --- /dev/null +++ b/validation-suite/frontend/atlas/app/(app)/users/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, KeyRound, RotateCcw, Search, UserPlus2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useAuth } from "@/components/auth"; +import { apiDelete, apiFetch, apiGet, Paginated, User } from "@/lib/api"; + +const schema = z.object({ + first_name: z.string().min(1, "Vorname erforderlich"), + last_name: z.string().min(1, "Nachname erforderlich"), + email: z.string().email("Gueltige E-Mail erforderlich"), + role: z.enum(["admin", "pruefer", "mitarbeiter", "leser"]), + is_active: z.boolean().default(true), + must_change_password: z.boolean().default(true), + temporary_password: z.string().min(12, "Mindestens 12 Zeichen") +}); + +type UserForm = z.infer; + +const roleLabels: Record = { + admin: "ADMIN", + pruefer: "PRUEFER", + mitarbeiter: "MITARBEITER", + leser: "LESER" +}; + +export default function UsersPage() { + const { token, user } = useAuth(); + const client = useQueryClient(); + const [search, setSearch] = useState(""); + const [role, setRole] = useState(""); + const [active, setActive] = useState(""); + const [page, setPage] = useState(1); + const [sortBy, setSortBy] = useState("created_at"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + const [editing, setEditing] = useState(null); + const [temporaryPassword, setTemporaryPassword] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + first_name: "", + last_name: "", + email: "", + role: "mitarbeiter", + is_active: true, + must_change_password: true, + temporary_password: "" + } + }); + + const queryKey = useMemo( + () => ["users", token, page, search, role, active, sortBy, sortOrder], + [token, page, search, role, active, sortBy, sortOrder] + ); + + const users = useQuery({ + queryKey, + queryFn: () => + apiGet>( + `/users?page=${page}&page_size=10&search=${encodeURIComponent(search)}&role=${encodeURIComponent(role)}&active=${encodeURIComponent(active)}&sort_by=${encodeURIComponent(sortBy)}&sort_order=${sortOrder}`, + token ?? "" + ), + enabled: Boolean(token) + }); + + const saveMutation = useMutation({ + mutationFn: async (values: UserForm) => { + const payload = { + ...values, + email: values.email.toLowerCase() + }; + if (editing) { + return apiFetch(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) }); + } + return apiFetch<{ temporary_password: string; user: User }>(`/users`, token ?? "", { + method: "POST", + body: JSON.stringify(payload) + }); + }, + onSuccess: async (created) => { + if (!editing) { + setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? ""); + } + setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt"); + setError(""); + setEditing(null); + form.reset(); + await client.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (err: Error) => setError(err.message) + }); + + const deactivateMutation = useMutation({ + mutationFn: (item: User) => apiFetch(`/users/${item.id}/deactivate`, token ?? "", { method: "POST" }), + onSuccess: () => client.invalidateQueries({ queryKey: ["users"] }) + }); + const activateMutation = useMutation({ + mutationFn: (item: User) => apiFetch(`/users/${item.id}/activate`, token ?? "", { method: "POST" }), + onSuccess: () => client.invalidateQueries({ queryKey: ["users"] }) + }); + const resetMutation = useMutation({ + mutationFn: (item: User) => apiFetch<{ temporary_password: string }>(`/users/${item.id}/reset-password`, token ?? "", { method: "POST" }), + onSuccess: (result) => { + setTemporaryPassword(result.temporary_password); + setMessage("Temporäres Passwort erzeugt"); + } + }); + + if (user?.role !== "admin") { + return
Nur fuer Administratoren verfuegbar.
; + } + + function startCreate() { + setEditing(null); + form.reset({ first_name: "", last_name: "", email: "", role: "mitarbeiter", is_active: true, must_change_password: true, temporary_password: "" }); + } + + function startEdit(item: User) { + setEditing(item); + form.reset({ + first_name: item.first_name, + last_name: item.last_name, + email: item.email, + role: item.role, + is_active: item.is_active, + must_change_password: item.must_change_password, + temporary_password: "" + }); + } + + return ( +
+
+
+

Benutzer

+

Verwaltung von Rollen, Aktivstatus und Passworten.

+
+ +
+
+
+ + + + + +
+
+
+
+ + + + + + + + + + + + + + {(users.data?.items ?? []).map((item) => ( + + + + + + + + + + ))} + +
NameE-MailRolleStatusLetzter LoginPasswort zuletzt geaendertAktionen
{item.first_name} {item.last_name}{item.email}{roleLabels[item.role]}{item.is_active ? "Aktiv" : "Inaktiv"}{item.last_login_at ?? "nicht erfasst"}{item.password_changed_at ?? "nicht erfasst"} +
+ + + {item.is_active ? ( + + ) : ( + + )} +
+
+
+
+
+ {users.data?.total ?? 0} Datensätze +
+ + Seite {page} + +
+
+ {temporaryPassword &&

Temporäres Passwort

{temporaryPassword}

} + {message &&
{message}
} + {error &&
{error}
} +
saveMutation.mutate(values))} className="rounded-lg border border-border bg-surface p-5 shadow-soft"> +

{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}

+
+ {(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => ( + + ))} + + + +
+
+ + +
+
+
+ ); +} diff --git a/validation-suite/frontend/atlas/components/app-shell.tsx b/validation-suite/frontend/atlas/components/app-shell.tsx index 76734eff..fcecfdd6 100644 --- a/validation-suite/frontend/atlas/components/app-shell.tsx +++ b/validation-suite/frontend/atlas/components/app-shell.tsx @@ -8,11 +8,13 @@ import { LayoutDashboard, LogOut, MapPin, + Shield, Stethoscope, UserRound } from "lucide-react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; import { useAuth } from "@/components/auth"; import { BrandLogo } from "@/components/brand/brand-logo"; @@ -24,13 +26,21 @@ const navigation = [ { href: "/devices", label: "Geraete", icon: Stethoscope }, { href: "/equipment", label: "Pruefmittel", icon: Gauge }, { href: "/validations", label: "Validierungen", icon: ClipboardCheck }, - { href: "/documents", label: "Dokumente", icon: FileArchive } + { href: "/documents", label: "Dokumente", icon: FileArchive }, + { href: "/users", label: "Benutzer", icon: Shield, adminOnly: true } ]; export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const router = useRouter(); const auth = useAuth(); + useEffect(() => { + if (auth.user?.must_change_password && pathname !== "/profile/security") { + router.push("/profile/security"); + } + }, [auth.user?.must_change_password, pathname, router]); + return (