The admin-v2 application was incomplete in the repository. This commit restores all missing components: - Admin pages (76 pages): dashboard, ai, compliance, dsgvo, education, infrastructure, communication, development, onboarding, rbac - SDK pages (45 pages): tom, dsfa, vvt, loeschfristen, einwilligungen, vendor-compliance, tom-generator, dsr, and more - Developer portal (25 pages): API docs, SDK guides, frameworks - All components, lib files, hooks, and types - Updated package.json with all dependencies The issue was caused by incomplete initial repository state - the full admin-v2 codebase existed in backend/admin-v2 and docs-src/admin-v2 but was never fully synced to the main admin-v2 directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
485 lines
20 KiB
TypeScript
485 lines
20 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Control Catalogue Page
|
|
*
|
|
* Features:
|
|
* - List all 44+ controls with filters
|
|
* - Domain-based organization (9 domains)
|
|
* - Status update / Review workflow
|
|
* - Evidence linking
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import Link from 'next/link'
|
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
|
|
interface Control {
|
|
id: string
|
|
control_id: string
|
|
domain: string
|
|
control_type: string
|
|
title: string
|
|
description: string
|
|
pass_criteria: string
|
|
implementation_guidance: string
|
|
code_reference: string
|
|
is_automated: boolean
|
|
automation_tool: string
|
|
owner: string
|
|
status: string
|
|
status_notes: string
|
|
last_reviewed_at: string | null
|
|
next_review_at: string | null
|
|
evidence_count: number
|
|
}
|
|
|
|
const DOMAIN_LABELS: Record<string, string> = {
|
|
gov: 'Governance',
|
|
priv: 'Datenschutz',
|
|
iam: 'Identity & Access',
|
|
crypto: 'Kryptografie',
|
|
sdlc: 'Secure Dev',
|
|
ops: 'Operations',
|
|
ai: 'KI-spezifisch',
|
|
cra: 'Supply Chain',
|
|
aud: 'Audit',
|
|
}
|
|
|
|
const DOMAIN_COLORS: Record<string, string> = {
|
|
gov: 'bg-slate-100 text-slate-700',
|
|
priv: 'bg-blue-100 text-blue-700',
|
|
iam: 'bg-purple-100 text-purple-700',
|
|
crypto: 'bg-yellow-100 text-yellow-700',
|
|
sdlc: 'bg-green-100 text-green-700',
|
|
ops: 'bg-orange-100 text-orange-700',
|
|
ai: 'bg-pink-100 text-pink-700',
|
|
cra: 'bg-cyan-100 text-cyan-700',
|
|
aud: 'bg-indigo-100 text-indigo-700',
|
|
}
|
|
|
|
const STATUS_STYLES: Record<string, { bg: string; text: string; icon: string; label: string }> = {
|
|
pass: { bg: 'bg-green-100', text: 'text-green-700', icon: 'M5 13l4 4L19 7', label: 'Bestanden' },
|
|
partial: { bg: 'bg-yellow-100', text: 'text-yellow-700', icon: 'M12 8v4m0 4h.01', label: 'Teilweise' },
|
|
fail: { bg: 'bg-red-100', text: 'text-red-700', icon: 'M6 18L18 6M6 6l12 12', label: 'Nicht bestanden' },
|
|
planned: { bg: 'bg-slate-100', text: 'text-slate-700', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', label: 'Geplant' },
|
|
'n/a': { bg: 'bg-slate-100', text: 'text-slate-500', icon: 'M20 12H4', label: 'Nicht anwendbar' },
|
|
}
|
|
|
|
export default function ControlsPage() {
|
|
const [controls, setControls] = useState<Control[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [selectedControl, setSelectedControl] = useState<Control | null>(null)
|
|
const [filterDomain, setFilterDomain] = useState<string>('')
|
|
const [filterStatus, setFilterStatus] = useState<string>('')
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [reviewModalOpen, setReviewModalOpen] = useState(false)
|
|
const [reviewStatus, setReviewStatus] = useState('pass')
|
|
const [reviewNotes, setReviewNotes] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
loadControls()
|
|
}, [filterDomain, filterStatus])
|
|
|
|
const loadControls = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
if (filterDomain) params.append('domain', filterDomain)
|
|
if (filterStatus) params.append('status', filterStatus)
|
|
if (searchTerm) params.append('search', searchTerm)
|
|
|
|
const res = await fetch(`/api/admin/compliance/controls?${params}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setControls(data.controls || [])
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load controls:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleSearch = () => {
|
|
loadControls()
|
|
}
|
|
|
|
const openReviewModal = (control: Control) => {
|
|
setSelectedControl(control)
|
|
setReviewStatus(control.status || 'planned')
|
|
setReviewNotes(control.status_notes || '')
|
|
setReviewModalOpen(true)
|
|
}
|
|
|
|
const submitReview = async () => {
|
|
if (!selectedControl) return
|
|
|
|
setSaving(true)
|
|
try {
|
|
const res = await fetch(`/api/admin/compliance/controls/${selectedControl.control_id}/review`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
status: reviewStatus,
|
|
status_notes: reviewNotes,
|
|
}),
|
|
})
|
|
|
|
if (res.ok) {
|
|
setReviewModalOpen(false)
|
|
loadControls()
|
|
} else {
|
|
alert('Fehler beim Speichern')
|
|
}
|
|
} catch (error) {
|
|
console.error('Review failed:', error)
|
|
alert('Fehler beim Speichern')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const filteredControls = controls.filter((c) => {
|
|
if (searchTerm) {
|
|
const term = searchTerm.toLowerCase()
|
|
return (
|
|
c.control_id.toLowerCase().includes(term) ||
|
|
c.title.toLowerCase().includes(term) ||
|
|
(c.description && c.description.toLowerCase().includes(term))
|
|
)
|
|
}
|
|
return true
|
|
})
|
|
|
|
const getDaysUntilReview = (nextReview: string | null) => {
|
|
if (!nextReview) return null
|
|
const days = Math.ceil((new Date(nextReview).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
return days
|
|
}
|
|
|
|
// Statistics
|
|
const stats = {
|
|
total: controls.length,
|
|
pass: controls.filter(c => c.status === 'pass').length,
|
|
partial: controls.filter(c => c.status === 'partial').length,
|
|
fail: controls.filter(c => c.status === 'fail').length,
|
|
planned: controls.filter(c => c.status === 'planned').length,
|
|
automated: controls.filter(c => c.is_automated).length,
|
|
overdue: controls.filter(c => {
|
|
if (!c.next_review_at) return false
|
|
return new Date(c.next_review_at) < new Date()
|
|
}).length,
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 p-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">Control Catalogue</h1>
|
|
<p className="text-slate-600">Technische & organisatorische Massnahmen</p>
|
|
</div>
|
|
<Link
|
|
href="/compliance/hub"
|
|
className="flex items-center gap-2 text-slate-600 hover:text-slate-800"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
Compliance Hub
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Page Purpose */}
|
|
<PagePurpose
|
|
title="Control Catalogue"
|
|
purpose="Der Control-Katalog dokumentiert alle technischen und organisatorischen Massnahmen (TOMs) zur Einhaltung von ISO 27001, DSGVO, AI Act und BSI TR-03161. Jede Massnahme wird regelmaessig reviewed und mit Nachweisen verknuepft."
|
|
audience={['CISO', 'DSB', 'Compliance Officer', 'Entwickler']}
|
|
gdprArticles={['Art. 32 (Sicherheit)', 'Art. 25 (Privacy by Design)']}
|
|
architecture={{
|
|
services: ['Python Backend (FastAPI)', 'compliance_controls Modul'],
|
|
databases: ['PostgreSQL (compliance_controls Table)'],
|
|
}}
|
|
relatedPages={[
|
|
{ name: 'Evidence', href: '/compliance/evidence', description: 'Nachweise zu Controls verwalten' },
|
|
{ name: 'Audit Checklist', href: '/compliance/audit-checklist', description: 'Anforderungen pruefen' },
|
|
{ name: 'Risks', href: '/compliance/risks', description: 'Risiko-Matrix verwalten' },
|
|
]}
|
|
/>
|
|
|
|
{/* Statistics Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
|
|
<div className="bg-white rounded-xl p-4 border border-slate-200">
|
|
<p className="text-sm text-slate-500">Gesamt</p>
|
|
<p className="text-2xl font-bold text-slate-900">{stats.total}</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl p-4 border border-green-200">
|
|
<p className="text-sm text-green-600">Bestanden</p>
|
|
<p className="text-2xl font-bold text-green-700">{stats.pass}</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl p-4 border border-yellow-200">
|
|
<p className="text-sm text-yellow-600">Teilweise</p>
|
|
<p className="text-2xl font-bold text-yellow-700">{stats.partial}</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl p-4 border border-red-200">
|
|
<p className="text-sm text-red-600">Nicht bestanden</p>
|
|
<p className="text-2xl font-bold text-red-700">{stats.fail}</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl p-4 border border-slate-200">
|
|
<p className="text-sm text-slate-500">Geplant</p>
|
|
<p className="text-2xl font-bold text-slate-700">{stats.planned}</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl p-4 border border-blue-200">
|
|
<p className="text-sm text-blue-600">Automatisiert</p>
|
|
<p className="text-2xl font-bold text-blue-700">{stats.automated}</p>
|
|
</div>
|
|
<div className={`bg-white rounded-xl p-4 border ${stats.overdue > 0 ? 'border-red-300 bg-red-50' : 'border-slate-200'}`}>
|
|
<p className="text-sm text-slate-500">Review faellig</p>
|
|
<p className={`text-2xl font-bold ${stats.overdue > 0 ? 'text-red-600' : 'text-slate-700'}`}>
|
|
{stats.overdue}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<input
|
|
type="text"
|
|
placeholder="Control suchen (ID, Titel, Beschreibung)..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={filterDomain}
|
|
onChange={(e) => setFilterDomain(e.target.value)}
|
|
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
>
|
|
<option value="">Alle Domains</option>
|
|
{Object.entries(DOMAIN_LABELS).map(([key, label]) => (
|
|
<option key={key} value={key}>{label}</option>
|
|
))}
|
|
</select>
|
|
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value)}
|
|
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
>
|
|
<option value="">Alle Status</option>
|
|
{Object.entries(STATUS_STYLES).map(([key, style]) => (
|
|
<option key={key} value={key}>{style.label}</option>
|
|
))}
|
|
</select>
|
|
|
|
<button
|
|
onClick={handleSearch}
|
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
|
>
|
|
Filtern
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls Table */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
|
|
</div>
|
|
) : filteredControls.length === 0 ? (
|
|
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
|
|
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
<p className="text-slate-500">Keine Controls gefunden</p>
|
|
<p className="text-sm text-slate-400 mt-1">
|
|
Versuchen Sie andere Filter oder laden Sie die Compliance-Daten im Hub
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
|
<div className="px-4 py-3 border-b bg-slate-50 flex justify-between items-center">
|
|
<span className="text-sm text-slate-500">{filteredControls.length} Controls</span>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-slate-50 border-b">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Automatisiert</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Nachweise</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Review</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-200">
|
|
{filteredControls.map((control) => {
|
|
const statusStyle = STATUS_STYLES[control.status] || STATUS_STYLES.planned
|
|
const daysUntilReview = getDaysUntilReview(control.next_review_at)
|
|
|
|
return (
|
|
<tr key={control.id} className="hover:bg-slate-50">
|
|
<td className="px-4 py-3">
|
|
<span className="font-mono font-medium text-primary-600">{control.control_id}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`px-2 py-1 text-xs rounded-full ${DOMAIN_COLORS[control.domain] || 'bg-slate-100 text-slate-700'}`}>
|
|
{DOMAIN_LABELS[control.domain] || control.domain}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div>
|
|
<p className="font-medium text-slate-900">{control.title}</p>
|
|
{control.description && (
|
|
<p className="text-sm text-slate-500 truncate max-w-md">{control.description}</p>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full ${statusStyle.bg} ${statusStyle.text}`}>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={statusStyle.icon} />
|
|
</svg>
|
|
{statusStyle.label}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
{control.is_automated ? (
|
|
<span className="inline-flex items-center gap-1 text-green-600">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<span className="text-xs">{control.automation_tool}</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-400 text-xs">Manuell</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<Link
|
|
href={`/compliance/evidence?control=${control.control_id}`}
|
|
className="text-primary-600 hover:text-primary-700 font-medium"
|
|
>
|
|
{control.evidence_count || 0}
|
|
</Link>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
{daysUntilReview !== null ? (
|
|
<span className={`text-sm ${
|
|
daysUntilReview < 0
|
|
? 'text-red-600 font-medium'
|
|
: daysUntilReview < 14
|
|
? 'text-yellow-600'
|
|
: 'text-slate-500'
|
|
}`}>
|
|
{daysUntilReview < 0
|
|
? `${Math.abs(daysUntilReview)}d ueberfaellig`
|
|
: `${daysUntilReview}d`}
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-400 text-sm">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<button
|
|
onClick={() => openReviewModal(control)}
|
|
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
|
>
|
|
Review
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Review Modal */}
|
|
{reviewModalOpen && selectedControl && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
|
<div className="p-6 border-b">
|
|
<h3 className="text-lg font-semibold text-slate-900">
|
|
Control Review
|
|
</h3>
|
|
<p className="text-sm text-slate-500 font-mono">{selectedControl.control_id}</p>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4">
|
|
<div>
|
|
<p className="font-medium text-slate-700 mb-1">{selectedControl.title}</p>
|
|
{selectedControl.pass_criteria && (
|
|
<div className="p-3 bg-slate-50 rounded-lg text-sm">
|
|
<p className="font-medium text-slate-600 mb-1">Pass-Kriterium:</p>
|
|
<p className="text-slate-600">{selectedControl.pass_criteria}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
|
|
<div className="grid grid-cols-5 gap-2">
|
|
{Object.entries(STATUS_STYLES).map(([key, style]) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setReviewStatus(key)}
|
|
className={`p-2 rounded-lg border-2 text-xs font-medium transition-colors ${
|
|
reviewStatus === key
|
|
? `${style.bg} ${style.text} border-current`
|
|
: 'border-slate-200 hover:border-slate-300'
|
|
}`}
|
|
>
|
|
{style.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Notizen</label>
|
|
<textarea
|
|
value={reviewNotes}
|
|
onChange={(e) => setReviewNotes(e.target.value)}
|
|
placeholder="Begruendung, Nachweise, naechste Schritte..."
|
|
rows={3}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setReviewModalOpen(false)}
|
|
className="px-4 py-2 text-slate-600 hover:text-slate-800"
|
|
disabled={saving}
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={submitReview}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichern...' : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|