fix(users): repair create delete and duplicate email handling
This commit is contained in:
parent
155fdbb16a
commit
0bbcaba211
22 changed files with 773 additions and 107 deletions
|
|
@ -0,0 +1,108 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "202607110006"
|
||||||
|
down_revision = "202607110005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
ALLOWED_ROLES = ("admin", "pruefer", "mitarbeiter", "leser")
|
||||||
|
|
||||||
|
|
||||||
|
def _other_columns_use_userrole(connection) -> bool:
|
||||||
|
result = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class c ON c.oid = a.attrelid
|
||||||
|
JOIN pg_type t ON t.oid = a.atttypid
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE t.typname = 'userrole'
|
||||||
|
AND NOT (n.nspname = 'public' AND c.relname = 'users' AND a.attname = 'role')
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return bool(result.scalar())
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_roles(connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET role = CASE upper(role::text)
|
||||||
|
WHEN 'ADMIN' THEN 'admin'
|
||||||
|
WHEN 'PRUEFER' THEN 'pruefer'
|
||||||
|
WHEN 'EMPLOYEE' THEN 'mitarbeiter'
|
||||||
|
WHEN 'MITARBEITER' THEN 'mitarbeiter'
|
||||||
|
WHEN 'AUDITOR' THEN 'leser'
|
||||||
|
WHEN 'LESER' THEN 'leser'
|
||||||
|
ELSE role::text
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invalid_roles = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT role::text
|
||||||
|
FROM users
|
||||||
|
WHERE role::text NOT IN ('admin', 'pruefer', 'mitarbeiter', 'leser')
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
).scalars().all()
|
||||||
|
if invalid_roles:
|
||||||
|
raise RuntimeError(f"Unknown user roles encountered during migration: {', '.join(sorted(invalid_roles))}")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"role",
|
||||||
|
existing_type=sa.Enum(name="userrole"),
|
||||||
|
type_=sa.String(length=50),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=None,
|
||||||
|
postgresql_using="role::text",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
_normalize_roles(connection)
|
||||||
|
op.execute(sa.text("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'mitarbeiter'"))
|
||||||
|
if not _other_columns_use_userrole(connection):
|
||||||
|
op.execute(sa.text("DROP TYPE IF EXISTS userrole"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"CREATE TYPE userrole AS ENUM ('admin', 'pruefer', 'mitarbeiter', 'leser')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invalid_roles = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT role
|
||||||
|
FROM users
|
||||||
|
WHERE role NOT IN ('admin', 'pruefer', 'mitarbeiter', 'leser')
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
).scalars().all()
|
||||||
|
if invalid_roles:
|
||||||
|
raise RuntimeError(f"Cannot downgrade user roles with invalid values: {', '.join(sorted(invalid_roles))}")
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"role",
|
||||||
|
existing_type=sa.String(length=50),
|
||||||
|
type_=sa.Enum("admin", "pruefer", "mitarbeiter", "leser", name="userrole"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.execute(sa.text("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'mitarbeiter'::userrole"))
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""add deleted_at to users for soft delete
|
||||||
|
|
||||||
|
Revision ID: 202607110007
|
||||||
|
Revises: 202607110006
|
||||||
|
Create Date: 2026-07-11 00:07:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "202607110007"
|
||||||
|
down_revision = "202607110006"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "deleted_at")
|
||||||
Binary file not shown.
|
|
@ -6,13 +6,14 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError, ProgrammingError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.dependencies import current_admin, current_user
|
from app.api.dependencies import current_admin, current_user
|
||||||
|
|
@ -560,7 +561,7 @@ def _user_payload(user: User) -> dict:
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"first_name": user.first_name,
|
"first_name": user.first_name,
|
||||||
"last_name": user.last_name,
|
"last_name": user.last_name,
|
||||||
"role": user.role,
|
"role": UserRole(user.role),
|
||||||
"is_active": user.is_active,
|
"is_active": user.is_active,
|
||||||
"must_change_password": user.must_change_password,
|
"must_change_password": user.must_change_password,
|
||||||
"last_login_at": user.last_login_at,
|
"last_login_at": user.last_login_at,
|
||||||
|
|
@ -581,6 +582,7 @@ def list_users(
|
||||||
_: User = Depends(current_admin),
|
_: User = Depends(current_admin),
|
||||||
):
|
):
|
||||||
query = select(User)
|
query = select(User)
|
||||||
|
query = query.where(User.deleted_at.is_(None))
|
||||||
if search:
|
if search:
|
||||||
term = f"%{search.strip()}%"
|
term = f"%{search.strip()}%"
|
||||||
query = query.where(
|
query = query.where(
|
||||||
|
|
@ -605,8 +607,9 @@ def list_users(
|
||||||
sort_column = sort_columns.get(sort_by, User.created_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())
|
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
|
total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||||
|
pages = max((total + page_size - 1) // page_size, 1)
|
||||||
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
|
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
|
||||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users", status_code=201)
|
@router.post("/users", status_code=201)
|
||||||
|
|
@ -615,9 +618,17 @@ def create_user(payload: UserCreate, session: Session = Depends(get_session), _:
|
||||||
import secrets
|
import secrets
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
normalized_email = payload.email.strip().lower()
|
||||||
|
logger.debug("create_user request email=%s normalized_email=%s", payload.email, normalized_email)
|
||||||
|
existing = session.scalar(select(User).where(func.lower(User.email) == normalized_email))
|
||||||
|
logger.debug("create_user select result=%s count=%s", bool(existing), 1 if existing else 0)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.")
|
||||||
|
|
||||||
temporary_password = payload.temporary_password or secrets.token_urlsafe(12)
|
temporary_password = payload.temporary_password or secrets.token_urlsafe(12)
|
||||||
|
try:
|
||||||
user = User(
|
user = User(
|
||||||
email=payload.email.lower(),
|
email=normalized_email,
|
||||||
first_name=payload.first_name,
|
first_name=payload.first_name,
|
||||||
last_name=payload.last_name,
|
last_name=payload.last_name,
|
||||||
role=payload.role.value,
|
role=payload.role.value,
|
||||||
|
|
@ -625,16 +636,29 @@ def create_user(payload: UserCreate, session: Session = Depends(get_session), _:
|
||||||
is_active=payload.is_active,
|
is_active=payload.is_active,
|
||||||
must_change_password=payload.must_change_password,
|
must_change_password=payload.must_change_password,
|
||||||
)
|
)
|
||||||
|
logger.debug("create_user insert email=%s", normalized_email)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
logger.debug("create_user commit ok email=%s id=%s", normalized_email, user.id)
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
return JSONResponse(content={"temporary_password": temporary_password, "user": jsonable_encoder(user)}, status_code=201)
|
logger.debug("create_user response email=%s status=201", normalized_email)
|
||||||
|
return JSONResponse(
|
||||||
|
content={"temporary_password": temporary_password, "user": jsonable_encoder(user)},
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
logger.exception("create_user integrity error email=%s", normalized_email)
|
||||||
|
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.") from exc
|
||||||
|
except ProgrammingError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=422, detail="Die Benutzerrolle ist ungültig.") from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{item_id}", response_model=UserRead)
|
@router.get("/users/{item_id}", response_model=UserRead)
|
||||||
def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
|
def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
|
||||||
user = session.get(User, item_id)
|
user = session.get(User, item_id)
|
||||||
if user is None:
|
if user is None or user.deleted_at is not None:
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
@ -642,7 +666,7 @@ def get_user(item_id: str, session: Session = Depends(get_session), _: User = De
|
||||||
@router.put("/users/{item_id}", response_model=UserRead)
|
@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)):
|
def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(get_session), me: User = Depends(current_admin)):
|
||||||
user = session.get(User, item_id)
|
user = session.get(User, item_id)
|
||||||
if user is None:
|
if user is None or user.deleted_at is not None:
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
if user.id == me.id and not payload.is_active:
|
if user.id == me.id and not payload.is_active:
|
||||||
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
|
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
|
||||||
|
|
@ -651,44 +675,58 @@ def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(ge
|
||||||
) or 0
|
) or 0
|
||||||
if user.role == UserRole.ADMIN.value and active_admins <= 1 and (not payload.is_active or payload.role != UserRole.ADMIN.value):
|
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.")
|
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert oder herabgestuft werden.")
|
||||||
|
try:
|
||||||
|
normalized_email = payload.email.strip().lower()
|
||||||
|
logger.debug("update_user request id=%s email=%s normalized_email=%s", item_id, payload.email, normalized_email)
|
||||||
|
existing = session.scalar(
|
||||||
|
select(User).where(func.lower(User.email) == normalized_email, User.id != user.id)
|
||||||
|
)
|
||||||
|
logger.debug("update_user select result=%s count=%s", bool(existing), 1 if existing else 0)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.")
|
||||||
user.first_name = payload.first_name
|
user.first_name = payload.first_name
|
||||||
user.last_name = payload.last_name
|
user.last_name = payload.last_name
|
||||||
user.email = payload.email.lower()
|
user.email = normalized_email
|
||||||
user.role = payload.role.value
|
user.role = payload.role.value
|
||||||
user.is_active = payload.is_active
|
user.is_active = payload.is_active
|
||||||
user.must_change_password = payload.must_change_password
|
user.must_change_password = payload.must_change_password
|
||||||
|
logger.debug("update_user commit start id=%s email=%s", user.id, normalized_email)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
logger.debug("update_user commit ok id=%s email=%s", user.id, normalized_email)
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
logger.exception("update_user integrity error id=%s email=%s", item_id, payload.email)
|
||||||
|
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.") from exc
|
||||||
|
except ProgrammingError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=422, detail="Die Benutzerrolle ist ungültig.") from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{item_id}/deactivate", response_model=UserRead)
|
@router.delete("/users/{item_id}", status_code=204)
|
||||||
def deactivate_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
|
def delete_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
|
||||||
user = session.get(User, item_id)
|
user = session.get(User, item_id)
|
||||||
if user is None:
|
if user is None or user.deleted_at is not None:
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
if user.id == me.id:
|
if user.id == me.id:
|
||||||
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
|
raise HTTPException(status_code=409, detail="Das eigene Benutzerkonto kann nicht geloescht werden.")
|
||||||
active_admins = session.scalar(
|
active_admins = session.scalar(
|
||||||
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
|
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
|
||||||
) or 0
|
) or 0
|
||||||
if user.role == UserRole.ADMIN.value and active_admins <= 1:
|
if user.role == UserRole.ADMIN.value and active_admins <= 1:
|
||||||
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert werden.")
|
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht geloescht werden.")
|
||||||
|
referenced_validations = session.scalar(
|
||||||
|
select(func.count()).select_from(Validation).where(Validation.examiner_id == user.id)
|
||||||
|
) or 0
|
||||||
|
if referenced_validations == 0:
|
||||||
|
session.delete(user)
|
||||||
|
session.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
user.is_active = False
|
user.is_active = False
|
||||||
|
user.deleted_at = datetime.now(UTC)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
return Response(status_code=204)
|
||||||
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")
|
@router.post("/users/{item_id}/reset-password")
|
||||||
|
|
@ -697,7 +735,7 @@ def reset_user_password(item_id: str, session: Session = Depends(get_session), _
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
|
|
||||||
user = session.get(User, item_id)
|
user = session.get(User, item_id)
|
||||||
if user is None:
|
if user is None or user.deleted_at is not None:
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
temporary_password = secrets.token_urlsafe(12)
|
temporary_password = secrets.token_urlsafe(12)
|
||||||
user.password_hash = hash_password(temporary_password)
|
user.password_hash = hash_password(temporary_password)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -4,7 +4,7 @@ import enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, String
|
from sqlalchemy import Boolean, DateTime, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||||
|
|
||||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
@ -20,15 +20,21 @@ class User(Base, UUIDMixin, TimestampMixin):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
full_name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
first_name: Mapped[str] = mapped_column(String(80))
|
first_name: Mapped[str] = mapped_column(String(80))
|
||||||
last_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)
|
role: Mapped[str] = mapped_column(String(50), nullable=False, default=UserRole.MITARBEITER.value)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255))
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
|
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=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)
|
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
@property
|
@validates("first_name", "last_name")
|
||||||
def full_name(self) -> str:
|
def _sync_full_name(self, key: str, value: str) -> str:
|
||||||
return f"{self.first_name} {self.last_name}".strip()
|
first_name = value if key == "first_name" else getattr(self, "first_name", None)
|
||||||
|
last_name = value if key == "last_name" else getattr(self, "last_name", None)
|
||||||
|
if first_name is not None and last_name is not None:
|
||||||
|
self.full_name = f"{first_name} {last_name}".strip()
|
||||||
|
return value
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -18,7 +18,7 @@ class UserRepository(Repository[User]):
|
||||||
model = User
|
model = User
|
||||||
|
|
||||||
def by_email(self, email: str) -> User | None:
|
def by_email(self, email: str) -> User | None:
|
||||||
return self.session.scalar(select(User).where(User.email == email.lower()))
|
return self.session.scalar(select(User).where(User.email == email.strip().lower()))
|
||||||
|
|
||||||
|
|
||||||
class CustomerRepository(Repository[Customer]):
|
class CustomerRepository(Repository[Customer]):
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -23,3 +23,4 @@ class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
total: int
|
total: int
|
||||||
page: int
|
page: int
|
||||||
page_size: int
|
page_size: int
|
||||||
|
pages: int
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -23,7 +23,7 @@ class AuthService:
|
||||||
detail="Invalid credentials",
|
detail="Invalid credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
if not user.is_active:
|
if not user.is_active or user.deleted_at is not None:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
|
||||||
user.last_login_at = datetime.now(UTC)
|
user.last_login_at = datetime.now(UTC)
|
||||||
self.users.session.commit()
|
self.users.session.commit()
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,9 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
from sqlalchemy import create_engine, select
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -19,8 +21,9 @@ from app.services.validation_workflow import ValidationWorkflowService
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
from app.services.reference_masterdata import ReferenceMasterdataImportService
|
from app.services.reference_masterdata import ReferenceMasterdataImportService
|
||||||
from app.api.v1.auth import login as auth_login
|
from app.api.v1.auth import login as auth_login
|
||||||
|
from app.api.v1 import domain as domain_api
|
||||||
from app.schemas.auth import LoginRequest
|
from app.schemas.auth import LoginRequest
|
||||||
from app.schemas.domain import ValidationCreate
|
from app.schemas.domain import UserCreate, UserUpdate, ValidationCreate
|
||||||
from app.modules.orion.service import OrionReportService
|
from app.modules.orion.service import OrionReportService
|
||||||
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
|
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
|
||||||
from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
|
from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
|
||||||
|
|
@ -212,6 +215,319 @@ def test_user_model_supports_password_management_fields():
|
||||||
assert user.role == UserRole.PRUEFER
|
assert user.role == UserRole.PRUEFER
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_appears_in_user_list():
|
||||||
|
db = session()
|
||||||
|
admin = User(
|
||||||
|
email="admin@schubamed.de",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.ADMIN.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
result = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
|
||||||
|
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert result["pages"] == 1
|
||||||
|
assert any(item.id == admin.id for item in result["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_list_pagination_and_filters_work():
|
||||||
|
db = session()
|
||||||
|
admin = User(
|
||||||
|
email="admin@schubamed.de",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.ADMIN.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
inactive = User(
|
||||||
|
email="inactive@schubamed.de",
|
||||||
|
first_name="Inactive",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.LESER.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=False,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add_all([admin, inactive])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
result = domain_api.list_users(session=db, _=admin, page=1, page_size=1, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
|
||||||
|
assert result["total"] == 2
|
||||||
|
assert result["pages"] == 2
|
||||||
|
assert len(result["items"]) == 1
|
||||||
|
|
||||||
|
active_only = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=True, sort_by="created_at", sort_order="desc")
|
||||||
|
assert active_only["total"] == 1
|
||||||
|
assert all(item.is_active for item in active_only["items"])
|
||||||
|
|
||||||
|
inactive_only = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=False, sort_by="created_at", sort_order="desc")
|
||||||
|
assert inactive_only["total"] == 1
|
||||||
|
assert all(not item.is_active for item in inactive_only["items"])
|
||||||
|
|
||||||
|
role_filtered = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=UserRole.ADMIN.value, active=None, sort_by="created_at", sort_order="desc")
|
||||||
|
assert role_filtered["total"] == 1
|
||||||
|
assert role_filtered["items"][0].role == UserRole.ADMIN.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_create_users_with_supported_roles():
|
||||||
|
db = session()
|
||||||
|
admin = User(
|
||||||
|
email="admin@schubamed.de",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.ADMIN.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for role in (UserRole.MITARBEITER, UserRole.PRUEFER, UserRole.LESER):
|
||||||
|
response = domain_api.create_user(
|
||||||
|
UserCreate(
|
||||||
|
email=f"{role.value}@schubamed.de",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
role=role,
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
temporary_password="TempPassword123!",
|
||||||
|
),
|
||||||
|
session=db,
|
||||||
|
_=admin,
|
||||||
|
)
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert payload["user"]["role"] == role.value
|
||||||
|
assert db.scalar(select(User).where(User.email == f"{role.value}@schubamed.de")) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_trims_and_normalizes_email():
|
||||||
|
db = session()
|
||||||
|
admin = User(
|
||||||
|
email="admin@schubamed.de",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.ADMIN.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
response = domain_api.create_user(
|
||||||
|
UserCreate(
|
||||||
|
email=" New.User@Schubamed.DE ",
|
||||||
|
first_name="New",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.MITARBEITER,
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
temporary_password="TempPassword123!",
|
||||||
|
),
|
||||||
|
session=db,
|
||||||
|
_=admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert payload["user"]["email"] == "new.user@schubamed.de"
|
||||||
|
assert db.scalar(select(User).where(User.email == "new.user@schubamed.de")) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_email_returns_409_and_rolls_back_session():
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
db = session()
|
||||||
|
admin = User(
|
||||||
|
email="admin@schubamed.de",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.ADMIN.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
first = domain_api.create_user(
|
||||||
|
UserCreate(
|
||||||
|
email="duplicate@schubamed.de",
|
||||||
|
first_name="First",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.MITARBEITER,
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
temporary_password="TempPassword123!",
|
||||||
|
),
|
||||||
|
session=db,
|
||||||
|
_=admin,
|
||||||
|
)
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
domain_api.create_user(
|
||||||
|
UserCreate(
|
||||||
|
email="duplicate@schubamed.de",
|
||||||
|
first_name="Second",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.PRUEFER,
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
temporary_password="TempPassword123!",
|
||||||
|
),
|
||||||
|
session=db,
|
||||||
|
_=admin,
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
|
||||||
|
follow_up = domain_api.create_user(
|
||||||
|
UserCreate(
|
||||||
|
email="fresh@schubamed.de",
|
||||||
|
first_name="Fresh",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.LESER,
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
temporary_password="TempPassword123!",
|
||||||
|
),
|
||||||
|
session=db,
|
||||||
|
_=admin,
|
||||||
|
)
|
||||||
|
assert follow_up.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_keeps_same_email_without_false_duplicate():
|
||||||
|
db = session()
|
||||||
|
admin = User(
|
||||||
|
email="admin@schubamed.de",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.ADMIN.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
target = User(
|
||||||
|
email="target@schubamed.de",
|
||||||
|
first_name="Target",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.MITARBEITER.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add_all([admin, target])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
response = domain_api.update_user(
|
||||||
|
target.id,
|
||||||
|
UserUpdate(
|
||||||
|
first_name="Target",
|
||||||
|
last_name="User",
|
||||||
|
email="target@schubamed.de",
|
||||||
|
role=UserRole.MITARBEITER,
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
),
|
||||||
|
session=db,
|
||||||
|
me=admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.email == "target@schubamed.de"
|
||||||
|
assert response.id == target.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_without_references_removes_row():
|
||||||
|
db = session()
|
||||||
|
admin = User(
|
||||||
|
email="admin@schubamed.de",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.ADMIN.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
email="delete-me@schubamed.de",
|
||||||
|
first_name="Delete",
|
||||||
|
last_name="Me",
|
||||||
|
role=UserRole.MITARBEITER.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add_all([admin, user])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
response = domain_api.delete_user(user.id, session=db, me=admin)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert db.get(User, user.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_with_validation_reference_soft_deletes_row():
|
||||||
|
db = session()
|
||||||
|
customer, location, device = seed(db)
|
||||||
|
admin = User(
|
||||||
|
email="admin@schubamed.de",
|
||||||
|
first_name="Admin",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.ADMIN.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
examiner = User(
|
||||||
|
email="examiner@schubamed.de",
|
||||||
|
first_name="Examiner",
|
||||||
|
last_name="User",
|
||||||
|
role=UserRole.PRUEFER.value,
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
validation = valid_validation(customer, location, device)
|
||||||
|
db.add_all([admin, examiner, validation])
|
||||||
|
db.flush()
|
||||||
|
validation.examiner_id = examiner.id
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
response = domain_api.delete_user(examiner.id, session=db, me=admin)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
db.refresh(examiner)
|
||||||
|
assert examiner.deleted_at is not None
|
||||||
|
assert examiner.is_active is False
|
||||||
|
listed = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
|
||||||
|
assert all(item.id != examiner.id for item in listed["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_user_role_is_rejected_by_schema():
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
UserCreate(
|
||||||
|
email="bad-role@schubamed.de",
|
||||||
|
first_name="Bad",
|
||||||
|
last_name="Role",
|
||||||
|
role="invalid-role", # type: ignore[arg-type]
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
temporary_password="TempPassword123!",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_login_updates_last_login_at():
|
def test_login_updates_last_login_at():
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ type DashboardData = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const primaryCards = [
|
const primaryCards = [
|
||||||
{ label: "Neue Validierung", value: "Starten", href: "/validations/new", icon: Plus },
|
{ label: "Neue Validierung starten", value: "Schnellstart", href: "/validations/quick-start", icon: Plus },
|
||||||
{ label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList },
|
{ label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList },
|
||||||
{ label: "Kunden", key: "customers", href: "/customers", icon: Users },
|
{ label: "Kunden", key: "customers", href: "/customers", icon: Users },
|
||||||
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },
|
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, KeyRound, RotateCcw, Search, UserPlus2 } from "lucide-react";
|
import { Copy, RotateCcw, Search, Trash2, UserPlus2, X } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useAuth } from "@/components/auth";
|
import { useAuth } from "@/components/auth";
|
||||||
import { apiDelete, apiFetch, apiGet, Paginated, User } from "@/lib/api";
|
import { apiDelete, apiFetch, apiGet, Paginated, User } from "@/lib/api";
|
||||||
|
import { buildUsersQueryParams } from "@/lib/users-query";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
first_name: z.string().min(1, "Vorname erforderlich"),
|
first_name: z.string().min(1, "Vorname erforderlich"),
|
||||||
|
|
@ -33,12 +34,14 @@ export default function UsersPage() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [role, setRole] = useState("");
|
const [role, setRole] = useState("");
|
||||||
const [active, setActive] = useState("");
|
const [active, setActive] = useState<"all" | "active" | "inactive">("all");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortBy, setSortBy] = useState("created_at");
|
const [sortBy, setSortBy] = useState("created_at");
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||||
const [editing, setEditing] = useState<User | null>(null);
|
const [editing, setEditing] = useState<User | null>(null);
|
||||||
const [temporaryPassword, setTemporaryPassword] = useState("");
|
const [temporaryPassword, setTemporaryPassword] = useState("");
|
||||||
|
const [temporaryPasswordOpen, setTemporaryPasswordOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
|
@ -55,26 +58,30 @@ export default function UsersPage() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryKey = useMemo(
|
|
||||||
() => ["users", token, page, search, role, active, sortBy, sortOrder],
|
|
||||||
[token, page, search, role, active, sortBy, sortOrder]
|
|
||||||
);
|
|
||||||
|
|
||||||
const users = useQuery({
|
const users = useQuery({
|
||||||
queryKey,
|
queryKey: ["users", page, 20, search, role, active, sortBy, sortOrder],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiGet<Paginated<User>>(
|
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}`,
|
`/users?${buildUsersQueryParams({
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
search,
|
||||||
|
role,
|
||||||
|
active: active === "all" ? undefined : active === "active",
|
||||||
|
sortBy,
|
||||||
|
sortOrder
|
||||||
|
}).toString()}`,
|
||||||
token ?? ""
|
token ?? ""
|
||||||
),
|
),
|
||||||
enabled: Boolean(token)
|
enabled: Boolean(token)
|
||||||
});
|
});
|
||||||
|
const pages = users.data?.pages ?? Math.max(1, Math.ceil((users.data?.total ?? 0) / (users.data?.page_size ?? 20)));
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (values: UserForm) => {
|
mutationFn: async (values: UserForm) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
email: values.email.toLowerCase()
|
email: values.email.trim().toLowerCase()
|
||||||
};
|
};
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return apiFetch<User>(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) });
|
return apiFetch<User>(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) });
|
||||||
|
|
@ -85,31 +92,38 @@ export default function UsersPage() {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (created) => {
|
onSuccess: async (created) => {
|
||||||
|
const isCreate = !editing;
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? "");
|
setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? "");
|
||||||
|
} else {
|
||||||
|
setTemporaryPassword("");
|
||||||
}
|
}
|
||||||
setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt");
|
setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt");
|
||||||
setError("");
|
setError("");
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
setTemporaryPasswordOpen(isCreate && Boolean((created as { temporary_password?: string }).temporary_password));
|
||||||
form.reset();
|
form.reset();
|
||||||
await client.invalidateQueries({ queryKey: ["users"] });
|
await client.invalidateQueries({ queryKey: ["users"] });
|
||||||
},
|
},
|
||||||
onError: (err: Error) => setError(err.message)
|
onError: (err: Error) => setError(err.message)
|
||||||
});
|
});
|
||||||
|
|
||||||
const deactivateMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/deactivate`, token ?? "", { method: "POST" }),
|
mutationFn: (item: User) => apiDelete(`/users/${item.id}`, token ?? ""),
|
||||||
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
|
onSuccess: async () => {
|
||||||
});
|
setMessage("Benutzer geloescht");
|
||||||
const activateMutation = useMutation({
|
setError("");
|
||||||
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/activate`, token ?? "", { method: "POST" }),
|
setDeleteTarget(null);
|
||||||
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
|
await client.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => setError(err.message)
|
||||||
});
|
});
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
mutationFn: (item: User) => apiFetch<{ temporary_password: string }>(`/users/${item.id}/reset-password`, token ?? "", { method: "POST" }),
|
mutationFn: (item: User) => apiFetch<{ temporary_password: string }>(`/users/${item.id}/reset-password`, token ?? "", { method: "POST" }),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setTemporaryPassword(result.temporary_password);
|
setTemporaryPassword(result.temporary_password);
|
||||||
setMessage("Temporäres Passwort erzeugt");
|
setMessage("Temporäres Passwort erzeugt");
|
||||||
|
setTemporaryPasswordOpen(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -135,6 +149,11 @@ export default function UsersPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submit(values: UserForm) {
|
||||||
|
if (saveMutation.isPending) return;
|
||||||
|
saveMutation.mutate(values);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
|
@ -154,10 +173,10 @@ export default function UsersPage() {
|
||||||
<option value="">Alle Rollen</option>
|
<option value="">Alle Rollen</option>
|
||||||
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={active} onChange={(e) => setActive(e.target.value)} className="h-12 rounded-lg border border-border px-3">
|
<select value={active} onChange={(e) => setActive(e.target.value as "all" | "active" | "inactive")} className="h-12 rounded-lg border border-border px-3">
|
||||||
<option value="">Alle Status</option>
|
<option value="all">Alle Status</option>
|
||||||
<option value="true">Aktiv</option>
|
<option value="active">Aktiv</option>
|
||||||
<option value="false">Inaktiv</option>
|
<option value="inactive">Inaktiv</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="h-12 rounded-lg border border-border px-3">
|
<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="created_at">Erstellt am</option>
|
||||||
|
|
@ -173,6 +192,14 @@ export default function UsersPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
|
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
|
||||||
|
{users.isLoading ? (
|
||||||
|
<div className="flex items-center gap-3 px-5 py-6 text-sm text-text-light">
|
||||||
|
<span className="spinner" />
|
||||||
|
Benutzer werden geladen...
|
||||||
|
</div>
|
||||||
|
) : users.isError ? (
|
||||||
|
<div className="px-5 py-6 text-sm text-danger">Benutzer konnten nicht geladen werden.</div>
|
||||||
|
) : null}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-left text-sm">
|
<table className="min-w-full text-left text-sm">
|
||||||
<thead className="bg-background text-xs uppercase text-text-light">
|
<thead className="bg-background text-xs uppercase text-text-light">
|
||||||
|
|
@ -187,7 +214,14 @@ export default function UsersPage() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{(users.data?.items ?? []).map((item) => (
|
{!users.isLoading && !users.isError && (users.data?.items.length ?? 0) === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-5 py-6 text-sm text-text-light" colSpan={7}>
|
||||||
|
Keine Benutzer vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
(users.data?.items ?? []).map((item) => (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td className="px-5 py-4">{item.first_name} {item.last_name}</td>
|
<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">{item.email}</td>
|
||||||
|
|
@ -198,16 +232,13 @@ export default function UsersPage() {
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex justify-end gap-2">
|
<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={() => 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>
|
<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" />} Passwort zurücksetzen</button>
|
||||||
{item.is_active ? (
|
<button type="button" className="btn btn-danger h-10 px-3" onClick={() => setDeleteTarget(item)}><Trash2 className="h-4 w-4" /> Löschen</button>
|
||||||
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -216,14 +247,29 @@ export default function UsersPage() {
|
||||||
<span>{users.data?.total ?? 0} Datensätze</span>
|
<span>{users.data?.total ?? 0} Datensätze</span>
|
||||||
<div className="flex items-center gap-3">
|
<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>
|
<button type="button" className="btn btn-secondary" disabled={page <= 1} onClick={() => setPage((v) => v - 1)}>Zurück</button>
|
||||||
<span>Seite {page}</span>
|
<span>Seite {page} von {pages}</span>
|
||||||
<button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 10} onClick={() => setPage((v) => v + 1)}>Weiter</button>
|
<button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 20 || page >= pages} onClick={() => setPage((v) => v + 1)}>Weiter</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>}
|
{temporaryPasswordOpen && temporaryPassword && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Passwort zurückgesetzt</h2>
|
||||||
|
<button type="button" aria-label="Schliessen" onClick={() => setTemporaryPasswordOpen(false)} className="icon-btn"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-text-light">Dieses Passwort wird nur einmal angezeigt.</p>
|
||||||
|
<div className="mt-4 rounded-lg border border-border bg-background px-4 py-3 font-mono text-lg tracking-wide">{temporaryPassword}</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button type="button" className="btn btn-secondary h-11" onClick={async () => navigator.clipboard.writeText(temporaryPassword)}><Copy className="h-4 w-4" /> Kopieren</button>
|
||||||
|
<button type="button" className="btn btn-primary h-11" onClick={() => setTemporaryPasswordOpen(false)}>Schliessen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>}
|
{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>}
|
{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">
|
<form onSubmit={form.handleSubmit(submit)} className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||||
<h2 className="text-xl font-semibold">{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}</h2>
|
<h2 className="text-xl font-semibold">{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}</h2>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
{(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => (
|
{(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => (
|
||||||
|
|
@ -243,9 +289,28 @@ export default function UsersPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end gap-3">
|
<div className="mt-5 flex justify-end gap-3">
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => form.reset()}>Zurücksetzen</button>
|
<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>
|
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null}{saveMutation.isPending ? "Speichern..." : "Speichern"}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{deleteTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
|
||||||
|
<div className="w-full max-w-lg rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||||
|
<h2 className="text-xl font-semibold">Benutzer wirklich löschen?</h2>
|
||||||
|
<p className="mt-2 text-sm text-text-light">
|
||||||
|
{deleteTarget.first_name} {deleteTarget.last_name}
|
||||||
|
<br />
|
||||||
|
{deleteTarget.email}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex justify-end gap-3">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setDeleteTarget(null)}>Abbrechen</button>
|
||||||
|
<button type="button" className="btn btn-danger" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(deleteTarget)}>
|
||||||
|
{deleteMutation.isPending ? <span className="spinner" /> : null}
|
||||||
|
{deleteMutation.isPending ? "Löschen..." : "Löschen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import ValidationEditor from "@/components/validations/validation-editor";
|
||||||
|
|
||||||
|
export default function QuickStartValidationPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="rounded-lg border border-border bg-surface p-6 shadow-soft">
|
||||||
|
<p className="text-sm font-medium uppercase tracking-wide text-primary">Schnellstart</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-text">Neue Validierung starten</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-text-light">
|
||||||
|
Wir arbeiten die Validierung direkt auf einer Seite durch und ziehen vorhandene Daten sofort nach.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Link className="btn btn-secondary" href="/validations">
|
||||||
|
Zur Validierungsübersicht
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ValidationEditor />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -118,8 +118,22 @@ export type Paginated<T> = {
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
|
pages?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response, fallback: string) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { detail?: string; message?: string };
|
||||||
|
return parsed.detail ?? parsed.message ?? text;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
const response = await fetch(`/api/login`, {
|
const response = await fetch(`/api/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -155,8 +169,7 @@ export async function apiSend<T>(path: string, token: string, method: "POST" | "
|
||||||
credentials: "include"
|
credentials: "include"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.text();
|
throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
|
||||||
throw new Error(detail || `API request failed: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +180,7 @@ export async function apiDelete(path: string, token: string): Promise<void> {
|
||||||
credentials: "include"
|
credentials: "include"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed: ${response.status}`);
|
throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +191,7 @@ export async function apiFetch<T>(path: string, token: string, init?: RequestIni
|
||||||
credentials: "include"
|
credentials: "include"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await response.text());
|
throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
validation-suite/frontend/atlas/lib/users-query.d.ts
vendored
Normal file
9
validation-suite/frontend/atlas/lib/users-query.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function buildUsersQueryParams(input: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
|
role?: string;
|
||||||
|
active?: boolean | null;
|
||||||
|
sortBy: string;
|
||||||
|
sortOrder: "asc" | "desc";
|
||||||
|
}): URLSearchParams;
|
||||||
18
validation-suite/frontend/atlas/lib/users-query.js
Normal file
18
validation-suite/frontend/atlas/lib/users-query.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
export function buildUsersQueryParams({ page, pageSize, search, role, active, sortBy, sortOrder }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: sortOrder
|
||||||
|
});
|
||||||
|
if (search && search.trim()) {
|
||||||
|
params.set("search", search.trim());
|
||||||
|
}
|
||||||
|
if (role) {
|
||||||
|
params.set("role", role);
|
||||||
|
}
|
||||||
|
if (active === true || active === false) {
|
||||||
|
params.set("active", String(active));
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test:auth": "node --test tests/auth-cookie.test.mjs"
|
"test:auth": "node --test tests/auth-cookie.test.mjs",
|
||||||
|
"test:users-query": "node --test tests/users-query.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
|
|
||||||
42
validation-suite/frontend/atlas/tests/users-query.test.mjs
Normal file
42
validation-suite/frontend/atlas/tests/users-query.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildUsersQueryParams } from "../lib/users-query.js";
|
||||||
|
|
||||||
|
test("builds query without empty optional filters", () => {
|
||||||
|
const params = buildUsersQueryParams({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
search: "",
|
||||||
|
role: "",
|
||||||
|
active: undefined,
|
||||||
|
sortBy: "created_at",
|
||||||
|
sortOrder: "desc"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(params.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds query with active filters", () => {
|
||||||
|
const activeParams = buildUsersQueryParams({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
search: " Admin ",
|
||||||
|
role: "admin",
|
||||||
|
active: true,
|
||||||
|
sortBy: "created_at",
|
||||||
|
sortOrder: "desc"
|
||||||
|
});
|
||||||
|
|
||||||
|
const inactiveParams = buildUsersQueryParams({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
search: "",
|
||||||
|
role: "",
|
||||||
|
active: false,
|
||||||
|
sortBy: "created_at",
|
||||||
|
sortOrder: "desc"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(activeParams.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc&search=Admin&role=admin&active=true");
|
||||||
|
assert.equal(inactiveParams.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc&active=false");
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue