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

@ -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")

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})

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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))

View file

@ -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)

View file

@ -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<typeof schema>;
export default function SecurityPage() {
const { token, refreshUser } = useAuth();
const [message, setMessage] = useState("");
const form = useForm<SecurityForm>({
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 (
<div className="mx-auto max-w-xl">
<header className="mb-6">
<h1 className="text-3xl font-semibold text-text">Sicherheit</h1>
<p className="mt-2 text-text-light">Hier aenderst du dein Passwort.</p>
</header>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 rounded-lg border border-border bg-surface p-6 shadow-soft">
<label className="block">
<span className="text-sm font-medium">Aktuelles Passwort</span>
<input type="password" {...form.register("current_password")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
</label>
<label className="block">
<span className="text-sm font-medium">Neues Passwort</span>
<input type="password" {...form.register("new_password")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
</label>
<label className="block">
<span className="text-sm font-medium">Neues Passwort wiederholen</span>
<input type="password" {...form.register("new_password_confirmation")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
</label>
{message && <div className="rounded-lg border border-success/30 bg-white p-3 text-sm text-success shadow-soft">{message}</div>}
<div className="flex justify-end">
<button type="submit" className="btn btn-primary h-12"><KeyRound className="h-4 w-4" /> Passwort ändern</button>
</div>
</form>
</div>
);
}

View file

@ -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<typeof schema>;
const roleLabels: Record<User["role"], string> = {
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<User | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const form = useForm<UserForm>({
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<Paginated<User>>(
`/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<User>(`/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<User>(`/users/${item.id}/deactivate`, token ?? "", { method: "POST" }),
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
});
const activateMutation = useMutation({
mutationFn: (item: User) => apiFetch<User>(`/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 <div className="rounded-lg border border-border bg-surface p-6 text-text-light">Nur fuer Administratoren verfuegbar.</div>;
}
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 (
<div className="space-y-6">
<header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-semibold text-text">Benutzer</h1>
<p className="mt-2 text-text-light">Verwaltung von Rollen, Aktivstatus und Passworten.</p>
</div>
<button type="button" className="btn btn-primary h-12" onClick={startCreate}><UserPlus2 className="h-4 w-4" /> Benutzer anlegen</button>
</header>
<section className="rounded-lg border border-border bg-surface p-4 shadow-soft">
<div className="grid gap-3 md:grid-cols-4">
<label className="flex h-12 items-center gap-2 rounded-lg border border-border px-3 md:col-span-2">
<Search className="h-4 w-4 text-primary" />
<input value={search} onChange={(e) => setSearch(e.target.value)} className="flex-1 outline-none" placeholder="Suchen" />
</label>
<select value={role} onChange={(e) => setRole(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="">Alle Rollen</option>
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<select value={active} onChange={(e) => setActive(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="">Alle Status</option>
<option value="true">Aktiv</option>
<option value="false">Inaktiv</option>
</select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="created_at">Erstellt am</option>
<option value="name">Name</option>
<option value="email">E-Mail</option>
<option value="role">Rolle</option>
<option value="last_login_at">Letzter Login</option>
<option value="password_changed_at">Passwort geaendert</option>
</select>
<button type="button" className="btn btn-secondary h-12" onClick={() => setSortOrder((value) => (value === "asc" ? "desc" : "asc"))}>
Sortierung: {sortOrder.toUpperCase()}
</button>
</div>
</section>
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="bg-background text-xs uppercase text-text-light">
<tr>
<th className="px-5 py-4">Name</th>
<th className="px-5 py-4">E-Mail</th>
<th className="px-5 py-4">Rolle</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Letzter Login</th>
<th className="px-5 py-4">Passwort zuletzt geaendert</th>
<th className="px-5 py-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{(users.data?.items ?? []).map((item) => (
<tr key={item.id}>
<td className="px-5 py-4">{item.first_name} {item.last_name}</td>
<td className="px-5 py-4">{item.email}</td>
<td className="px-5 py-4">{roleLabels[item.role]}</td>
<td className="px-5 py-4">{item.is_active ? "Aktiv" : "Inaktiv"}</td>
<td className="px-5 py-4">{item.last_login_at ?? "nicht erfasst"}</td>
<td className="px-5 py-4">{item.password_changed_at ?? "nicht erfasst"}</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => startEdit(item)}>Bearbeiten</button>
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => resetMutation.mutate(item)}>{resetMutation.isPending ? <span className="spinner" /> : <RotateCcw className="h-4 w-4" />} Temporäres Passwort setzen</button>
{item.is_active ? (
<button type="button" className="btn btn-danger h-10 px-3" onClick={() => deactivateMutation.mutate(item)}>Deaktivieren</button>
) : (
<button type="button" className="btn btn-success h-10 px-3" onClick={() => activateMutation.mutate(item)}><Check className="h-4 w-4" /> Reaktivieren</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<footer className="flex items-center justify-between text-sm text-text-light">
<span>{users.data?.total ?? 0} Datensätze</span>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-secondary" disabled={page <= 1} onClick={() => setPage((v) => v - 1)}>Zurück</button>
<span>Seite {page}</span>
<button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 10} onClick={() => setPage((v) => v + 1)}>Weiter</button>
</div>
</footer>
{temporaryPassword && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 shadow-soft"><p className="text-sm text-text-light">Temporäres Passwort</p><p className="mt-1 font-mono text-sm">{temporaryPassword}</p></div>}
{message && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 text-sm text-success shadow-soft">{message}</div>}
{error && <div className="fixed right-4 top-4 z-50 rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{error}</div>}
<form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))} className="rounded-lg border border-border bg-surface p-5 shadow-soft">
<h2 className="text-xl font-semibold">{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => (
<label key={field} className="block">
<span className="text-sm font-medium">{field === "first_name" ? "Vorname" : field === "last_name" ? "Nachname" : field === "email" ? "E-Mail" : "Temporäres Passwort"}</span>
<input type={field === "email" ? "email" : "text"} {...form.register(field)} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
</label>
))}
<label className="block">
<span className="text-sm font-medium">Rolle</span>
<select {...form.register("role")} className="mt-2 h-12 w-full rounded-lg border border-border px-4">
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
<label className="flex items-center gap-3 pt-7"><input type="checkbox" {...form.register("is_active")} /> Aktiv</label>
<label className="flex items-center gap-3 pt-7"><input type="checkbox" {...form.register("must_change_password")} /> Passwortwechsel erzwingen</label>
</div>
<div className="mt-5 flex justify-end gap-3">
<button type="button" className="btn btn-secondary" onClick={() => form.reset()}>Zurücksetzen</button>
<button type="submit" className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null} Speichern</button>
</div>
</form>
</div>
);
}

View file

@ -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 (
<div className="min-h-screen bg-background text-text">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
@ -40,6 +50,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<nav className="space-y-1">
{navigation.map((item, index) => {
const Icon = item.icon;
if ("adminOnly" in item && item.adminOnly && auth.user?.role !== "admin") {
return null;
}
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link

View file

@ -2,11 +2,14 @@
import { useRouter } from "next/navigation";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { apiGet, User } from "@/lib/api";
type AuthContextValue = {
token: string | null;
setToken: (value: string | null) => void;
logout: () => void;
user: User | null;
refreshUser: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
@ -14,11 +17,23 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [token, setTokenState] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
setTokenState(window.localStorage.getItem("atlas_token"));
const stored = window.localStorage.getItem("atlas_token");
setTokenState(stored);
if (!stored) return;
void apiGet<User>("/auth/me", stored).then(setUser).catch(() => setUser(null));
}, []);
useEffect(() => {
if (!token) {
setUser(null);
return;
}
void apiGet<User>("/auth/me", token).then(setUser).catch(() => setUser(null));
}, [token]);
const value = useMemo<AuthContextValue>(() => {
const setToken = (next: string | null) => {
setTokenState(next);
@ -34,9 +49,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout: () => {
setToken(null);
router.push("/login");
},
user,
refreshUser: () => {
if (token) {
void apiGet<User>("/auth/me", token).then(setUser).catch(() => setUser(null));
}
}
};
}, [router, token]);
}, [router, token, user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
@ -48,4 +69,3 @@ export function useAuth() {
}
return value;
}

View file

@ -1,4 +1,4 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000/api/v1";
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
export type Entity = {
id: string;
@ -61,6 +61,17 @@ export type Equipment = Entity & {
status: "green" | "yellow" | "red";
};
export type User = Entity & {
email: string;
first_name: string;
last_name: string;
role: "admin" | "pruefer" | "mitarbeiter" | "leser";
is_active: boolean;
must_change_password: boolean;
last_login_at?: string | null;
password_changed_at?: string | null;
};
export type ValidationItem = Entity & {
report_number: string;
customer_id: string;
@ -154,3 +165,14 @@ export async function apiDelete(path: string, token: string): Promise<void> {
throw new Error(`API request failed: ${response.status}`);
}
}
export async function apiFetch<T>(path: string, token: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers ?? {}) }
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<T>;
}