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>
526 lines
20 KiB
TypeScript
526 lines
20 KiB
TypeScript
'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>
|
|
)
|
|
}
|