fix(admin-v2): Restore complete admin-v2 application

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>
This commit is contained in:
BreakPilot Dev
2026-02-08 23:40:15 -08:00
parent f28244753f
commit 660295e218
385 changed files with 138126 additions and 3079 deletions

View File

@@ -0,0 +1,413 @@
'use client'
import { useState, useEffect } from 'react'
interface AuditLogEntry {
id: string
action: string
entity_type: string
entity_id?: string
old_value?: any
new_value?: any
user_email?: string
created_at: string
}
interface BlockedContentEntry {
id: string
url: string
domain: string
block_reason: string
rule_id?: string
details?: any
created_at: string
}
interface AuditTabProps {
apiBase: string
}
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
create: { label: 'Erstellt', color: 'bg-green-100 text-green-700' },
update: { label: 'Aktualisiert', color: 'bg-blue-100 text-blue-700' },
delete: { label: 'Geloescht', color: 'bg-red-100 text-red-700' },
}
const ENTITY_LABELS: Record<string, string> = {
source_policy: 'Policy',
allowed_source: 'Quelle',
operation_permission: 'Operation',
pii_rule: 'PII-Regel',
}
const BLOCK_REASON_LABELS: Record<string, { label: string; color: string }> = {
not_whitelisted: { label: 'Nicht in Whitelist', color: 'bg-amber-100 text-amber-700' },
pii_detected: { label: 'PII erkannt', color: 'bg-red-100 text-red-700' },
license_violation: { label: 'Lizenzverletzung', color: 'bg-orange-100 text-orange-700' },
training_forbidden: { label: 'Training verboten', color: 'bg-slate-800 text-white' },
}
export function AuditTab({ apiBase }: AuditTabProps) {
const [activeView, setActiveView] = useState<'changes' | 'blocked'>('changes')
// Audit logs
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([])
const [auditLoading, setAuditLoading] = useState(true)
const [auditTotal, setAuditTotal] = useState(0)
// Blocked content
const [blockedContent, setBlockedContent] = useState<BlockedContentEntry[]>([])
const [blockedLoading, setBlockedLoading] = useState(true)
const [blockedTotal, setBlockedTotal] = useState(0)
// Filters
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [entityFilter, setEntityFilter] = useState('')
// Export
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (activeView === 'changes') {
fetchAuditLogs()
} else {
fetchBlockedContent()
}
}, [activeView, dateFrom, dateTo, entityFilter])
const fetchAuditLogs = async () => {
try {
setAuditLoading(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
if (entityFilter) params.append('entity_type', entityFilter)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/policy-audit?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setAuditLogs(data.logs || [])
setAuditTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setAuditLoading(false)
}
}
const fetchBlockedContent = async () => {
try {
setBlockedLoading(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/blocked-content?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setBlockedContent(data.blocked || [])
setBlockedTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setBlockedLoading(false)
}
}
const exportReport = async () => {
try {
setExporting(true)
const params = new URLSearchParams()
if (dateFrom) params.append('from', dateFrom)
if (dateTo) params.append('to', dateTo)
params.append('format', 'download')
const res = await fetch(`${apiBase}/v1/admin/compliance-report?${params}`)
if (!res.ok) throw new Error('Fehler beim Export')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `compliance-report-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setExporting(false)
}
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* View Toggle & Filters */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex gap-2">
<button
onClick={() => setActiveView('changes')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeView === 'changes'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Aenderungshistorie
</button>
<button
onClick={() => setActiveView('blocked')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
activeView === 'blocked'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Blockierte URLs
</button>
</div>
<div className="flex-1 flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2">
<label className="text-sm text-slate-600">Von:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-slate-600">Bis:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
{activeView === 'changes' && (
<select
value={entityFilter}
onChange={(e) => setEntityFilter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
>
<option value="">Alle Typen</option>
<option value="source_policy">Policies</option>
<option value="allowed_source">Quellen</option>
<option value="operation_permission">Operations</option>
<option value="pii_rule">PII-Regeln</option>
</select>
)}
<button
onClick={exportReport}
disabled={exporting}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2 ml-auto"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
{exporting ? 'Exportiere...' : 'JSON Export'}
</button>
</div>
</div>
{/* Changes View */}
{activeView === 'changes' && (
<>
{auditLoading ? (
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
) : auditLogs.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Eintraege vorhanden</h3>
<p className="text-sm text-slate-500">
Aenderungen werden hier protokolliert.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 text-sm text-slate-600">
{auditTotal} Eintraege gesamt
</div>
<div className="divide-y divide-slate-100">
{auditLogs.map((log) => {
const actionConfig = ACTION_LABELS[log.action] || { label: log.action, color: 'bg-slate-100 text-slate-700' }
return (
<div key={log.id} className="px-4 py-4 hover:bg-slate-50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${actionConfig.color}`}>
{actionConfig.label}
</span>
<span className="text-sm text-slate-700">
{ENTITY_LABELS[log.entity_type] || log.entity_type}
</span>
{log.entity_id && (
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-500">
{log.entity_id.substring(0, 8)}...
</code>
)}
</div>
<div className="text-xs text-slate-500">
{formatDate(log.created_at)}
</div>
</div>
{log.user_email && (
<div className="mt-2 text-xs text-slate-500">
Benutzer: {log.user_email}
</div>
)}
{(log.old_value || log.new_value) && (
<div className="mt-2 flex gap-4 text-xs">
{log.old_value && (
<div className="flex-1 p-2 bg-red-50 rounded">
<div className="text-red-600 font-medium mb-1">Vorher:</div>
<pre className="text-red-700 overflow-x-auto">
{typeof log.old_value === 'string' ? log.old_value : JSON.stringify(log.old_value, null, 2)}
</pre>
</div>
)}
{log.new_value && (
<div className="flex-1 p-2 bg-green-50 rounded">
<div className="text-green-600 font-medium mb-1">Nachher:</div>
<pre className="text-green-700 overflow-x-auto">
{typeof log.new_value === 'string' ? log.new_value : JSON.stringify(log.new_value, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</>
)}
{/* Blocked Content View */}
{activeView === 'blocked' && (
<>
{blockedLoading ? (
<div className="text-center py-12 text-slate-500">Lade blockierte URLs...</div>
) : blockedContent.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-green-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine blockierten URLs</h3>
<p className="text-sm text-slate-500">
Alle gecrawlten URLs waren in der Whitelist.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 text-sm text-slate-600">
{blockedTotal} blockierte URLs gesamt
</div>
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">URL</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Grund</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Zeitpunkt</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{blockedContent.map((entry) => {
const reasonConfig = BLOCK_REASON_LABELS[entry.block_reason] || {
label: entry.block_reason,
color: 'bg-slate-100 text-slate-700',
}
return (
<tr key={entry.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="text-sm text-slate-700 truncate max-w-md" title={entry.url}>
{entry.url}
</div>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded">{entry.domain}</code>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${reasonConfig.color}`}>
{reasonConfig.label}
</span>
</td>
<td className="px-4 py-3 text-xs text-slate-500">
{formatDate(entry.created_at)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
{/* Auditor Info Box */}
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-blue-800">Fuer Auditoren</h3>
<p className="text-sm text-blue-700 mt-1">
Dieses Audit-Log ist unveraenderlich und protokolliert alle Aenderungen an der Quellen-Policy.
Jeder Eintrag enthaelt:
</p>
<ul className="text-sm text-blue-700 mt-2 list-disc list-inside">
<li>Zeitstempel der Aenderung</li>
<li>Art der Aenderung (Erstellen/Aendern/Loeschen)</li>
<li>Betroffene Entitaet und ID</li>
<li>Vorheriger und neuer Wert</li>
<li>E-Mail des Benutzers (falls angemeldet)</li>
</ul>
<p className="text-sm text-blue-600 mt-2 font-medium">
Der JSON-Export ist fuer die externe Pruefung und Archivierung geeignet.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,271 @@
'use client'
import { useState, useEffect } from 'react'
interface OperationPermission {
id: string
source_id: string
operation: string
is_allowed: boolean
requires_citation: boolean
notes?: string
}
interface SourceWithOperations {
id: string
domain: string
name: string
license: string
is_active: boolean
operations: OperationPermission[]
}
interface OperationsMatrixTabProps {
apiBase: string
}
const OPERATIONS = [
{ id: 'lookup', name: 'Lookup', description: 'Inhalt anzeigen/durchsuchen', icon: '🔍' },
{ id: 'rag', name: 'RAG', description: 'Retrieval Augmented Generation', icon: '🤖' },
{ id: 'training', name: 'Training', description: 'KI-Training (VERBOTEN)', icon: '🚫' },
{ id: 'export', name: 'Export', description: 'Daten exportieren', icon: '📤' },
]
export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
const [sources, setSources] = useState<SourceWithOperations[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updating, setUpdating] = useState<string | null>(null)
useEffect(() => {
fetchMatrix()
}, [])
const fetchMatrix = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/operations-matrix`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setSources(data.sources || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const togglePermission = async (
source: SourceWithOperations,
operationId: string,
field: 'is_allowed' | 'requires_citation'
) => {
// Find the permission
const permission = source.operations.find((op) => op.operation === operationId)
if (!permission) return
// Block enabling training
if (operationId === 'training' && field === 'is_allowed' && !permission.is_allowed) {
setError('Training mit externen Daten ist VERBOTEN und kann nicht aktiviert werden.')
return
}
const updateId = `${permission.id}-${field}`
setUpdating(updateId)
try {
const newValue = !permission[field]
const res = await fetch(`${apiBase}/v1/admin/operations/${permission.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: newValue }),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.message || errData.error || 'Fehler beim Aktualisieren')
}
fetchMatrix()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setUpdating(null)
}
}
if (loading) {
return <div className="text-center py-12 text-slate-500">Lade Operations-Matrix...</div>
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Legend */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
<h3 className="font-medium text-slate-900 mb-3">Legende</h3>
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-green-100 text-green-700 rounded">
<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>
<span className="text-slate-600">Erlaubt</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 rounded">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
<span className="text-slate-600">Verboten</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-amber-100 text-amber-700 rounded text-xs">
Cite
</span>
<span className="text-slate-600">Zitation erforderlich</span>
</div>
<div className="flex items-center gap-2">
<span className="w-8 h-8 flex items-center justify-center bg-slate-800 text-white rounded">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</span>
<span className="text-slate-600">System-gesperrt (Training)</span>
</div>
</div>
</div>
{/* Matrix Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-x-auto">
<table className="w-full min-w-[800px]">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Quelle</th>
{OPERATIONS.map((op) => (
<th key={op.id} className="text-center px-4 py-3">
<div className="flex flex-col items-center gap-1">
<span className="text-lg">{op.icon}</span>
<span className="text-xs font-medium text-slate-500 uppercase">{op.name}</span>
<span className="text-xs text-slate-400 font-normal">{op.description}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sources.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Keine Quellen vorhanden
</td>
</tr>
) : (
sources.map((source) => (
<tr key={source.id} className={`hover:bg-slate-50 ${!source.is_active ? 'opacity-50' : ''}`}>
<td className="px-4 py-3">
<div>
<div className="font-medium text-slate-800">{source.name}</div>
<code className="text-xs text-slate-500">{source.domain}</code>
</div>
</td>
{OPERATIONS.map((op) => {
const permission = source.operations.find((p) => p.operation === op.id)
const isTraining = op.id === 'training'
const isAllowed = permission?.is_allowed ?? false
const requiresCitation = permission?.requires_citation ?? false
const isUpdating = updating === `${permission?.id}-is_allowed` || updating === `${permission?.id}-requires_citation`
return (
<td key={op.id} className="px-4 py-3 text-center">
<div className="flex flex-col items-center gap-2">
{/* Is Allowed Toggle */}
<button
onClick={() => togglePermission(source, op.id, 'is_allowed')}
disabled={isTraining || isUpdating || !source.is_active}
className={`w-10 h-10 flex items-center justify-center rounded transition-colors ${
isTraining
? 'bg-slate-800 text-white cursor-not-allowed'
: isAllowed
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-red-100 text-red-700 hover:bg-red-200'
} ${isUpdating ? 'opacity-50' : ''}`}
title={isTraining ? 'Training ist system-weit gesperrt' : isAllowed ? 'Klicken zum Deaktivieren' : 'Klicken zum Aktivieren'}
>
{isTraining ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : isAllowed ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</button>
{/* Citation Required Toggle (only for allowed non-training ops) */}
{isAllowed && !isTraining && (
<button
onClick={() => togglePermission(source, op.id, 'requires_citation')}
disabled={isUpdating || !source.is_active}
className={`px-2 py-1 text-xs rounded transition-colors ${
requiresCitation
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
} ${isUpdating ? 'opacity-50' : ''}`}
title={requiresCitation ? 'Zitation erforderlich - Klicken zum Aendern' : 'Klicken um Zitation zu erfordern'}
>
{requiresCitation ? 'Cite ✓' : 'Cite'}
</button>
)}
</div>
</td>
)
})}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Training Warning */}
<div className="mt-6 bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-red-800">Training-Operation: System-gesperrt</h3>
<p className="text-sm text-red-700 mt-1">
Das Training von KI-Modellen mit gecrawlten externen Daten ist aufgrund von Urheberrechts- und
Datenschutzbestimmungen grundsaetzlich verboten. Diese Einschraenkung ist im System hart kodiert
und kann nicht ueber diese Oberflaeche geaendert werden.
</p>
<p className="text-sm text-red-600 mt-2 font-medium">
Ausnahmen erfordern eine schriftliche Genehmigung des DSB und eine rechtliche Pruefung.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,562 @@
'use client'
import { useState, useEffect } from 'react'
interface PIIRule {
id: string
name: string
rule_type: string
pattern: string
severity: string
is_active: boolean
created_at: string
updated_at: string
}
interface PIIMatch {
rule_id: string
rule_name: string
rule_type: string
severity: string
match: string
start_index: number
end_index: number
}
interface PIITestResult {
has_pii: boolean
matches: PIIMatch[]
should_block: boolean
block_level: string
}
interface PIIRulesTabProps {
apiBase: string
onUpdate?: () => void
}
const RULE_TYPES = [
{ value: 'regex', label: 'Regex (Muster)' },
{ value: 'keyword', label: 'Keyword (Stichwort)' },
]
const SEVERITIES = [
{ value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' },
{ value: 'redact', label: 'Schwärzen', color: 'bg-orange-100 text-orange-700' },
{ value: 'block', label: 'Blockieren', color: 'bg-red-100 text-red-700' },
]
export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const [rules, setRules] = useState<PIIRule[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Test panel
const [testText, setTestText] = useState('')
const [testResult, setTestResult] = useState<PIITestResult | null>(null)
const [testing, setTesting] = useState(false)
// Edit modal
const [editingRule, setEditingRule] = useState<PIIRule | null>(null)
const [isNewRule, setIsNewRule] = useState(false)
const [saving, setSaving] = useState(false)
// New rule form
const [newRule, setNewRule] = useState({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
})
useEffect(() => {
fetchRules()
}, [])
const fetchRules = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setRules(data.rules || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createRule = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newRule),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewRule({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
})
setIsNewRule(false)
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const updateRule = async () => {
if (!editingRule) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${editingRule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingRule(null)
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const deleteRule = async (id: string) => {
if (!confirm('Regel wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const toggleRuleStatus = async (rule: PIIRule) => {
try {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchRules()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const runTest = async () => {
if (!testText) return
try {
setTesting(true)
const res = await fetch(`${apiBase}/v1/admin/pii-rules/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText }),
})
if (!res.ok) throw new Error('Fehler beim Testen')
const data = await res.json()
setTestResult(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setTesting(false)
}
}
const getSeverityBadge = (severity: string) => {
const config = SEVERITIES.find((s) => s.value === severity)
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}>
{config?.label || severity}
</span>
)
}
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Test Panel */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<h3 className="font-semibold text-slate-900 mb-4">PII-Test</h3>
<p className="text-sm text-slate-600 mb-4">
Testen Sie, ob ein Text personenbezogene Daten (PII) enthaelt.
</p>
<textarea
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder="Geben Sie hier einen Text zum Testen ein...
Beispiel:
Kontaktieren Sie mich unter max.mustermann@example.com oder
rufen Sie mich an unter +49 170 1234567.
Meine IBAN ist DE89 3704 0044 0532 0130 00."
rows={6}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
<div className="flex justify-between items-center mt-4">
<button
onClick={() => {
setTestText('')
setTestResult(null)
}}
className="text-sm text-slate-500 hover:text-slate-700"
>
Zuruecksetzen
</button>
<button
onClick={runTest}
disabled={testing || !testText}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{testing ? 'Teste...' : 'Testen'}
</button>
</div>
{/* Test Results */}
{testResult && (
<div className={`mt-4 p-4 rounded-lg ${testResult.should_block ? 'bg-red-50 border border-red-200' : testResult.has_pii ? 'bg-amber-50 border border-amber-200' : 'bg-green-50 border border-green-200'}`}>
<div className="flex items-center gap-2 mb-3">
{testResult.should_block ? (
<>
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="font-medium text-red-800">Blockiert - Kritische PII gefunden</span>
</>
) : testResult.has_pii ? (
<>
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="font-medium text-amber-800">Warnung - PII gefunden ({testResult.matches.length} Treffer)</span>
</>
) : (
<>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="font-medium text-green-800">Keine PII gefunden</span>
</>
)}
</div>
{testResult.matches.length > 0 && (
<div className="space-y-2">
{testResult.matches.map((match, idx) => (
<div key={idx} className="flex items-center gap-3 text-sm bg-white bg-opacity-50 rounded px-3 py-2">
{getSeverityBadge(match.severity)}
<span className="text-slate-700 font-medium">{match.rule_name}</span>
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
{match.match.length > 30 ? match.match.substring(0, 30) + '...' : match.match}
</code>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Rules List Header */}
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
<button
onClick={() => setIsNewRule(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Regel
</button>
</div>
{/* Rules Table */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Regeln...</div>
) : rules.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Regeln vorhanden</h3>
<p className="text-sm text-slate-500">
Fuegen Sie PII-Erkennungsregeln hinzu.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Muster</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Severity</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rules.map((rule) => (
<tr key={rule.id} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm font-medium text-slate-800">{rule.name}</td>
<td className="px-4 py-3">
<span className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded">
{rule.rule_type}
</span>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded max-w-xs truncate block">
{rule.pattern.length > 40 ? rule.pattern.substring(0, 40) + '...' : rule.pattern}
</code>
</td>
<td className="px-4 py-3">{getSeverityBadge(rule.severity)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggleRuleStatus(rule)}
className={`text-xs px-2 py-1 rounded ${
rule.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{rule.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingRule(rule)}
className="text-purple-600 hover:text-purple-700 mr-3"
>
Bearbeiten
</button>
<button
onClick={() => deleteRule(rule.id)}
className="text-red-600 hover:text-red-700"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Rule Modal */}
{isNewRule && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue PII-Regel</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newRule.name}
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
placeholder="z.B. Deutsche Telefonnummern"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ *</label>
<select
value={newRule.rule_type}
onChange={(e) => setNewRule({ ...newRule, rule_type: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
<textarea
value={newRule.pattern}
onChange={(e) => setNewRule({ ...newRule, pattern: e.target.value })}
placeholder={newRule.rule_type === 'regex' ? 'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...' : 'Keywords getrennt durch Komma, z.B. password,secret,api_key'}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity *</label>
<select
value={newRule.severity}
onChange={(e) => setNewRule({ ...newRule, severity: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setIsNewRule(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={createRule}
disabled={saving || !newRule.name || !newRule.pattern}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Rule Modal */}
{editingRule && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">PII-Regel bearbeiten</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={editingRule.name}
onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={editingRule.rule_type}
onChange={(e) => setEditingRule({ ...editingRule, rule_type: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
<textarea
value={editingRule.pattern}
onChange={(e) => setEditingRule({ ...editingRule, pattern: e.target.value })}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity</label>
<select
value={editingRule.severity}
onChange={(e) => setEditingRule({ ...editingRule, severity: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="edit_is_active"
checked={editingRule.is_active}
onChange={(e) => setEditingRule({ ...editingRule, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="edit_is_active" className="text-sm text-slate-700">
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setEditingRule(null)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={updateRule}
disabled={saving}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,525 @@
'use client'
import { useState, useEffect } from 'react'
interface AllowedSource {
id: string
policy_id: string
domain: string
name: string
license: string
legal_basis?: string
citation_template?: string
trust_boost: number
is_active: boolean
created_at: string
updated_at: string
}
interface SourcesTabProps {
apiBase: string
onUpdate?: () => void
}
const LICENSES = [
{ value: 'DL-DE-BY-2.0', label: 'Datenlizenz Deutschland' },
{ value: 'CC-BY', label: 'Creative Commons BY' },
{ value: 'CC-BY-SA', label: 'Creative Commons BY-SA' },
{ value: 'CC0', label: 'Public Domain' },
{ value: '§5 UrhG', label: 'Amtliche Werke (§5 UrhG)' },
]
const BUNDESLAENDER = [
{ value: '', label: 'Bundesebene' },
{ value: 'NI', label: 'Niedersachsen' },
{ value: 'BY', label: 'Bayern' },
{ value: 'BW', label: 'Baden-Wuerttemberg' },
{ value: 'NW', label: 'Nordrhein-Westfalen' },
{ value: 'HE', label: 'Hessen' },
{ value: 'SN', label: 'Sachsen' },
{ value: 'BE', label: 'Berlin' },
{ value: 'HH', label: 'Hamburg' },
]
export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const [sources, setSources] = useState<AllowedSource[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [searchTerm, setSearchTerm] = useState('')
const [licenseFilter, setLicenseFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
// Edit modal
const [editingSource, setEditingSource] = useState<AllowedSource | null>(null)
const [isNewSource, setIsNewSource] = useState(false)
const [saving, setSaving] = useState(false)
// New source form
const [newSource, setNewSource] = useState({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '', // Will be set from policies
})
useEffect(() => {
fetchSources()
}, [licenseFilter, statusFilter])
const fetchSources = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (licenseFilter) params.append('license', licenseFilter)
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false')
const res = await fetch(`${apiBase}/v1/admin/sources?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setSources(data.sources || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
const createSource = async () => {
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSource),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
setNewSource({
domain: '',
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '',
})
setIsNewSource(false)
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const updateSource = async () => {
if (!editingSource) return
try {
setSaving(true)
const res = await fetch(`${apiBase}/v1/admin/sources/${editingSource.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingSource),
})
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
setEditingSource(null)
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSaving(false)
}
}
const deleteSource = async (id: string) => {
if (!confirm('Quelle wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Fehler beim Loeschen')
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const toggleSourceStatus = async (source: AllowedSource) => {
try {
const res = await fetch(`${apiBase}/v1/admin/sources/${source.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !source.is_active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
fetchSources()
onUpdate?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}
const filteredSources = sources.filter((source) => {
if (searchTerm) {
const term = searchTerm.toLowerCase()
if (!source.domain.toLowerCase().includes(term) && !source.name.toLowerCase().includes(term)) {
return false
}
}
return true
})
return (
<div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Filters & Actions */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Domain oder Name suchen..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<select
value={licenseFilter}
onChange={(e) => setLicenseFilter(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Alle Lizenzen</option>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="all">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<button
onClick={() => setIsNewSource(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Quelle
</button>
</div>
{/* Sources Table */}
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Quellen...</div>
) : filteredSources.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Quellen gefunden</h3>
<p className="text-sm text-slate-500">
Fuegen Sie neue Quellen zur Whitelist hinzu.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Lizenz</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Trust</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredSources.map((source) => (
<tr key={source.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<code className="text-sm bg-slate-100 px-2 py-1 rounded">{source.domain}</code>
</td>
<td className="px-4 py-3 text-sm text-slate-700">{source.name}</td>
<td className="px-4 py-3">
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
{source.license}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{(source.trust_boost * 100).toFixed(0)}%
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleSourceStatus(source)}
className={`text-xs px-2 py-1 rounded ${
source.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{source.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingSource(source)}
className="text-purple-600 hover:text-purple-700 mr-3"
>
Bearbeiten
</button>
<button
onClick={() => deleteSource(source.id)}
className="text-red-600 hover:text-red-700"
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* New Source Modal */}
{isNewSource && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue Quelle hinzufuegen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Domain *</label>
<input
type="text"
value={newSource.domain}
onChange={(e) => setNewSource({ ...newSource, domain: e.target.value })}
placeholder="z.B. nibis.de"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newSource.name}
onChange={(e) => setNewSource({ ...newSource, name: e.target.value })}
placeholder="z.B. NiBiS Bildungsserver"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Lizenz *</label>
<select
value={newSource.license}
onChange={(e) => setNewSource({ ...newSource, license: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<input
type="text"
value={newSource.legal_basis}
onChange={(e) => setNewSource({ ...newSource, legal_basis: e.target.value })}
placeholder="z.B. §5 UrhG (Amtliche Werke)"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={newSource.trust_boost}
onChange={(e) => setNewSource({ ...newSource, trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(newSource.trust_boost * 100).toFixed(0)}%
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setIsNewSource(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={createSource}
disabled={saving || !newSource.domain || !newSource.name}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Source Modal */}
{editingSource && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quelle bearbeiten</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Domain</label>
<input
type="text"
value={editingSource.domain}
disabled
className="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-50 text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
value={editingSource.name}
onChange={(e) => setEditingSource({ ...editingSource, name: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Lizenz *</label>
<select
value={editingSource.license}
onChange={(e) => setEditingSource({ ...editingSource, license: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{LICENSES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
<input
type="text"
value={editingSource.legal_basis || ''}
onChange={(e) => setEditingSource({ ...editingSource, legal_basis: e.target.value })}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitiervorlage</label>
<input
type="text"
value={editingSource.citation_template || ''}
onChange={(e) => setEditingSource({ ...editingSource, citation_template: e.target.value })}
placeholder="Quelle: {source}, {title}, {date}"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={editingSource.trust_boost}
onChange={(e) => setEditingSource({ ...editingSource, trust_boost: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="text-xs text-slate-500 text-right">
{(editingSource.trust_boost * 100).toFixed(0)}%
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={editingSource.is_active}
onChange={(e) => setEditingSource({ ...editingSource, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600"
/>
<label htmlFor="is_active" className="text-sm text-slate-700">
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
<button
onClick={() => setEditingSource(null)}
className="px-4 py-2 text-slate-600 hover:text-slate-700"
>
Abbrechen
</button>
<button
onClick={updateSource}
disabled={saving}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,804 @@
'use client'
/**
* Source Policy Management Page
*
* Whitelist-based data source management for edu-search-service.
* For auditors: Full audit trail for all changes.
*/
import { useState, useEffect } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { SourcesTab } from './components/SourcesTab'
import { OperationsMatrixTab } from './components/OperationsMatrixTab'
import { PIIRulesTab } from './components/PIIRulesTab'
import { AuditTab } from './components/AuditTab'
// API base URL for edu-search-service
// Uses nginx HTTPS proxy on port 8089 when accessed remotely
const getApiBase = () => {
if (typeof window === 'undefined') return 'http://localhost:8088'
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:8088'
}
// Use nginx HTTPS proxy on port 8089 (proxies to edu-search-service:8088)
return `https://${hostname}:8089`
}
interface PolicyStats {
active_policies: number
allowed_sources: number
pii_rules: number
blocked_today: number
blocked_total: number
}
type TabId = 'dashboard' | 'sources' | 'operations' | 'pii' | 'audit'
export default function SourcePolicyPage() {
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
const [stats, setStats] = useState<PolicyStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [apiBase, setApiBase] = useState<string | null>(null)
useEffect(() => {
// Set API base on client side - only runs in browser
const base = getApiBase()
setApiBase(base)
}, [])
useEffect(() => {
// Only fetch when apiBase has been set by the first useEffect
if (apiBase !== null) {
fetchStats()
}
}, [apiBase])
const fetchStats = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/policy-stats`)
if (!res.ok) {
throw new Error('Fehler beim Laden der Statistiken')
}
const data = await res.json()
setStats(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
// Set default stats on error
setStats({
active_policies: 0,
allowed_sources: 0,
pii_rules: 0,
blocked_today: 0,
blocked_total: 0,
})
} finally {
setLoading(false)
}
}
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'dashboard',
name: 'Dashboard',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
),
},
{
id: 'sources',
name: 'Quellen',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
),
},
{
id: 'operations',
name: 'Operations',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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 2m-6 9l2 2 4-4" />
</svg>
),
},
{
id: 'pii',
name: 'PII-Regeln',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
),
},
{
id: 'audit',
name: 'Audit',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
),
},
]
return (
<div>
{/* Page Purpose */}
<PagePurpose
title="Quellen-Policy"
purpose="Whitelist-basiertes Datenquellen-Management fuer das Bildungssuch-System. Nur offizielle Open-Data-Portale und amtliche Quellen (§5 UrhG). Training mit externen Daten ist VERBOTEN. Fuer Auditoren pruefbar mit vollstaendigem Audit-Trail."
audience={['DSB', 'Compliance Officer', 'Auditor']}
gdprArticles={[
'Art. 5 (Rechtmaessigkeit)',
'Art. 6 (Rechtsgrundlage)',
'Art. 24 (Verantwortung)',
]}
architecture={{
services: ['edu-search-service (Go)', 'PostgreSQL'],
databases: ['source_policies', 'allowed_sources', 'pii_rules', 'policy_audit_log'],
}}
relatedPages={[
{ name: 'Audit Report', href: '/compliance/audit-report', description: 'Compliance-Berichte' },
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Kontrollen' },
{ name: 'Education Search', href: '/education/edu-search', description: 'Bildungsquellen' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
&times;
</button>
</div>
)}
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-purple-600">{stats.active_policies}</div>
<div className="text-sm text-slate-500">Aktive Policies</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.allowed_sources}</div>
<div className="text-sm text-slate-500">Zugelassene Quellen</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-red-600">{stats.blocked_today}</div>
<div className="text-sm text-slate-500">Blockiert (heute)</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.pii_rules}</div>
<div className="text-sm text-slate-500">PII-Regeln</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 mb-6 flex-wrap">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
activeTab === tab.id
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{tab.icon}
{tab.name}
</button>
))}
</div>
{/* Tab Content */}
{apiBase === null ? (
<div className="text-center py-12 text-slate-500">Initialisiere...</div>
) : (
<>
{activeTab === 'dashboard' && (
<DashboardTab stats={stats} loading={loading} apiBase={apiBase} />
)}
{activeTab === 'sources' && <SourcesTab apiBase={apiBase} onUpdate={fetchStats} />}
{activeTab === 'operations' && <OperationsMatrixTab apiBase={apiBase} />}
{activeTab === 'pii' && <PIIRulesTab apiBase={apiBase} onUpdate={fetchStats} />}
{activeTab === 'audit' && <AuditTab apiBase={apiBase} />}
</>
)}
</div>
)
}
// Dashboard Tab Component
function DashboardTab({
stats,
loading,
apiBase,
}: {
stats: PolicyStats | null
loading: boolean
apiBase: string
}) {
const [complianceCheck, setComplianceCheck] = useState({
url: '',
operation: 'lookup',
})
const [checkResult, setCheckResult] = useState<any>(null)
const [checking, setChecking] = useState(false)
const runComplianceCheck = async () => {
if (!complianceCheck.url) return
try {
setChecking(true)
const res = await fetch(`${apiBase}/v1/admin/check-compliance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(complianceCheck),
})
const data = await res.json()
setCheckResult(data)
} catch (err) {
setCheckResult({ error: 'Fehler bei der Pruefung' })
} finally {
setChecking(false)
}
}
if (loading) {
return <div className="text-center py-12 text-slate-500">Lade Dashboard...</div>
}
return (
<div className="space-y-6">
{/* Important Notice */}
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-red-800">Training mit externen Daten: VERBOTEN</h3>
<p className="text-sm text-red-700 mt-1">
Gemaess unserer Datenschutz-Policy ist das Training von KI-Modellen mit gecrawlten Daten
strengstens untersagt. Diese Einschraenkung kann nicht ueber die UI geaendert werden.
</p>
</div>
</div>
</div>
{/* Quick Compliance Check */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Schnell-Pruefung</h3>
<p className="text-sm text-slate-600 mb-4">
Pruefen Sie, ob eine URL in der Whitelist enthalten ist und welche Operationen erlaubt sind.
</p>
<div className="flex flex-col md:flex-row gap-4">
<input
type="url"
value={complianceCheck.url}
onChange={(e) => setComplianceCheck({ ...complianceCheck, url: e.target.value })}
placeholder="https://nibis.de/beispiel-seite"
className="flex-1 px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<select
value={complianceCheck.operation}
onChange={(e) => setComplianceCheck({ ...complianceCheck, operation: e.target.value })}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="lookup">Lookup (Anzeigen)</option>
<option value="rag">RAG (Retrieval)</option>
<option value="export">Export</option>
<option value="training">Training</option>
</select>
<button
onClick={runComplianceCheck}
disabled={checking || !complianceCheck.url}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{checking ? 'Pruefe...' : 'Pruefen'}
</button>
</div>
{checkResult && (
<div className={`mt-4 p-4 rounded-lg ${checkResult.is_allowed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className="flex items-center gap-2 mb-2">
{checkResult.is_allowed ? (
<>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="font-medium text-green-800">Erlaubt</span>
</>
) : (
<>
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="font-medium text-red-800">
Blockiert: {checkResult.block_reason || 'Nicht in Whitelist'}
</span>
</>
)}
</div>
{checkResult.source && (
<div className="text-sm text-slate-600">
<p><strong>Quelle:</strong> {checkResult.source.name}</p>
<p><strong>Lizenz:</strong> {checkResult.license}</p>
{checkResult.requires_citation && (
<p className="text-amber-600">Zitation erforderlich</p>
)}
</div>
)}
</div>
)}
</div>
{/* Operations Matrix by Source Type */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zulaessige Operationen nach Quellentyp</h3>
<p className="text-sm text-slate-600 mb-4">
Uebersicht welche Operationen fuer welche Datenquellen erlaubt sind.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left py-2 px-3 font-medium text-slate-700">Quelle / Typ</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Daten</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Lookup</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">RAG</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Training</th>
<th className="text-center py-2 px-2 font-medium text-slate-700">Export</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Rechtsgrundlage</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Auflagen / Controls</th>
</tr>
</thead>
<tbody>
{[
{ source: 'Landes-Open-Data-Portale (alle Laender)', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'no', export: 'warn', basis: 'DL-DE-BY-2.0', note: 'Namensnennung, Quellenlink, Zweckbindung' },
{ source: 'Landes-Open-Data-Portale', data: 'PBD', lookup: 'no', rag: 'no', training: 'no', export: 'no', basis: '—', note: 'Technisch filtern (Schema-Block)' },
{ source: 'Regelwerke / Schulordnungen (Ministerien)', data: 'DOK', lookup: 'yes', rag: 'yes', training: 'warn', export: 'warn', basis: 'UrhG §5 / CC / DL', note: 'Nur amtliche Texte, Versions-Hash' },
{ source: 'GovData', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'no', export: 'warn', basis: 'DL-DE-BY-2.0', note: 'Bundesweiter Fallback' },
{ source: 'Einzelschul-Websites', data: 'SMD', lookup: 'warn', rag: 'no', training: 'no', export: 'no', basis: '§60d greift nicht', note: 'Nur manuell, kein Crawling' },
{ source: 'Private Schulverzeichnisse', data: 'SMD', lookup: 'no', rag: 'no', training: 'no', export: 'no', basis: 'Datenbankrecht', note: 'Nicht zulaessig' },
{ source: 'Vom Lehrer eingegebene Daten', data: 'SMD', lookup: 'yes', rag: 'yes', training: 'warn', export: 'warn', basis: 'Art. 6(1)b DSGVO', note: 'Zweckbindung, Namespace' },
{ source: 'Vom Lehrer hochgeladene Dokumente', data: 'DOK', lookup: 'yes', rag: 'yes', training: 'no', export: 'no', basis: 'Art. 6(1)b DSGVO', note: 'Kein Training, nur Session-RAG' },
].map((row, idx) => (
<tr key={idx} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-2 px-3 font-medium text-slate-800 text-xs">{row.source}</td>
<td className="py-2 px-3">
<span className={`px-1.5 py-0.5 rounded text-xs ${
row.data === 'SMD' ? 'bg-blue-100 text-blue-700' : row.data === 'PBD' ? 'bg-red-100 text-red-700' : 'bg-purple-100 text-purple-700'
}`}>{row.data}</span>
</td>
<td className="py-2 px-2 text-center">{row.lookup === 'yes' ? '✅' : row.lookup === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.rag === 'yes' ? '✅' : row.rag === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.training === 'yes' ? '✅' : row.training === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-2 text-center">{row.export === 'yes' ? '✅' : row.export === 'warn' ? '⚠️' : '❌'}</td>
<td className="py-2 px-3 text-xs">
<span className={`px-1.5 py-0.5 rounded ${
row.basis === '—' ? 'bg-slate-100 text-slate-500' :
row.basis.includes('DSGVO') ? 'bg-blue-100 text-blue-700' :
row.basis.includes('DL-DE') ? 'bg-green-100 text-green-700' :
row.basis.includes('UrhG') || row.basis.includes('CC') ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
}`}>{row.basis}</span>
</td>
<td className="py-2 px-3 text-slate-600 text-xs">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Legend and Explanation */}
<div className="mt-4 p-4 bg-slate-50 rounded-lg">
<h4 className="font-medium text-slate-800 mb-3">Geltungsbereich der Matrix</h4>
{/* Datenarten */}
<div className="mb-4">
<div className="text-sm font-medium text-slate-700 mb-2">Datenarten</div>
<div className="flex flex-wrap gap-3 text-xs">
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">SMD</span>
<span className="text-slate-600">= Schul-Metadaten (Name, Nummer, Schulform, Ort, Traeger)</span>
</span>
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">PBD</span>
<span className="text-slate-600">= Personenbezogene Daten (Leitung, E-Mail, Telefon)</span>
</span>
<span className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">DOK</span>
<span className="text-slate-600">= Regelwerke / Ordnungen / Lehrplaene</span>
</span>
</div>
</div>
{/* Verarbeitungsarten mit aufklappbarer Erklärung */}
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-slate-700 mb-2 flex items-center gap-2 hover:text-purple-600">
<svg className="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Verarbeitungsarten (Details anzeigen)
</summary>
<div className="ml-6 mt-2 space-y-3 text-sm">
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-green-600">Lookup</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Auswahl / Validierung / Anzeige</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden abgerufen und dem Nutzer angezeigt, z.B. bei der Schulauswahl im Onboarding
oder zur Validierung eingegebener Schulnummern. Keine dauerhafte Speicherung oder Weiterverarbeitung.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-blue-600">RAG</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Retrieval-Index (Kontext, Zitierquelle)</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden in einen Vektor-Index aufgenommen und koennen als Kontext fuer KI-Antworten
herangezogen werden. Die Quelle wird zitiert. Keine Veraenderung der Modelgewichte.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-red-600">Training</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Modellanpassung / Fine-Tuning</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten fliessen in das Training oder Fine-Tuning eines KI-Modells ein und veraendern
dessen Gewichte permanent. <strong className="text-red-600">Grundsaetzlich VERBOTEN</strong> fuer
externe Daten gemaess unserer Datenschutz-Policy.
</p>
</div>
<div className="p-3 bg-white rounded border border-slate-200">
<div className="font-medium text-slate-800 flex items-center gap-2">
<span className="text-amber-600">Export</span>
<span className="text-slate-400">=</span>
<span className="text-slate-600">Weitergabe / Download / API</span>
</div>
<p className="text-xs text-slate-500 mt-1">
Daten werden an Dritte weitergegeben, zum Download bereitgestellt oder ueber eine API
ausgegeben. Erfordert Pruefung der Lizenzbedingungen und ggf. Namensnennung.
</p>
</div>
</div>
</details>
</div>
</div>
{/* KI Use-Case Risk Matrix */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">KI-Use-Case Risikomatrix</h3>
<p className="text-sm text-slate-600 mb-4">
Zulaessigkeit von KI-Anwendungsfaellen nach Datenquelle.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left py-2 px-3 font-medium text-slate-700">KI-Use-Case</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Open-Data SMD</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Regelwerke</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Lehrer-Uploads</th>
<th className="text-center py-2 px-3 font-medium text-slate-700">Risiko</th>
</tr>
</thead>
<tbody>
{[
{ useCase: 'Schul-Auswahl / Onboarding', openData: 'yes', rules: 'na', uploads: 'na', risk: 'low' },
{ useCase: 'Erwartungshorizont-Suche', openData: 'na', rules: 'yes', uploads: 'warn', risk: 'medium' },
{ useCase: 'Klausur-Korrektur (RAG)', openData: 'na', rules: 'warn', uploads: 'yes', risk: 'medium' },
{ useCase: 'Modell-Training', openData: 'no', rules: 'warn', uploads: 'no', risk: 'high' },
{ useCase: 'Auto-Schulerkennung', openData: 'no', rules: 'no', uploads: 'no', risk: 'high' },
].map((row, idx) => (
<tr key={idx} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-2 px-3 font-medium text-slate-800">{row.useCase}</td>
<td className="py-2 px-3 text-center">{row.openData === 'yes' ? '✅' : row.openData === 'warn' ? '⚠️' : row.openData === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">{row.rules === 'yes' ? '✅' : row.rules === 'warn' ? '⚠️' : row.rules === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">{row.uploads === 'yes' ? '✅' : row.uploads === 'warn' ? '⚠️' : row.uploads === 'no' ? '❌' : '—'}</td>
<td className="py-2 px-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
row.risk === 'low' ? 'bg-green-100 text-green-700' :
row.risk === 'medium' ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
}`}>
{row.risk === 'low' ? 'Niedrig' : row.risk === 'medium' ? 'Mittel' : 'Hoch'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Licenses Info */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Unterstuetzte Lizenzen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">DL-DE-BY-2.0</div>
<div className="text-xs text-slate-500 mt-1">Datenlizenz Deutschland - Namensnennung</div>
<div className="text-xs text-green-600 mt-2">Attribution erforderlich</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC-BY</div>
<div className="text-xs text-slate-500 mt-1">Creative Commons Attribution</div>
<div className="text-xs text-green-600 mt-2">Attribution erforderlich</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC-BY-SA</div>
<div className="text-xs text-slate-500 mt-1">CC Attribution-ShareAlike</div>
<div className="text-xs text-amber-600 mt-2">Attribution + ShareAlike</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">CC0</div>
<div className="text-xs text-slate-500 mt-1">Public Domain</div>
<div className="text-xs text-slate-400 mt-2">Keine Attribution noetig</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="font-medium text-slate-800">§5 UrhG</div>
<div className="text-xs text-slate-500 mt-1">Amtliche Werke</div>
<div className="text-xs text-green-600 mt-2">Quellenangabe erforderlich</div>
</div>
</div>
</div>
{/* Technische Controls fuer Attribution */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Technische Controls fuer Attribution</h3>
<p className="text-sm text-slate-600 mb-4">
Massnahmen zur Sicherstellung der lizenzkonformen Quellenangabe im System.
</p>
<div className="space-y-3">
{[
{
id: 'CTRL-SRC-001',
name: 'Attribution bei Schulsuche',
description: 'Bei jedem Suchergebnis aus Open-Data-Portalen wird die Datenquelle, Lizenz und ein Link zum Bereitsteller angezeigt.',
status: 'implemented',
location: 'studio-v2/components/SchoolSearch.tsx',
},
{
id: 'CTRL-SRC-002',
name: 'Attribution bei RAG-Ergebnissen',
description: 'Pro EH-Vorschlag werden Dokumentname, Herausgeber und Lizenz angezeigt. Bei Einfuegen in Gutachten wird Zitation automatisch ergaenzt.',
status: 'implemented',
location: 'studio-v2/components/korrektur/EHSuggestionPanel.tsx',
},
{
id: 'CTRL-SRC-003',
name: 'Export-Attribution',
description: 'Bei PDF-Export wird ein Quellenverzeichnis am Ende eingefuegt. Bei Daten-Export werden Attribution-Metadaten mitgeliefert.',
status: 'planned',
location: 'klausur-service/export',
},
{
id: 'CTRL-SRC-004',
name: 'Attribution-Audit-Trail',
description: 'Logging welche Quellen fuer welche Outputs verwendet wurden. Nachweis fuer Auditoren ueber policy_audit_log.',
status: 'planned',
location: 'edu-search-service/internal/policy/audit.go',
},
].map((ctrl) => (
<div key={ctrl.id} className="p-4 border border-slate-200 rounded-lg hover:bg-slate-50">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs px-2 py-0.5 bg-purple-100 text-purple-700 rounded">{ctrl.id}</span>
<span className="font-medium text-slate-800">{ctrl.name}</span>
</div>
<p className="text-sm text-slate-600">{ctrl.description}</p>
<p className="text-xs text-slate-400 mt-1 font-mono">{ctrl.location}</p>
</div>
<span className={`flex-shrink-0 px-2 py-1 rounded text-xs font-medium ${
ctrl.status === 'implemented' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
}`}>
{ctrl.status === 'implemented' ? 'Implementiert' : 'Geplant'}
</span>
</div>
</div>
))}
</div>
</div>
{/* Erlaubte Referenz-Domains */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Erlaubte Referenz-Domains (Audit-Dokumentation)</h3>
<p className="text-sm text-slate-600 mb-4">
Domains, auf die das System zu Referenz- und Compliance-Zwecken zugreifen darf.
Diese Zugriffe dienen ausschliesslich der rechtssicheren Klassifikation und Dokumentation.
</p>
<div className="space-y-3">
{[
{
domain: 'govdata.de',
reason: 'Der Zugriff auf govdata.de ist dauerhaft erlaubt, da es sich um ein amtliches Open-Data-Portal mit klarer Lizenz handelt. Die Nutzung erfolgt ausschliesslich zu Recherche- und Referenzzwecken, nicht fuer KI-Training.',
type: 'Datenquelle',
},
{
domain: 'creativecommons.org',
reason: 'Der Zugriff auf creativecommons.org ist dauerhaft erlaubt, da es sich um offizielle Lizenztexte handelt, die fuer die rechtssichere Klassifikation und Nutzung von Open-Data-Quellen erforderlich sind.',
type: 'Lizenz-Referenz',
},
{
domain: 'wiki.creativecommons.org',
reason: 'Der Zugriff auf wiki.creativecommons.org ist dauerhaft erlaubt, da es sich um offizielle Lizenzdokumentation handelt, die zur rechtssicheren Klassifikation von Datenquellen erforderlich ist.',
type: 'Lizenz-Dokumentation',
},
{
domain: 'gesetze-im-internet.de',
reason: 'Der Zugriff auf gesetze-im-internet.de ist dauerhaft erlaubt, da es sich um amtliche, urheberrechtsfreie Rechtsquellen (§5 UrhG) handelt, die zur rechtlichen Einordnung und Compliance-Dokumentation erforderlich sind.',
type: 'Rechtsquelle',
},
{
domain: 'nibis.de',
reason: 'Der Zugriff auf nibis.de (Niedersaechsischer Bildungsserver) ist dauerhaft erlaubt fuer den Abruf von Kerncurricula und Erwartungshorizonten. Die Nutzung erfolgt unter DL-DE-BY-2.0 mit Attribution.',
type: 'Bildungsquelle',
},
{
domain: 'kmk.org',
reason: 'Der Zugriff auf kmk.org (Kultusministerkonferenz) ist dauerhaft erlaubt, da KMK-Beschluesse als amtliche Werke nach §5 UrhG frei nutzbar sind. Quellenangabe erforderlich.',
type: 'Amtliche Quelle',
},
].map((item, idx) => (
<div key={item.domain} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm font-medium text-slate-800">{item.domain}</span>
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">{item.type}</span>
</div>
<p className="text-sm text-slate-600 italic">&quot;{item.reason}&quot;</p>
</div>
</div>
</div>
))}
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
<strong>Fuer Auditoren:</strong> Diese Statements dokumentieren die rechtliche Grundlage fuer den Systemzugriff auf externe Domains.
Alle Zugriffe werden im Audit-Log protokolliert.
</div>
</div>
{/* Bundesweite Quellen */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Bundesweite Quellen</h3>
<p className="text-sm text-slate-600 mb-4">
Uebergreifende Open-Data-Portale und amtliche Quellen auf Bundesebene.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-700">Quelle</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Typ</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Lizenz</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Einsatz</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-2 px-3 font-medium text-slate-800">GovData</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">Bund-ODP</span>
</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">DL-DE-BY-2.0</span>
</td>
<td className="py-2 px-3 text-slate-600">Aggregation / Fallback</td>
</tr>
<tr className="hover:bg-slate-50">
<td className="py-2 px-3 font-medium text-slate-800">Statistische Landesaemter</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">Amtlich</span>
</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 rounded text-xs">variabel</span>
</td>
<td className="py-2 px-3 text-slate-600">Plausibilisierung</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Bundeslaender Open Data Portale */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 mb-4">Bundeslaender Open Data Portale</h3>
<p className="text-sm text-slate-600 mb-4">
Zulaessige Landes-Open-Data-Portale fuer Schulstammdaten und Bildungsinformationen.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-700">Bundesland</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Zulaessige Quelle</th>
<th className="text-left py-2 px-3 font-medium text-slate-700">Lizenz</th>
<th className="text-left py-2 px-3 font-medium text-slate-700 hidden md:table-cell">Hinweise</th>
</tr>
</thead>
<tbody>
{[
{ bl: 'BW', name: 'Baden-Wuerttemberg', source: 'Open Data Baden-Wuerttemberg', license: 'DL-DE-BY-2.0', note: 'Schulverzeichnisse ueber Ministerium / Kommunen' },
{ bl: 'BY', name: 'Bayern', source: 'Open Data Bayern', license: 'DL-DE-BY-2.0', note: 'Amtliche Schulnummern, Standorte' },
{ bl: 'BE', name: 'Berlin', source: 'Datenportal Berlin', license: 'CC-BY', note: 'Sehr gut gepflegte Schulstammdaten' },
{ bl: 'BB', name: 'Brandenburg', source: 'Daten Brandenburg', license: 'DL-DE-BY-2.0', note: 'Kommunale Ergaenzungen pruefen' },
{ bl: 'HB', name: 'Bremen', source: 'Open Data Bremen', license: 'CC-BY', note: 'Kleine Datenmenge, sauber' },
{ bl: 'HH', name: 'Hamburg', source: 'Transparenzportal Hamburg', license: 'DL-DE-BY-2.0', note: 'Sehr gute Metadaten' },
{ bl: 'HE', name: 'Hessen', source: 'Open Data Hessen', license: 'DL-DE-BY-2.0', note: 'Schultraegerdaten' },
{ bl: 'MV', name: 'Mecklenburg-Vorpommern', source: 'Open Data MV', license: 'DL-DE-BY-2.0', note: 'Teilweise CSV/Excel' },
{ bl: 'NI', name: 'Niedersachsen', source: 'Open Data Niedersachsen', license: 'DL-DE-BY-2.0', note: 'Ergaenzend: NIBIS nur Regelwerke, nicht Personen' },
{ bl: 'NW', name: 'Nordrhein-Westfalen', source: 'Open.NRW', license: 'DL-DE-BY-2.0', note: 'Umfangreich, kommunale Qualitaet pruefen' },
{ bl: 'RP', name: 'Rheinland-Pfalz', source: 'Open Data Rheinland-Pfalz', license: 'DL-DE-BY-2.0', note: 'Schulformen & Standorte' },
{ bl: 'SL', name: 'Saarland', source: 'Open Data Saarland', license: 'DL-DE-BY-2.0', note: 'Klein, aber zulaessig' },
{ bl: 'SN', name: 'Sachsen', source: 'Datenportal Sachsen', license: 'DL-DE-BY-2.0', note: 'Gute Pflege' },
{ bl: 'ST', name: 'Sachsen-Anhalt', source: 'Open Data Sachsen-Anhalt', license: 'DL-DE-BY-2.0', note: 'CSV/JSON verfuegbar' },
{ bl: 'SH', name: 'Schleswig-Holstein', source: 'Open Data Schleswig-Holstein', license: 'DL-DE-BY-2.0', note: 'Einheitliche IDs' },
{ bl: 'TH', name: 'Thueringen', source: 'Open Data Thueringen', license: 'DL-DE-BY-2.0', note: 'Kommunale Ergaenzungen' },
].map((item, idx) => (
<tr key={item.bl} className={`border-b border-slate-100 hover:bg-slate-50 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="py-2 px-3">
<span className="inline-flex items-center gap-2">
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-mono">{item.bl}</span>
<span className="text-slate-700 hidden sm:inline">{item.name}</span>
</span>
</td>
<td className="py-2 px-3 font-medium text-slate-800">{item.source}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
item.license === 'CC-BY'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}`}>
{item.license}
</span>
</td>
<td className="py-2 px-3 text-slate-500 text-xs hidden md:table-cell">{item.note}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
<strong>Hinweis:</strong> Alle Landes-ODP sind vom Typ &quot;Landes-ODP&quot; und erfordern Attribution gemaess der jeweiligen Lizenz.
</div>
</div>
</div>
)
}