From 8f21650d7459993c645d6c49a1c6b3b7ea31f4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20B=C3=B6nisch?= Date: Wed, 17 Jun 2026 21:21:28 +0200 Subject: [PATCH] feat(sdk): Kunden-Dokumente + CRA-Meldewesen, Screening aus Frontend genommen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /sdk/dokumente: Kundensicht nur auf veroeffentlichte Rechtsdokumente (Ansehen + Download); Proxy mit Allow-List nur /public — Templates/Drafts/ Generator bleiben unerreichbar. - /sdk/cra-meldewesen: CRA Art. 14 Meldewesen (24h/72h/14d-Kaskade) mit Fristen-Tracking + ENISA-SRP-Export-Entwurf (kein Live-API). Backend: cra_meldewesen (pure, getestet) + cra_incident_store (schema-neutral ueber compliance_cra_documents) + /api/v1/cra/incidents (additiv, contract-safe). - Screening (Self-Scan) aus dem Frontend genommen: Flow-Stepper-Eintrag ausgeblendet (visibleWhen), Dashboard-Kachel + Import-Button entfernt. Repo-Scanning laeuft extern im Compliance-Scanner; Backend-Router bleibt vorerst gemountet (Contract-Stabilitaet). Co-Authored-By: Claude Opus 4.7 --- .../sdk/v1/cra/incidents/[[...path]]/route.ts | 40 +++++ .../v1/legal-documents/[[...path]]/route.ts | 40 +++++ .../_components/IncidentCard.tsx | 88 ++++++++++ .../_components/NewIncidentForm.tsx | 66 ++++++++ .../cra-meldewesen/_hooks/useMeldewesen.ts | 126 +++++++++++++++ .../app/sdk/cra-meldewesen/page.tsx | 85 ++++++++++ .../app/sdk/dokumente/_hooks/useDokumente.ts | 75 +++++++++ admin-compliance/app/sdk/dokumente/page.tsx | 104 ++++++++++++ admin-compliance/app/sdk/import/page.tsx | 12 +- admin-compliance/app/sdk/page.tsx | 5 +- .../sdk/Sidebar/SidebarModuleList.tsx | 7 +- admin-compliance/lib/sdk/types/sdk-steps.ts | 8 +- .../compliance/api/cra_incident_routes.py | 132 +++++++++++++++ .../compliance/services/cra_incident_store.py | 142 +++++++++++++++++ .../compliance/services/cra_meldewesen.py | 150 ++++++++++++++++++ backend-compliance/main.py | 5 + .../tests/test_cra_meldewesen.py | 87 ++++++++++ 17 files changed, 1155 insertions(+), 17 deletions(-) create mode 100644 admin-compliance/app/api/sdk/v1/cra/incidents/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/legal-documents/[[...path]]/route.ts create mode 100644 admin-compliance/app/sdk/cra-meldewesen/_components/IncidentCard.tsx create mode 100644 admin-compliance/app/sdk/cra-meldewesen/_components/NewIncidentForm.tsx create mode 100644 admin-compliance/app/sdk/cra-meldewesen/_hooks/useMeldewesen.ts create mode 100644 admin-compliance/app/sdk/cra-meldewesen/page.tsx create mode 100644 admin-compliance/app/sdk/dokumente/_hooks/useDokumente.ts create mode 100644 admin-compliance/app/sdk/dokumente/page.tsx create mode 100644 backend-compliance/compliance/api/cra_incident_routes.py create mode 100644 backend-compliance/compliance/services/cra_incident_store.py create mode 100644 backend-compliance/compliance/services/cra_meldewesen.py create mode 100644 backend-compliance/tests/test_cra_meldewesen.py diff --git a/admin-compliance/app/api/sdk/v1/cra/incidents/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/cra/incidents/[[...path]]/route.ts new file mode 100644 index 00000000..f50e57c7 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/cra/incidents/[[...path]]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' + +// Proxy for the CRA Art. 14 incident-reporting (Meldewesen) endpoints. +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +function tenant(request: NextRequest): string { + return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001' +} + +async function forward(request: NextRequest, path: string[], method: string) { + const sub = path.join('/') + const { searchParams } = new URL(request.url) + const qs = searchParams.toString() + const url = `${BACKEND_URL}/api/v1/cra/incidents${sub ? `/${sub}` : ''}${qs ? `?${qs}` : ''}` + const init: RequestInit = { + method, + headers: { 'X-Tenant-ID': tenant(request), 'Content-Type': 'application/json' }, + } + if (method !== 'GET') init.body = await request.text() + try { + const resp = await fetch(url, init) + const body = await resp.text() + return new NextResponse(body, { + status: resp.status, + headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' }, + }) + } catch (err) { + return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 }) + } +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) { + return forward(request, (await params).path || [], 'GET') +} +export async function POST(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) { + return forward(request, (await params).path || [], 'POST') +} +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) { + return forward(request, (await params).path || [], 'PATCH') +} diff --git a/admin-compliance/app/api/sdk/v1/legal-documents/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/legal-documents/[[...path]]/route.ts new file mode 100644 index 00000000..c3670ca3 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/legal-documents/[[...path]]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' + +// Customer-facing proxy to the legal-documents API. The customer "Dokumente" +// page only ever reads PUBLISHED documents (GET /public). Templates, drafts and +// the generator stay behind the internal API and are never proxied here. +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +function tenantHeader(request: NextRequest): string { + return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001' +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> }, +) { + const { path = [] } = await params + const sub = path.join('/') + // Hard allow-list: customers may only read the public (published) views. + if (sub !== 'public' && !sub.startsWith('public/')) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + const { searchParams } = new URL(request.url) + const qs = searchParams.toString() + try { + const resp = await fetch( + `${BACKEND_URL}/api/compliance/legal-documents/${sub}${qs ? `?${qs}` : ''}`, + { headers: { 'X-Tenant-ID': tenantHeader(request) } }, + ) + const body = await resp.text() + return new NextResponse(body, { + status: resp.status, + headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' }, + }) + } catch (err) { + return NextResponse.json( + { error: 'Backend unreachable', details: String(err) }, + { status: 502 }, + ) + } +} diff --git a/admin-compliance/app/sdk/cra-meldewesen/_components/IncidentCard.tsx b/admin-compliance/app/sdk/cra-meldewesen/_components/IncidentCard.tsx new file mode 100644 index 00000000..95b940ad --- /dev/null +++ b/admin-compliance/app/sdk/cra-meldewesen/_components/IncidentCard.tsx @@ -0,0 +1,88 @@ +'use client' + +import { Incident, Deadline, downloadStageExport } from '../_hooks/useMeldewesen' + +const STATUS_STYLE: Record = { + submitted: 'bg-green-100 text-green-800 border-green-300', + overdue: 'bg-red-100 text-red-800 border-red-300', + due_soon: 'bg-amber-100 text-amber-800 border-amber-300', + pending: 'bg-gray-100 text-gray-700 border-gray-300', +} +const STATUS_LABEL: Record = { + submitted: 'gemeldet', overdue: 'überfällig', due_soon: 'bald fällig', pending: 'offen', +} +const SEV_STYLE: Record = { + critical: 'bg-red-600', high: 'bg-orange-500', medium: 'bg-amber-500', low: 'bg-gray-400', +} + +function remaining(sec: number | null): string { + if (sec === null) return '' + const past = sec < 0 + const a = Math.abs(sec) + const h = Math.floor(a / 3600) + const txt = h >= 48 ? `${Math.floor(h / 24)} Tage` : `${h} h` + return past ? `seit ${txt} überfällig` : `noch ${txt}` +} + +function fmt(iso: string | null): string { + if (!iso) return '—' + try { return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) } catch { return iso } +} + +function StageRow({ d, incidentId, summary, onSubmit }: { + d: Deadline; incidentId: string; summary: string; onSubmit: (stage: string) => void +}) { + return ( +
+ + {STATUS_LABEL[d.status]} + + {d.label} + {d.article} + + Frist: {fmt(d.due_at)}{d.status !== 'submitted' && d.remaining_seconds !== null ? ` · ${remaining(d.remaining_seconds)}` : ''} + {d.status === 'submitted' ? ` · übermittelt ${fmt(d.submitted_at)}` : ''} + +
+ + {d.status !== 'submitted' && ( + + )} +
+
+ ) +} + +export function IncidentCard({ inc, onSubmit }: { inc: Incident; onSubmit: (id: string, stage: string) => void }) { + return ( +
+
+
+
+ +

{inc.summary || 'Vorfall'}

+
+

+ {inc.product_name} {inc.product_version} · {inc.kind === 'exploited_vulnerability' ? 'ausgenutzte Schwachstelle' : 'schwerer Vorfall'} · bekannt seit {fmt(inc.aware_at || null)} +

+
+ Status: {inc.status} +
+
+ {inc.deadlines.map((d) => ( + onSubmit(inc.id, stage)} /> + ))} +
+

+ Übermittlung an die ENISA Single Reporting Platform erfolgt manuell mit dem Entwurf — keine automatische Übertragung. +

+
+ ) +} diff --git a/admin-compliance/app/sdk/cra-meldewesen/_components/NewIncidentForm.tsx b/admin-compliance/app/sdk/cra-meldewesen/_components/NewIncidentForm.tsx new file mode 100644 index 00000000..54b90c5d --- /dev/null +++ b/admin-compliance/app/sdk/cra-meldewesen/_components/NewIncidentForm.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from 'react' +import { Meta } from '../_hooks/useMeldewesen' + +const KIND_LABEL: Record = { + exploited_vulnerability: 'Aktiv ausgenutzte Schwachstelle', + severe_incident: 'Schwerwiegender Sicherheitsvorfall', +} +const SEV_LABEL: Record = { + critical: 'kritisch', high: 'hoch', medium: 'mittel', low: 'niedrig', +} + +export function NewIncidentForm({ meta, onCreate, onCancel }: { + meta: Meta | null + onCreate: (body: Record) => Promise + onCancel: () => void +}) { + const [f, setF] = useState>({ + summary: '', product_name: '', product_version: '', manufacturer: '', + kind: 'exploited_vulnerability', severity: 'high', contact: '', impact: '', + }) + const [busy, setBusy] = useState(false) + const set = (k: string, v: string) => setF((p) => ({ ...p, [k]: v })) + const field = 'w-full text-sm rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 p-2' + + const submit = async () => { + setBusy(true) + try { if (await onCreate(f)) onCancel() } finally { setBusy(false) } + } + + return ( +
+

Neue CRA-Meldung erfassen

+

+ Die 24h/72h/14-Tage-Fristen laufen ab dem Zeitpunkt, an dem Sie Kenntnis erlangt haben. +

+ set('summary', e.target.value)} /> +
+ set('product_name', e.target.value)} /> + set('product_version', e.target.value)} /> + set('manufacturer', e.target.value)} /> + set('contact', e.target.value)} /> + + +
+