From 0bbcaba21186873b3a3475e1ff3b27b4290d4dc9 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 11 Jul 2026 17:14:41 +0200 Subject: [PATCH] fix(users): repair create delete and duplicate email handling --- ...110006_convert_user_role_enum_to_string.py | 108 ++++++ .../202607110007_add_user_deleted_at.py | 25 ++ .../api/v1/__pycache__/domain.cpython-313.pyc | Bin 40006 -> 43561 bytes .../backend/mercury/app/api/v1/domain.py | 132 +++++--- .../models/__pycache__/user.cpython-313.pyc | Bin 2229 -> 2633 bytes .../backend/mercury/app/models/user.py | 16 +- .../__pycache__/domain.cpython-313.pyc | Bin 3728 -> 3772 bytes .../mercury/app/repositories/domain.py | 2 +- .../__pycache__/common.cpython-313.pyc | Bin 1222 -> 1244 bytes .../backend/mercury/app/schemas/common.py | 1 + .../__pycache__/auth_service.cpython-313.pyc | Bin 2086 -> 2133 bytes .../mercury/app/services/auth_service.py | 2 +- ...tion_workflow.cpython-313-pytest-8.3.4.pyc | Bin 93493 -> 119835 bytes .../mercury/tests/test_validation_workflow.py | 318 +++++++++++++++++- .../atlas/app/(app)/dashboard/page.tsx | 2 +- .../frontend/atlas/app/(app)/users/page.tsx | 157 ++++++--- .../(app)/validations/quick-start/page.tsx | 24 ++ validation-suite/frontend/atlas/lib/api.ts | 21 +- .../frontend/atlas/lib/users-query.d.ts | 9 + .../frontend/atlas/lib/users-query.js | 18 + validation-suite/frontend/atlas/package.json | 3 +- .../frontend/atlas/tests/users-query.test.mjs | 42 +++ 22 files changed, 773 insertions(+), 107 deletions(-) create mode 100644 validation-suite/backend/mercury/alembic/versions/202607110006_convert_user_role_enum_to_string.py create mode 100644 validation-suite/backend/mercury/alembic/versions/202607110007_add_user_deleted_at.py create mode 100644 validation-suite/frontend/atlas/app/(app)/validations/quick-start/page.tsx create mode 100644 validation-suite/frontend/atlas/lib/users-query.d.ts create mode 100644 validation-suite/frontend/atlas/lib/users-query.js create mode 100644 validation-suite/frontend/atlas/tests/users-query.test.mjs diff --git a/validation-suite/backend/mercury/alembic/versions/202607110006_convert_user_role_enum_to_string.py b/validation-suite/backend/mercury/alembic/versions/202607110006_convert_user_role_enum_to_string.py new file mode 100644 index 00000000..0a9ae074 --- /dev/null +++ b/validation-suite/backend/mercury/alembic/versions/202607110006_convert_user_role_enum_to_string.py @@ -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")) diff --git a/validation-suite/backend/mercury/alembic/versions/202607110007_add_user_deleted_at.py b/validation-suite/backend/mercury/alembic/versions/202607110007_add_user_deleted_at.py new file mode 100644 index 00000000..8f02545a --- /dev/null +++ b/validation-suite/backend/mercury/alembic/versions/202607110007_add_user_deleted_at.py @@ -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") diff --git a/validation-suite/backend/mercury/app/api/v1/__pycache__/domain.cpython-313.pyc b/validation-suite/backend/mercury/app/api/v1/__pycache__/domain.cpython-313.pyc index 4df9b1ecc7a60134e60a273a8a0bb0d4e06bbd97..ec283d1d3fca305f8115eede30e442713f8dad7f 100644 GIT binary patch literal 43561 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πSTzp-T-0wt_tO{%!V15TCW_b z5=)pA>C>Z7twimJeu^Y*08RAGD|7kg(I+b+-KZ1WM!Qf{KY|AlJdWT=1kWHC10ZVP zjboU710$;Y`orWZ_B@T?+X(RU5cXXM`5t1gBKQGjsFKpQxAxF>j2C3xl_SP2rWlE_5_Cvl_!1OfmtV>L{Azt6LlT!UWtj|=^g z?)qYJ$=h7vd)&53ZrcR6?QL%1O>WZ!x9KKV`8H?0df|(+vm(`eCl9CnDK#SRWa+20 zh)kt%xyGqnkf~cQ}gJ~vF#BfpNpIgQ!2ow z7DU;`_4BT+94Z;-H@>-S^|hYs%UUP4?;78I@cMS&r<_4zX@S82othKlskv5&JnP?F nvg%sn^(C7pgq`C(crWoOmoD*a03JZ6OfnDspRpSD!ubCI_4B70 diff --git a/validation-suite/backend/mercury/app/api/v1/domain.py b/validation-suite/backend/mercury/app/api/v1/domain.py index 11ad978a..abdb6d4c 100644 --- a/validation-suite/backend/mercury/app/api/v1/domain.py +++ b/validation-suite/backend/mercury/app/api/v1/domain.py @@ -6,13 +6,14 @@ import json import logging import shutil import uuid +from datetime import UTC, datetime 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.responses import FileResponse, HTMLResponse, JSONResponse from sqlalchemy import func, select -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.orm import Session from app.api.dependencies import current_admin, current_user @@ -560,7 +561,7 @@ def _user_payload(user: User) -> dict: "email": user.email, "first_name": user.first_name, "last_name": user.last_name, - "role": user.role, + "role": UserRole(user.role), "is_active": user.is_active, "must_change_password": user.must_change_password, "last_login_at": user.last_login_at, @@ -581,6 +582,7 @@ def list_users( _: User = Depends(current_admin), ): query = select(User) + query = query.where(User.deleted_at.is_(None)) if search: term = f"%{search.strip()}%" query = query.where( @@ -605,8 +607,9 @@ def list_users( 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 + pages = max((total + page_size - 1) // page_size, 1) 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) @@ -615,26 +618,47 @@ def create_user(payload: UserCreate, session: Session = Depends(get_session), _: import secrets 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) - 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) + try: + user = User( + email=normalized_email, + 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, + ) + logger.debug("create_user insert email=%s", normalized_email) + session.add(user) + session.commit() + logger.debug("create_user commit ok email=%s id=%s", normalized_email, user.id) + session.refresh(user) + 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) 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: + if user is None or user.deleted_at is not None: raise HTTPException(status_code=404, detail="Resource not found") 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) 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: + if user is None or user.deleted_at is not 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") @@ -651,44 +675,58 @@ def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(ge ) 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 + 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.last_name = payload.last_name + user.email = normalized_email + user.role = payload.role.value + user.is_active = payload.is_active + user.must_change_password = payload.must_change_password + logger.debug("update_user commit start id=%s email=%s", user.id, normalized_email) + session.commit() + logger.debug("update_user commit ok id=%s email=%s", user.id, normalized_email) + session.refresh(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) -def deactivate_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)): +@router.delete("/users/{item_id}", status_code=204) +def delete_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)): 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") 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( 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.") + 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.deleted_at = datetime.now(UTC) 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 + return Response(status_code=204) @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 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") temporary_password = secrets.token_urlsafe(12) user.password_hash = hash_password(temporary_password) diff --git a/validation-suite/backend/mercury/app/models/__pycache__/user.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/user.cpython-313.pyc index ffea153cacb68a8cb79f356d4624f37a06861d88..c13cecac75d782733dc67ae7a03b641bd4f5a171 100644 GIT binary patch delta 1606 zcmah}%}*Ow5P$pO_4k?&8{5EwL!e|G6+#lnP**gfBnZ<&%Zo)CHf&A3iAaru-mcry zqMBQM=poiBDyme8Q$>|aq^44O<4AicmZgIB)SUVUKu=Yr&RZKNq>B1v{dVTfZ^kq8 z-n^e$e+i05e!mC7N-FdBeiJHUg!#TQIQ3!LOT00J5f)~AX+QC&MIxpHBoIRp)Q7OM z6JeLqqOkLQ)cN#E@O7QE*<3epJ&IomC}P6Vu(VfxeVgG-kqLV(_b1u>N-k??`i}#Q zkLeEq=ZNyGw(Q#4@ap}Xme+@ix<-c9)=2;iBw%LPvqEoO{F03_znfd^TnW3eXN)Co zEGRDQ9R#!D=tGJC3HuTZsIX4H#ACDk|HJayEb$a8a1sN%72kYkJuaZ!l;*peVpG5^ zV;l+C;abfVuD8@;%Wcijt(eE{kbWBuLi(Zq?6=$d9nJk9l*Qxl7z0Sl4&ey+MPF&} zua0m3$+%O*r1xF3rNww^!k#oe9g9-nBGL!wwBop*cWnAyvZcW=Y?b(yhT zg>)J1kz;@^>umV{P4Xz(^Fe=YBe^Hdczk zA~6~#pz7;|kJM(9tzd@E$4k3(Huc*8o9K_8Guuk7XQYyO^667!SJ}r4wdBpR=TM4m z&(x&h%C#p8PjBp|_TRfvOJvIKLvN_+J+r-1eJynui*If0jO=_++L_sxMh~R%sx)4c zu9hdio;?f%%RWK}5l)oWF)IsX9KIF@wUM_#A`?^k%YAy(3iT=s+#?+q!I&Jm;8le)sY=m}Qj4E051UO6`WK($wa3`HLDq zW&SN(h$i;V9i*44>7|2ot_r`1wmDfoU*k1%!kG-mlRvxm`m3XJ`?r>=adk7j-!fR^ zRr53F#eruIvAnUhSaZlntiusH;*b!xrFup*898X~Ip4o!r)_y*WQKCfL(5q>cP~xV zN5N0QZa9CfP+YCI$FAgbb(ZT*cvp*gZJfLVE_7J*3A5mej-;w6^(RVJQSw;enP6Fe dF!{}NW%?L_ax8|JQ2FwM=+^9C2t+%#e*ucVO+)|y delta 1185 zcmZ`(-A~g{6u<4-(S5XKZ0rk=@g-Cvg8(s1z!)(k7-wABlQm6o|FHO~p4*Lu#n^1WoqK-gocp`yp6+YxOGK#id=SCI zP87DjGy6i4`nW&3q-2ncWdGgJau|n~dCV^hSjeCx8boqO8p)g@Ds&-365F2{rm@Bm zFE#+NK_#pR3O~a(nXdhBx0AH%zo%8JddcO>-mm(&V${m!=V>ZTQ8DKh-RBn|0Siuv z9`avmn6Ky*wd)+x_d92(X0?FqS2#J~oY8Tm>>yJ7AUkx5Hp|Er(}mEO@ne=e@6gTl8$9Rku@SyqJhAf{4z4{9T?u((*?^2P1dQQh-sY9g|~_ z*Zp6<+vDRNAD7cfIgv(N%uG`$PRL1|Tu(_o&Mvdy{9t;A$AFS3x1*%5X*I)k8C)@R zm#=D;^|XRZ>V{@*I1+nf1cErTW*FM4p+8SFuHs=yvvqs3tY=w}G@?G)|?KaLYf5Fo?j@~Uc+ z-jU5!6$c2>L4e$qE2^qyniX5KH!G$EeCs&a8|oes#6HoCnvSoLtZ@RQGdN3Nf`FgE z)EvQS0GF%cN>#`9GdxRteFTW)hIrBtJa7(CFsz@*ndAnXbuOD2JI&>fbNSQU$}xPB z#ht~yt0zp+`NUm!eshzT&RD)qQ|x@*hx~$PHL8+=Qq)-qJSe&$cu&VmtK+L0w3_^9 z;1Gc@0ix1n?wGZ5*Qs~; JUkFTZ`@f`@?7jd1 diff --git a/validation-suite/backend/mercury/app/models/user.py b/validation-suite/backend/mercury/app/models/user.py index e68750cc..a2eea975 100644 --- a/validation-suite/backend/mercury/app/models/user.py +++ b/validation-suite/backend/mercury/app/models/user.py @@ -4,7 +4,7 @@ import enum from datetime import datetime 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 @@ -20,15 +20,21 @@ 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), nullable=False) 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) + role: Mapped[str] = mapped_column(String(50), nullable=False, 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) + 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) 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() + @validates("first_name", "last_name") + def _sync_full_name(self, key: str, value: str) -> str: + 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 diff --git a/validation-suite/backend/mercury/app/repositories/__pycache__/domain.cpython-313.pyc b/validation-suite/backend/mercury/app/repositories/__pycache__/domain.cpython-313.pyc index 20c3149e60486670d0f93e0e32031a12d7a618ab..9a5359d5b806ee7c027eba04182927b3449a988b 100644 GIT binary patch delta 1160 zcmah|&ufSE( z;^p4t<~Mnh%}NatWpAYD>MRdPV}Q$m&;X|R!BjZxJ5BHE*cRYIrE`SBT?eZ_6{30*kYCz^JrCjH(9q}Gcqk8VMUC-4%3l{)%w8_ID&keMP zd7!<6#e5IT7O8jWpkY!iy@&NW4=dmn-zL;SuP^QutTyL)nHhHKi;Sd@5k$<{9 delta 1069 zcmah|&r2IY6wa7+f5hD=)j-!8jnNQNwTHAeD5h+yUOX(QK~T{p#)ZZjx6E!1o?5W< zEIe8eFTM3p5X7tJo)s^$2mgci(wD^o0pFrqWEvDUP+K1OcUf%l8IiNcW?_-46pT72{_hwR4; ze1SSnjf>rPj~?XWJ?JDtJM$-)K)_4=Z$8yD$8?1){NPfwmnK*cYK)K4!8nhUJiL)~ zk@q>zEBr2H$vFI!QoCXOl{(88kb;2SZPoPvn`$=eDsam)d8jng5}d^-%v)(u@9k+3 zDMc7o8l@GPj}B|ftPu!HZHGo{3AcME1G5HPDFZ>fjE3e2q+|L 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]): diff --git a/validation-suite/backend/mercury/app/schemas/__pycache__/common.cpython-313.pyc b/validation-suite/backend/mercury/app/schemas/__pycache__/common.cpython-313.pyc index 93a934854567ddaf506db78aaf7404eadf7f3565..49a71948048e4e067cf888c22d2fad9e76ad151d 100644 GIT binary patch delta 108 zcmX@cd54qtGcPX}0}$i|1!Y~`$g9B2=rLK3*+!5hh9#Io9VijZS;U&orO7?HklB^} z7HdIbdTR0HZOodC(vu%Bi?cWT-QebH^P2pVxtvjFav_U@C@UlX49*4SD-0J}ePsZ0 JibR2?008LV93B7w delta 85 zcmcb^d5n|yGcPX}0}xaO1!f)F$g9B2Xg67p*@m5?h&7#4lWTG{v+LyJ%$kg1lRq+x mGqw3m=4UBq)SleV;vmYxC_IC6f%yu{JW{JrYnbt}t@-i@lG6xBO)S!Z- z$@a_=fkLQ)AYL$QFqtQxN delta 324 zcmcaAuuOpWGcPX}0}x!d4$4Z{$eYf@cw}-Z)5-c22?mB}PLK=;M1$3USV00H5)}lq z2D4c*r7}hf!Nh|^U@Qc!#1Jh8lRvGg$tO+LyJFj*VVlFNz zD$)e9iu6H*?&NiBL5$j)8QGZ_r9kEu0Rw}fNEAri;;_lhPbtkwwJWlnEX!d508)V{ AhX4Qo diff --git a/validation-suite/backend/mercury/app/services/auth_service.py b/validation-suite/backend/mercury/app/services/auth_service.py index 0b37bfb0..c17b06db 100644 --- a/validation-suite/backend/mercury/app/services/auth_service.py +++ b/validation-suite/backend/mercury/app/services/auth_service.py @@ -23,7 +23,7 @@ class AuthService: detail="Invalid credentials", 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") user.last_login_at = datetime.now(UTC) self.users.session.commit() diff --git a/validation-suite/backend/mercury/tests/__pycache__/test_validation_workflow.cpython-313-pytest-8.3.4.pyc b/validation-suite/backend/mercury/tests/__pycache__/test_validation_workflow.cpython-313-pytest-8.3.4.pyc index ba2700468335ef9cb5140d00908c1b296d9685a8..73c3851a62739ff23b14736c5cda56d4f7025b00 100644 GIT binary patch delta 25785 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 delta 10882 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"all"); const [page, setPage] = useState(1); const [sortBy, setSortBy] = useState("created_at"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [editing, setEditing] = useState(null); const [temporaryPassword, setTemporaryPassword] = useState(""); + const [temporaryPasswordOpen, setTemporaryPasswordOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); const [message, setMessage] = 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({ - queryKey, + queryKey: ["users", page, 20, search, role, active, sortBy, sortOrder], queryFn: () => apiGet>( - `/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 ?? "" ), 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({ mutationFn: async (values: UserForm) => { const payload = { ...values, - email: values.email.toLowerCase() + email: values.email.trim().toLowerCase() }; if (editing) { return apiFetch(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) }); @@ -85,31 +92,38 @@ export default function UsersPage() { }); }, onSuccess: async (created) => { + const isCreate = !editing; if (!editing) { setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? ""); + } else { + setTemporaryPassword(""); } setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt"); setError(""); setEditing(null); + setTemporaryPasswordOpen(isCreate && Boolean((created as { temporary_password?: string }).temporary_password)); form.reset(); await client.invalidateQueries({ queryKey: ["users"] }); }, onError: (err: Error) => setError(err.message) }); - const deactivateMutation = useMutation({ - mutationFn: (item: User) => apiFetch(`/users/${item.id}/deactivate`, token ?? "", { method: "POST" }), - onSuccess: () => client.invalidateQueries({ queryKey: ["users"] }) - }); - const activateMutation = useMutation({ - mutationFn: (item: User) => apiFetch(`/users/${item.id}/activate`, token ?? "", { method: "POST" }), - onSuccess: () => client.invalidateQueries({ queryKey: ["users"] }) + const deleteMutation = useMutation({ + mutationFn: (item: User) => apiDelete(`/users/${item.id}`, token ?? ""), + onSuccess: async () => { + setMessage("Benutzer geloescht"); + setError(""); + setDeleteTarget(null); + await client.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (err: Error) => setError(err.message) }); 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"); + setTemporaryPasswordOpen(true); } }); @@ -135,6 +149,11 @@ export default function UsersPage() { }); } + function submit(values: UserForm) { + if (saveMutation.isPending) return; + saveMutation.mutate(values); + } + return (
@@ -154,10 +173,10 @@ export default function UsersPage() { {Object.entries(roleLabels).map(([value, label]) => )} - setActive(e.target.value as "all" | "active" | "inactive")} className="h-12 rounded-lg border border-border px-3"> + + +