fix: SDK-Module Frontend-Backend-Mismatches beheben + fehlende Proxy-Routes

- P0: enableBackendSync=true in SDKProvider aktiviert (PostgreSQL State-Persistenz)
- P0: Source Policy 4 Tabs an Backend-Schema angepasst (is_active→active,
  data.logs→data.entries, is_allowed→allowed, rule_type→category, severity→action)
- P0: OperationsMatrixTab holt jetzt Sources+Operations separat und joint client-side
- P0: PIIRulesTab PII-Test auf client-side Regex umgestellt (kein Backend-Endpoint noetig)
- P1: GET Proxy-Routes fuer Import, Screening und UCCA [id] (GET+DELETE) erstellt
- P1: Compliance Scope ScopeOverviewTab/ScopeExportTab Prop-Interfaces erweitert
- P2: Company Profile speichert jetzt auch zum dedizierten Backend-Endpoint
- P2: UCCA Wizard von 5 auf 8 Steps erweitert (Rechtsgrundlage, Datentransfer, Vertraege)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-02 11:40:44 +01:00
parent e6d666b89b
commit 80a988dc58
12 changed files with 563 additions and 181 deletions

View File

@@ -166,7 +166,7 @@ export default function SDKRootLayout({
} }
return ( return (
<SDKProvider> <SDKProvider enableBackendSync={true}>
<SDKInnerLayout>{children}</SDKInnerLayout> <SDKInnerLayout>{children}</SDKInnerLayout>
</SDKProvider> </SDKProvider>
) )

View File

@@ -1161,7 +1161,7 @@ export default function CompanyProfilePage() {
} }
} }
const completeAndSaveProfile = () => { const completeAndSaveProfile = async () => {
const completeProfile: CompanyProfile = { const completeProfile: CompanyProfile = {
...formData, ...formData,
isComplete: true, isComplete: true,
@@ -1170,6 +1170,41 @@ export default function CompanyProfilePage() {
setCompanyProfile(completeProfile) setCompanyProfile(completeProfile)
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' }) dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
// Also persist to dedicated backend endpoint
try {
await fetch('/api/sdk/v1/company-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
company_name: formData.companyName || '',
legal_form: formData.legalForm || 'GmbH',
industry: formData.industry || '',
founded_year: formData.foundedYear || null,
business_model: formData.businessModel || 'B2B',
offerings: formData.offerings || [],
company_size: formData.companySize || 'small',
employee_count: formData.employeeCount || '',
annual_revenue: formData.annualRevenue || '',
headquarters_country: formData.headquartersCountry || 'DE',
headquarters_city: formData.headquartersCity || '',
has_international_locations: formData.hasInternationalLocations || false,
international_countries: formData.internationalCountries || [],
target_markets: formData.targetMarkets || [],
primary_jurisdiction: formData.primaryJurisdiction || 'DE',
is_data_controller: formData.isDataController ?? true,
is_data_processor: formData.isDataProcessor ?? false,
uses_ai: formData.usesAI ?? false,
ai_use_cases: formData.aiUseCases || [],
dpo_name: formData.dpoName || '',
dpo_email: formData.dpoEmail || '',
is_complete: true,
}),
})
} catch (err) {
console.error('Failed to save company profile to backend:', err)
}
goToNextStep() goToNextStep()
} }

View File

@@ -11,9 +11,12 @@ import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/Asses
const WIZARD_STEPS = [ const WIZARD_STEPS = [
{ id: 1, title: 'Grundlegendes', description: 'Titel und Beschreibung' }, { id: 1, title: 'Grundlegendes', description: 'Titel und Beschreibung' },
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' }, { id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
{ id: 3, title: 'Automatisierung', description: 'Grad der Automatisierung' }, { id: 3, title: 'Verarbeitungszweck', description: 'Rechtsgrundlage und Zweck' },
{ id: 4, title: 'Hosting & Modell', description: 'Technische Details' }, { id: 4, title: 'Automatisierung', description: 'Grad der Automatisierung' },
{ id: 5, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' }, { id: 5, title: 'Hosting & Modell', description: 'Technische Details' },
{ id: 6, title: 'Datentransfer', description: 'Internationaler Datentransfer' },
{ id: 7, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' },
{ id: 8, title: 'Vertraege', description: 'Compliance und Vereinbarungen' },
] ]
const DOMAINS = [ const DOMAINS = [
@@ -70,9 +73,20 @@ export default function NewUseCasePage() {
model_finetune: false, model_finetune: false,
model_training: false, model_training: false,
model_inference: true, model_inference: true,
// Retention // Legal Basis (Step 3)
legal_basis: 'consent' as 'consent' | 'contract' | 'legitimate_interest' | 'legal_obligation' | 'vital_interest' | 'public_interest',
// Data Transfer (Step 6)
international_transfer: false,
transfer_countries: [] as string[],
transfer_mechanism: 'none' as 'none' | 'scc' | 'bcr' | 'adequacy' | 'derogation',
// Retention (Step 7)
retention_days: 90, retention_days: 90,
retention_purpose: '', retention_purpose: '',
// Contracts (Step 8)
has_dpa: false,
has_aia_documentation: false,
has_risk_assessment: false,
subprocessors: '',
}) })
const updateForm = (updates: Partial<typeof form>) => { const updateForm = (updates: Partial<typeof form>) => {
@@ -113,10 +127,22 @@ export default function NewUseCasePage() {
training: form.model_training, training: form.model_training,
inference: form.model_inference, inference: form.model_inference,
}, },
legal_basis: form.legal_basis,
international_transfer: {
enabled: form.international_transfer,
countries: form.transfer_countries,
mechanism: form.transfer_mechanism,
},
retention: { retention: {
days: form.retention_days, days: form.retention_days,
purpose: form.retention_purpose, purpose: form.retention_purpose,
}, },
contracts: {
has_dpa: form.has_dpa,
has_aia_documentation: form.has_aia_documentation,
has_risk_assessment: form.has_risk_assessment,
subprocessors: form.subprocessors,
},
store_raw_text: true, store_raw_text: true,
} }
@@ -276,6 +302,29 @@ export default function NewUseCasePage() {
</div> </div>
</label> </label>
))} ))}
</div>
)}
{/* Step 3: Verarbeitungszweck & Rechtsgrundlage */}
{currentStep === 3 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Verarbeitungszweck & Rechtsgrundlage</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsgrundlage (Art. 6 DSGVO)</label>
<select
value={form.legal_basis}
onChange={e => updateForm({ legal_basis: e.target.value as typeof form.legal_basis })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="consent">Einwilligung (Art. 6 Abs. 1a)</option>
<option value="contract">Vertragserfullung (Art. 6 Abs. 1b)</option>
<option value="legal_obligation">Rechtliche Verpflichtung (Art. 6 Abs. 1c)</option>
<option value="vital_interest">Lebenswichtige Interessen (Art. 6 Abs. 1d)</option>
<option value="public_interest">Oeffentliches Interesse (Art. 6 Abs. 1e)</option>
<option value="legitimate_interest">Berechtigtes Interesse (Art. 6 Abs. 1f)</option>
</select>
</div>
<h3 className="text-sm font-medium text-gray-700 mt-4">Zweck der Verarbeitung</h3> <h3 className="text-sm font-medium text-gray-700 mt-4">Zweck der Verarbeitung</h3>
{[ {[
@@ -301,8 +350,8 @@ export default function NewUseCasePage() {
</div> </div>
)} )}
{/* Step 3: Automatisierung */} {/* Step 4: Automatisierung */}
{currentStep === 3 && ( {currentStep === 4 && (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2> <h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
{[ {[
@@ -335,8 +384,8 @@ export default function NewUseCasePage() {
</div> </div>
)} )}
{/* Step 4: Hosting & Modell */} {/* Step 5: Hosting & Modell */}
{currentStep === 4 && ( {currentStep === 5 && (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2> <h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
<div> <div>
@@ -390,8 +439,59 @@ export default function NewUseCasePage() {
</div> </div>
)} )}
{/* Step 5: Datenhaltung */} {/* Step 6: Internationaler Datentransfer */}
{currentStep === 5 && ( {currentStep === 6 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Internationaler Datentransfer</h2>
<label className="flex items-start gap-3 p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
<input
type="checkbox"
checked={form.international_transfer}
onChange={e => updateForm({ international_transfer: e.target.checked })}
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">Daten werden in Drittlaender uebermittelt</div>
<div className="text-sm text-gray-500">Ausserhalb des EWR (z.B. USA, UK, Schweiz)</div>
</div>
</label>
{form.international_transfer && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ziellaender</label>
<input
type="text"
value={form.transfer_countries.join(', ')}
onChange={e => updateForm({ transfer_countries: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
placeholder="z.B. USA, UK, CH"
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<p className="text-xs text-gray-500 mt-1">Kommagetrennte Laenderkuerzel</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Transfer-Mechanismus</label>
<select
value={form.transfer_mechanism}
onChange={e => updateForm({ transfer_mechanism: e.target.value as typeof form.transfer_mechanism })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="none">Noch nicht festgelegt</option>
<option value="adequacy">Angemessenheitsbeschluss</option>
<option value="scc">Standardvertragsklauseln (SCC)</option>
<option value="bcr">Binding Corporate Rules (BCR)</option>
<option value="derogation">Ausnahmeregelung (Art. 49 DSGVO)</option>
</select>
</div>
</>
)}
</div>
)}
{/* Step 7: Datenhaltung */}
{currentStep === 7 && (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2> <h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
<div> <div>
@@ -420,6 +520,43 @@ export default function NewUseCasePage() {
</div> </div>
</div> </div>
)} )}
{/* Step 8: Vertraege & Compliance */}
{currentStep === 8 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Vertraege & Compliance-Dokumentation</h2>
{[
{ key: 'has_dpa', label: 'Auftragsverarbeitungsvertrag (AVV/DPA)', desc: 'Vertrag mit KI-Anbieter / Subprozessor nach Art. 28 DSGVO' },
{ key: 'has_aia_documentation', label: 'AI Act Dokumentation', desc: 'Risikoklassifizierung und technische Dokumentation nach EU AI Act' },
{ key: 'has_risk_assessment', label: 'Risikobewertung / DSFA', desc: 'Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO' },
].map(item => (
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
<input
type="checkbox"
checked={form[item.key as keyof typeof form] as boolean}
onChange={e => updateForm({ [item.key]: e.target.checked })}
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">{item.label}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
</label>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subprozessoren</label>
<textarea
value={form.subprocessors}
onChange={e => updateForm({ subprocessors: e.target.value })}
rows={3}
placeholder="z.B. OpenAI (USA, SCC), Hetzner Cloud (DE)..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
)}
</div> </div>
{/* Navigation Buttons */} {/* Navigation Buttons */}
@@ -431,7 +568,7 @@ export default function NewUseCasePage() {
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'} {currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
</button> </button>
{currentStep < 5 ? ( {currentStep < 8 ? (
<button <button
onClick={() => setCurrentStep(currentStep + 1)} onClick={() => setCurrentStep(currentStep + 1)}
disabled={currentStep === 1 && !form.title} disabled={currentStep === 1 && !form.title}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy: GET /api/sdk/v1/import → Backend GET /api/v1/import
* Lists imported documents for the current tenant.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/v1/import${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to fetch imported documents:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
/**
* Proxy: GET /api/sdk/v1/screening → Backend GET /api/v1/screening
* Lists screenings for the current tenant.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/v1/screening${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to fetch screenings:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
/**
* Proxy: GET /api/sdk/v1/ucca/assessments/[id] → Go Backend GET /sdk/v1/ucca/assessments/:id
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/assessments/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'UCCA backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to fetch UCCA assessment:', error)
return NextResponse.json(
{ error: 'Failed to connect to UCCA backend' },
{ status: 503 }
)
}
}
/**
* Proxy: DELETE /api/sdk/v1/ucca/assessments/[id] → Go Backend DELETE /sdk/v1/ucca/assessments/:id
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/assessments/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...(request.headers.get('X-Tenant-ID') && {
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
}),
},
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'UCCA backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to delete UCCA assessment:', error)
return NextResponse.json(
{ error: 'Failed to connect to UCCA backend' },
{ status: 503 }
)
}
}

View File

@@ -4,11 +4,16 @@ import type { ScopeDecision, ScopeProfilingAnswer } from '@/lib/sdk/compliance-s
import { DEPTH_LEVEL_LABELS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types' import { DEPTH_LEVEL_LABELS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
interface ScopeExportTabProps { interface ScopeExportTabProps {
decision: ScopeDecision | null decision?: ScopeDecision | null
answers: ScopeProfilingAnswer[] answers?: ScopeProfilingAnswer[]
scopeState?: { decision: ScopeDecision | null; answers: ScopeProfilingAnswer[] }
onBackToDecision?: () => void
} }
export function ScopeExportTab({ decision, answers }: ScopeExportTabProps) { export function ScopeExportTab({ decision: decisionProp, answers: answersProp, scopeState, onBackToDecision }: ScopeExportTabProps) {
const decision = decisionProp ?? scopeState?.decision ?? null
const answers = answersProp ?? scopeState?.answers ?? []
// onBackToDecision is accepted but not used in this component (navigation handled by parent)
const [copiedMarkdown, setCopiedMarkdown] = useState(false) const [copiedMarkdown, setCopiedMarkdown] = useState(false)
const handleDownloadJSON = useCallback(() => { const handleDownloadJSON = useCallback(() => {

View File

@@ -5,11 +5,16 @@ import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUM
interface ScopeOverviewTabProps { interface ScopeOverviewTabProps {
scopeState: ComplianceScopeState scopeState: ComplianceScopeState
completionStats?: { total: number; answered: number; percentage: number; isComplete: boolean }
onStartProfiling: () => void onStartProfiling: () => void
onRefreshDecision: () => void onReset?: () => void
onGoToWizard?: () => void
onGoToDecision?: () => void
onGoToExport?: () => void
onRefreshDecision?: () => void
} }
export function ScopeOverviewTab({ scopeState, onStartProfiling, onRefreshDecision }: ScopeOverviewTabProps) { export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling, onReset, onGoToWizard, onGoToDecision, onGoToExport, onRefreshDecision }: ScopeOverviewTabProps) {
const { decision, answers } = scopeState const { decision, answers } = scopeState
const hasAnswers = answers && answers.length > 0 const hasAnswers = answers && answers.length > 0
@@ -254,12 +259,22 @@ export function ScopeOverviewTab({ scopeState, onStartProfiling, onRefreshDecisi
: 'Haben sich Ihre Unternehmensparameter geändert? Aktualisieren Sie Ihre Bewertung.'} : 'Haben sich Ihre Unternehmensparameter geändert? Aktualisieren Sie Ihre Bewertung.'}
</p> </p>
</div> </div>
<button <div className="flex items-center gap-3">
onClick={!hasAnswers ? onStartProfiling : onRefreshDecision} {hasAnswers && onReset && (
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium whitespace-nowrap" <button
> onClick={onReset}
{!hasAnswers ? 'Scope-Profiling starten' : 'Ergebnis aktualisieren'} className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
</button> >
Zurücksetzen
</button>
)}
<button
onClick={!hasAnswers ? onStartProfiling : (onGoToWizard || onRefreshDecision || onStartProfiling)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium whitespace-nowrap"
>
{!hasAnswers ? 'Scope-Profiling starten' : 'Ergebnis aktualisieren'}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,9 +7,9 @@ interface AuditLogEntry {
action: string action: string
entity_type: string entity_type: string
entity_id?: string entity_id?: string
old_value?: any old_values?: any
new_value?: any new_values?: any
user_email?: string user_id?: string
created_at: string created_at: string
} }
@@ -91,7 +91,7 @@ export function AuditTab({ apiBase }: AuditTabProps) {
if (!res.ok) throw new Error('Fehler beim Laden') if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json() const data = await res.json()
setAuditLogs(data.logs || []) setAuditLogs(data.entries || [])
setAuditTotal(data.total || 0) setAuditTotal(data.total || 0)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler') setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
@@ -109,13 +109,20 @@ export function AuditTab({ apiBase }: AuditTabProps) {
params.append('limit', '100') params.append('limit', '100')
const res = await fetch(`${apiBase}/v1/admin/blocked-content?${params}`) const res = await fetch(`${apiBase}/v1/admin/blocked-content?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden') if (!res.ok) {
// Endpoint may not exist yet — show empty state
setBlockedContent([])
setBlockedTotal(0)
return
}
const data = await res.json() const data = await res.json()
setBlockedContent(data.blocked || []) setBlockedContent(data.blocked || [])
setBlockedTotal(data.total || 0) setBlockedTotal(data.total || 0)
} catch (err) { } catch {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler') // Endpoint not available — show empty state gracefully
setBlockedContent([])
setBlockedTotal(0)
} finally { } finally {
setBlockedLoading(false) setBlockedLoading(false)
} }
@@ -284,26 +291,26 @@ export function AuditTab({ apiBase }: AuditTabProps) {
{formatDate(log.created_at)} {formatDate(log.created_at)}
</div> </div>
</div> </div>
{log.user_email && ( {log.user_id && (
<div className="mt-2 text-xs text-slate-500"> <div className="mt-2 text-xs text-slate-500">
Benutzer: {log.user_email} Benutzer: {log.user_id}
</div> </div>
)} )}
{(log.old_value || log.new_value) && ( {(log.old_values || log.new_values) && (
<div className="mt-2 flex gap-4 text-xs"> <div className="mt-2 flex gap-4 text-xs">
{log.old_value && ( {log.old_values && (
<div className="flex-1 p-2 bg-red-50 rounded"> <div className="flex-1 p-2 bg-red-50 rounded">
<div className="text-red-600 font-medium mb-1">Vorher:</div> <div className="text-red-600 font-medium mb-1">Vorher:</div>
<pre className="text-red-700 overflow-x-auto"> <pre className="text-red-700 overflow-x-auto">
{typeof log.old_value === 'string' ? log.old_value : JSON.stringify(log.old_value, null, 2)} {typeof log.old_values === 'string' ? log.old_values : JSON.stringify(log.old_values, null, 2)}
</pre> </pre>
</div> </div>
)} )}
{log.new_value && ( {log.new_values && (
<div className="flex-1 p-2 bg-green-50 rounded"> <div className="flex-1 p-2 bg-green-50 rounded">
<div className="text-green-600 font-medium mb-1">Nachher:</div> <div className="text-green-600 font-medium mb-1">Nachher:</div>
<pre className="text-green-700 overflow-x-auto"> <pre className="text-green-700 overflow-x-auto">
{typeof log.new_value === 'string' ? log.new_value : JSON.stringify(log.new_value, null, 2)} {typeof log.new_values === 'string' ? log.new_values : JSON.stringify(log.new_values, null, 2)}
</pre> </pre>
</div> </div>
)} )}

View File

@@ -6,17 +6,16 @@ interface OperationPermission {
id: string id: string
source_id: string source_id: string
operation: string operation: string
is_allowed: boolean allowed: boolean
requires_citation: boolean conditions?: string
notes?: string
} }
interface SourceWithOperations { interface SourceWithOperations {
id: string id: string
domain: string domain: string
name: string name: string
license: string license?: string
is_active: boolean active: boolean
operations: OperationPermission[] operations: OperationPermission[]
} }
@@ -44,11 +43,33 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
const fetchMatrix = async () => { const fetchMatrix = async () => {
try { try {
setLoading(true) setLoading(true)
const res = await fetch(`${apiBase}/v1/admin/operations-matrix`) const [sourcesRes, opsRes] = await Promise.all([
if (!res.ok) throw new Error('Fehler beim Laden') fetch(`${apiBase}/v1/admin/sources`),
fetch(`${apiBase}/v1/admin/operations-matrix`),
])
if (!sourcesRes.ok || !opsRes.ok) throw new Error('Fehler beim Laden')
const data = await res.json() const sourcesData = await sourcesRes.json()
setSources(data.sources || []) const opsData = await opsRes.json()
// Join: group operations by source_id
const opsBySource = new Map<string, OperationPermission[]>()
for (const op of opsData.operations || []) {
const list = opsBySource.get(op.source_id) || []
list.push(op)
opsBySource.set(op.source_id, list)
}
const joined: SourceWithOperations[] = (sourcesData.sources || []).map((s: any) => ({
id: s.id,
domain: s.domain,
name: s.name,
license: s.license,
active: s.active,
operations: opsBySource.get(s.id) || [],
}))
setSources(joined)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler') setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally { } finally {
@@ -58,28 +79,26 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
const togglePermission = async ( const togglePermission = async (
source: SourceWithOperations, source: SourceWithOperations,
operationId: string, operationId: string
field: 'is_allowed' | 'requires_citation'
) => { ) => {
// Find the permission // Find the permission
const permission = source.operations.find((op) => op.operation === operationId) const permission = source.operations.find((op) => op.operation === operationId)
if (!permission) return if (!permission) return
// Block enabling training // Block enabling training
if (operationId === 'training' && field === 'is_allowed' && !permission.is_allowed) { if (operationId === 'training' && !permission.allowed) {
setError('Training mit externen Daten ist VERBOTEN und kann nicht aktiviert werden.') setError('Training mit externen Daten ist VERBOTEN und kann nicht aktiviert werden.')
return return
} }
const updateId = `${permission.id}-${field}` const updateId = `${permission.id}-allowed`
setUpdating(updateId) setUpdating(updateId)
try { try {
const newValue = !permission[field]
const res = await fetch(`${apiBase}/v1/admin/operations/${permission.id}`, { const res = await fetch(`${apiBase}/v1/admin/operations/${permission.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: newValue }), body: JSON.stringify({ allowed: !permission.allowed }),
}) })
if (!res.ok) { if (!res.ok) {
@@ -174,7 +193,7 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
</tr> </tr>
) : ( ) : (
sources.map((source) => ( sources.map((source) => (
<tr key={source.id} className={`hover:bg-slate-50 ${!source.is_active ? 'opacity-50' : ''}`}> <tr key={source.id} className={`hover:bg-slate-50 ${!source.active ? 'opacity-50' : ''}`}>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div> <div>
<div className="font-medium text-slate-800">{source.name}</div> <div className="font-medium text-slate-800">{source.name}</div>
@@ -184,17 +203,17 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
{OPERATIONS.map((op) => { {OPERATIONS.map((op) => {
const permission = source.operations.find((p) => p.operation === op.id) const permission = source.operations.find((p) => p.operation === op.id)
const isTraining = op.id === 'training' const isTraining = op.id === 'training'
const isAllowed = permission?.is_allowed ?? false const isAllowed = permission?.allowed ?? false
const requiresCitation = permission?.requires_citation ?? false const hasConditions = !!permission?.conditions
const isUpdating = updating === `${permission?.id}-is_allowed` || updating === `${permission?.id}-requires_citation` const isUpdating = updating === `${permission?.id}-allowed`
return ( return (
<td key={op.id} className="px-4 py-3 text-center"> <td key={op.id} className="px-4 py-3 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{/* Is Allowed Toggle */} {/* Allowed Toggle */}
<button <button
onClick={() => togglePermission(source, op.id, 'is_allowed')} onClick={() => togglePermission(source, op.id)}
disabled={isTraining || isUpdating || !source.is_active} disabled={isTraining || isUpdating || !source.active}
className={`w-10 h-10 flex items-center justify-center rounded transition-colors ${ className={`w-10 h-10 flex items-center justify-center rounded transition-colors ${
isTraining isTraining
? 'bg-slate-800 text-white cursor-not-allowed' ? 'bg-slate-800 text-white cursor-not-allowed'
@@ -219,20 +238,14 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
)} )}
</button> </button>
{/* Citation Required Toggle (only for allowed non-training ops) */} {/* Conditions indicator (read-only) */}
{isAllowed && !isTraining && ( {isAllowed && !isTraining && hasConditions && (
<button <span
onClick={() => togglePermission(source, op.id, 'requires_citation')} className="px-2 py-1 text-xs rounded bg-amber-100 text-amber-700"
disabled={isUpdating || !source.is_active} title={permission?.conditions || ''}
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'} Bedingung
</button> </span>
)} )}
</div> </div>
</td> </td>

View File

@@ -5,19 +5,19 @@ import { useState, useEffect } from 'react'
interface PIIRule { interface PIIRule {
id: string id: string
name: string name: string
rule_type: string description?: string
pattern: string pattern?: string
severity: string category: string
is_active: boolean action: string
active: boolean
created_at: string created_at: string
updated_at: string
} }
interface PIIMatch { interface PIIMatch {
rule_id: string rule_id: string
rule_name: string rule_name: string
rule_type: string category: string
severity: string action: string
match: string match: string
start_index: number start_index: number
end_index: number end_index: number
@@ -27,7 +27,6 @@ interface PIITestResult {
has_pii: boolean has_pii: boolean
matches: PIIMatch[] matches: PIIMatch[]
should_block: boolean should_block: boolean
block_level: string
} }
interface PIIRulesTabProps { interface PIIRulesTabProps {
@@ -35,14 +34,20 @@ interface PIIRulesTabProps {
onUpdate?: () => void onUpdate?: () => void
} }
const RULE_TYPES = [ const CATEGORIES = [
{ value: 'regex', label: 'Regex (Muster)' }, { value: 'email', label: 'E-Mail-Adressen' },
{ value: 'keyword', label: 'Keyword (Stichwort)' }, { value: 'phone', label: 'Telefonnummern' },
{ value: 'iban', label: 'IBAN/Bankdaten' },
{ value: 'name', label: 'Personennamen' },
{ value: 'address', label: 'Adressen' },
{ value: 'id_number', label: 'Ausweisnummern' },
{ value: 'health', label: 'Gesundheitsdaten' },
{ value: 'other', label: 'Sonstige' },
] ]
const SEVERITIES = [ const ACTIONS = [
{ value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' }, { value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' },
{ value: 'redact', label: 'Schwärzen', color: 'bg-orange-100 text-orange-700' }, { value: 'mask', label: 'Maskieren', color: 'bg-orange-100 text-orange-700' },
{ value: 'block', label: 'Blockieren', color: 'bg-red-100 text-red-700' }, { value: 'block', label: 'Blockieren', color: 'bg-red-100 text-red-700' },
] ]
@@ -64,10 +69,10 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
// New rule form // New rule form
const [newRule, setNewRule] = useState({ const [newRule, setNewRule] = useState({
name: '', name: '',
rule_type: 'regex',
pattern: '', pattern: '',
severity: 'block', category: 'email',
is_active: true, action: 'block',
active: true,
}) })
useEffect(() => { useEffect(() => {
@@ -102,10 +107,10 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
setNewRule({ setNewRule({
name: '', name: '',
rule_type: 'regex',
pattern: '', pattern: '',
severity: 'block', category: 'email',
is_active: true, action: 'block',
active: true,
}) })
setIsNewRule(false) setIsNewRule(false)
fetchRules() fetchRules()
@@ -162,7 +167,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${rule.id}`, { const res = await fetch(`${apiBase}/v1/admin/pii-rules/${rule.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }), body: JSON.stringify({ active: !rule.active }),
}) })
if (!res.ok) throw new Error('Fehler beim Aendern des Status') if (!res.ok) throw new Error('Fehler beim Aendern des Status')
@@ -174,33 +179,47 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
} }
} }
const runTest = async () => { const runTest = () => {
if (!testText) return if (!testText) return
try { setTesting(true)
setTesting(true) const matches: PIIMatch[] = []
const res = await fetch(`${apiBase}/v1/admin/pii-rules/test`, { const activeRules = rules.filter((r) => r.active && r.pattern)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText }),
})
if (!res.ok) throw new Error('Fehler beim Testen') for (const rule of activeRules) {
try {
const data = await res.json() const regex = new RegExp(rule.pattern!, 'gi')
setTestResult(data) let m: RegExpExecArray | null
} catch (err) { while ((m = regex.exec(testText)) !== null) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler') matches.push({
} finally { rule_id: rule.id,
setTesting(false) rule_name: rule.name,
category: rule.category,
action: rule.action,
match: m[0],
start_index: m.index,
end_index: m.index + m[0].length,
})
}
} catch {
// Invalid regex — skip
}
} }
const shouldBlock = matches.some((m) => m.action === 'block')
setTestResult({
has_pii: matches.length > 0,
matches,
should_block: shouldBlock,
})
setTesting(false)
} }
const getSeverityBadge = (severity: string) => { const getActionBadge = (action: string) => {
const config = SEVERITIES.find((s) => s.value === severity) const config = ACTIONS.find((a) => a.value === action)
return ( return (
<span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}> <span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}>
{config?.label || severity} {config?.label || action}
</span> </span>
) )
} }
@@ -288,7 +307,7 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<div className="space-y-2"> <div className="space-y-2">
{testResult.matches.map((match, idx) => ( {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"> <div key={idx} className="flex items-center gap-3 text-sm bg-white bg-opacity-50 rounded px-3 py-2">
{getSeverityBadge(match.severity)} {getActionBadge(match.action)}
<span className="text-slate-700 font-medium">{match.rule_name}</span> <span className="text-slate-700 font-medium">{match.rule_name}</span>
<code className="text-xs bg-slate-100 px-2 py-1 rounded"> <code className="text-xs bg-slate-100 px-2 py-1 rounded">
{match.match.length > 30 ? match.match.substring(0, 30) + '...' : match.match} {match.match.length > 30 ? match.match.substring(0, 30) + '...' : match.match}
@@ -334,9 +353,9 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<thead className="bg-slate-50 border-b border-slate-200"> <thead className="bg-slate-50 border-b border-slate-200">
<tr> <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">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">Kategorie</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">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">Aktion</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</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> <th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr> </tr>
@@ -347,25 +366,25 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<td className="px-4 py-3 text-sm font-medium text-slate-800">{rule.name}</td> <td className="px-4 py-3 text-sm font-medium text-slate-800">{rule.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded"> <span className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded">
{rule.rule_type} {CATEGORIES.find((c) => c.value === rule.category)?.label || rule.category}
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<code className="text-xs bg-slate-100 px-2 py-1 rounded max-w-xs truncate block"> <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} {rule.pattern && rule.pattern.length > 40 ? rule.pattern.substring(0, 40) + '...' : rule.pattern || '-'}
</code> </code>
</td> </td>
<td className="px-4 py-3">{getSeverityBadge(rule.severity)}</td> <td className="px-4 py-3">{getActionBadge(rule.action)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <button
onClick={() => toggleRuleStatus(rule)} onClick={() => toggleRuleStatus(rule)}
className={`text-xs px-2 py-1 rounded ${ className={`text-xs px-2 py-1 rounded ${
rule.is_active rule.active
? 'bg-green-100 text-green-700' ? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700' : 'bg-red-100 text-red-700'
}`} }`}
> >
{rule.is_active ? 'Aktiv' : 'Inaktiv'} {rule.active ? 'Aktiv' : 'Inaktiv'}
</button> </button>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -408,41 +427,41 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ *</label> <label className="block text-sm font-medium text-slate-700 mb-1">Kategorie *</label>
<select <select
value={newRule.rule_type} value={newRule.category}
onChange={(e) => setNewRule({ ...newRule, rule_type: e.target.value })} onChange={(e) => setNewRule({ ...newRule, category: 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" 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) => ( {CATEGORIES.map((c) => (
<option key={t.value} value={t.value}> <option key={c.value} value={c.value}>
{t.label} {c.label}
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label> <label className="block text-sm font-medium text-slate-700 mb-1">Muster (Regex)</label>
<textarea <textarea
value={newRule.pattern} value={newRule.pattern}
onChange={(e) => setNewRule({ ...newRule, pattern: e.target.value })} 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'} placeholder={'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...'}
rows={3} 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" 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>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity *</label> <label className="block text-sm font-medium text-slate-700 mb-1">Aktion *</label>
<select <select
value={newRule.severity} value={newRule.action}
onChange={(e) => setNewRule({ ...newRule, severity: e.target.value })} onChange={(e) => setNewRule({ ...newRule, action: 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" 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) => ( {ACTIONS.map((a) => (
<option key={s.value} value={s.value}> <option key={a.value} value={a.value}>
{s.label} {a.label}
</option> </option>
))} ))}
</select> </select>
@@ -486,24 +505,24 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label> <label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select <select
value={editingRule.rule_type} value={editingRule.category}
onChange={(e) => setEditingRule({ ...editingRule, rule_type: e.target.value })} onChange={(e) => setEditingRule({ ...editingRule, category: 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" 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) => ( {CATEGORIES.map((c) => (
<option key={t.value} value={t.value}> <option key={c.value} value={c.value}>
{t.label} {c.label}
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label> <label className="block text-sm font-medium text-slate-700 mb-1">Muster (Regex)</label>
<textarea <textarea
value={editingRule.pattern} value={editingRule.pattern || ''}
onChange={(e) => setEditingRule({ ...editingRule, pattern: e.target.value })} onChange={(e) => setEditingRule({ ...editingRule, pattern: e.target.value })}
rows={3} 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" 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"
@@ -511,15 +530,15 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Severity</label> <label className="block text-sm font-medium text-slate-700 mb-1">Aktion</label>
<select <select
value={editingRule.severity} value={editingRule.action}
onChange={(e) => setEditingRule({ ...editingRule, severity: e.target.value })} onChange={(e) => setEditingRule({ ...editingRule, action: 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" 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) => ( {ACTIONS.map((a) => (
<option key={s.value} value={s.value}> <option key={a.value} value={a.value}>
{s.label} {a.label}
</option> </option>
))} ))}
</select> </select>
@@ -528,12 +547,12 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
id="edit_is_active" id="edit_active"
checked={editingRule.is_active} checked={editingRule.active}
onChange={(e) => setEditingRule({ ...editingRule, is_active: e.target.checked })} onChange={(e) => setEditingRule({ ...editingRule, active: e.target.checked })}
className="w-4 h-4 text-purple-600" className="w-4 h-4 text-purple-600"
/> />
<label htmlFor="edit_is_active" className="text-sm text-slate-700"> <label htmlFor="edit_active" className="text-sm text-slate-700">
Aktiv Aktiv
</label> </label>
</div> </div>

View File

@@ -4,16 +4,17 @@ import { useState, useEffect } from 'react'
interface AllowedSource { interface AllowedSource {
id: string id: string
policy_id: string
domain: string domain: string
name: string name: string
license: string description?: string
license?: string
legal_basis?: string legal_basis?: string
citation_template?: string
trust_boost: number trust_boost: number
is_active: boolean source_type: string
active: boolean
metadata?: Record<string, unknown>
created_at: string created_at: string
updated_at: string updated_at?: string
} }
interface SourcesTabProps { interface SourcesTabProps {
@@ -62,10 +63,8 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
name: '', name: '',
license: 'DL-DE-BY-2.0', license: 'DL-DE-BY-2.0',
legal_basis: '', legal_basis: '',
citation_template: '',
trust_boost: 0.5, trust_boost: 0.5,
is_active: true, active: true,
policy_id: '', // Will be set from policies
}) })
useEffect(() => { useEffect(() => {
@@ -107,10 +106,8 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
name: '', name: '',
license: 'DL-DE-BY-2.0', license: 'DL-DE-BY-2.0',
legal_basis: '', legal_basis: '',
citation_template: '',
trust_boost: 0.5, trust_boost: 0.5,
is_active: true, active: true,
policy_id: '',
}) })
setIsNewSource(false) setIsNewSource(false)
fetchSources() fetchSources()
@@ -167,7 +164,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const res = await fetch(`${apiBase}/v1/admin/sources/${source.id}`, { const res = await fetch(`${apiBase}/v1/admin/sources/${source.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !source.is_active }), body: JSON.stringify({ active: !source.active }),
}) })
if (!res.ok) throw new Error('Fehler beim Aendern des Status') if (!res.ok) throw new Error('Fehler beim Aendern des Status')
@@ -289,12 +286,12 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
<button <button
onClick={() => toggleSourceStatus(source)} onClick={() => toggleSourceStatus(source)}
className={`text-xs px-2 py-1 rounded ${ className={`text-xs px-2 py-1 rounded ${
source.is_active source.active
? 'bg-green-100 text-green-700' ? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700' : 'bg-red-100 text-red-700'
}`} }`}
> >
{source.is_active ? 'Aktiv' : 'Inaktiv'} {source.active ? 'Aktiv' : 'Inaktiv'}
</button> </button>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -461,17 +458,6 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
/> />
</div> </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> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label> <label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
<input <input
@@ -491,12 +477,12 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
id="is_active" id="active"
checked={editingSource.is_active} checked={editingSource.active}
onChange={(e) => setEditingSource({ ...editingSource, is_active: e.target.checked })} onChange={(e) => setEditingSource({ ...editingSource, active: e.target.checked })}
className="w-4 h-4 text-purple-600" className="w-4 h-4 text-purple-600"
/> />
<label htmlFor="is_active" className="text-sm text-slate-700"> <label htmlFor="active" className="text-sm text-slate-700">
Aktiv Aktiv
</label> </label>
</div> </div>