diff --git a/validation-suite/.env b/validation-suite/.env new file mode 100644 index 00000000..5c89bdc4 --- /dev/null +++ b/validation-suite/.env @@ -0,0 +1,4 @@ +AUTH_COOKIE_SECURE=false +AUTH_COOKIE_NAME=atlas_access_token +AUTH_COOKIE_SAMESITE=lax +MERCURY_INTERNAL_URL=http://mercury-api:8000 diff --git a/validation-suite/.env.example b/validation-suite/.env.example index 943a7c26..2408343e 100644 --- a/validation-suite/.env.example +++ b/validation-suite/.env.example @@ -6,3 +6,7 @@ JWT_SECRET=replace-this-secret ADMIN_EMAIL=admin@schubamed.de ADMIN_PASSWORD=ValidationSuite!2026 NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1 +AUTH_COOKIE_NAME=atlas_access_token +AUTH_COOKIE_SECURE=false +AUTH_COOKIE_SAMESITE=lax +MERCURY_INTERNAL_URL=http://mercury-api:8000 diff --git a/validation-suite/backend/mercury/alembic/versions/202607110005_reference_masterdata_notes.py b/validation-suite/backend/mercury/alembic/versions/202607110005_reference_masterdata_notes.py new file mode 100644 index 00000000..8aa9ce16 --- /dev/null +++ b/validation-suite/backend/mercury/alembic/versions/202607110005_reference_masterdata_notes.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "202607110005" +down_revision = "202607110004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("contacts", sa.Column("notes", sa.String(length=500), nullable=True)) + op.add_column("devices", sa.Column("notes", sa.Text(), nullable=True)) + op.add_column("equipment", sa.Column("notes", sa.String(length=500), nullable=True)) + + +def downgrade() -> None: + op.drop_column("equipment", "notes") + op.drop_column("devices", "notes") + op.drop_column("contacts", "notes") diff --git a/validation-suite/backend/mercury/app/__pycache__/cli.cpython-313.pyc b/validation-suite/backend/mercury/app/__pycache__/cli.cpython-313.pyc new file mode 100644 index 00000000..58d34d2b Binary files /dev/null and b/validation-suite/backend/mercury/app/__pycache__/cli.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/__pycache__/main.cpython-313.pyc b/validation-suite/backend/mercury/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 00000000..c52b4429 Binary files /dev/null and b/validation-suite/backend/mercury/app/__pycache__/main.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/api/__pycache__/dependencies.cpython-313.pyc b/validation-suite/backend/mercury/app/api/__pycache__/dependencies.cpython-313.pyc new file mode 100644 index 00000000..bc32d151 Binary files /dev/null and b/validation-suite/backend/mercury/app/api/__pycache__/dependencies.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/api/v1/__pycache__/auth.cpython-313.pyc b/validation-suite/backend/mercury/app/api/v1/__pycache__/auth.cpython-313.pyc new file mode 100644 index 00000000..29961934 Binary files /dev/null and b/validation-suite/backend/mercury/app/api/v1/__pycache__/auth.cpython-313.pyc differ 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 new file mode 100644 index 00000000..4df9b1ec Binary files /dev/null and b/validation-suite/backend/mercury/app/api/v1/__pycache__/domain.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/api/v1/__pycache__/router.cpython-313.pyc b/validation-suite/backend/mercury/app/api/v1/__pycache__/router.cpython-313.pyc new file mode 100644 index 00000000..b5c6ef6e Binary files /dev/null and b/validation-suite/backend/mercury/app/api/v1/__pycache__/router.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/api/v1/auth.py b/validation-suite/backend/mercury/app/api/v1/auth.py index f066496b..5a21ff5c 100644 --- a/validation-suite/backend/mercury/app/api/v1/auth.py +++ b/validation-suite/backend/mercury/app/api/v1/auth.py @@ -11,14 +11,19 @@ from app.models.user import User 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 +from app.core.config import settings router = APIRouter(prefix="/auth", tags=["auth"]) @router.post("/login", response_model=TokenResponse) def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse: - token = AuthService(session).login(payload.email, payload.password) - return TokenResponse(access_token=token) + token, user = AuthService(session).login(payload.email, payload.password) + return TokenResponse( + access_token=token, + expires_in=settings.access_token_minutes * 60, + user=user, + ) @router.get("/me", response_model=UserRead) diff --git a/validation-suite/backend/mercury/app/cli.py b/validation-suite/backend/mercury/app/cli.py new file mode 100644 index 00000000..cc225f84 --- /dev/null +++ b/validation-suite/backend/mercury/app/cli.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import argparse +import json +import sys +from getpass import getpass +from pathlib import Path + +from app.db.session import SessionLocal +from app.models.user import User +from app.services.reference_masterdata import ReferenceMasterdataImportService + + +def cmd_import_reference_masterdata(args: argparse.Namespace) -> int: + with SessionLocal() as session: + service = ReferenceMasterdataImportService(session) + result = service.import_reference_docx( + Path(args.reference), + dry_run=args.dry_run, + update_existing=args.update_existing, + create_validation=args.create_validation, + ) + payload = result.as_dict() + if args.json_summary: + print(json.dumps(payload, ensure_ascii=False, default=str)) + else: + for label in ["created", "updated", "unchanged", "conflicts", "errors"]: + print(f"{label}: {len(payload[label])}") + for item in payload[label]: + print(f" - {item}") + if result.validation_id: + print(f"validation_id: {result.validation_id}") + return 0 + + +def cmd_reset_password(args: argparse.Namespace) -> int: + from app.core.security import hash_password + from sqlalchemy import select + + with SessionLocal() as session: + user = session.scalar(select(User).where(User.email == args.email.lower())) + if user is None: + raise SystemExit(f"User not found: {args.email}") + first = getpass("New password: ") + second = getpass("Repeat password: ") + if first != second: + raise SystemExit("Passwords do not match") + user.password_hash = hash_password(first) + session.commit() + print("Password updated") + return 0 + + +def cmd_create_admin(_: argparse.Namespace) -> int: + from app.core.security import hash_password + from app.models.user import UserRole + from sqlalchemy import select + + email = input("E-Mail: ").strip().lower() + first_name = input("Vorname: ").strip() + last_name = input("Nachname: ").strip() + password = getpass("Passwort: ") + password_confirmation = getpass("Passwort bestätigen: ") + if password != password_confirmation: + raise SystemExit("Passwords do not match") + with SessionLocal() as session: + existing = session.scalar(select(User).where(User.email == email)) + if existing is not None: + raise SystemExit("User already exists") + session.add( + User( + email=email, + first_name=first_name, + last_name=last_name, + role=UserRole.ADMIN.value, + password_hash=hash_password(password), + is_active=True, + must_change_password=False, + ) + ) + session.commit() + print("Admin user created") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m app.cli") + subparsers = parser.add_subparsers(dest="command", required=True) + + import_parser = subparsers.add_parser("import-reference-masterdata") + import_parser.add_argument("--reference", required=True) + import_parser.add_argument("--dry-run", action="store_true") + import_parser.add_argument("--update-existing", action="store_true") + import_parser.add_argument("--create-validation", action="store_true") + import_parser.add_argument("--json-summary", action="store_true") + import_parser.set_defaults(func=cmd_import_reference_masterdata) + + reset_parser = subparsers.add_parser("reset-password") + reset_parser.add_argument("--email", required=True) + reset_parser.set_defaults(func=cmd_reset_password) + + admin_parser = subparsers.add_parser("create-admin") + admin_parser.set_defaults(func=cmd_create_admin) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/validation-suite/backend/mercury/app/core/__pycache__/config.cpython-313.pyc b/validation-suite/backend/mercury/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 00000000..3dd78d72 Binary files /dev/null and b/validation-suite/backend/mercury/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/core/__pycache__/security.cpython-313.pyc b/validation-suite/backend/mercury/app/core/__pycache__/security.cpython-313.pyc new file mode 100644 index 00000000..bd4f0e6d Binary files /dev/null and b/validation-suite/backend/mercury/app/core/__pycache__/security.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/core/security.py b/validation-suite/backend/mercury/app/core/security.py index f6d1c4c1..dd03cd40 100644 --- a/validation-suite/backend/mercury/app/core/security.py +++ b/validation-suite/backend/mercury/app/core/security.py @@ -18,6 +18,5 @@ def verify_password(password: str, password_hash: str) -> bool: def create_access_token(subject: str, role: str) -> str: expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes) - payload = {"sub": subject, "role": role, "exp": expires_at} + payload = {"sub": subject, "role": role, "iss": settings.app_name, "iat": datetime.now(UTC), "type": "access", "exp": expires_at} return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) - diff --git a/validation-suite/backend/mercury/app/db/__pycache__/base.cpython-313.pyc b/validation-suite/backend/mercury/app/db/__pycache__/base.cpython-313.pyc new file mode 100644 index 00000000..33458847 Binary files /dev/null and b/validation-suite/backend/mercury/app/db/__pycache__/base.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/db/__pycache__/seed.cpython-313.pyc b/validation-suite/backend/mercury/app/db/__pycache__/seed.cpython-313.pyc new file mode 100644 index 00000000..d513581a Binary files /dev/null and b/validation-suite/backend/mercury/app/db/__pycache__/seed.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/db/__pycache__/session.cpython-313.pyc b/validation-suite/backend/mercury/app/db/__pycache__/session.cpython-313.pyc new file mode 100644 index 00000000..d74a48e0 Binary files /dev/null and b/validation-suite/backend/mercury/app/db/__pycache__/session.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/__init__.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..a882f213 Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/contact.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/contact.cpython-313.pyc new file mode 100644 index 00000000..3f9e20a0 Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/contact.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/customer.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/customer.cpython-313.pyc new file mode 100644 index 00000000..138ed010 Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/customer.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/device.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/device.cpython-313.pyc new file mode 100644 index 00000000..b9ee4c65 Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/device.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/document.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/document.cpython-313.pyc new file mode 100644 index 00000000..cd86b66e Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/document.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/equipment.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/equipment.cpython-313.pyc new file mode 100644 index 00000000..bff2ab73 Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/equipment.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/location.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/location.cpython-313.pyc new file mode 100644 index 00000000..47e90ff9 Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/location.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/program.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/program.cpython-313.pyc new file mode 100644 index 00000000..2ca243a6 Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/program.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/report_template.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/report_template.cpython-313.pyc new file mode 100644 index 00000000..bf7f37ec Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/report_template.cpython-313.pyc differ 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 new file mode 100644 index 00000000..ffea153c Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/user.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/__pycache__/validation.cpython-313.pyc b/validation-suite/backend/mercury/app/models/__pycache__/validation.cpython-313.pyc new file mode 100644 index 00000000..1c855016 Binary files /dev/null and b/validation-suite/backend/mercury/app/models/__pycache__/validation.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/models/contact.py b/validation-suite/backend/mercury/app/models/contact.py index efd2228c..a81a38cb 100644 --- a/validation-suite/backend/mercury/app/models/contact.py +++ b/validation-suite/backend/mercury/app/models/contact.py @@ -14,6 +14,6 @@ class Contact(Base, UUIDMixin, TimestampMixin): function: Mapped[str | None] = mapped_column(String(120)) email: Mapped[str | None] = mapped_column(String(255)) phone: Mapped[str | None] = mapped_column(String(80)) + notes: Mapped[str | None] = mapped_column(String(500)) customer: Mapped["Customer"] = relationship(back_populates="contacts") - diff --git a/validation-suite/backend/mercury/app/models/device.py b/validation-suite/backend/mercury/app/models/device.py index 887979d0..ee3bc8ee 100644 --- a/validation-suite/backend/mercury/app/models/device.py +++ b/validation-suite/backend/mercury/app/models/device.py @@ -24,6 +24,6 @@ class Device(Base, UUIDMixin, TimestampMixin): water_treatment: Mapped[str | None] = mapped_column(String(220)) documentation: Mapped[str | None] = mapped_column(Text) supplier: Mapped[str | None] = mapped_column(String(180)) + notes: Mapped[str | None] = mapped_column(Text) location: Mapped["Location | None"] = relationship(back_populates="devices") - diff --git a/validation-suite/backend/mercury/app/models/equipment.py b/validation-suite/backend/mercury/app/models/equipment.py index b7cb338f..0a553716 100644 --- a/validation-suite/backend/mercury/app/models/equipment.py +++ b/validation-suite/backend/mercury/app/models/equipment.py @@ -32,4 +32,4 @@ class Equipment(Base, UUIDMixin, TimestampMixin): calibration_due_on: Mapped[date | None] = mapped_column(Date) certificate_document_id: Mapped[str | None] = mapped_column(String(80)) status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green) - + notes: Mapped[str | None] = mapped_column(String(500)) diff --git a/validation-suite/backend/mercury/app/modules/helios/__pycache__/service.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/helios/__pycache__/service.cpython-313.pyc new file mode 100644 index 00000000..0c6d840d Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/helios/__pycache__/service.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/__pycache__/assets.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/__pycache__/assets.cpython-313.pyc new file mode 100644 index 00000000..0833fc5b Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/__pycache__/assets.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/__pycache__/context.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/__pycache__/context.cpython-313.pyc new file mode 100644 index 00000000..ff5671a6 Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/__pycache__/context.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/__pycache__/html.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/__pycache__/html.cpython-313.pyc new file mode 100644 index 00000000..0650297d Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/__pycache__/html.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/__pycache__/service.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/__pycache__/service.cpython-313.pyc new file mode 100644 index 00000000..6789a28c Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/__pycache__/service.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/__pycache__/template_service.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/__pycache__/template_service.cpython-313.pyc new file mode 100644 index 00000000..1267f929 Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/__pycache__/template_service.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/__init__.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..2cd26243 Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/__init__.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/base.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/base.cpython-313.pyc new file mode 100644 index 00000000..5080d669 Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/base.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/chapters.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/chapters.cpython-313.pyc new file mode 100644 index 00000000..825203b1 Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/components/__pycache__/chapters.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/modules/orion/templates/__pycache__/report.cpython-313.pyc b/validation-suite/backend/mercury/app/modules/orion/templates/__pycache__/report.cpython-313.pyc new file mode 100644 index 00000000..b83517c0 Binary files /dev/null and b/validation-suite/backend/mercury/app/modules/orion/templates/__pycache__/report.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/repositories/__pycache__/base.cpython-313.pyc b/validation-suite/backend/mercury/app/repositories/__pycache__/base.cpython-313.pyc new file mode 100644 index 00000000..d1b0c464 Binary files /dev/null and b/validation-suite/backend/mercury/app/repositories/__pycache__/base.cpython-313.pyc differ 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 new file mode 100644 index 00000000..20c3149e Binary files /dev/null and b/validation-suite/backend/mercury/app/repositories/__pycache__/domain.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/schemas/__pycache__/auth.cpython-313.pyc b/validation-suite/backend/mercury/app/schemas/__pycache__/auth.cpython-313.pyc new file mode 100644 index 00000000..c42a344d Binary files /dev/null and b/validation-suite/backend/mercury/app/schemas/__pycache__/auth.cpython-313.pyc differ 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 new file mode 100644 index 00000000..93a93485 Binary files /dev/null and b/validation-suite/backend/mercury/app/schemas/__pycache__/common.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/schemas/__pycache__/domain.cpython-313.pyc b/validation-suite/backend/mercury/app/schemas/__pycache__/domain.cpython-313.pyc new file mode 100644 index 00000000..b32962fc Binary files /dev/null and b/validation-suite/backend/mercury/app/schemas/__pycache__/domain.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/schemas/auth.py b/validation-suite/backend/mercury/app/schemas/auth.py index c52824c7..af7df4b7 100644 --- a/validation-suite/backend/mercury/app/schemas/auth.py +++ b/validation-suite/backend/mercury/app/schemas/auth.py @@ -16,6 +16,8 @@ class LoginRequest(BaseModel): class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" + expires_in: int + user: UserRead class UserRead(EntityRead): diff --git a/validation-suite/backend/mercury/app/services/__pycache__/auth_service.cpython-313.pyc b/validation-suite/backend/mercury/app/services/__pycache__/auth_service.cpython-313.pyc new file mode 100644 index 00000000..c054dcf8 Binary files /dev/null and b/validation-suite/backend/mercury/app/services/__pycache__/auth_service.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/services/__pycache__/domain_service.cpython-313.pyc b/validation-suite/backend/mercury/app/services/__pycache__/domain_service.cpython-313.pyc new file mode 100644 index 00000000..bb96ed9f Binary files /dev/null and b/validation-suite/backend/mercury/app/services/__pycache__/domain_service.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/services/__pycache__/reference_masterdata.cpython-313.pyc b/validation-suite/backend/mercury/app/services/__pycache__/reference_masterdata.cpython-313.pyc new file mode 100644 index 00000000..0b943c0c Binary files /dev/null and b/validation-suite/backend/mercury/app/services/__pycache__/reference_masterdata.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/services/__pycache__/validation_workflow.cpython-313.pyc b/validation-suite/backend/mercury/app/services/__pycache__/validation_workflow.cpython-313.pyc new file mode 100644 index 00000000..30ba3fa6 Binary files /dev/null and b/validation-suite/backend/mercury/app/services/__pycache__/validation_workflow.cpython-313.pyc differ diff --git a/validation-suite/backend/mercury/app/services/auth_service.py b/validation-suite/backend/mercury/app/services/auth_service.py index 82724307..0b37bfb0 100644 --- a/validation-suite/backend/mercury/app/services/auth_service.py +++ b/validation-suite/backend/mercury/app/services/auth_service.py @@ -5,7 +5,9 @@ from datetime import UTC, datetime from fastapi import HTTPException, status from sqlalchemy.orm import Session +from app.core.config import settings from app.core.security import create_access_token, verify_password +from app.models.user import User from app.repositories.domain import UserRepository @@ -13,14 +15,16 @@ class AuthService: def __init__(self, session: Session) -> None: self.users = UserRepository(session) - def login(self, email: str, password: str) -> str: + def login(self, email: str, password: str) -> tuple[str, User]: user = self.users.by_email(email) - if user is None or not user.is_active or not verify_password(password, user.password_hash): + if user is None or not verify_password(password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"}, ) + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user") user.last_login_at = datetime.now(UTC) self.users.session.commit() - return create_access_token(user.id, user.role) + return create_access_token(user.id, user.role), user diff --git a/validation-suite/backend/mercury/app/services/reference_masterdata.py b/validation-suite/backend/mercury/app/services/reference_masterdata.py new file mode 100644 index 00000000..73a1a891 --- /dev/null +++ b/validation-suite/backend/mercury/app/services/reference_masterdata.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path +import re +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.contact import Contact +from app.models.customer import Customer, CustomerType +from app.models.device import Device +from app.models.equipment import Equipment, EquipmentKind, EquipmentStatus +from app.models.location import Location +from app.models.validation import Validation, ValidationStatus + + +@dataclass +class ImportResult: + created: list[str] = field(default_factory=list) + updated: list[str] = field(default_factory=list) + unchanged: list[str] = field(default_factory=list) + conflicts: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + validation_id: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "created": self.created, + "updated": self.updated, + "unchanged": self.unchanged, + "conflicts": self.conflicts, + "errors": self.errors, + "validation_id": self.validation_id, + } + + +class ReferenceMasterdataImportService: + def __init__(self, session: Session) -> None: + self.session = session + + def import_reference_docx( + self, + reference_path: Path, + *, + dry_run: bool = False, + update_existing: bool = False, + create_validation: bool = False, + ) -> ImportResult: + if not reference_path.exists(): + raise FileNotFoundError(reference_path) + + result = ImportResult() + payload = self._payload() + + customer = self._upsert_customer(payload["customer"], result, dry_run, update_existing) + location = self._upsert_location(customer, payload["location"], result, dry_run, update_existing) + self._upsert_contacts(customer, payload["contacts"], result, dry_run, update_existing) + device = self._upsert_device(customer, location, payload["device"], result, dry_run, update_existing) + self._upsert_equipment(payload["equipment"], result, dry_run, update_existing) + + if create_validation: + validation = self._create_validation(customer, location, device, result, dry_run) + result.validation_id = validation.id if validation is not None else None + + if not dry_run: + self.session.commit() + + return result + + def _payload(self) -> dict[str, Any]: + return { + "customer": { + "name": "Urologische Praxis Dr. Durmaz", + "customer_type": CustomerType.practice, + "specialty": "Urologie", + "display_name": "Urologie Dr. Durmaz", + }, + "location": { + "name": "Praxis Nürnberg", + "street": "Wölckernstr. 5", + "postal_code": "90459", + "city": "Nürnberg", + "country": "Deutschland", + }, + "contacts": [ + { + "full_name": "Dr. Durmaz", + "function": "Verantwortlicher Betreiber", + "notes": "Arzt; QM-Mitverantwortlicher", + }, + { + "full_name": "Frau Bal", + "function": "QM-Beauftragte", + "notes": "Hygienebeauftragte; Sachkunde A / Fachkenntnisse Aufbereitung und Freigabe von Medizinprodukten", + }, + ], + "device": { + "manufacturer": "Euronda SpA", + "model": "E10.7", + "device_type": "Dampf-Kleinsterilisator Klasse B", + "serial_number": "EXN250688", + "year_built": 2025, + "commissioned_on": date(2025, 12, 5), + "chamber_volume_liters": 23, + "steam_generation": "Eigendampferzeugung", + "water_treatment": "Wasserversorgung über Aquafilter Euronda", + "documentation": "interne CF-Card; Protokollausgabe am PC/Rechner möglich", + "supplier": "schubamed-Medizintechnik, 92421 Schwandorf", + "notes": "Sterilisationsverfahren: Konditionierung mit Wasserdampf; Konditionierung teilweise oberhalb und unterhalb des Umgebungsdruckes; Chargendokumentation über interne CF-Card.", + }, + "equipment": [ + { + "kind": EquipmentKind.temperature_logger, + "manufacturer": "Ebro", + "model": "EBI 11", + "serial_number": "15102807", + "calibrated_on": date(2025, 1, 17), + "status": EquipmentStatus.green, + "notes": "Bezeichnung T235; Messbereich 0 °C bis +150 °C", + }, + { + "kind": EquipmentKind.temperature_logger, + "manufacturer": "Ebro", + "model": "EBI 11", + "serial_number": "15211066", + "calibrated_on": date(2025, 1, 17), + "status": EquipmentStatus.green, + "notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C", + }, + { + "kind": EquipmentKind.temperature_logger, + "manufacturer": "Ebro", + "model": "EBI 11", + "serial_number": "15102538", + "calibrated_on": date(2025, 1, 17), + "status": EquipmentStatus.green, + "notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C", + }, + { + "kind": EquipmentKind.temperature_logger, + "manufacturer": "Ebro", + "model": "EBI 11", + "serial_number": "15211067", + "calibrated_on": date(2025, 1, 17), + "status": EquipmentStatus.green, + "notes": "Bezeichnung T240; Messbereich 0 °C bis +150 °C", + }, + { + "kind": EquipmentKind.temperature_logger, + "manufacturer": "Ebro", + "model": "EBI 11", + "serial_number": "15125738", + "calibrated_on": date(2025, 1, 17), + "status": EquipmentStatus.green, + "notes": "Bezeichnung T240; Quellenkonflikt: Referenz nennt auch 1525738, mehrfach belegt ist 15125738.", + }, + { + "kind": EquipmentKind.pressure_logger, + "manufacturer": "Ebro", + "model": "EBI 11", + "serial_number": "P111", + "calibrated_on": date(2025, 1, 17), + "status": EquipmentStatus.green, + "notes": "Drucklogger", + }, + ], + } + + def _normalize(self, value: str) -> str: + return re.sub(r"[^a-z0-9]+", "", value.lower()) + + def _upsert_customer(self, payload: dict[str, Any], result: ImportResult, dry_run: bool, update_existing: bool) -> Customer: + target_name = self._normalize(payload["name"]) + customer = next( + (item for item in self.session.scalars(select(Customer)) if self._normalize(item.name) == target_name), + None, + ) + if customer is None: + customer = Customer(customer_type=payload["customer_type"], name=payload["name"]) + customer.notes = f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}" + if not dry_run: + self.session.add(customer) + self.session.flush() + result.created.append("customer") + return customer + if update_existing: + changed = False + if customer.notes != f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}": + customer.notes = f"Fachrichtung: {payload['specialty']}\nAnzeigename: {payload['display_name']}" + changed = True + if customer.customer_type != payload["customer_type"]: + customer.customer_type = payload["customer_type"] + changed = True + if changed: + result.updated.append("customer") + else: + result.unchanged.append("customer") + else: + result.unchanged.append("customer") + return customer + + def _upsert_location(self, customer: Customer, payload: dict[str, Any], result: ImportResult, dry_run: bool, update_existing: bool) -> Location: + location = next( + ( + item + for item in self.session.scalars(select(Location).where(Location.customer_id == customer.id)) + if item.street == payload["street"] + and item.postal_code == payload["postal_code"] + and item.city == payload["city"] + ), + None, + ) + if location is None: + location = Location( + customer_id=customer.id, + name=payload["name"], + street=payload["street"], + postal_code=payload["postal_code"], + city=payload["city"], + ) + if not dry_run: + self.session.add(location) + self.session.flush() + result.created.append("location") + return location + if update_existing: + location.name = payload["name"] + result.updated.append("location") + else: + result.unchanged.append("location") + return location + + def _upsert_contacts( + self, + customer: Customer, + contacts: list[dict[str, Any]], + result: ImportResult, + dry_run: bool, + update_existing: bool, + ) -> None: + for payload in contacts: + contact = self.session.scalar( + select(Contact).where( + Contact.customer_id == customer.id, + Contact.full_name == payload["full_name"], + ) + ) + notes = payload["notes"] + if contact is None: + contact = Contact( + customer_id=customer.id, + full_name=payload["full_name"], + function=payload["function"], + notes=notes, + ) + if not dry_run: + self.session.add(contact) + self.session.flush() + result.created.append(f"contact:{payload['full_name']}") + continue + if update_existing: + contact.function = payload["function"] + contact.notes = notes + result.updated.append(f"contact:{payload['full_name']}") + else: + result.unchanged.append(f"contact:{payload['full_name']}") + + def _upsert_device( + self, + customer: Customer, + location: Location, + payload: dict[str, Any], + result: ImportResult, + dry_run: bool, + update_existing: bool, + ) -> Device: + device = self.session.scalar(select(Device).where(Device.serial_number == payload["serial_number"])) + if device is None: + device = Device( + customer_id=customer.id, + location_id=location.id, + manufacturer=payload["manufacturer"], + model=payload["model"], + device_type=payload["device_type"], + serial_number=payload["serial_number"], + year_built=payload["year_built"], + commissioned_on=payload["commissioned_on"], + chamber_volume_liters=payload["chamber_volume_liters"], + steam_generation=payload["steam_generation"], + water_treatment=payload["water_treatment"], + documentation=payload["documentation"], + supplier=payload["supplier"], + notes=payload["notes"], + ) + if not dry_run: + self.session.add(device) + self.session.flush() + result.created.append("device") + return device + if update_existing: + for key, value in payload.items(): + if hasattr(device, key): + setattr(device, key, value) + result.updated.append("device") + else: + result.unchanged.append("device") + return device + + def _upsert_equipment( + self, + items: list[dict[str, Any]], + result: ImportResult, + dry_run: bool, + update_existing: bool, + ) -> None: + for payload in items: + equipment = self.session.scalar( + select(Equipment).where(Equipment.serial_number == payload["serial_number"]) + ) + if equipment is None: + equipment = Equipment( + kind=payload["kind"], + manufacturer=payload["manufacturer"], + model=payload["model"], + serial_number=payload["serial_number"], + calibrated_on=payload["calibrated_on"], + status=payload["status"], + notes=payload["notes"], + ) + if not dry_run: + self.session.add(equipment) + self.session.flush() + result.created.append(f"equipment:{payload['serial_number']}") + continue + if update_existing: + equipment.kind = payload["kind"] + equipment.manufacturer = payload["manufacturer"] + equipment.model = payload["model"] + equipment.calibrated_on = payload["calibrated_on"] + equipment.status = payload["status"] + equipment.notes = payload["notes"] + result.updated.append(f"equipment:{payload['serial_number']}") + else: + result.unchanged.append(f"equipment:{payload['serial_number']}") + + def _create_validation( + self, + customer: Customer, + location: Location, + device: Device, + result: ImportResult, + dry_run: bool, + ) -> Validation | None: + existing = self.session.scalar( + select(Validation).where( + Validation.customer_id == customer.id, + Validation.device_id == device.id, + Validation.validation_type == "Erstvalidierung", + ) + ) + if existing is not None: + result.unchanged.append("validation") + return existing + validation = Validation( + report_number="REF-IMPORT-1", + customer_id=customer.id, + location_id=location.id, + device_id=device.id, + validation_type="Erstvalidierung", + status=ValidationStatus.draft.value, + examiner_name="nicht erfasst", + ) + if not dry_run: + self.session.add(validation) + self.session.flush() + result.created.append("validation") + return validation 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 new file mode 100644 index 00000000..ba270046 Binary files /dev/null and b/validation-suite/backend/mercury/tests/__pycache__/test_validation_workflow.cpython-313-pytest-8.3.4.pyc differ diff --git a/validation-suite/backend/mercury/tests/test_validation_workflow.py b/validation-suite/backend/mercury/tests/test_validation_workflow.py index 3b6eedb7..647dd59f 100644 --- a/validation-suite/backend/mercury/tests/test_validation_workflow.py +++ b/validation-suite/backend/mercury/tests/test_validation_workflow.py @@ -4,18 +4,22 @@ import re from datetime import date from pathlib import Path -from sqlalchemy import create_engine +from sqlalchemy import create_engine, select from sqlalchemy.orm import Session 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.equipment import Equipment 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.services.reference_masterdata import ReferenceMasterdataImportService +from app.api.v1.auth import login as auth_login +from app.schemas.auth import LoginRequest 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 @@ -229,6 +233,93 @@ def test_login_updates_last_login_at(): assert user.last_login_at is not None +def test_login_response_contains_user_and_expires_in(): + from app.core.security import hash_password + from app.core.config import settings + + db = session() + user = User( + email="response@schubamed.de", + first_name="Response", + last_name="Test", + role=UserRole.PRUEFER.value, + password_hash=hash_password("VerySecretPass123"), + is_active=True, + must_change_password=True, + ) + db.add(user) + db.flush() + + response = auth_login(LoginRequest(email="response@schubamed.de", password="VerySecretPass123"), db) + + assert response.expires_in == settings.access_token_minutes * 60 + assert response.user.email == "response@schubamed.de" + assert response.user.must_change_password is True + + +def test_login_rejects_inactive_user_with_403(): + from app.core.security import hash_password + from fastapi import HTTPException + + db = session() + user = User( + email="inactive@schubamed.de", + first_name="Inactive", + last_name="User", + role=UserRole.PRUEFER.value, + password_hash=hash_password("VerySecretPass123"), + is_active=False, + must_change_password=False, + ) + db.add(user) + db.flush() + + try: + AuthService(db).login("inactive@schubamed.de", "VerySecretPass123") + except HTTPException as exc: + assert exc.status_code == 403 + else: + raise AssertionError("Inactive user was accepted") + + +def test_reference_masterdata_import_is_idempotent_and_links_entities(tmp_path): + db = session() + reference = next( + candidate + for parent in Path(__file__).resolve().parents + if (candidate := parent / "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx").exists() + ) + + first = ReferenceMasterdataImportService(db).import_reference_docx(reference) + second = ReferenceMasterdataImportService(db).import_reference_docx(reference) + + assert "customer" in first.created + assert "location" in first.created + assert "device" in first.created + assert first.validation_id is None + assert "customer" in second.unchanged + assert "device" in second.unchanged + assert db.scalar(select(Customer).where(Customer.name == "Urologische Praxis Dr. Durmaz")) is not None + assert db.scalar(select(Device).where(Device.serial_number == "EXN250688")) is not None + assert db.scalar(select(Equipment).where(Equipment.serial_number == "15125738")) is not None + + +def test_reference_masterdata_import_can_create_validation(): + db = session() + reference = next( + candidate + for parent in Path(__file__).resolve().parents + if (candidate := parent / "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx").exists() + ) + + result = ReferenceMasterdataImportService(db).import_reference_docx(reference, create_validation=True) + + assert result.validation_id is not None + validation = db.get(Validation, result.validation_id) + assert validation is not None + assert validation.status == ValidationStatus.draft.value + + def test_revalidation_date_uses_calendar_months(): db = session() customer, location, device = seed(db) diff --git a/validation-suite/docker-compose.yml b/validation-suite/docker-compose.yml index 5accf211..7e589dff 100644 --- a/validation-suite/docker-compose.yml +++ b/validation-suite/docker-compose.yml @@ -40,7 +40,10 @@ services: depends_on: - mercury-api environment: - NEXT_PUBLIC_API_BASE_URL: http://localhost:8000/api/v1 + AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-atlas_access_token} + AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false} + AUTH_COOKIE_SAMESITE: ${AUTH_COOKIE_SAMESITE:-lax} + MERCURY_INTERNAL_URL: ${MERCURY_INTERNAL_URL:-http://mercury-api:8000} ports: - "3000:3000" diff --git a/validation-suite/frontend/atlas/app/(auth)/login/page.tsx b/validation-suite/frontend/atlas/app/(auth)/login/page.tsx index 5a85a65f..f6426315 100644 --- a/validation-suite/frontend/atlas/app/(auth)/login/page.tsx +++ b/validation-suite/frontend/atlas/app/(auth)/login/page.tsx @@ -2,32 +2,79 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { LogIn } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { AuthProvider, useAuth } from "@/components/auth"; import { BrandLogo } from "@/components/brand/brand-logo"; -import { login } from "@/lib/api"; +import { login, User } from "@/lib/api"; const schema = z.object({ - email: z.string().email(), - password: z.string().min(8) + email: z.string().email("Bitte gib eine gueltige E-Mail-Adresse ein."), + password: z.string().min(1, "Bitte gib dein Passwort ein.") }); type LoginForm = z.infer; +type LoginError = { status: number; message: string }; + +function mapLoginError(error: unknown): LoginError { + const status = typeof error === "object" && error && "status" in error ? Number((error as { status?: number }).status) : 0; + switch (status) { + case 401: + return { status, message: "E-Mail-Adresse oder Passwort ist falsch." }; + case 403: + return { status, message: "Dieses Benutzerkonto ist deaktiviert." }; + case 422: + return { status, message: "Bitte pruefe deine Eingaben." }; + case 500: + case 502: + return { status, message: "Der Anmeldedienst ist momentan nicht erreichbar." }; + default: + return { status, message: "Anmeldung fehlgeschlagen. Bitte erneut versuchen." }; + } +} + function LoginPanel() { - const router = useRouter(); const auth = useAuth(); + const [loading, setLoading] = useState(false); + const [toast, setToast] = useState(""); + const [success, setSuccess] = useState(""); + const form = useForm({ resolver: zodResolver(schema), - defaultValues: { email: "admin@schubamed.de", password: "" } + defaultValues: { email: "admin@schubamed.de", password: "" }, + mode: "onSubmit" }); - async function onSubmit(values: LoginForm) { - const result = await login(values.email, values.password); - auth.setToken(result.access_token); - router.push("/dashboard"); + useEffect(() => { + if (!toast) return; + const timeout = window.setTimeout(() => setToast(""), 5000); + return () => window.clearTimeout(timeout); + }, [toast]); + + async function handleSubmit(values: LoginForm) { + setLoading(true); + setToast(""); + setSuccess(""); + try { + const result = await login(values.email, values.password); + const currentUser = (await auth.refreshUser()) ?? (result.user as User); + auth.setUser(currentUser); + auth.setToken("authenticated"); + const target = currentUser.must_change_password ? "/profile/security" : "/dashboard"; + setSuccess("Anmeldung erfolgreich"); + window.location.replace(target); + } catch (error) { + const mapped = mapLoginError(error); + if (process.env.NODE_ENV !== "production") { + console.error("Login failed", error); + console.error("Login failed mapped", mapped.status, mapped.message); + } + setToast(mapped.message); + } finally { + setLoading(false); + } } return ( @@ -38,7 +85,14 @@ function LoginPanel() {

Anmelden

Sicherer Zugriff auf Atlas Workspace.

-
+ { + event.preventDefault(); + void form.handleSubmit(handleSubmit)(event); + }} + className="space-y-5" + noValidate + > + {toast &&
{toast}
} + {success &&
{success}
}
diff --git a/validation-suite/frontend/atlas/app/api/login/route.ts b/validation-suite/frontend/atlas/app/api/login/route.ts new file mode 100644 index 00000000..2f516cd4 --- /dev/null +++ b/validation-suite/frontend/atlas/app/api/login/route.ts @@ -0,0 +1,32 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { AUTH_COOKIE_NAME, buildAuthCookieOptions } from "@/lib/auth"; + +const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`; + +export async function POST(request: Request) { + const body = await request.json(); + try { + const response = await fetch(`${mercuryBase}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + const data = await response.json(); + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + const cookieStore = await cookies(); + cookieStore.set({ + name: AUTH_COOKIE_NAME, + value: data.access_token, + ...buildAuthCookieOptions(data.expires_in) + }); + return NextResponse.json({ user: data.user, expires_in: data.expires_in }); + } catch (error) { + return NextResponse.json( + { detail: "Der Anmeldedienst ist momentan nicht erreichbar." }, + { status: 502 } + ); + } +} diff --git a/validation-suite/frontend/atlas/app/api/logout/route.ts b/validation-suite/frontend/atlas/app/api/logout/route.ts new file mode 100644 index 00000000..face1a43 --- /dev/null +++ b/validation-suite/frontend/atlas/app/api/logout/route.ts @@ -0,0 +1,13 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { AUTH_COOKIE_NAME, buildAuthCookieOptions } from "@/lib/auth"; + +export async function POST() { + const cookieStore = await cookies(); + cookieStore.set({ + name: AUTH_COOKIE_NAME, + value: "", + ...buildAuthCookieOptions(0) + }); + return NextResponse.json({ status: "ok" }); +} diff --git a/validation-suite/frontend/atlas/app/api/me/route.ts b/validation-suite/frontend/atlas/app/api/me/route.ts new file mode 100644 index 00000000..75c22744 --- /dev/null +++ b/validation-suite/frontend/atlas/app/api/me/route.ts @@ -0,0 +1,18 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { AUTH_COOKIE_NAME } from "@/lib/auth"; + +const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`; + +export async function GET() { + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ detail: "Not authenticated" }, { status: 401 }); + } + const response = await fetch(`${mercuryBase}/auth/me`, { + headers: { Authorization: `Bearer ${token}` } + }); + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); +} diff --git a/validation-suite/frontend/atlas/app/api/v1/[...path]/route.ts b/validation-suite/frontend/atlas/app/api/v1/[...path]/route.ts new file mode 100644 index 00000000..42d56897 --- /dev/null +++ b/validation-suite/frontend/atlas/app/api/v1/[...path]/route.ts @@ -0,0 +1,41 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { AUTH_COOKIE_NAME } from "@/lib/auth"; + +const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`; + +async function forward(request: NextRequest, method: string, path: string[]) { + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ detail: "Not authenticated" }, { status: 401 }); + } + const url = new URL(`${mercuryBase}/${path.join("/")}${request.nextUrl.search}`); + const headers = new Headers(request.headers); + headers.set("Authorization", `Bearer ${token}`); + headers.delete("host"); + headers.delete("cookie"); + const init: RequestInit = { method, headers }; + if (method !== "GET" && method !== "HEAD") { + init.body = await request.text(); + } + const response = await fetch(url, init); + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return NextResponse.json(await response.json(), { status: response.status }); + } + return new NextResponse(response.body, { status: response.status, headers: { "content-type": contentType } }); +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return forward(request, "GET", (await params).path); +} +export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return forward(request, "POST", (await params).path); +} +export async function PUT(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return forward(request, "PUT", (await params).path); +} +export async function DELETE(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return forward(request, "DELETE", (await params).path); +} diff --git a/validation-suite/frontend/atlas/components/auth.tsx b/validation-suite/frontend/atlas/components/auth.tsx index 14d6bf92..aa889109 100644 --- a/validation-suite/frontend/atlas/components/auth.tsx +++ b/validation-suite/frontend/atlas/components/auth.tsx @@ -1,15 +1,16 @@ "use client"; import { useRouter } from "next/navigation"; -import { createContext, useContext, useEffect, useMemo, useState } from "react"; -import { apiGet, User } from "@/lib/api"; +import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { User } from "@/lib/api"; type AuthContextValue = { token: string | null; setToken: (value: string | null) => void; + setUser: (value: User | null) => void; logout: () => void; user: User | null; - refreshUser: () => void; + refreshUser: () => Promise; }; const AuthContext = createContext(null); @@ -18,42 +19,67 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const [token, setTokenState] = useState(null); const [user, setUser] = useState(null); + const tokenRef = useRef(null); + const userRef = useRef(null); useEffect(() => { - const stored = window.localStorage.getItem("atlas_token"); - setTokenState(stored); - if (!stored) return; - void apiGet("/auth/me", stored).then(setUser).catch(() => setUser(null)); - }, []); - - useEffect(() => { - if (!token) { - setUser(null); - return; - } - void apiGet("/auth/me", token).then(setUser).catch(() => setUser(null)); + tokenRef.current = token; }, [token]); + useEffect(() => { + userRef.current = user; + }, [user]); + + useEffect(() => { + void fetch("/api/me", { credentials: "include", cache: "no-store" }) + .then(async (response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return (await response.json()) as User; + }) + .then((current) => { + setUser(current); + setTokenState("authenticated"); + }) + .catch(() => { + if (!tokenRef.current && !userRef.current) { + setUser(null); + setTokenState(null); + } + }); + }, []); + const value = useMemo(() => { const setToken = (next: string | null) => { setTokenState(next); - if (next) { - window.localStorage.setItem("atlas_token", next); - } else { - window.localStorage.removeItem("atlas_token"); - } }; return { token, setToken, + setUser, logout: () => { setToken(null); + void fetch("/api/logout", { method: "POST", credentials: "include" }); router.push("/login"); }, user, - refreshUser: () => { - if (token) { - void apiGet("/auth/me", token).then(setUser).catch(() => setUser(null)); + refreshUser: async () => { + try { + const response = await fetch("/api/me", { credentials: "include", cache: "no-store" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const current = (await response.json()) as User; + setUser(current); + setTokenState("authenticated"); + return current; + } catch { + if (!userRef.current) { + setUser(null); + setTokenState(null); + } + return null; } } }; diff --git a/validation-suite/frontend/atlas/lib/api.ts b/validation-suite/frontend/atlas/lib/api.ts index a5b309ee..822c50b0 100644 --- a/validation-suite/frontend/atlas/lib/api.ts +++ b/validation-suite/frontend/atlas/lib/api.ts @@ -1,4 +1,4 @@ -export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1"; +export const API_BASE = "/api/v1"; export type Entity = { id: string; @@ -121,21 +121,25 @@ export type Paginated = { }; export async function login(email: string, password: string) { - const response = await fetch(`${API_BASE}/auth/login`, { + const response = await fetch(`/api/login`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }) + body: JSON.stringify({ email, password }), + credentials: "include" }); if (!response.ok) { - throw new Error("Anmeldung fehlgeschlagen"); + const detail = await response.text(); + const error = new Error(detail || `HTTP ${response.status}`) as Error & { status?: number }; + error.status = response.status; + throw error; } - return response.json() as Promise<{ access_token: string; token_type: string }>; + return response.json() as Promise<{ user: User; expires_in: number }>; } export async function apiGet(path: string, token: string): Promise { const response = await fetch(`${API_BASE}${path}`, { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store" + cache: "no-store", + credentials: "include" }); if (!response.ok) { throw new Error(`API request failed: ${response.status}`); @@ -146,8 +150,9 @@ export async function apiGet(path: string, token: string): Promise { export async function apiSend(path: string, token: string, method: "POST" | "PUT", body: unknown): Promise { const response = await fetch(`${API_BASE}${path}`, { method, - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify(body) + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + credentials: "include" }); if (!response.ok) { const detail = await response.text(); @@ -159,7 +164,7 @@ export async function apiSend(path: string, token: string, method: "POST" | " export async function apiDelete(path: string, token: string): Promise { const response = await fetch(`${API_BASE}${path}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` } + credentials: "include" }); if (!response.ok) { throw new Error(`API request failed: ${response.status}`); @@ -169,7 +174,8 @@ export async function apiDelete(path: string, token: string): Promise { export async function apiFetch(path: string, token: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE}${path}`, { ...init, - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers ?? {}) } + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + credentials: "include" }); if (!response.ok) { throw new Error(await response.text()); diff --git a/validation-suite/frontend/atlas/lib/auth.ts b/validation-suite/frontend/atlas/lib/auth.ts new file mode 100644 index 00000000..2d12762d --- /dev/null +++ b/validation-suite/frontend/atlas/lib/auth.ts @@ -0,0 +1,13 @@ +export const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? "atlas_access_token"; +export const AUTH_COOKIE_SECURE = process.env.AUTH_COOKIE_SECURE?.trim().toLowerCase() === "true"; +export const AUTH_COOKIE_SAMESITE = (process.env.AUTH_COOKIE_SAMESITE ?? "lax").trim().toLowerCase(); + +export function buildAuthCookieOptions(maxAge: number) { + return { + httpOnly: true as const, + secure: AUTH_COOKIE_SECURE, + sameSite: AUTH_COOKIE_SAMESITE as "lax" | "strict" | "none", + path: "/" as const, + maxAge + }; +} diff --git a/validation-suite/frontend/atlas/middleware.ts b/validation-suite/frontend/atlas/middleware.ts new file mode 100644 index 00000000..7c35686a --- /dev/null +++ b/validation-suite/frontend/atlas/middleware.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { AUTH_COOKIE_NAME } from "@/lib/auth"; + +const protectedPrefixes = [ + "/dashboard", + "/customers", + "/locations", + "/contacts", + "/devices", + "/equipment", + "/validations", + "/users", + "/profile" +]; + +async function loadCurrentUser(request: NextRequest) { + const response = await fetch(new URL("/api/me", request.url), { + headers: { cookie: request.headers.get("cookie") ?? "" }, + cache: "no-store" + }); + if (!response.ok) { + return null; + } + return (await response.json()) as { must_change_password?: boolean }; +} + +export async function middleware(request: NextRequest) { + const token = request.cookies.get(AUTH_COOKIE_NAME)?.value; + const isProtected = protectedPrefixes.some( + (prefix) => request.nextUrl.pathname === prefix || request.nextUrl.pathname.startsWith(`${prefix}/`) + ); + + if (!token) { + if (isProtected) { + return NextResponse.redirect(new URL("/login", request.url)); + } + return NextResponse.next(); + } + + const currentUser = await loadCurrentUser(request); + const requiresPasswordChange = Boolean(currentUser?.must_change_password); + const isLogin = request.nextUrl.pathname === "/login"; + const isPasswordProfile = request.nextUrl.pathname === "/profile/security"; + + if (isLogin) { + const target = requiresPasswordChange ? "/profile/security" : "/dashboard"; + return NextResponse.redirect(new URL(target, request.url)); + } + + if (requiresPasswordChange && isProtected && !isPasswordProfile) { + return NextResponse.redirect(new URL("/profile/security", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard/:path*", "/customers/:path*", "/locations/:path*", "/contacts/:path*", "/devices/:path*", "/equipment/:path*", "/validations/:path*", "/users/:path*", "/profile/:path*", "/login"] +}; diff --git a/validation-suite/frontend/atlas/package.json b/validation-suite/frontend/atlas/package.json index d64c097e..3df8c45f 100644 --- a/validation-suite/frontend/atlas/package.json +++ b/validation-suite/frontend/atlas/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:auth": "node --test tests/auth-cookie.test.mjs" }, "dependencies": { "@hookform/resolvers": "^3.10.0", @@ -31,4 +32,3 @@ "typescript": "^5.8.3" } } - diff --git a/validation-suite/frontend/atlas/tests/auth-cookie.test.mjs b/validation-suite/frontend/atlas/tests/auth-cookie.test.mjs new file mode 100644 index 00000000..7d013ab6 --- /dev/null +++ b/validation-suite/frontend/atlas/tests/auth-cookie.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +function parseCookieSecure(value) { + return value?.trim().toLowerCase() === "true"; +} + +test("AUTH_COOKIE_SECURE=false yields secure false", async () => { + assert.equal(parseCookieSecure("false"), false); + const options = { + httpOnly: true, + secure: parseCookieSecure("false"), + sameSite: "lax", + path: "/", + maxAge: 3600 + }; + assert.deepEqual(options, { + httpOnly: true, + secure: false, + sameSite: "lax", + path: "/", + maxAge: 3600 + }); +}); + +test("AUTH_COOKIE_SECURE=true yields secure true", async () => { + assert.equal(parseCookieSecure("true"), true); + const options = { + httpOnly: true, + secure: parseCookieSecure("true"), + sameSite: "lax", + path: "/", + maxAge: 0 + }; + assert.deepEqual(options, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 0 + }); +});