Phase A: TOM document HTML generator (12 sections, inline CSS, A4 print) Phase B: TOMDocumentTab component (org-header form, revisions, print/download) Phase C: 11 compliance checks with severity-weighted scoring Phase D: MkDocs documentation for TOM module Phase E: 25 new controls (63 → 88) in 13 categories Canonical Control Mapping (three-layer architecture): - Migration 068: tom_control_mappings + tom_control_sync_state tables - 6 API endpoints: sync, list, by-tom, stats, manual add, delete - Category mapping: 13 TOM categories → 17 canonical categories - Frontend: sync button + coverage card (Overview), drill-down (Editor), belegende Controls count (Document) - 20 tests (unit + API with mocked DB) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
365 lines
16 KiB
TypeScript
365 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState, useEffect, useCallback } from 'react'
|
|
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
|
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
|
|
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
|
|
|
|
interface TOMOverviewTabProps {
|
|
state: TOMGeneratorState
|
|
onSelectTOM: (tomId: string) => void
|
|
onStartGenerator: () => void
|
|
}
|
|
|
|
interface MappingStats {
|
|
sync_state: {
|
|
profile_hash: string | null
|
|
total_mappings: number
|
|
canonical_controls_matched: number
|
|
tom_controls_covered: number
|
|
last_synced_at: string | null
|
|
}
|
|
category_breakdown: { tom_category: string; total_mappings: number; unique_controls: number }[]
|
|
total_canonical_controls_available: number
|
|
}
|
|
|
|
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
|
|
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
|
|
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
|
|
NOT_IMPLEMENTED: { label: 'Fehlend', className: 'bg-red-100 text-red-700' },
|
|
}
|
|
|
|
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
|
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
|
|
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
|
|
}
|
|
|
|
const SCHUTZZIELE: { key: SDMGewaehrleistungsziel; label: string }[] = [
|
|
{ key: 'Vertraulichkeit', label: 'Vertraulichkeit' },
|
|
{ key: 'Integritaet', label: 'Integritaet' },
|
|
{ key: 'Verfuegbarkeit', label: 'Verfuegbarkeit' },
|
|
{ key: 'Nichtverkettung', label: 'Nichtverkettung' },
|
|
]
|
|
|
|
export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOverviewTabProps) {
|
|
const [categoryFilter, setCategoryFilter] = useState<string>('ALL')
|
|
const [typeFilter, setTypeFilter] = useState<string>('ALL')
|
|
const [statusFilter, setStatusFilter] = useState<string>('ALL')
|
|
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
|
|
const [mappingStats, setMappingStats] = useState<MappingStats | null>(null)
|
|
const [syncing, setSyncing] = useState(false)
|
|
|
|
const categories = useMemo(() => getAllCategories(), [])
|
|
|
|
// Load mapping stats
|
|
useEffect(() => {
|
|
if (state.derivedTOMs.length === 0) return
|
|
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(data => { if (data) setMappingStats(data) })
|
|
.catch(() => {})
|
|
}, [state.derivedTOMs.length])
|
|
|
|
const handleSyncControls = useCallback(async () => {
|
|
setSyncing(true)
|
|
try {
|
|
const resp = await fetch('/api/sdk/v1/compliance/tom-mappings/sync', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
industry: state.companyProfile?.industry || null,
|
|
company_size: state.companyProfile?.size || null,
|
|
force: false,
|
|
}),
|
|
})
|
|
if (resp.ok) {
|
|
// Reload stats after sync
|
|
const statsResp = await fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
|
if (statsResp.ok) setMappingStats(await statsResp.json())
|
|
}
|
|
} catch { /* ignore */ }
|
|
setSyncing(false)
|
|
}, [state.companyProfile])
|
|
|
|
const stats = useMemo(() => {
|
|
const toms = state.derivedTOMs
|
|
return {
|
|
total: toms.length,
|
|
implemented: toms.filter(t => t.implementationStatus === 'IMPLEMENTED').length,
|
|
partial: toms.filter(t => t.implementationStatus === 'PARTIAL').length,
|
|
missing: toms.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length,
|
|
}
|
|
}, [state.derivedTOMs])
|
|
|
|
const sdmStats = useMemo(() => {
|
|
const allStats = getSDMCoverageStats(state.derivedTOMs)
|
|
return SCHUTZZIELE.map(sz => ({
|
|
...sz,
|
|
stats: allStats[sz.key] || { total: 0, implemented: 0, partial: 0, missing: 0 },
|
|
}))
|
|
}, [state.derivedTOMs])
|
|
|
|
const filteredTOMs = useMemo(() => {
|
|
let toms = state.derivedTOMs
|
|
|
|
if (categoryFilter !== 'ALL') {
|
|
const categoryControlIds = getControlsByCategory(categoryFilter).map(c => c.id)
|
|
toms = toms.filter(t => categoryControlIds.includes(t.controlId))
|
|
}
|
|
|
|
if (typeFilter !== 'ALL') {
|
|
toms = toms.filter(t => {
|
|
const ctrl = getControlById(t.controlId)
|
|
return ctrl?.type === typeFilter
|
|
})
|
|
}
|
|
|
|
if (statusFilter !== 'ALL') {
|
|
toms = toms.filter(t => t.implementationStatus === statusFilter)
|
|
}
|
|
|
|
if (applicabilityFilter !== 'ALL') {
|
|
toms = toms.filter(t => t.applicability === applicabilityFilter)
|
|
}
|
|
|
|
return toms
|
|
}, [state.derivedTOMs, categoryFilter, typeFilter, statusFilter, applicabilityFilter])
|
|
|
|
if (state.derivedTOMs.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<div className="text-gray-400 mb-4">
|
|
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOMs vorhanden</h3>
|
|
<p className="text-gray-500 mb-6 max-w-md">
|
|
Starten Sie den TOM Generator, um technische und organisatorische Massnahmen basierend auf Ihrem Verarbeitungsverzeichnis abzuleiten.
|
|
</p>
|
|
<button
|
|
onClick={onStartGenerator}
|
|
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-3 font-medium transition-colors"
|
|
>
|
|
TOM Generator starten
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Stats Row */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
|
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
|
|
<div className="text-sm text-gray-500 mt-1">Gesamt TOMs</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
|
<div className="text-3xl font-bold text-green-600">{stats.implemented}</div>
|
|
<div className="text-sm text-gray-500 mt-1">Implementiert</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
|
<div className="text-3xl font-bold text-yellow-600">{stats.partial}</div>
|
|
<div className="text-sm text-gray-500 mt-1">Teilweise</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
|
<div className="text-3xl font-bold text-red-600">{stats.missing}</div>
|
|
<div className="text-sm text-gray-500 mt-1">Fehlend</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Art. 32 Schutzziele */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Art. 32 DSGVO Schutzziele</h3>
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{sdmStats.map(sz => {
|
|
const total = sz.stats.total || 1
|
|
const implPercent = Math.round((sz.stats.implemented / total) * 100)
|
|
const partialPercent = Math.round((sz.stats.partial / total) * 100)
|
|
return (
|
|
<div key={sz.key} className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="text-sm font-medium text-gray-700 mb-2">{sz.label}</div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
|
<div className="h-full flex">
|
|
<div
|
|
className="bg-green-500 h-full"
|
|
style={{ width: `${implPercent}%` }}
|
|
/>
|
|
<div
|
|
className="bg-yellow-400 h-full"
|
|
style={{ width: `${partialPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{sz.stats.implemented}/{sz.stats.total} implementiert
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Canonical Control Library Coverage */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700">Canonical Control Library</h3>
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
Belegende Security-Controls aus OWASP, NIST, ENISA
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleSyncControls}
|
|
disabled={syncing}
|
|
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 rounded-lg px-4 py-2 text-xs font-medium transition-colors"
|
|
>
|
|
{syncing ? 'Synchronisiere...' : 'Controls synchronisieren'}
|
|
</button>
|
|
</div>
|
|
{mappingStats ? (
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.total_mappings}</div>
|
|
<div className="text-xs text-gray-500">Zugeordnete Controls</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-xl font-bold text-purple-600">{mappingStats.sync_state.canonical_controls_matched}</div>
|
|
<div className="text-xs text-gray-500">Einzigartige Controls</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.tom_controls_covered}/13</div>
|
|
<div className="text-xs text-gray-500">Kategorien abgedeckt</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-xl font-bold text-gray-900">{mappingStats.total_canonical_controls_available}</div>
|
|
<div className="text-xs text-gray-500">Verfuegbare Controls</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-4">
|
|
<p className="text-sm text-gray-400">
|
|
Noch keine Controls synchronisiert. Klicken Sie "Controls synchronisieren", um relevante
|
|
Security-Controls aus der Canonical Control Library zuzuordnen.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{mappingStats?.sync_state?.last_synced_at && (
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
Letzte Synchronisation: {new Date(mappingStats.sync_state.last_synced_at).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filter Controls */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
|
|
<select
|
|
value={categoryFilter}
|
|
onChange={e => setCategoryFilter(e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
>
|
|
<option value="ALL">Alle Kategorien</option>
|
|
{categories.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-600 mb-1">Typ</label>
|
|
<select
|
|
value={typeFilter}
|
|
onChange={e => setTypeFilter(e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
>
|
|
<option value="ALL">Alle</option>
|
|
<option value="TECHNICAL">Technisch</option>
|
|
<option value="ORGANIZATIONAL">Organisatorisch</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={e => setStatusFilter(e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
>
|
|
<option value="ALL">Alle</option>
|
|
<option value="IMPLEMENTED">Implementiert</option>
|
|
<option value="PARTIAL">Teilweise</option>
|
|
<option value="NOT_IMPLEMENTED">Fehlend</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-600 mb-1">Anwendbarkeit</label>
|
|
<select
|
|
value={applicabilityFilter}
|
|
onChange={e => setApplicabilityFilter(e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
>
|
|
<option value="ALL">Alle</option>
|
|
<option value="REQUIRED">Erforderlich</option>
|
|
<option value="RECOMMENDED">Empfohlen</option>
|
|
<option value="OPTIONAL">Optional</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* TOM Card Grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{filteredTOMs.map(tom => {
|
|
const control = getControlById(tom.controlId)
|
|
const statusBadge = STATUS_BADGES[tom.implementationStatus] || STATUS_BADGES.NOT_IMPLEMENTED
|
|
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
|
|
const evidenceCount = tom.linkedEvidence?.length || 0
|
|
|
|
return (
|
|
<button
|
|
key={tom.id}
|
|
onClick={() => onSelectTOM(tom.id)}
|
|
className="bg-white rounded-xl border border-gray-200 p-5 text-left hover:border-purple-300 hover:shadow-md transition-all group"
|
|
>
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-xs font-mono text-gray-400">{control?.code || tom.controlId}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge.className}`}>
|
|
{statusBadge.label}
|
|
</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge.className}`}>
|
|
{typeBadge.label}
|
|
</span>
|
|
</div>
|
|
{evidenceCount > 0 && (
|
|
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
|
{evidenceCount} Nachweise
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h4 className="text-sm font-semibold text-gray-800 group-hover:text-purple-700 transition-colors mb-1">
|
|
{control?.name?.de || tom.controlId}
|
|
</h4>
|
|
<div className="text-xs text-gray-400">
|
|
{control?.category || 'Unbekannte Kategorie'}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{filteredTOMs.length === 0 && state.derivedTOMs.length > 0 && (
|
|
<div className="text-center py-10 text-gray-500">
|
|
<p>Keine TOMs entsprechen den aktuellen Filterkriterien.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|