Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
413
admin-compliance/components/sdk/source-policy/AuditTab.tsx
Normal file
413
admin-compliance/components/sdk/source-policy/AuditTab.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
562
admin-compliance/components/sdk/source-policy/PIIRulesTab.tsx
Normal file
562
admin-compliance/components/sdk/source-policy/PIIRulesTab.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
525
admin-compliance/components/sdk/source-policy/SourcesTab.tsx
Normal file
525
admin-compliance/components/sdk/source-policy/SourcesTab.tsx
Normal 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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user