feat(sdk): Kunden-Dokumente + CRA-Meldewesen, Screening aus Frontend genommen
CI / detect-changes (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 15s
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Successful in 25s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m9s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 31s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

- /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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Bönisch
2026-06-17 21:21:28 +02:00
parent 72093e5501
commit 8f21650d74
17 changed files with 1155 additions and 17 deletions
@@ -0,0 +1,88 @@
'use client'
import { Incident, Deadline, downloadStageExport } from '../_hooks/useMeldewesen'
const STATUS_STYLE: Record<string, string> = {
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<string, string> = {
submitted: 'gemeldet', overdue: 'überfällig', due_soon: 'bald fällig', pending: 'offen',
}
const SEV_STYLE: Record<string, string> = {
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 (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 border-t border-gray-100 dark:border-gray-700">
<span className={`text-xs font-medium px-2 py-0.5 rounded border ${STATUS_STYLE[d.status]}`}>
{STATUS_LABEL[d.status]}
</span>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">{d.label}</span>
<span className="text-xs text-gray-400">{d.article}</span>
<span className="text-xs text-gray-500">
Frist: {fmt(d.due_at)}{d.status !== 'submitted' && d.remaining_seconds !== null ? ` · ${remaining(d.remaining_seconds)}` : ''}
{d.status === 'submitted' ? ` · übermittelt ${fmt(d.submitted_at)}` : ''}
</span>
<div className="ml-auto flex items-center gap-2">
<button onClick={() => downloadStageExport(incidentId, d.key, summary)}
className="text-xs rounded border border-gray-300 dark:border-gray-600 px-2 py-1 hover:bg-gray-50 dark:hover:bg-gray-700">
ENISA-Entwurf
</button>
{d.status !== 'submitted' && (
<button onClick={() => onSubmit(d.key)}
className="text-xs rounded bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-1">
Als gemeldet markieren
</button>
)}
</div>
</div>
)
}
export function IncidentCard({ inc, onSubmit }: { inc: Incident; onSubmit: (id: string, stage: string) => void }) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${SEV_STYLE[inc.severity || 'low']}`} title={inc.severity} />
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">{inc.summary || 'Vorfall'}</h3>
</div>
<p className="text-xs text-gray-500 mt-0.5">
{inc.product_name} {inc.product_version} · {inc.kind === 'exploited_vulnerability' ? 'ausgenutzte Schwachstelle' : 'schwerer Vorfall'} · bekannt seit {fmt(inc.aware_at || null)}
</p>
</div>
<span className="text-xs text-gray-400 shrink-0">Status: {inc.status}</span>
</div>
<div className="mt-3">
{inc.deadlines.map((d) => (
<StageRow key={d.key} d={d} incidentId={inc.id} summary={inc.summary}
onSubmit={(stage) => onSubmit(inc.id, stage)} />
))}
</div>
<p className="text-[11px] text-gray-400 italic mt-2">
Übermittlung an die ENISA Single Reporting Platform erfolgt manuell mit dem Entwurf keine automatische Übertragung.
</p>
</div>
)
}
@@ -0,0 +1,66 @@
'use client'
import { useState } from 'react'
import { Meta } from '../_hooks/useMeldewesen'
const KIND_LABEL: Record<string, string> = {
exploited_vulnerability: 'Aktiv ausgenutzte Schwachstelle',
severe_incident: 'Schwerwiegender Sicherheitsvorfall',
}
const SEV_LABEL: Record<string, string> = {
critical: 'kritisch', high: 'hoch', medium: 'mittel', low: 'niedrig',
}
export function NewIncidentForm({ meta, onCreate, onCancel }: {
meta: Meta | null
onCreate: (body: Record<string, unknown>) => Promise<boolean>
onCancel: () => void
}) {
const [f, setF] = useState<Record<string, string>>({
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 (
<div className="rounded-xl border border-indigo-200 dark:border-indigo-800 bg-indigo-50/40 dark:bg-indigo-900/20 p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Neue CRA-Meldung erfassen</h3>
<p className="text-xs text-gray-600 dark:text-gray-300">
Die 24h/72h/14-Tage-Fristen laufen ab dem Zeitpunkt, an dem Sie Kenntnis erlangt haben.
</p>
<input className={field} placeholder="Kurzbeschreibung des Vorfalls *"
value={f.summary} onChange={(e) => set('summary', e.target.value)} />
<div className="grid grid-cols-2 gap-2">
<input className={field} placeholder="Produkt" value={f.product_name} onChange={(e) => set('product_name', e.target.value)} />
<input className={field} placeholder="Version" value={f.product_version} onChange={(e) => set('product_version', e.target.value)} />
<input className={field} placeholder="Hersteller" value={f.manufacturer} onChange={(e) => set('manufacturer', e.target.value)} />
<input className={field} placeholder="Kontakt (PSIRT-E-Mail)" value={f.contact} onChange={(e) => set('contact', e.target.value)} />
<select className={field} value={f.kind} onChange={(e) => set('kind', e.target.value)}>
{(meta?.kinds || ['exploited_vulnerability', 'severe_incident']).map((k) => (
<option key={k} value={k}>{KIND_LABEL[k] || k}</option>
))}
</select>
<select className={field} value={f.severity} onChange={(e) => set('severity', e.target.value)}>
{(meta?.severities || ['low', 'medium', 'high', 'critical']).map((s) => (
<option key={s} value={s}>{SEV_LABEL[s] || s}</option>
))}
</select>
</div>
<textarea className={field} rows={2} placeholder="Auswirkung (kurz)" value={f.impact} onChange={(e) => set('impact', e.target.value)} />
<div className="flex items-center gap-2">
<button onClick={submit} disabled={busy || f.summary.trim().length < 3}
className="rounded bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white text-sm px-4 py-2">
{busy ? 'Lege an …' : 'Meldung anlegen'}
</button>
<button onClick={onCancel} className="text-sm text-gray-500 hover:underline">Abbrechen</button>
</div>
</div>
)
}