fix(auth): correct local cookie security configuration
This commit is contained in:
parent
b584e60273
commit
155fdbb16a
67 changed files with 1003 additions and 62 deletions
4
validation-suite/.env
Normal file
4
validation-suite/.env
Normal file
|
|
@ -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
|
||||||
|
|
@ -6,3 +6,7 @@ JWT_SECRET=replace-this-secret
|
||||||
ADMIN_EMAIL=admin@schubamed.de
|
ADMIN_EMAIL=admin@schubamed.de
|
||||||
ADMIN_PASSWORD=ValidationSuite!2026
|
ADMIN_PASSWORD=ValidationSuite!2026
|
||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1
|
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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -11,14 +11,19 @@ from app.models.user import User
|
||||||
from app.core.security import hash_password, verify_password
|
from app.core.security import hash_password, verify_password
|
||||||
from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserRead
|
from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserRead
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse:
|
def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse:
|
||||||
token = AuthService(session).login(payload.email, payload.password)
|
token, user = AuthService(session).login(payload.email, payload.password)
|
||||||
return TokenResponse(access_token=token)
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
expires_in=settings.access_token_minutes * 60,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserRead)
|
@router.get("/me", response_model=UserRead)
|
||||||
|
|
|
||||||
114
validation-suite/backend/mercury/app/cli.py
Normal file
114
validation-suite/backend/mercury/app/cli.py
Normal file
|
|
@ -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:]))
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -18,6 +18,5 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
|
||||||
def create_access_token(subject: str, role: str) -> str:
|
def create_access_token(subject: str, role: str) -> str:
|
||||||
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes)
|
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)
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -14,6 +14,6 @@ class Contact(Base, UUIDMixin, TimestampMixin):
|
||||||
function: Mapped[str | None] = mapped_column(String(120))
|
function: Mapped[str | None] = mapped_column(String(120))
|
||||||
email: Mapped[str | None] = mapped_column(String(255))
|
email: Mapped[str | None] = mapped_column(String(255))
|
||||||
phone: Mapped[str | None] = mapped_column(String(80))
|
phone: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
notes: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
|
||||||
customer: Mapped["Customer"] = relationship(back_populates="contacts")
|
customer: Mapped["Customer"] = relationship(back_populates="contacts")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,6 @@ class Device(Base, UUIDMixin, TimestampMixin):
|
||||||
water_treatment: Mapped[str | None] = mapped_column(String(220))
|
water_treatment: Mapped[str | None] = mapped_column(String(220))
|
||||||
documentation: Mapped[str | None] = mapped_column(Text)
|
documentation: Mapped[str | None] = mapped_column(Text)
|
||||||
supplier: Mapped[str | None] = mapped_column(String(180))
|
supplier: Mapped[str | None] = mapped_column(String(180))
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
location: Mapped["Location | None"] = relationship(back_populates="devices")
|
location: Mapped["Location | None"] = relationship(back_populates="devices")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,4 @@ class Equipment(Base, UUIDMixin, TimestampMixin):
|
||||||
calibration_due_on: Mapped[date | None] = mapped_column(Date)
|
calibration_due_on: Mapped[date | None] = mapped_column(Date)
|
||||||
certificate_document_id: Mapped[str | None] = mapped_column(String(80))
|
certificate_document_id: Mapped[str | None] = mapped_column(String(80))
|
||||||
status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green)
|
status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green)
|
||||||
|
notes: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -16,6 +16,8 @@ class LoginRequest(BaseModel):
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
user: UserRead
|
||||||
|
|
||||||
|
|
||||||
class UserRead(EntityRead):
|
class UserRead(EntityRead):
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -5,7 +5,9 @@ from datetime import UTC, datetime
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import create_access_token, verify_password
|
from app.core.security import create_access_token, verify_password
|
||||||
|
from app.models.user import User
|
||||||
from app.repositories.domain import UserRepository
|
from app.repositories.domain import UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -13,14 +15,16 @@ class AuthService:
|
||||||
def __init__(self, session: Session) -> None:
|
def __init__(self, session: Session) -> None:
|
||||||
self.users = UserRepository(session)
|
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)
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid credentials",
|
detail="Invalid credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
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)
|
user.last_login_at = datetime.now(UTC)
|
||||||
self.users.session.commit()
|
self.users.session.commit()
|
||||||
return create_access_token(user.id, user.role)
|
return create_access_token(user.id, user.role), user
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Binary file not shown.
|
|
@ -4,18 +4,22 @@ import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.customer import Customer, CustomerType
|
from app.models.customer import Customer, CustomerType
|
||||||
from app.models.contact import Contact
|
from app.models.contact import Contact
|
||||||
from app.models.device import Device
|
from app.models.device import Device
|
||||||
|
from app.models.equipment import Equipment
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
from app.models.validation import Validation, ValidationStatus
|
from app.models.validation import Validation, ValidationStatus
|
||||||
from app.services.validation_workflow import ValidationWorkflowService
|
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.api.v1.auth import login as auth_login
|
||||||
|
from app.schemas.auth import LoginRequest
|
||||||
from app.schemas.domain import ValidationCreate
|
from app.schemas.domain import 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
|
||||||
|
|
@ -229,6 +233,93 @@ def test_login_updates_last_login_at():
|
||||||
assert user.last_login_at is not None
|
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():
|
def test_revalidation_date_uses_calendar_months():
|
||||||
db = session()
|
db = session()
|
||||||
customer, location, device = seed(db)
|
customer, location, device = seed(db)
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,10 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- mercury-api
|
- mercury-api
|
||||||
environment:
|
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:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,79 @@
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { LogIn } from "lucide-react";
|
import { LogIn } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AuthProvider, useAuth } from "@/components/auth";
|
import { AuthProvider, useAuth } from "@/components/auth";
|
||||||
import { BrandLogo } from "@/components/brand/brand-logo";
|
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||||
import { login } from "@/lib/api";
|
import { login, User } from "@/lib/api";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email("Bitte gib eine gueltige E-Mail-Adresse ein."),
|
||||||
password: z.string().min(8)
|
password: z.string().min(1, "Bitte gib dein Passwort ein.")
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoginForm = z.infer<typeof schema>;
|
type LoginForm = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
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() {
|
function LoginPanel() {
|
||||||
const router = useRouter();
|
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
const form = useForm<LoginForm>({
|
const form = useForm<LoginForm>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { email: "admin@schubamed.de", password: "" }
|
defaultValues: { email: "admin@schubamed.de", password: "" },
|
||||||
|
mode: "onSubmit"
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: LoginForm) {
|
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 result = await login(values.email, values.password);
|
||||||
auth.setToken(result.access_token);
|
const currentUser = (await auth.refreshUser()) ?? (result.user as User);
|
||||||
router.push("/dashboard");
|
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 (
|
return (
|
||||||
|
|
@ -38,7 +85,14 @@ function LoginPanel() {
|
||||||
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
|
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
|
||||||
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
|
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void form.handleSubmit(handleSubmit)(event);
|
||||||
|
}}
|
||||||
|
className="space-y-5"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-sm font-medium text-text">E-Mail</span>
|
<span className="text-sm font-medium text-text">E-Mail</span>
|
||||||
<input
|
<input
|
||||||
|
|
@ -46,6 +100,9 @@ function LoginPanel() {
|
||||||
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
|
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
|
||||||
{...form.register("email")}
|
{...form.register("email")}
|
||||||
/>
|
/>
|
||||||
|
{form.formState.errors.email && (
|
||||||
|
<p className="mt-2 text-sm text-danger">{form.formState.errors.email.message}</p>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-sm font-medium text-text">Passwort</span>
|
<span className="text-sm font-medium text-text">Passwort</span>
|
||||||
|
|
@ -54,13 +111,19 @@ function LoginPanel() {
|
||||||
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
|
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
|
||||||
{...form.register("password")}
|
{...form.register("password")}
|
||||||
/>
|
/>
|
||||||
|
{form.formState.errors.password && (
|
||||||
|
<p className="mt-2 text-sm text-danger">{form.formState.errors.password.message}</p>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
{toast && <div className="rounded-lg border border-danger/30 bg-white px-4 py-3 text-sm text-danger shadow-soft">{toast}</div>}
|
||||||
|
{success && <div className="rounded-lg border border-success/30 bg-white px-4 py-3 text-sm text-success shadow-soft">{success}</div>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft transition hover:bg-primary-dark"
|
disabled={loading}
|
||||||
|
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft transition hover:bg-primary-dark disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<LogIn className="h-5 w-5" />
|
{loading ? <span className="spinner" /> : <LogIn className="h-5 w-5" />}
|
||||||
Einloggen
|
<span>{loading ? "Anmeldung läuft…" : "Anmelden"}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
32
validation-suite/frontend/atlas/app/api/login/route.ts
Normal file
32
validation-suite/frontend/atlas/app/api/login/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
validation-suite/frontend/atlas/app/api/logout/route.ts
Normal file
13
validation-suite/frontend/atlas/app/api/logout/route.ts
Normal file
|
|
@ -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" });
|
||||||
|
}
|
||||||
18
validation-suite/frontend/atlas/app/api/me/route.ts
Normal file
18
validation-suite/frontend/atlas/app/api/me/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { apiGet, User } from "@/lib/api";
|
import { User } from "@/lib/api";
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
setToken: (value: string | null) => void;
|
setToken: (value: string | null) => void;
|
||||||
|
setUser: (value: User | null) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
refreshUser: () => void;
|
refreshUser: () => Promise<User | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
@ -18,42 +19,67 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [token, setTokenState] = useState<string | null>(null);
|
const [token, setTokenState] = useState<string | null>(null);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const tokenRef = useRef<string | null>(null);
|
||||||
|
const userRef = useRef<User | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = window.localStorage.getItem("atlas_token");
|
tokenRef.current = token;
|
||||||
setTokenState(stored);
|
|
||||||
if (!stored) return;
|
|
||||||
void apiGet<User>("/auth/me", stored).then(setUser).catch(() => setUser(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!token) {
|
|
||||||
setUser(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void apiGet<User>("/auth/me", token).then(setUser).catch(() => setUser(null));
|
|
||||||
}, [token]);
|
}, [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<AuthContextValue>(() => {
|
const value = useMemo<AuthContextValue>(() => {
|
||||||
const setToken = (next: string | null) => {
|
const setToken = (next: string | null) => {
|
||||||
setTokenState(next);
|
setTokenState(next);
|
||||||
if (next) {
|
|
||||||
window.localStorage.setItem("atlas_token", next);
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem("atlas_token");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
setToken,
|
setToken,
|
||||||
|
setUser,
|
||||||
logout: () => {
|
logout: () => {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
void fetch("/api/logout", { method: "POST", credentials: "include" });
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
},
|
},
|
||||||
user,
|
user,
|
||||||
refreshUser: () => {
|
refreshUser: async () => {
|
||||||
if (token) {
|
try {
|
||||||
void apiGet<User>("/auth/me", token).then(setUser).catch(() => setUser(null));
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export type Entity = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -121,21 +121,25 @@ export type Paginated<T> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
const response = await fetch(`/api/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password })
|
body: JSON.stringify({ email, password }),
|
||||||
|
credentials: "include"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
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<T>(path: string, token: string): Promise<T> {
|
export async function apiGet<T>(path: string, token: string): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
cache: "no-store",
|
||||||
cache: "no-store"
|
credentials: "include"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed: ${response.status}`);
|
throw new Error(`API request failed: ${response.status}`);
|
||||||
|
|
@ -146,8 +150,9 @@ export async function apiGet<T>(path: string, token: string): Promise<T> {
|
||||||
export async function apiSend<T>(path: string, token: string, method: "POST" | "PUT", body: unknown): Promise<T> {
|
export async function apiSend<T>(path: string, token: string, method: "POST" | "PUT", body: unknown): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body),
|
||||||
|
credentials: "include"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.text();
|
const detail = await response.text();
|
||||||
|
|
@ -159,7 +164,7 @@ export async function apiSend<T>(path: string, token: string, method: "POST" | "
|
||||||
export async function apiDelete(path: string, token: string): Promise<void> {
|
export async function apiDelete(path: string, token: string): Promise<void> {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: "include"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed: ${response.status}`);
|
throw new Error(`API request failed: ${response.status}`);
|
||||||
|
|
@ -169,7 +174,8 @@ export async function apiDelete(path: string, token: string): Promise<void> {
|
||||||
export async function apiFetch<T>(path: string, token: string, init?: RequestInit): Promise<T> {
|
export async function apiFetch<T>(path: string, token: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers ?? {}) }
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
credentials: "include"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
|
|
|
||||||
13
validation-suite/frontend/atlas/lib/auth.ts
Normal file
13
validation-suite/frontend/atlas/lib/auth.ts
Normal file
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
60
validation-suite/frontend/atlas/middleware.ts
Normal file
60
validation-suite/frontend/atlas/middleware.ts
Normal file
|
|
@ -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"]
|
||||||
|
};
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
|
@ -31,4 +32,3 @@
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
42
validation-suite/frontend/atlas/tests/auth-cookie.test.mjs
Normal file
42
validation-suite/frontend/atlas/tests/auth-cookie.test.mjs
Normal file
|
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue