cc80e59e5e
Migration 121: compliance_cra_vulnerabilities table with full lifecycle tracking
- Status state machine: reported → triaged → patched → disclosed (+ withdrawn)
- CRA Art. 14(2) deadlines tracked: reported_to_enisa_at (24h), detailed_report_at (72h)
- CVE-ID, severity, CVSS, affected_components (JSONB), embargo_until
Backend endpoints in cra_routes.py:
- POST /vulnerabilities — create with validation (severity, CVSS range)
- GET /vulnerabilities — list with deadline-breach summary (24h/72h counters)
- PATCH /vulnerabilities/{id} — update fields + auto-set lifecycle timestamps
- DELETE /vulnerabilities/{id} — soft-delete (withdrawn)
- GET /monitoring — combined view: CRA deadlines + vuln summary + post-market checklist
Frontend:
- /vuln page: intake form, vuln cards with 24h/72h-countdown buttons,
status-transition flow with auto-timestamps
- /monitoring page: CRA deadlines (11.06.26 / 11.09.26 / 11.12.27), breach banner
if 24h/72h obligations missed, post-market checklist with deep-links
- Dashboard: +2 buttons (Vulns, Monitoring)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useState, useCallback, use } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { ClassificationBadge } from '../_components/ClassificationBadge'
|
|
import { StatusStepper } from '../_components/StatusStepper'
|
|
import { SeverityBadge } from '../_components/SeverityBadge'
|
|
|
|
interface CRAProject {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
cra_classification: string | null
|
|
classification_rationale: string[]
|
|
conformity_path: string | null
|
|
status: string
|
|
intended_use: string
|
|
repo_url: string | null
|
|
primary_language: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface BacklogItem {
|
|
rank: number
|
|
req_id: string
|
|
title: string
|
|
category: string
|
|
severity: string
|
|
effort_days: number
|
|
priority_score: number
|
|
}
|
|
|
|
interface BacklogData {
|
|
days_to_ce_deadline: number
|
|
deadlines: { date: string; label: string }[]
|
|
total: number
|
|
items: BacklogItem[]
|
|
}
|
|
|
|
const PATH_LABEL: Record<string, string> = {
|
|
self_assessment: 'Modul A — Self-Assessment',
|
|
harmonized_standard: 'Modul B — Harmonized Standard',
|
|
eucc: 'Modul H — EUCC',
|
|
notified_body: 'Modul C — Notified Body',
|
|
}
|
|
|
|
export default function CRAProjectDashboard({
|
|
params,
|
|
}: {
|
|
params: Promise<{ projectId: string }>
|
|
}) {
|
|
const { projectId } = use(params)
|
|
const router = useRouter()
|
|
const [project, setProject] = useState<CRAProject | null>(null)
|
|
const [backlog, setBacklog] = useState<BacklogData | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const headers = { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' }
|
|
const [projRes, backlogRes] = await Promise.all([
|
|
fetch(`/api/sdk/v1/cra/projects/${projectId}`, { headers }),
|
|
fetch(`/api/sdk/v1/cra/projects/${projectId}/backlog`, { headers }),
|
|
])
|
|
if (!projRes.ok) throw new Error(await projRes.text())
|
|
setProject(await projRes.json())
|
|
if (backlogRes.ok) {
|
|
setBacklog(await backlogRes.json())
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [projectId])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
|
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
|
|
if (!project) return null
|
|
|
|
const nextStep =
|
|
project.status === 'draft' ? { href: `/sdk/cra/${projectId}/intake`, label: 'Intake starten' } :
|
|
project.status === 'scoped' ? { href: `/sdk/cra/${projectId}/scope`, label: 'Scope-Check ausfuehren' } :
|
|
project.status === 'classified' ? { href: `/sdk/cra/${projectId}/path`, label: 'Konformitaetspfad waehlen' } :
|
|
project.status === 'path_selected' ? { href: null, label: 'Phase 2 (Requirements) folgt' } :
|
|
{ href: null, label: '' }
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 py-8">
|
|
<div className="max-w-5xl mx-auto px-4">
|
|
<div className="mb-4">
|
|
<a href="/sdk/cra" className="text-sm text-blue-600 hover:underline">← Alle CRA-Projekte</a>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
|
<div className="flex items-start justify-between gap-4 mb-4">
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
|
|
{project.description && (
|
|
<p className="text-gray-600 mt-1">{project.description}</p>
|
|
)}
|
|
</div>
|
|
<ClassificationBadge value={project.cra_classification} size="lg" />
|
|
</div>
|
|
|
|
<StatusStepper current={project.status} />
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
{backlog && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
|
<KPICard
|
|
label="Annex-I Requirements"
|
|
value={backlog.total}
|
|
hint="aus Migration 059"
|
|
color="blue"
|
|
/>
|
|
<KPICard
|
|
label="Critical-Anforderungen"
|
|
value={backlog.items.filter(i => i.severity === 'CRITICAL').length}
|
|
hint={`+ ${backlog.items.filter(i => i.severity === 'HIGH').length} High`}
|
|
color="red"
|
|
/>
|
|
<KPICard
|
|
label="Tage bis CE-Pflicht"
|
|
value={backlog.days_to_ce_deadline}
|
|
hint="11.12.2027"
|
|
color={backlog.days_to_ce_deadline < 365 ? 'orange' : 'green'}
|
|
/>
|
|
<KPICard
|
|
label="Compliance"
|
|
value="0%"
|
|
hint="Evidence in Phase 3"
|
|
color="gray"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Top-10 Backlog-Snippet */}
|
|
{backlog && backlog.items.length > 0 && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Top-10 Prioritaeten</h3>
|
|
<a href={`/sdk/cra/${projectId}/backlog`} className="text-xs text-blue-600 hover:underline">
|
|
Vollstaendiges Backlog →
|
|
</a>
|
|
</div>
|
|
<table className="w-full text-sm">
|
|
<thead className="text-xs text-gray-500 uppercase">
|
|
<tr>
|
|
<th className="text-left py-1">#</th>
|
|
<th className="text-left py-1">Anforderung</th>
|
|
<th className="text-left py-1">Severity</th>
|
|
<th className="text-left py-1">Aufwand</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{backlog.items.slice(0, 10).map(item => (
|
|
<tr key={item.req_id} className="hover:bg-gray-50">
|
|
<td className="py-2 text-gray-500">{item.rank}</td>
|
|
<td className="py-2">
|
|
<div className="font-medium text-gray-900">{item.title}</div>
|
|
<div className="text-xs text-gray-500">{item.category}</div>
|
|
</td>
|
|
<td className="py-2"><SeverityBadge value={item.severity} /></td>
|
|
<td className="py-2 text-gray-600">{item.effort_days} PT</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-7 gap-2 mb-6">
|
|
<a href={`/sdk/cra/${projectId}/requirements`} className="text-center py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 text-xs font-medium">Requirements</a>
|
|
<a href={`/sdk/cra/${projectId}/backlog`} className="text-center py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-xs font-medium">Backlog</a>
|
|
<a href={`/sdk/cra/${projectId}/sbom`} className="text-center py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 text-xs font-medium">SBOM</a>
|
|
<a href={`/sdk/cra/${projectId}/checks`} className="text-center py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 text-xs font-medium">Checks</a>
|
|
<a href={`/sdk/cra/${projectId}/vuln`} className="text-center py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 text-xs font-medium">Vulns (CVD)</a>
|
|
<a href={`/sdk/cra/${projectId}/monitoring`} className="text-center py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 text-xs font-medium">Monitoring</a>
|
|
<a href={`/sdk/cra/${projectId}/documents`} className="text-center py-2 bg-teal-100 text-teal-700 rounded-lg hover:bg-teal-200 text-xs font-medium">Dokumente</a>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
|
<InfoCard
|
|
title="Intake"
|
|
content={
|
|
project.status === 'draft'
|
|
? <span className="text-gray-400">Noch nicht erfasst</span>
|
|
: (
|
|
<div className="space-y-1 text-sm">
|
|
{project.intended_use && <div><span className="text-gray-500">Use:</span> {project.intended_use}</div>}
|
|
{project.primary_language && <div><span className="text-gray-500">Sprache:</span> {project.primary_language}</div>}
|
|
{project.repo_url && <div><span className="text-gray-500">Repo:</span> {project.repo_url}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
actionHref={`/sdk/cra/${projectId}/intake`}
|
|
actionLabel={project.status === 'draft' ? 'Erfassen' : 'Bearbeiten'}
|
|
/>
|
|
|
|
<InfoCard
|
|
title="Klassifikation"
|
|
content={
|
|
project.cra_classification ? (
|
|
<div>
|
|
<ClassificationBadge value={project.cra_classification} size="md" />
|
|
{project.classification_rationale?.length > 0 && (
|
|
<ul className="mt-2 text-xs text-gray-600 list-disc list-inside space-y-0.5">
|
|
{project.classification_rationale.map((r, i) => <li key={i}>{r}</li>)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : <span className="text-gray-400">Scope-Check ausstehend</span>
|
|
}
|
|
actionHref={`/sdk/cra/${projectId}/scope`}
|
|
actionLabel={project.cra_classification ? 'Neu pruefen' : 'Pruefen'}
|
|
/>
|
|
|
|
<InfoCard
|
|
title="Konformitaetspfad"
|
|
content={
|
|
project.conformity_path
|
|
? <span className="font-medium text-purple-700">{PATH_LABEL[project.conformity_path] || project.conformity_path}</span>
|
|
: <span className="text-gray-400">Noch nicht gewaehlt</span>
|
|
}
|
|
actionHref={project.cra_classification ? `/sdk/cra/${projectId}/path` : null}
|
|
actionLabel={project.conformity_path ? 'Aendern' : 'Waehlen'}
|
|
/>
|
|
|
|
<InfoCard
|
|
title="Status"
|
|
content={
|
|
<div className="space-y-1 text-sm">
|
|
<div><span className="text-gray-500">Aktuell:</span> {project.status}</div>
|
|
<div className="text-xs text-gray-400">Aktualisiert: {new Date(project.updated_at).toLocaleString('de-DE')}</div>
|
|
</div>
|
|
}
|
|
actionHref={null}
|
|
actionLabel=""
|
|
/>
|
|
</div>
|
|
|
|
{nextStep.href && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="font-semibold text-blue-900">Naechster Schritt</h3>
|
|
<p className="text-sm text-blue-700 mt-1">{nextStep.label}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => router.push(nextStep.href!)}
|
|
className="px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
|
>
|
|
Weiter
|
|
</button>
|
|
</div>
|
|
)}
|
|
{!nextStep.href && nextStep.label && (
|
|
<div className="bg-gray-100 border border-gray-200 rounded-xl p-5 text-center text-gray-600">
|
|
{nextStep.label}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InfoCard({
|
|
title, content, actionHref, actionLabel,
|
|
}: {
|
|
title: string
|
|
content: React.ReactNode
|
|
actionHref: string | null
|
|
actionLabel: string
|
|
}) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">{title}</h3>
|
|
{actionHref && actionLabel && (
|
|
<a href={actionHref} className="text-xs text-blue-600 hover:underline">{actionLabel}</a>
|
|
)}
|
|
</div>
|
|
<div>{content}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KPICard({
|
|
label, value, hint, color,
|
|
}: {
|
|
label: string
|
|
value: string | number
|
|
hint: string
|
|
color: 'blue' | 'red' | 'orange' | 'green' | 'gray'
|
|
}) {
|
|
const colors = {
|
|
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
|
red: 'bg-red-50 border-red-200 text-red-700',
|
|
orange: 'bg-orange-50 border-orange-200 text-orange-700',
|
|
green: 'bg-green-50 border-green-200 text-green-700',
|
|
gray: 'bg-gray-50 border-gray-200 text-gray-700',
|
|
}
|
|
return (
|
|
<div className={`rounded-xl border p-4 ${colors[color]}`}>
|
|
<p className="text-xs text-gray-600 uppercase tracking-wide">{label}</p>
|
|
<p className="text-2xl font-bold mt-1">{value}</p>
|
|
<p className="text-xs mt-1 opacity-80">{hint}</p>
|
|
</div>
|
|
)
|
|
}
|