Remove duplicate compliance and DSGVO admin pages that have been superseded by the unified SDK pipeline. Update navigation, sidebar, roles, and module registry to reflect the new structure. Add DSFA corpus API proxy and source-policy components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
414 lines
17 KiB
TypeScript
414 lines
17 KiB
TypeScript
'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">
|
|
×
|
|
</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>
|
|
)
|
|
}
|