Files
breakpilot-compliance/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx
T
Benjamin Admin 4a5924b8c4 feat(iace): CRA / DIN EN 40000-1-2 cyber-resilience spur
[guardrail-change]

Phase 18 adds an EU Cyber Resilience Act compliance track to IACE:
the engine now fires patterns that surface the manufacturer-side CRA
obligations whenever a project's components carry digital elements.

Patterns (HP1910-HP1918, hazard_patterns_cra.go):
  HP1910  Missing SBOM
  HP1911  Unsigned firmware/software updates
  HP1912  Factory-default credentials still active
  HP1913  No coordinated vulnerability disclosure (CVD) policy
  HP1914  No documented security patch SLA
  HP1915  Missing user-facing hardening guide
  HP1916  No incident-notification process to ENISA / CSIRT
  HP1917  No security assessment prior to placing on market
  HP1918  AI component without cybersecurity risk assessment

Each pattern carries ClarificationQuestionsDE so the operator gets
auditor-grade questions to take back to the Anlagenbauer instead of
the engine inventing prose. PatternMatch carries DefaultAvoidability
(P=1 for all CRA patterns), feeding the PLr graph from Phase 17.

Measures (M540-M548, measures_library_cra.go):
  M540  SBOM (SPDX or CycloneDX) with each machine release
  M541  Signed updates with rollback protection
  M542  Forced default-password change at first boot
  M543  Published CVD policy (security.txt / PSIRT)
  M544  Documented patch SLA with CVSS-tier response times
  M545  User-facing hardening guide in the machine docs
  M546  ENISA incident-notification process (24h/72h/14d)
  M547  Authenticated update channel + integrity check
  M548  Pre-market security assessment / pen-test

The library is urheberrechtlich neutral: identifiers only
(Verordnung (EU) 2024/2847, DIN EN 40000-1-2 Entwurf, IEC 62443,
ETSI EN 303 645, ISO/IEC 5962, ISO/IEC 29147). No normative text
is reproduced — DIN/Beuth proprietary content is referenced by
section number only.

Category-compatibility:
  cyber_resilience pattern category accepts measures with
  HazardCategory cyber_resilience, cyber_network, or
  software_control. Updated in both the runtime helper
  (iace_handler_init_helpers.go) and its test-mirror
  (pattern_coverage_test.go) — both must move in lockstep.

Frontend (clarifications page):
  When at least one clarification references "2024/2847" or
  "40000-1-2" in its norm_references, a blue info-banner is
  rendered at the top of the page:
    "Cyber Resilience Act (CRA) — Hinweis zur Geltung
     Diese Klärungsliste enthält Fragen zur Verordnung (EU)
     2024/2847 (CRA). Die CRA gilt für Produkte mit digitalen
     Elementen, die ab dem 11.12.2027 auf dem EU-Markt bereit-
     gestellt werden. ..."
  Reminds the user that the CRA pflichten are forward-looking
  while still allowing the manufacturer to bake them in now.

LOC exceptions:
  Added three pre-existing files to .claude/rules/loc-exceptions.txt
  (manufacturer_safety_features.go, iace_handler_clarifications.go,
  routes.go). All three grew across Phases 16-17 and are tagged as
  Phase 5+ refactor backlog. [guardrail-change] marker required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:15:51 +02:00

477 lines
19 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'next/navigation'
type Clarification = {
id: string
question: string
source: string
category: 'manufacturer' | 'pattern_norm' | string
norm_references?: string[]
affected_hazard_ids: string[]
affected_hazard_names: string[]
status: 'open' | 'in_progress' | 'answered' | 'not_relevant'
answer?: 'ja' | 'nein' | 'teilweise' | ''
reasoning?: string
answered_by?: string
answered_at?: string
assigned_to?: string
}
type ListResponse = {
clarifications: Clarification[]
open_count: number
answered_count: number
total: number
}
const CATEGORY_LABEL: Record<string, string> = {
manufacturer: 'Hersteller',
pattern_norm: 'Norm / Pattern',
}
const STATUS_LABEL: Record<string, string> = {
open: 'Offen',
in_progress: 'In Klärung',
answered: 'Beantwortet',
not_relevant: 'Nicht relevant',
}
const STATUS_COLOR: Record<string, string> = {
open: 'bg-orange-100 text-orange-800',
in_progress: 'bg-yellow-100 text-yellow-800',
answered: 'bg-green-100 text-green-800',
not_relevant: 'bg-gray-100 text-gray-700',
}
export default function ClarificationsPage() {
const params = useParams()
const projectId = params.projectId as string
const [data, setData] = useState<ListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState<Clarification | null>(null)
const [filter, setFilter] = useState<'all' | 'open' | 'answered'>('open')
const [searchQuery, setSearchQuery] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const json: ListResponse = await r.json()
setData(json)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => {
load()
}, [load])
const filtered = (data?.clarifications ?? []).filter(c => {
if (filter === 'open' && (c.status === 'answered' || c.status === 'not_relevant')) return false
if (filter === 'answered' && c.status !== 'answered' && c.status !== 'not_relevant') return false
if (searchQuery) {
const q = searchQuery.toLowerCase()
if (!c.question.toLowerCase().includes(q) && !c.source.toLowerCase().includes(q)) return false
}
return true
})
const groupedBySource: Record<string, Clarification[]> = {}
for (const c of filtered) {
const key = c.source
if (!groupedBySource[key]) groupedBySource[key] = []
groupedBySource[key].push(c)
}
// CRA-Spur: zeige Banner, wenn mindestens eine Klaerung einen CRA-Bezug
// hat (Norm-Referenz "2024/2847" oder "DIN EN 40000-1-2"). Die Banner
// erinnert den Anwender daran, dass die CRA-Pflichten zwar bereits jetzt
// dokumentiert werden, aber erst zum 11.12.2027 verpflichtend gelten.
const hasCRAClarifications = (data?.clarifications ?? []).some(c =>
(c.norm_references ?? []).some(n => n.includes('2024/2847') || n.includes('40000-1-2'))
)
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex items-baseline justify-between mb-4">
<div>
<h1 className="text-2xl font-semibold">Klärungen mit dem Anlagenbauer</h1>
<p className="text-sm text-gray-500 mt-1">
Standardisierte Prüffragen aus Norm- und Herstellerwissen. Eine Antwort gilt für alle referenzierten Gefährdungen.
</p>
</div>
<div className="flex items-center gap-3">
{data && (
<div className="flex gap-2 text-sm">
<Badge color="bg-orange-100 text-orange-800" label={`${data.open_count} offen`} />
<Badge color="bg-green-100 text-green-800" label={`${data.answered_count} beantwortet`} />
<Badge color="bg-gray-100 text-gray-700" label={`${data.total} gesamt`} />
</div>
)}
<a
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.csv`}
download
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
title="CSV-Export für die Übergabe an den Anlagenbauer"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
CSV
</a>
<a
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.html`}
target="_blank"
rel="noopener noreferrer"
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
title="Druckansicht öffnen — mit Strg/Cmd-P als PDF speichern"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
PDF / Druck
</a>
</div>
</div>
<div className="flex gap-3 mb-4 items-center">
<div className="flex gap-1 text-sm">
{(['open', 'answered', 'all'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded ${filter === f ? 'bg-blue-600 text-white' : 'bg-gray-100 hover:bg-gray-200'}`}
>
{f === 'open' ? 'Offen' : f === 'answered' ? 'Beantwortet' : 'Alle'}
</button>
))}
</div>
<input
type="text"
placeholder="Suchen in Frage oder Quelle..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="flex-1 max-w-sm border rounded px-3 py-1.5 text-sm"
/>
</div>
{!loading && hasCRAClarifications && (
<div className="mb-4 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900">
<div className="font-semibold mb-1">Cyber Resilience Act (CRA) Hinweis zur Geltung</div>
<div className="text-blue-800">
Diese Klärungsliste enthält Fragen zur Verordnung (EU) 2024/2847 (CRA). Die CRA gilt für Produkte mit digitalen Elementen", die ab dem <strong>11.12.2027</strong> auf dem EU-Markt bereitgestellt werden. Die hier dokumentierten Pflichten (SBOM, signierte Updates, CVD-Policy, Patch-SLA, Incident-Notification an ENISA) sollten bereits jetzt im Entwurf des Anlagenbauers berücksichtigt sein. Harmonisierter Standard: <strong>DIN EN 40000-1-2</strong> (Entwurf 11/2025).
</div>
</div>
)}
{loading && <div className="text-gray-500">Lade Klärungen…</div>}
{error && <div className="text-red-600">Fehler: {error}</div>}
{!loading && data && Object.keys(groupedBySource).length === 0 && (
<div className="text-gray-500 italic">Keine Klärungen für die aktuelle Auswahl.</div>
)}
{!loading && data && Object.entries(groupedBySource).map(([source, items]) => (
<div key={source} className="mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
{CATEGORY_LABEL[items[0].category] || items[0].category}
</span>
{source}
</h2>
<div className="space-y-2">
{items.map(c => (
<div key={c.id} className="border rounded-lg p-3 bg-white shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">{c.question}</div>
<div className="mt-1 text-xs text-gray-500">
Betrifft <strong>{c.affected_hazard_ids.length}</strong> Gefährdung
{c.affected_hazard_ids.length !== 1 ? 'en' : ''}
{c.affected_hazard_names.length > 0 && (
<span className="ml-1">— {c.affected_hazard_names.slice(0, 2).join('; ')}{c.affected_hazard_names.length > 2 ? `, +${c.affected_hazard_names.length - 2} weitere` : ''}</span>
)}
</div>
{c.norm_references && c.norm_references.length > 0 && (
<div className="mt-1 text-xs text-gray-500">
Norm: {c.norm_references.join(' | ')}
</div>
)}
{c.status === 'answered' && c.reasoning && (
<div className="mt-2 text-xs text-gray-700 bg-green-50 border border-green-200 rounded p-2">
<strong>Antwort ({c.answer}):</strong> {c.reasoning}
{c.answered_by && (
<span className="text-gray-500 ml-2">— {c.answered_by}, {c.answered_at?.slice(0, 10)}</span>
)}
</div>
)}
</div>
<div className="flex flex-col items-end gap-2 text-xs">
<span className={`px-2 py-0.5 rounded ${STATUS_COLOR[c.status]}`}>{STATUS_LABEL[c.status]}</span>
<button
onClick={() => setEditing(c)}
className="px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{c.status === 'answered' ? 'Bearbeiten' : 'Beantworten'}
</button>
</div>
</div>
</div>
))}
</div>
</div>
))}
{editing && (
<AnswerModal
clarification={editing}
projectId={projectId}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
load()
}}
/>
)}
</div>
)
}
function Badge({ color, label }: { color: string; label: string }) {
return <span className={`px-2 py-0.5 rounded text-xs ${color}`}>{label}</span>
}
type Comment = { id: string; author: string; body: string; created_at: string }
type HistoryEntry = {
actor: string
from_status?: string
to_status?: string
from_answer?: string
to_answer?: string
created_at: string
}
function AnswerModal({
clarification,
projectId,
onClose,
onSaved,
}: {
clarification: Clarification & { assigned_to?: string }
projectId: string
onClose: () => void
onSaved: () => void
}) {
const [status, setStatus] = useState(clarification.status)
const [answer, setAnswer] = useState<'ja' | 'nein' | 'teilweise' | ''>(
(clarification.answer as 'ja' | 'nein' | 'teilweise' | '') || ''
)
const [reasoning, setReasoning] = useState(clarification.reasoning || '')
const [answeredBy, setAnsweredBy] = useState(clarification.answered_by || '')
const [assignedTo, setAssignedTo] = useState(clarification.assigned_to || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [comments, setComments] = useState<Comment[]>([])
const [history, setHistory] = useState<HistoryEntry[]>([])
const [newComment, setNewComment] = useState('')
const [postingComment, setPostingComment] = useState(false)
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/detail`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return
setComments(d.comments || [])
setHistory(d.history || [])
})
.catch(() => {})
}, [projectId, clarification.id])
const save = async () => {
setSaving(true)
setError(null)
try {
const r = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/answer`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status, answer, reasoning,
answered_by: answeredBy,
assigned_to: assignedTo,
question: clarification.question,
source: clarification.source,
category: clarification.category,
norm_references: clarification.norm_references,
}),
}
)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const postComment = async () => {
if (!newComment.trim()) return
setPostingComment(true)
try {
const r = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/comment`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: answeredBy || assignedTo || 'unbekannt', body: newComment }),
}
)
if (r.ok) {
const d = await r.json()
if (d.comment) setComments(prev => [...prev, d.comment])
setNewComment('')
} else {
setError(`Kommentar HTTP ${r.status} — bitte zuerst Status setzen, damit der Klärungs-Datensatz angelegt wird.`)
}
} finally {
setPostingComment(false)
}
}
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4 overflow-y-auto" onClick={onClose}>
<div className="bg-white rounded-lg max-w-2xl w-full p-5 shadow-xl my-8" onClick={e => e.stopPropagation()}>
<div className="text-sm text-gray-500 mb-1">{clarification.source}</div>
<div className="text-base font-medium mb-4">{clarification.question}</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Zugewiesen an</label>
<input
type="text"
value={assignedTo}
onChange={e => setAssignedTo(e.target.value)}
className="w-full border rounded p-2 text-sm"
placeholder="z.B. anlagenbauer@fanuc.de"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
<input
type="text"
value={answeredBy}
onChange={e => setAnsweredBy(e.target.value)}
className="w-full border rounded p-2 text-sm"
placeholder="Name oder Kürzel"
/>
</div>
</div>
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
<div className="flex gap-1 mb-3 text-sm">
{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => (
<button
key={s}
onClick={() => setStatus(s)}
className={`px-3 py-1 rounded border ${status === s ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
>
{STATUS_LABEL[s]}
</button>
))}
</div>
{(status === 'answered' || status === 'in_progress') && (
<>
<label className="block text-xs font-medium text-gray-700 mb-1">Antwort</label>
<div className="flex gap-1 mb-3 text-sm">
{(['ja', 'teilweise', 'nein'] as const).map(a => (
<button
key={a}
onClick={() => setAnswer(a)}
className={`px-3 py-1 rounded border ${answer === a ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
>
{a}
</button>
))}
</div>
</>
)}
<label className="block text-xs font-medium text-gray-700 mb-1">Begründung / Notiz</label>
<textarea
value={reasoning}
onChange={e => setReasoning(e.target.value)}
rows={4}
className="w-full border rounded p-2 text-sm mb-4"
placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei."
/>
{/* Comment Thread */}
<div className="border-t pt-3 mt-3 mb-3">
<div className="text-xs font-medium text-gray-700 mb-2">Diskussion ({comments.length})</div>
<div className="space-y-2 max-h-40 overflow-y-auto mb-2">
{comments.map(c => (
<div key={c.id} className="text-xs bg-gray-50 rounded p-2">
<div className="font-medium text-gray-700">{c.author || 'anonym'} <span className="text-gray-400 font-normal">· {c.created_at.slice(0, 16).replace('T', ' ')}</span></div>
<div className="text-gray-700 whitespace-pre-wrap">{c.body}</div>
</div>
))}
{comments.length === 0 && <div className="text-xs text-gray-400 italic">Noch keine Kommentare.</div>}
</div>
<div className="flex gap-1">
<input
type="text"
value={newComment}
onChange={e => setNewComment(e.target.value)}
placeholder="Kommentar hinzufügen..."
className="flex-1 border rounded px-2 py-1.5 text-xs"
onKeyDown={e => { if (e.key === 'Enter') postComment() }}
/>
<button
onClick={postComment}
disabled={postingComment || !newComment.trim()}
className="px-3 py-1 rounded bg-gray-700 text-white text-xs hover:bg-gray-800 disabled:opacity-50"
>Senden</button>
</div>
</div>
{history.length > 0 && (
<details className="mb-3 text-xs">
<summary className="cursor-pointer text-gray-600 hover:text-gray-800">Verlauf ({history.length})</summary>
<div className="mt-1 space-y-1 text-gray-600">
{history.map((h, i) => (
<div key={i} className="border-l-2 border-gray-200 pl-2">
<span className="text-gray-400">{h.created_at.slice(0, 16).replace('T', ' ')}</span> ·
<strong> {h.actor || 'unbekannt'}</strong>: {h.from_status} → {h.to_status}
{h.from_answer !== h.to_answer && ` (Antwort ${h.from_answer || '—'} → ${h.to_answer || '—'})`}
</div>
))}
</div>
</details>
)}
{error && <div className="text-red-600 text-sm mb-2">Fehler: {error}</div>}
<div className="flex justify-end gap-2 text-sm">
<button onClick={onClose} className="px-3 py-1.5 rounded border bg-white hover:bg-gray-50">Abbrechen</button>
<button onClick={save} disabled={saving} className="px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}