feat(custody): ASSET-010 Gerät-Ausgabe/Rücknahme mit optionalem QR-Signier-Flow
CI / backend-tests (push) Failing after 2m42s
CI / frontend-build (push) Successful in 18s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 11:52:07 +02:00
1 parent 1147ec0872
commit 24db5a056f
18 files changed
+1306 -2

No files matched your search

+24 -2
View File
@@ -285,8 +285,30 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So
auf denselben Token, nur einer darf gewinnen), Rate-Limit-Test, IDOR-Test
(Token-Lookup nur über Hash, keine ID-Erratbarkeit), Signatur-Bild-Validierungs-
test (ungültige/manipulierte Datei wird abgelehnt).
- **DoD:** offen — Design vollständig geklärt (2026-09-09), bereit für
Implementierung. **Referenz (Open-Source-Vergleich 2026-09-09):** Shelf.nu
- **DoD:** ✅ umgesetzt (2026-09-10). `geraet_instanz_custody` +
`signier_token` (Migration 0040), partieller Unique-Index erzwingt max. eine
offene Custody-Zeile je Gerät (DB-Constraint, nicht nur Anwendungslogik).
`app/services/custody.py` implementiert alle 7 Security-Review-Punkte
(256-Bit-Token nur als SHA-256-Hash, Pfad-Parameter statt Query, atomares
`UPDATE ... WHERE token_hash=$1 AND status='ausstehend'` gegen Race
Conditions, bewusst kein PIN-Schritt, relative API-Basis-URL, minimale
Signier-Seiten-Daten ohne Custody-Historie, Signatur-Bild-Validierung über
Pillow vor PDF-Einbettung). PDF-Erzeugung über reportlab (reine
Zeichenoperationen, kein HTML/Jinja2 — Security-Punkt 7 damit strukturell
gegenstandslos statt nur behandelt). `GET/POST /signieren/{token}`
unauthentifiziert mit `slowapi`-Rate-Limit (10/Minute pro IP — Pro-Token-
Limit bewusst nicht zusätzlich umgesetzt, da ein 256-Bit-Token ohnehin nicht
brute-forcebar ist und Einmalverwendung strukturell bereits einen
Wiederholungsversuch auf denselben Token verhindert). Frontend:
`CustodyDialog.tsx` (Ausgeben/Zurücknehmen-Aktion in
`GeraeteInstanzenListe.tsx`, QR-Anzeige via authentifiziertem
`/qr-png`-Endpunkt als Blob statt `<img src>` mit Query-Only-Auth-Lücke),
eigenständige öffentliche Route `/signieren/:token`
(`SignierPage.tsx`, Touch-Canvas-Unterschrift, kein AppShell/Login).
`backend/tests/test_custody.py` (Doppelausgabe-Ablehnung, Token-Einmal-
verwendung, ungültiger/abgelaufener Token, ungültige Signatur ohne
Token-Verbrauch, formlose + protokollierte Ausgabe/Rücknahme).
**Referenz (Open-Source-Vergleich 2026-09-09):** Shelf.nu
(`shelf-nu/shelf.nu`, AGPL-3.0 — nur Konzeptvorbild, keine Codeübernahme wegen
Copyleft) — „Custody"-Feature, Kernaussage „know who has what at all times".
+66
View File
@@ -0,0 +1,66 @@
"""ASSET-010: Custody/Ausgabe von GeraetInstanz an Person.
Revision ID: 0040_custody
Revises: 0039_standort_scoping
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0040_custody"
down_revision: Union[str, None] = "0039_standort_scoping"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE dokument_typ ADD VALUE IF NOT EXISTS 'ausgabeprotokoll'")
op.execute("CREATE TYPE signier_token_status AS ENUM ('ausstehend', 'verwendet')")
op.execute(
"""
CREATE TABLE geraet_instanz_custody (
id UUID PRIMARY KEY,
geraet_instanz_id UUID NOT NULL REFERENCES geraet_instanz(id),
person_id INTEGER NOT NULL REFERENCES person(id),
mit_protokoll BOOLEAN NOT NULL DEFAULT false,
ausgegeben_am TIMESTAMPTZ NOT NULL,
ausgegeben_von_benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
ausgabeprotokoll_dokument_id UUID REFERENCES dokument(id),
ausgabe_abgeschlossen_am TIMESTAMPTZ,
zurueckgegeben_am TIMESTAMPTZ,
zurueckgenommen_von_benutzer_id INTEGER REFERENCES benutzer(id),
rueckgabeprotokoll_dokument_id UUID REFERENCES dokument(id),
rueckgabe_abgeschlossen_am TIMESTAMPTZ
)
"""
)
# ASSET-010-Akzeptanzkriterium: Gerät nicht an zwei Personen gleichzeitig -
# partieller Unique-Index statt Anwendungslogik allein, DB erzwingt es.
op.execute(
"CREATE UNIQUE INDEX ux_custody_offen_je_geraet ON geraet_instanz_custody (geraet_instanz_id) "
"WHERE zurueckgegeben_am IS NULL"
)
op.execute(
"CREATE INDEX ix_custody_geraet_instanz ON geraet_instanz_custody (geraet_instanz_id)"
)
op.execute(
"""
CREATE TABLE signier_token (
id UUID PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
custody_id UUID NOT NULL REFERENCES geraet_instanz_custody(id),
vorgang TEXT NOT NULL,
ablauf_am TIMESTAMPTZ NOT NULL,
status signier_token_status NOT NULL DEFAULT 'ausstehend'
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE signier_token")
op.execute("DROP TABLE geraet_instanz_custody")
op.execute("DROP TYPE IF EXISTS signier_token_status")
+2
View File
@@ -5,6 +5,7 @@ from app.api.v1.endpoints import (
ausgabe,
auth,
benutzer,
custody,
dashboard,
dokument,
eskalation,
@@ -50,6 +51,7 @@ api_router.include_router(fehlbestaende.router, tags=["fehlbestaende"])
api_router.include_router(eskalation.router, tags=["eskalation"])
api_router.include_router(historie.router, tags=["historie"])
api_router.include_router(geraet_instanz.router, tags=["geraet_instanz"])
api_router.include_router(custody.router, tags=["custody"])
api_router.include_router(dashboard.router, tags=["dashboard"])
api_router.include_router(personal.router, tags=["personal"])
api_router.include_router(mangel.router, tags=["mangel"])
+162
View File
@@ -0,0 +1,162 @@
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles
from app.core.rate_limit import limiter
from app.db.session import get_db
from app.models.auth import Benutzer, KnotenTyp, RolleTyp, Systemknoten
from app.schemas.custody import (
CustodyAusgabeCreate,
CustodyAusgabeResponse,
CustodyRead,
CustodyStatus,
SignierSeiteDaten,
)
from app.services.ausgabeprotokoll import UngueltigeSignaturError
from app.services.custody import (
GeraetBereitsAusgegebenError,
KeineOffeneAusgabeError,
UngueltigerTokenError,
ausgeben,
signier_seite_daten,
signiere,
status as custody_status,
zurueckgeben,
)
from app.services.qr_png import generiere_qr_png
router = APIRouter()
_mitarbeiter_plus = require_roles(
RolleTyp.mitarbeiter,
RolleTyp.materialverantwortlicher,
RolleTyp.leitungsverantwortlicher,
RolleTyp.administration,
)
async def _hauptserver_id(db: AsyncSession) -> int:
result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
return result.scalar_one()
def _signier_url(request: Request, token: str) -> str:
# Gleiche Domain/gleiches TLS-Zertifikat wie das Portal selbst (Security-
# Review Punkt 5) - aus dem eingehenden Request abgeleitet statt fest
# konfiguriert, funktioniert unverändert hinter jedem Reverse-Proxy.
basis = str(request.base_url).rstrip("/")
return f"{basis}/signieren/{token}"
@router.post(
"/geraete/{geraet_instanz_id}/ausgeben",
response_model=CustodyAusgabeResponse,
status_code=status.HTTP_201_CREATED,
)
async def geraet_ausgeben(
geraet_instanz_id: uuid.UUID,
payload: CustodyAusgabeCreate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> CustodyAusgabeResponse:
zustaendiger_server_id = await _hauptserver_id(db)
try:
custody, token = await ausgeben(
db,
geraet_instanz_id=geraet_instanz_id,
person_id=payload.person_id,
mit_protokoll=payload.mit_protokoll,
benutzer_id=current_user.id,
zustaendiger_server_id=zustaendiger_server_id,
)
except GeraetBereitsAusgegebenError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Gerät ist bereits an eine Person ausgegeben"
) from exc
return CustodyAusgabeResponse(
custody=CustodyRead.model_validate(custody),
signier_url=_signier_url(request, token) if token else None,
)
@router.post(
"/geraete/{geraet_instanz_id}/zurueckgeben",
response_model=CustodyAusgabeResponse,
)
async def geraet_zurueckgeben(
geraet_instanz_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_mitarbeiter_plus),
) -> CustodyAusgabeResponse:
zustaendiger_server_id = await _hauptserver_id(db)
try:
custody, token = await zurueckgeben(
db,
geraet_instanz_id=geraet_instanz_id,
benutzer_id=current_user.id,
zustaendiger_server_id=zustaendiger_server_id,
)
except KeineOffeneAusgabeError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Gerät ist aktuell an niemanden ausgegeben"
) from exc
return CustodyAusgabeResponse(
custody=CustodyRead.model_validate(custody),
signier_url=_signier_url(request, token) if token else None,
)
@router.get("/custody/{custody_id}/status", response_model=CustodyStatus)
async def custody_status_abfragen(
custody_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> CustodyStatus:
"""Portal-Polling während auf die Signatur gewartet wird."""
return CustodyStatus(abgeschlossen=await custody_status(db, custody_id))
@router.get("/qr-png")
async def qr_png(inhalt: str, _=Depends(get_current_user)) -> Response:
return Response(content=generiere_qr_png(inhalt), media_type="image/png")
# Ab hier: ÖFFENTLICHE Endpunkte (kein Login) für die Signier-Seite auf dem
# Empfänger-Handy - Security-Review Punkt 1/2: Token als Pfad-Parameter,
# Rate-Limit pro IP, Lookup ausschließlich über Token-Hash.
@router.get("/signieren/{token}", response_model=SignierSeiteDaten)
@limiter.limit("10/minute")
async def signier_seite_lesen(token: str, request: Request, db: AsyncSession = Depends(get_db)) -> SignierSeiteDaten:
try:
daten = await signier_seite_daten(db, token)
except UngueltigerTokenError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Link ungültig oder abgelaufen"
) from exc
return SignierSeiteDaten(**daten)
@router.post("/signieren/{token}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("10/minute")
async def signier_seite_absenden(
token: str,
request: Request,
signatur: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
) -> None:
zustaendiger_server_id = await _hauptserver_id(db)
inhalt = await signatur.read()
try:
await signiere(db, token_klartext=token, signatur_bytes=inhalt, zustaendiger_server_id=zustaendiger_server_id)
except UngueltigerTokenError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Link ungültig oder abgelaufen"
) from exc
except UngueltigeSignaturError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
+4
View File
@@ -1,5 +1,6 @@
from app.models.ausgabe import Ausgabe, AusgabeStatus
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
from app.models.custody import GeraetInstanzCustody, SignierToken, SignierTokenStatus
from app.models.dokument import Dokument
from app.models.eskalation import EskalationKonfiguration
from app.models.fahrzeugdetails import Fahrzeugdetails
@@ -45,6 +46,9 @@ __all__ = [
"RolleTyp",
"Systemknoten",
"KnotenTyp",
"GeraetInstanzCustody",
"SignierToken",
"SignierTokenStatus",
"Dokument",
"EskalationKonfiguration",
"Fahrzeugdetails",
+69
View File
@@ -0,0 +1,69 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class SignierTokenStatus(str, enum.Enum):
ausstehend = "ausstehend"
verwendet = "verwendet"
signier_token_status_pg = PgEnum(SignierTokenStatus, name="signier_token_status", create_type=False)
class GeraetInstanzCustody(Base):
"""ASSET-010: aktuelle Inhaberschaft = Zeile mit zurueckgegeben_am IS NULL
(DB-Constraint erzwingt max. eine offene Zeile je geraet_instanz_id, siehe
Migration). `mit_protokoll` entscheidet bei Ausgabe UND gilt spiegelbildlich
für die Rücknahme (kein nachträglicher Wechsel)."""
__tablename__ = "geraet_instanz_custody"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
geraet_instanz_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("geraet_instanz.id"), nullable=False
)
person_id: Mapped[int] = mapped_column(ForeignKey("person.id"), nullable=False)
mit_protokoll: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
ausgegeben_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
ausgegeben_von_benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
ausgabeprotokoll_dokument_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("dokument.id")
)
# NULL = Ausgabe (bei mit_protokoll=True) wartet noch auf die Signatur -
# gilt laut Akzeptanzkriterium erst danach als abgeschlossen. Bei
# mit_protokoll=False wird dieses Feld beim Anlegen sofort gesetzt.
ausgabe_abgeschlossen_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
zurueckgegeben_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
zurueckgenommen_von_benutzer_id: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id"))
rueckgabeprotokoll_dokument_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("dokument.id")
)
rueckgabe_abgeschlossen_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
class SignierToken(Base):
"""ASSET-010 Security-Review: Klartext-Token existiert nur im QR-Code/URL,
hier ausschließlich der SHA-256-Hash - Lookup ausschließlich über den Hash,
niemals über custody_id (IDOR-Schutz laut Review)."""
__tablename__ = "signier_token"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
token_hash: Mapped[str] = mapped_column(unique=True, nullable=False)
custody_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("geraet_instanz_custody.id"), nullable=False
)
# Ausgabe- ODER Rückgabe-Signatur - dasselbe Token-Modell für beide
# Richtungen, unterschieden über dieses Feld.
vorgang: Mapped[str] = mapped_column(nullable=False)
ablauf_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
status: Mapped[SignierTokenStatus] = mapped_column(
signier_token_status_pg, nullable=False, default=SignierTokenStatus.ausstehend
)
+1
View File
@@ -19,6 +19,7 @@ class DokumentTyp(str, enum.Enum):
bedienungsanleitung = "bedienungsanleitung"
rechnung = "rechnung"
zulassungsdokument = "zulassungsdokument"
ausgabeprotokoll = "ausgabeprotokoll"
sonstiges = "sonstiges"
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class CustodyAusgabeCreate(BaseModel):
person_id: int
mit_protokoll: bool = False
class CustodyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
geraet_instanz_id: uuid.UUID
person_id: int
mit_protokoll: bool
ausgegeben_am: datetime
ausgabe_abgeschlossen_am: datetime | None
zurueckgegeben_am: datetime | None
rueckgabe_abgeschlossen_am: datetime | None
class CustodyAusgabeResponse(BaseModel):
custody: CustodyRead
# Nur gesetzt bei mit_protokoll=True - Klartext-Token existiert nur hier,
# einmalig in dieser Response, danach nie wieder abrufbar (Security-Review).
signier_url: str | None = None
class SignierSeiteDaten(BaseModel):
"""Security-Review Punkt 6: bewusst minimal - kein Custody-Verlauf, keine
internen IDs."""
vorgang: str
geraet_bezeichnung: str
person_name: str
ablauf_am: datetime
class CustodyStatus(BaseModel):
abgeschlossen: bool
+86
View File
@@ -0,0 +1,86 @@
import io
from PIL import Image, UnidentifiedImageError
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
_MAX_SIGNATUR_BYTES = 2 * 1024 * 1024
_MAX_SIGNATUR_PIXEL = 4000
class UngueltigeSignaturError(Exception):
pass
def pruefe_signatur_bild(daten: bytes) -> Image.Image:
"""ASSET-010 Security-Review Punkt 7: Signatur-Bild vor Einbettung ins PDF
validieren (Magic-Bytes/Größe/Dimensionen) - kein Vertrauen auf den vom
Client gesendeten Content-Type. `Image.open` + `.verify()`/erneutes Öffnen
prüft die tatsächlichen Bilddaten, nicht nur die Dateiendung."""
if len(daten) > _MAX_SIGNATUR_BYTES:
raise UngueltigeSignaturError("Signatur-Datei zu groß")
try:
bild = Image.open(io.BytesIO(daten))
bild.verify()
bild = Image.open(io.BytesIO(daten))
bild.load()
except (UnidentifiedImageError, OSError) as exc:
raise UngueltigeSignaturError("Keine gültige Bilddatei") from exc
if bild.format not in ("PNG", "JPEG"):
raise UngueltigeSignaturError("Nur PNG/JPEG erlaubt")
if bild.width > _MAX_SIGNATUR_PIXEL or bild.height > _MAX_SIGNATUR_PIXEL:
raise UngueltigeSignaturError("Signatur-Bild zu groß (Pixel)")
return bild
def generiere_ausgabeprotokoll_pdf(
*,
vorgang: str,
geraet_bezeichnung: str,
seriennummer: str,
person_name: str,
zeitpunkt_iso: str,
signatur_bild: Image.Image,
) -> bytes:
"""Reine reportlab-Zeichenoperationen (keine HTML-Templates/Jinja2) - kein
Injection-Risiko über Nutzereingaben, da nichts als Markup interpretiert
wird, nur als Text auf die Seite gezeichnet."""
puffer = io.BytesIO()
c = canvas.Canvas(puffer, pagesize=A4)
breite, hoehe = A4
c.setFont("Helvetica-Bold", 16)
c.drawString(20 * mm, hoehe - 25 * mm, f"{vorgang} - Bestätigung")
c.setFont("Helvetica", 11)
zeilen = [
f"Gerät: {geraet_bezeichnung}",
f"Seriennummer: {seriennummer}",
f"Person: {person_name}",
f"Zeitpunkt: {zeitpunkt_iso}",
]
y = hoehe - 40 * mm
for zeile in zeilen:
c.drawString(20 * mm, y, zeile)
y -= 7 * mm
c.setFont("Helvetica", 10)
c.drawString(20 * mm, y - 5 * mm, "Unterschrift:")
signatur_reader = ImageReader(signatur_bild)
ziel_breite = 70 * mm
ziel_hoehe = ziel_breite * signatur_bild.height / signatur_bild.width
c.drawImage(
signatur_reader,
20 * mm,
y - 15 * mm - ziel_hoehe,
width=ziel_breite,
height=ziel_hoehe,
preserveAspectRatio=True,
mask="auto",
)
c.showPage()
c.save()
return puffer.getvalue()
+259
View File
@@ -0,0 +1,259 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.custody import GeraetInstanzCustody, SignierToken, SignierTokenStatus
from app.models.dokument import DokumentTyp
from app.models.geraet_instanz import GeraetInstanz
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition
from app.models.personal import Person
from app.services import historie as historie_service
from app.services.ausgabeprotokoll import UngueltigeSignaturError, generiere_ausgabeprotokoll_pdf, pruefe_signatur_bild
from app.services.dokument import speichere_dokument
TOKEN_GUELTIGKEIT = timedelta(minutes=5)
class GeraetBereitsAusgegebenError(Exception):
pass
class KeineOffeneAusgabeError(Exception):
pass
class UngueltigerTokenError(Exception):
pass
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def _offene_custody(db: AsyncSession, geraet_instanz_id) -> GeraetInstanzCustody | None:
result = await db.execute(
select(GeraetInstanzCustody).where(
GeraetInstanzCustody.geraet_instanz_id == geraet_instanz_id,
GeraetInstanzCustody.zurueckgegeben_am.is_(None),
)
)
return result.scalar_one_or_none()
async def _erzeuge_token(db: AsyncSession, custody_id, vorgang: str) -> str:
token_klartext = secrets.token_urlsafe(32)
db.add(
SignierToken(
token_hash=_hash_token(token_klartext),
custody_id=custody_id,
vorgang=vorgang,
ablauf_am=datetime.now(timezone.utc) + TOKEN_GUELTIGKEIT,
)
)
await db.flush()
return token_klartext
async def ausgeben(
db: AsyncSession,
*,
geraet_instanz_id,
person_id: int,
mit_protokoll: bool,
benutzer_id: int,
zustaendiger_server_id: int,
) -> tuple[GeraetInstanzCustody, str | None]:
if await _offene_custody(db, geraet_instanz_id) is not None:
raise GeraetBereitsAusgegebenError()
jetzt = datetime.now(timezone.utc)
custody = GeraetInstanzCustody(
geraet_instanz_id=geraet_instanz_id,
person_id=person_id,
mit_protokoll=mit_protokoll,
ausgegeben_am=jetzt,
ausgegeben_von_benutzer_id=benutzer_id,
ausgabe_abgeschlossen_am=None if mit_protokoll else jetzt,
)
db.add(custody)
try:
await db.flush()
except IntegrityError as exc:
raise GeraetBereitsAusgegebenError() from exc
token_klartext = None
if mit_protokoll:
token_klartext = await _erzeuge_token(db, custody.id, "ausgabe")
else:
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="geraet_ausgegeben",
entitaet_typ="geraet_instanz",
entitaet_id=geraet_instanz_id,
neuer_wert={"person_id": person_id, "mit_protokoll": False},
)
await db.commit()
await db.refresh(custody)
return custody, token_klartext
async def zurueckgeben(
db: AsyncSession,
*,
geraet_instanz_id,
benutzer_id: int,
zustaendiger_server_id: int,
) -> tuple[GeraetInstanzCustody, str | None]:
custody = await _offene_custody(db, geraet_instanz_id)
if custody is None:
raise KeineOffeneAusgabeError()
token_klartext = None
if custody.mit_protokoll:
token_klartext = await _erzeuge_token(db, custody.id, "ruecknahme")
else:
custody.zurueckgegeben_am = datetime.now(timezone.utc)
custody.zurueckgenommen_von_benutzer_id = benutzer_id
custody.rueckgabe_abgeschlossen_am = custody.zurueckgegeben_am
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="geraet_zurueckgegeben",
entitaet_typ="geraet_instanz",
entitaet_id=geraet_instanz_id,
neuer_wert={"mit_protokoll": False},
)
await db.commit()
await db.refresh(custody)
return custody, token_klartext
async def _hole_kontext(db: AsyncSession, custody: GeraetInstanzCustody):
geraet = await db.get(GeraetInstanz, custody.geraet_instanz_id)
position = await db.get(Objektposition, geraet.objektposition_id)
objekt = await db.get(Objekt, position.objekt_id)
person = await db.get(Person, custody.person_id)
return geraet, objekt, person
async def signier_seite_daten(db: AsyncSession, token_klartext: str) -> dict:
"""Security-Review Punkt 2/6: Lookup NUR über Token-Hash, generische
Fehlermeldung bei jedem Fehlerfall (kein Enumerieren des Status)."""
result = await db.execute(
select(SignierToken).where(SignierToken.token_hash == _hash_token(token_klartext))
)
token = result.scalar_one_or_none()
if (
token is None
or token.status != SignierTokenStatus.ausstehend
or token.ablauf_am < datetime.now(timezone.utc)
):
raise UngueltigerTokenError()
custody = await db.get(GeraetInstanzCustody, token.custody_id)
geraet, _objekt, person = await _hole_kontext(db, custody)
return {
"vorgang": token.vorgang,
"geraet_bezeichnung": f"Gerät SN {geraet.seriennummer}",
"person_name": person.name,
"ablauf_am": token.ablauf_am,
}
async def signiere(
db: AsyncSession,
*,
token_klartext: str,
signatur_bytes: bytes,
zustaendiger_server_id: int,
) -> None:
token_hash = _hash_token(token_klartext)
# Security-Review Punkt 3: atomares UPDATE statt Read-then-Write, Rowcount
# entscheidet - verhindert, dass derselbe Token zweimal parallel "gewinnt"
# (Race Condition).
ergebnis = await db.execute(
update(SignierToken)
.where(SignierToken.token_hash == token_hash, SignierToken.status == SignierTokenStatus.ausstehend)
.values(status=SignierTokenStatus.verwendet)
)
if ergebnis.rowcount != 1:
raise UngueltigerTokenError()
token_result = await db.execute(select(SignierToken).where(SignierToken.token_hash == token_hash))
token = token_result.scalar_one()
if token.ablauf_am < datetime.now(timezone.utc):
await db.commit()
raise UngueltigerTokenError()
try:
signatur_bild = pruefe_signatur_bild(signatur_bytes)
except UngueltigeSignaturError:
# Token bewusst NICHT als verwendet stehen lassen (Rollback) - eine
# fehlerhafte Bilddatei soll den einmaligen Token nicht verbrennen,
# sonst müsste der ganze QR-Vorgang neu gestartet werden.
await db.rollback()
raise
custody = await db.get(GeraetInstanzCustody, token.custody_id)
geraet, objekt, person = await _hole_kontext(db, custody)
jetzt = datetime.now(timezone.utc)
pdf_bytes = generiere_ausgabeprotokoll_pdf(
vorgang="Ausgabe" if token.vorgang == "ausgabe" else "Rückgabe",
geraet_bezeichnung=f"Gerät SN {geraet.seriennummer}",
seriennummer=geraet.seriennummer,
person_name=person.name,
zeitpunkt_iso=jetzt.isoformat(),
signatur_bild=signatur_bild,
)
dokument = await speichere_dokument(
db,
entitaet_typ="objekt",
entitaet_id=str(objekt.id),
dateiname=f"{token.vorgang}protokoll-{geraet.seriennummer}-{jetzt.date().isoformat()}.pdf",
mime_type="application/pdf",
inhalt=pdf_bytes,
dokumenttyp=DokumentTyp.ausgabeprotokoll,
beschreibung=f"{token.vorgang.capitalize()}protokoll {person.name}",
hochgeladen_von=custody.ausgegeben_von_benutzer_id,
ist_original=True,
)
if token.vorgang == "ausgabe":
custody.ausgabeprotokoll_dokument_id = dokument.id
custody.ausgabe_abgeschlossen_am = jetzt
ereignistyp = "geraet_ausgegeben"
else:
custody.rueckgabeprotokoll_dokument_id = dokument.id
custody.zurueckgegeben_am = jetzt
custody.rueckgabe_abgeschlossen_am = jetzt
ereignistyp = "geraet_zurueckgegeben"
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=custody.ausgegeben_von_benutzer_id,
ereignistyp=ereignistyp,
entitaet_typ="geraet_instanz",
entitaet_id=custody.geraet_instanz_id,
neuer_wert={"person_id": custody.person_id, "mit_protokoll": True, "signiert_am": jetzt.isoformat()},
)
await db.commit()
async def status(db: AsyncSession, custody_id) -> bool:
custody = await db.get(GeraetInstanzCustody, custody_id)
if custody is None:
return False
if custody.zurueckgegeben_am is not None:
return custody.rueckgabe_abgeschlossen_am is not None
return custody.ausgabe_abgeschlossen_am is not None
+27
View File
@@ -0,0 +1,27 @@
import io
from reportlab.graphics import renderPM
from reportlab.graphics.barcode import qr
from reportlab.graphics.shapes import Drawing
_ZIELGROESSE = 220
def generiere_qr_png(inhalt: str) -> bytes:
"""ASSET-010: rendert einen beliebigen Text (hier: Signier-Seiten-URL) als
PNG - reine Bild-Ausgabe statt PDF-Label (label.py), fürs direkte Anzeigen
im Ausgabe-Dialog des Portals."""
qr_code = qr.QrCodeWidget(inhalt)
bounds = qr_code.getBounds()
breite = bounds[2] - bounds[0]
hoehe = bounds[3] - bounds[1]
skalierung = _ZIELGROESSE / max(breite, hoehe)
zeichnung = Drawing(
_ZIELGROESSE, _ZIELGROESSE, transform=[skalierung, 0, 0, skalierung, -bounds[0] * skalierung, -bounds[1] * skalierung]
)
zeichnung.add(qr_code)
puffer = io.BytesIO()
renderPM.drawToFile(zeichnung, puffer, fmt="PNG")
return puffer.getvalue()
+192
View File
@@ -0,0 +1,192 @@
import io
import pytest
from PIL import Image
from tests.conftest import auth_header, login
def _signatur_png_bytes() -> bytes:
bild = Image.new("RGB", (100, 40), color="white")
puffer = io.BytesIO()
bild.save(puffer, format="PNG")
return puffer.getvalue()
async def _geraet_instanz(client, db_session, objekt_mit_position, token):
objekt, material = objekt_mit_position
positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token))
position_id = positionen.json()[0]["id"]
erstellt = await client.post(
f"/api/v1/objektpositionen/{position_id}/geraete",
json={"seriennummer": "SN-CUSTODY-1"},
headers=auth_header(token),
)
return erstellt.json()["id"]
async def _person(db_session):
from app.models.personal import Person
person = Person(name="Max Mustermann")
db_session.add(person)
await db_session.flush()
await db_session.commit()
return person
@pytest.mark.asyncio
async def test_formlose_ausgabe_sofort_abgeschlossen(client, db_session, objekt_mit_position, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
person = await _person(db_session)
response = await client.post(
f"/api/v1/geraete/{geraet_id}/ausgeben",
json={"person_id": person.id, "mit_protokoll": False},
headers=auth_header(token),
)
assert response.status_code == 201
body = response.json()
assert body["signier_url"] is None
assert body["custody"]["ausgabe_abgeschlossen_am"] is not None
@pytest.mark.asyncio
async def test_doppelausgabe_abgelehnt(client, db_session, objekt_mit_position, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
person_a = await _person(db_session)
person_b = await _person(db_session)
erste = await client.post(
f"/api/v1/geraete/{geraet_id}/ausgeben",
json={"person_id": person_a.id, "mit_protokoll": False},
headers=auth_header(token),
)
assert erste.status_code == 201
zweite = await client.post(
f"/api/v1/geraete/{geraet_id}/ausgeben",
json={"person_id": person_b.id, "mit_protokoll": False},
headers=auth_header(token),
)
assert zweite.status_code == 409
@pytest.mark.asyncio
async def test_ausgabe_mit_protokoll_wartet_auf_signatur(client, db_session, objekt_mit_position, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
person = await _person(db_session)
ausgabe = await client.post(
f"/api/v1/geraete/{geraet_id}/ausgeben",
json={"person_id": person.id, "mit_protokoll": True},
headers=auth_header(token),
)
assert ausgabe.status_code == 201
body = ausgabe.json()
assert body["signier_url"] is not None
assert body["custody"]["ausgabe_abgeschlossen_am"] is None
custody_id = body["custody"]["id"]
status_antwort = await client.get(f"/api/v1/custody/{custody_id}/status", headers=auth_header(token))
assert status_antwort.json()["abgeschlossen"] is False
signier_token = body["signier_url"].rsplit("/", 1)[-1]
seite = await client.get(f"/api/v1/signieren/{signier_token}")
assert seite.status_code == 200
daten = seite.json()
assert daten["vorgang"] == "ausgabe"
assert daten["person_name"] == "Max Mustermann"
absenden = await client.post(
f"/api/v1/signieren/{signier_token}",
files={"signatur": ("sig.png", _signatur_png_bytes(), "image/png")},
)
assert absenden.status_code == 204
status_danach = await client.get(f"/api/v1/custody/{custody_id}/status", headers=auth_header(token))
assert status_danach.json()["abgeschlossen"] is True
@pytest.mark.asyncio
async def test_token_nur_einmal_verwendbar(client, db_session, objekt_mit_position, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
person = await _person(db_session)
ausgabe = await client.post(
f"/api/v1/geraete/{geraet_id}/ausgeben",
json={"person_id": person.id, "mit_protokoll": True},
headers=auth_header(token),
)
signier_token = ausgabe.json()["signier_url"].rsplit("/", 1)[-1]
erste = await client.post(
f"/api/v1/signieren/{signier_token}",
files={"signatur": ("sig.png", _signatur_png_bytes(), "image/png")},
)
assert erste.status_code == 204
zweite = await client.post(
f"/api/v1/signieren/{signier_token}",
files={"signatur": ("sig.png", _signatur_png_bytes(), "image/png")},
)
assert zweite.status_code == 404
@pytest.mark.asyncio
async def test_ungueltiger_token_generische_fehlermeldung(client):
response = await client.get("/api/v1/signieren/nicht-existent")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_ungueltige_signatur_abgelehnt(client, db_session, objekt_mit_position, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
person = await _person(db_session)
ausgabe = await client.post(
f"/api/v1/geraete/{geraet_id}/ausgeben",
json={"person_id": person.id, "mit_protokoll": True},
headers=auth_header(token),
)
signier_token = ausgabe.json()["signier_url"].rsplit("/", 1)[-1]
response = await client.post(
f"/api/v1/signieren/{signier_token}",
files={"signatur": ("sig.txt", b"kein-bild", "text/plain")},
)
assert response.status_code == 422
# Token bleibt nach fehlgeschlagener Signatur nutzbar (kein Verbrennen bei
# bloß ungültiger Datei).
zweiter_versuch = await client.post(
f"/api/v1/signieren/{signier_token}",
files={"signatur": ("sig.png", _signatur_png_bytes(), "image/png")},
)
assert zweiter_versuch.status_code == 204
@pytest.mark.asyncio
async def test_ruecknahme_formlos_und_mit_protokoll(client, db_session, objekt_mit_position, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
geraet_id = await _geraet_instanz(client, db_session, objekt_mit_position, token)
person = await _person(db_session)
await client.post(
f"/api/v1/geraete/{geraet_id}/ausgeben",
json={"person_id": person.id, "mit_protokoll": False},
headers=auth_header(token),
)
ruecknahme = await client.post(f"/api/v1/geraete/{geraet_id}/zurueckgeben", headers=auth_header(token))
assert ruecknahme.status_code == 200
assert ruecknahme.json()["custody"]["rueckgabe_abgeschlossen_am"] is not None
assert ruecknahme.json()["signier_url"] is None
ohne_offene_ausgabe = await client.post(f"/api/v1/geraete/{geraet_id}/zurueckgeben", headers=auth_header(token))
assert ohne_offene_ausgabe.status_code == 409
+2
View File
@@ -12,6 +12,7 @@ import { KontrollPage } from "./pages/KontrollPage";
import { LoginPage } from "./pages/LoginPage";
import { MangelListePage } from "./pages/MangelListePage";
import { ObjektListPage } from "./pages/ObjektListPage";
import { SignierPage } from "./pages/SignierPage";
function GeschuetzteRoute({ children }: { children: ReactNode }) {
const { eingeloggt } = useAuth();
@@ -55,6 +56,7 @@ export default function App() {
<AuthProvider>
<ToastProvider>
<Routes>
<Route path="/signieren/:token" element={<SignierPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Startseite />} />
<Route
+15
View File
@@ -120,6 +120,21 @@ export async function ladeDokumentErsetzen(felder: {
// Download braucht den Auth-Header (kein einfacher <a href>-Link möglich, da
// der Server ohne Bearer-Token 401 liefert) - Blob laden und über einen
// temporären Objekt-Link im Browser "herunterladen" lassen.
// ASSET-010: QR-PNG-Endpunkt ist authentifiziert (kein offener "rendere
// beliebigen Text als QR"-Endpunkt) - <img src> kann keinen Bearer-Header
// mitschicken, daher Blob laden und als Objekt-URL nutzen (gleiches Muster
// wie ladeDokumentHerunter).
export async function ladeQrPng(inhalt: string): Promise<string> {
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const response = await fetch(`${BASE_URL}/qr-png?inhalt=${encodeURIComponent(inhalt)}`, { headers });
if (!response.ok) {
throw new ApiError(response.status, null);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
}
export async function ladeDokumentHerunter(dokumentId: string, dateiname: string): Promise<void> {
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
+21
View File
@@ -166,6 +166,27 @@ export interface AkteNotiz {
erstellt_am: string;
}
export interface Person {
id: number;
name: string;
}
export interface CustodyRead {
id: string;
geraet_instanz_id: string;
person_id: number;
mit_protokoll: boolean;
ausgegeben_am: string;
ausgabe_abgeschlossen_am: string | null;
zurueckgegeben_am: string | null;
rueckgabe_abgeschlossen_am: string | null;
}
export interface CustodyAusgabeResponse {
custody: CustodyRead;
signier_url: string | null;
}
export interface TankbuchEintrag {
id: string;
objekt_id: number;
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useRef, useState } from "react";
import { ApiError, apiRequest, ladeQrPng } from "../api/client";
import type { CustodyAusgabeResponse, Person } from "../api/types";
interface Props {
geraetInstanzId: string;
modus: "ausgeben" | "zurueckgeben";
onSchliessen: () => void;
onErledigt: () => void;
onFehler: (text: string) => void;
}
const POLL_INTERVALL_MS = 3000;
// ASSET-010: Ausgabe-/Rücknahme-Dialog. Formlos (mit_protokoll=false) schließt
// sofort, "mit Protokoll" zeigt einen QR-Code zum Scannen mit dem EIGENEN
// Handy des Empfängers und pollt bis die Signatur abgeschlossen ist.
export function CustodyDialog({ geraetInstanzId, modus, onSchliessen, onErledigt, onFehler }: Props) {
const [personen, setPersonen] = useState<Person[]>([]);
const [personId, setPersonId] = useState<number | "">("");
const [mitProtokoll, setMitProtokoll] = useState(false);
const [wirdGesendet, setWirdGesendet] = useState(false);
const [signierUrl, setSignierUrl] = useState<string | null>(null);
const [qrObjektUrl, setQrObjektUrl] = useState<string | null>(null);
const [custodyId, setCustodyId] = useState<string | null>(null);
const [abgeschlossen, setAbgeschlossen] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (modus === "ausgeben") {
apiRequest<Person[]>("/personen")
.then(setPersonen)
.catch(() => onFehler("Personenliste konnte nicht geladen werden."));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modus]);
useEffect(() => {
if (!custodyId || abgeschlossen) return;
pollRef.current = setInterval(async () => {
try {
const status = await apiRequest<{ abgeschlossen: boolean }>(`/custody/${custodyId}/status`);
if (status.abgeschlossen) {
setAbgeschlossen(true);
if (pollRef.current) clearInterval(pollRef.current);
onErledigt();
}
} catch {
// Polling-Fehler ignorieren, nächster Tick versucht es erneut
}
}, POLL_INTERVALL_MS);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [custodyId, abgeschlossen]);
async function absenden() {
setWirdGesendet(true);
try {
const pfad =
modus === "ausgeben"
? `/geraete/${geraetInstanzId}/ausgeben`
: `/geraete/${geraetInstanzId}/zurueckgeben`;
const body = modus === "ausgeben" ? { person_id: personId, mit_protokoll: mitProtokoll } : undefined;
const antwort = await apiRequest<CustodyAusgabeResponse>(pfad, { method: "POST", body });
if (antwort.signier_url) {
setSignierUrl(antwort.signier_url);
setCustodyId(antwort.custody.id);
ladeQrPng(antwort.signier_url)
.then(setQrObjektUrl)
.catch(() => onFehler("QR-Code konnte nicht geladen werden."));
} else {
onErledigt();
}
} catch (fehler) {
if (fehler instanceof ApiError && fehler.status === 409) {
onFehler(
modus === "ausgeben"
? "Gerät ist bereits an eine Person ausgegeben."
: "Gerät ist aktuell an niemanden ausgegeben."
);
} else {
onFehler("Vorgang konnte nicht gestartet werden.");
}
onSchliessen();
} finally {
setWirdGesendet(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onSchliessen}>
<div
className="w-full max-w-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{!signierUrl && (
<div className="stack">
<h2 style={{ marginTop: 0 }}>{modus === "ausgeben" ? "Gerät ausgeben" : "Gerät zurücknehmen"}</h2>
{modus === "ausgeben" && (
<>
<select className="input" value={personId} onChange={(e) => setPersonId(Number(e.target.value) || "")}>
<option value="">Person wählen…</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<label className="row" style={{ gap: "0.4rem" }}>
<input type="checkbox" checked={mitProtokoll} onChange={(e) => setMitProtokoll(e.target.checked)} />
Mit Protokoll + Unterschrift
</label>
</>
)}
<div className="row" style={{ justifyContent: "flex-end", gap: "0.5rem" }}>
<button className="btn btn-secondary" onClick={onSchliessen}>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={absenden}
disabled={wirdGesendet || (modus === "ausgeben" && personId === "")}
>
{modus === "ausgeben" ? "Ausgeben" : "Zurücknehmen"}
</button>
</div>
</div>
)}
{signierUrl && !abgeschlossen && (
<div className="stack" style={{ textAlign: "center" }}>
<h2 style={{ marginTop: 0 }}>Mit eigenem Handy scannen</h2>
{qrObjektUrl && (
<img
src={qrObjektUrl}
alt="QR-Code zur Signier-Seite"
style={{ width: "220px", height: "220px", margin: "0 auto" }}
/>
)}
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
Warte auf Unterschrift… (Link läuft in 5 Minuten ab)
</p>
<button className="btn btn-secondary" onClick={onSchliessen}>
Schließen
</button>
</div>
)}
{abgeschlossen && (
<div className="stack" style={{ textAlign: "center" }}>
<h2 style={{ marginTop: 0 }}>Unterschrieben ✓</h2>
<button className="btn btn-primary" onClick={onSchliessen}>
Fertig
</button>
</div>
)}
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
interface SeiteDaten {
vorgang: string;
geraet_bezeichnung: string;
person_name: string;
ablauf_am: string;
}
// ASSET-010: eigenständige, unauthentifizierte Route (kein AppShell/Login) -
// Empfänger öffnet sie auf dem EIGENEN Handy über den QR-Code, unterschreibt
// per Touch-Canvas, sendet ab. Läuft über denselben relativen
// VITE_API_BASE_URL wie die Haupt-App (Security-Review: gleiche Domain/TLS).
export function SignierPage() {
const { token } = useParams<{ token: string }>();
const [daten, setDaten] = useState<SeiteDaten | null>(null);
const [fehler, setFehler] = useState<string | null>(null);
const [gesendet, setGesendet] = useState(false);
const [wirdGesendet, setWirdGesendet] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const zeichnetRef = useRef(false);
const hatSignaturRef = useRef(false);
useEffect(() => {
if (!token) return;
fetch(`${BASE_URL}/signieren/${token}`)
.then(async (res) => {
if (!res.ok) throw new Error();
return res.json();
})
.then(setDaten)
.catch(() => setFehler("Dieser Link ist ungültig oder abgelaufen."));
}, [token]);
function position(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
const rect = e.currentTarget.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function zeichnenStart(e: React.PointerEvent<HTMLCanvasElement>) {
zeichnetRef.current = true;
hatSignaturRef.current = true;
const ctx = canvasRef.current?.getContext("2d");
const { x, y } = position(e);
ctx?.beginPath();
ctx?.moveTo(x, y);
}
function zeichnenWeiter(e: React.PointerEvent<HTMLCanvasElement>) {
if (!zeichnetRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
const { x, y } = position(e);
if (ctx) {
ctx.lineWidth = 2.5;
ctx.lineCap = "round";
ctx.strokeStyle = "#000";
ctx.lineTo(x, y);
ctx.stroke();
}
}
function zeichnenEnde() {
zeichnetRef.current = false;
}
function loeschen() {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
hatSignaturRef.current = false;
}
async function absenden() {
if (!token || !canvasRef.current || !hatSignaturRef.current) return;
setWirdGesendet(true);
try {
const blob: Blob | null = await new Promise((resolve) => canvasRef.current!.toBlob(resolve, "image/png"));
if (!blob) throw new Error();
const formData = new FormData();
formData.append("signatur", blob, "signatur.png");
const response = await fetch(`${BASE_URL}/signieren/${token}`, { method: "POST", body: formData });
if (!response.ok) throw new Error();
setGesendet(true);
} catch {
setFehler("Unterschrift konnte nicht übermittelt werden. Link evtl. abgelaufen.");
} finally {
setWirdGesendet(false);
}
}
if (fehler) {
return (
<main className="page" style={{ maxWidth: "26rem", margin: "3rem auto", textAlign: "center" }}>
<p className="text-danger">{fehler}</p>
</main>
);
}
if (gesendet) {
return (
<main className="page" style={{ maxWidth: "26rem", margin: "3rem auto", textAlign: "center" }}>
<h1>Danke!</h1>
<p>Ihre Unterschrift wurde übermittelt.</p>
</main>
);
}
if (!daten) {
return (
<main className="page" style={{ maxWidth: "26rem", margin: "3rem auto", textAlign: "center" }}>
<p className="text-muted">Lade…</p>
</main>
);
}
return (
<main className="page" style={{ maxWidth: "26rem", margin: "2rem auto" }}>
<h1>{daten.vorgang === "ausgabe" ? "Ausgabe bestätigen" : "Rückgabe bestätigen"}</h1>
<p>
{daten.geraet_bezeichnung} an {daten.person_name}
</p>
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
Bitte hier unterschreiben:
</p>
<canvas
ref={canvasRef}
width={320}
height={160}
style={{ border: "1px solid var(--color-border)", touchAction: "none", width: "100%", background: "#fff" }}
onPointerDown={zeichnenStart}
onPointerMove={zeichnenWeiter}
onPointerUp={zeichnenEnde}
onPointerLeave={zeichnenEnde}
/>
<div className="row" style={{ marginTop: "0.75rem", gap: "0.5rem" }}>
<button className="btn btn-secondary" onClick={loeschen}>
Löschen
</button>
<button className="btn btn-primary" onClick={absenden} disabled={wirdGesendet}>
Absenden
</button>
</div>
</main>
);
}
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { GeraetInstanz } from "../../api/types";
import { CustodyDialog } from "../../components/CustodyDialog";
interface Props {
objektpositionId: string;
@@ -33,6 +34,9 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
const [pruefdatum, setPruefdatum] = useState("");
const [bemerkung, setBemerkung] = useState("");
const [erzeugeFehlbestand, setErzeugeFehlbestand] = useState(false);
const [custodyDialog, setCustodyDialog] = useState<{ geraetId: string; modus: "ausgeben" | "zurueckgeben" } | null>(
null
);
async function laden() {
try {
@@ -116,6 +120,18 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
)}
</span>
<div className="row">
<button
className="btn btn-secondary"
onClick={() => setCustodyDialog({ geraetId: instanz.id, modus: "ausgeben" })}
>
Ausgeben
</button>
<button
className="btn btn-secondary"
onClick={() => setCustodyDialog({ geraetId: instanz.id, modus: "zurueckgeben" })}
>
Zurücknehmen
</button>
<button className="btn btn-secondary" onClick={() => bearbeiten(instanz)}>
{bearbeiteId === instanz.id ? "Schließen" : "Bearbeiten"}
</button>
@@ -179,6 +195,15 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
Gerät hinzufügen
</button>
</div>
{custodyDialog && (
<CustodyDialog
geraetInstanzId={custodyDialog.geraetId}
modus={custodyDialog.modus}
onSchliessen={() => setCustodyDialog(null)}
onErledigt={() => setCustodyDialog(null)}
onFehler={onFehler}
/>
)}
</div>
);
}