refactor(admin-v2): Consolidate compliance/DSGVO pages into SDK pipeline

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>
This commit is contained in:
BreakPilot Dev
2026-02-10 23:26:05 +01:00
parent 36603259c6
commit f09e24d52c
61 changed files with 265 additions and 24517 deletions

View File

@@ -51,6 +51,16 @@ const categoryIcons: Record<string, React.ReactNode> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
globe: (
<svg className="w-5 h-5" 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>
),
'code-2': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
}
const metaIcons: Record<string, React.ReactNode> = {
@@ -184,7 +194,7 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
{/* Categories */}
<div className="px-2 space-y-1">
{visibleCategories.map((category) => {
const categoryHref = category.id === 'compliance-sdk' ? '/dashboard/catalog-manager' : `/${category.id === 'compliance' ? 'compliance' : category.id}`
const categoryHref = category.id === 'compliance-sdk' ? '/sdk' : `/${category.id}`
const isCategoryActive = category.id === 'compliance-sdk'
? category.modules.some(m => pathname.startsWith(m.href))
: pathname.startsWith(categoryHref)

View File

@@ -60,6 +60,7 @@ const EXAMPLE_QUESTIONS: Record<string, string[]> = {
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [isTyping, setIsTyping] = useState(false)
@@ -152,6 +153,11 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
setMessages((prev) =>
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
)
// Auto-scroll during streaming
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
})
}
setIsTyping(false)
@@ -214,7 +220,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
aria-label="Compliance Advisor oeffnen"
>
<svg
@@ -235,7 +241,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
}
return (
<div className="fixed bottom-6 right-6 w-[400px] h-[500px] bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200">
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[500px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -259,25 +265,55 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
<div className="text-xs text-white/80">KI-gestuetzter Assistent</div>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Schliessen"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-white/80 hover:text-white transition-colors"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isExpanded ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5"
/>
)}
</svg>
</button>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Schliessen"
>
<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>
</div>
</div>
{/* Messages Area */}

View File

@@ -392,7 +392,7 @@ export function DocumentUploadSection({
onOpenInEditor(doc)
} else {
// Default: navigate to workflow editor
router.push(`/compliance/workflow?documentType=${documentType}&documentId=${doc.id}`)
router.push(`/sdk/workflow?documentType=${documentType}&documentId=${doc.id}`)
}
}, [documentType, onOpenInEditor, router])

View File

@@ -363,8 +363,8 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
? 'right-4 bottom-6'
: 'left-4 bottom-6'
return (
<>
@@ -411,7 +411,7 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
{isDesktopCollapsed && (
<button
onClick={toggleDesktopSidebar}
className={`hidden xl:flex fixed right-6 bottom-20 z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all items-center justify-center group`}
className={`hidden xl:flex fixed right-6 bottom-6 z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all items-center justify-center group`}
aria-label="SDK Pipeline Navigation oeffnen"
title="Pipeline anzeigen"
>

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>
)
}