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 (
<SDKProvider>
<SDKProvider enableBackendSync={true}>
<SDKInnerLayout>{children}</SDKInnerLayout>
</SDKProvider>
)

View File

@@ -1161,7 +1161,7 @@ export default function CompanyProfilePage() {
}
}
const completeAndSaveProfile = () => {
const completeAndSaveProfile = async () => {
const completeProfile: CompanyProfile = {
...formData,
isComplete: true,
@@ -1170,6 +1170,41 @@ export default function CompanyProfilePage() {
setCompanyProfile(completeProfile)
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()
}

View File

@@ -11,9 +11,12 @@ import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/Asses
const WIZARD_STEPS = [
{ id: 1, title: 'Grundlegendes', description: 'Titel und Beschreibung' },
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
{ id: 3, title: 'Automatisierung', description: 'Grad der Automatisierung' },
{ id: 4, title: 'Hosting & Modell', description: 'Technische Details' },
{ id: 5, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' },
{ id: 3, title: 'Verarbeitungszweck', description: 'Rechtsgrundlage und Zweck' },
{ id: 4, title: 'Automatisierung', description: 'Grad der Automatisierung' },
{ 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 = [
@@ -70,9 +73,20 @@ export default function NewUseCasePage() {
model_finetune: false,
model_training: false,
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_purpose: '',
// Contracts (Step 8)
has_dpa: false,
has_aia_documentation: false,
has_risk_assessment: false,
subprocessors: '',
})
const updateForm = (updates: Partial<typeof form>) => {
@@ -113,10 +127,22 @@ export default function NewUseCasePage() {
training: form.model_training,
inference: form.model_inference,
},
legal_basis: form.legal_basis,
international_transfer: {
enabled: form.international_transfer,
countries: form.transfer_countries,
mechanism: form.transfer_mechanism,
},
retention: {
days: form.retention_days,
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,
}
@@ -276,6 +302,29 @@ export default function NewUseCasePage() {
</div>
</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>
{[
@@ -301,8 +350,8 @@ export default function NewUseCasePage() {
</div>
)}
{/* Step 3: Automatisierung */}
{currentStep === 3 && (
{/* Step 4: Automatisierung */}
{currentStep === 4 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
{[
@@ -335,8 +384,8 @@ export default function NewUseCasePage() {
</div>
)}
{/* Step 4: Hosting & Modell */}
{currentStep === 4 && (
{/* Step 5: Hosting & Modell */}
{currentStep === 5 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
<div>
@@ -390,8 +439,59 @@ export default function NewUseCasePage() {
</div>
)}
{/* Step 5: Datenhaltung */}
{currentStep === 5 && (
{/* Step 6: Internationaler Datentransfer */}
{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">
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
<div>
@@ -420,6 +520,43 @@ export default function NewUseCasePage() {
</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>
{/* Navigation Buttons */}
@@ -431,7 +568,7 @@ export default function NewUseCasePage() {
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
{currentStep < 5 ? (
{currentStep < 8 ? (
<button
onClick={() => setCurrentStep(currentStep + 1)}
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'
interface ScopeExportTabProps {
decision: ScopeDecision | null
answers: ScopeProfilingAnswer[]
decision?: ScopeDecision | null
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 handleDownloadJSON = useCallback(() => {

View File

@@ -5,11 +5,16 @@ import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUM
interface ScopeOverviewTabProps {
scopeState: ComplianceScopeState
completionStats?: { total: number; answered: number; percentage: number; isComplete: boolean }
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 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.'}
</p>
</div>
<button
onClick={!hasAnswers ? onStartProfiling : onRefreshDecision}
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 className="flex items-center gap-3">
{hasAnswers && onReset && (
<button
onClick={onReset}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
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>

View File

@@ -7,9 +7,9 @@ interface AuditLogEntry {
action: string
entity_type: string
entity_id?: string
old_value?: any
new_value?: any
user_email?: string
old_values?: any
new_values?: any
user_id?: string
created_at: string
}
@@ -91,7 +91,7 @@ export function AuditTab({ apiBase }: AuditTabProps) {
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
setAuditLogs(data.logs || [])
setAuditLogs(data.entries || [])
setAuditTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
@@ -109,13 +109,20 @@ export function AuditTab({ apiBase }: AuditTabProps) {
params.append('limit', '100')
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()
setBlockedContent(data.blocked || [])
setBlockedTotal(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} catch {
// Endpoint not available — show empty state gracefully
setBlockedContent([])
setBlockedTotal(0)
} finally {
setBlockedLoading(false)
}
@@ -284,26 +291,26 @@ export function AuditTab({ apiBase }: AuditTabProps) {
{formatDate(log.created_at)}
</div>
</div>
{log.user_email && (
{log.user_id && (
<div className="mt-2 text-xs text-slate-500">
Benutzer: {log.user_email}
Benutzer: {log.user_id}
</div>
)}
{(log.old_value || log.new_value) && (
{(log.old_values || log.new_values) && (
<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="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)}
{typeof log.old_values === 'string' ? log.old_values : JSON.stringify(log.old_values, null, 2)}
</pre>
</div>
)}
{log.new_value && (
{log.new_values && (
<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)}
{typeof log.new_values === 'string' ? log.new_values : JSON.stringify(log.new_values, null, 2)}
</pre>
</div>
)}

View File

@@ -6,17 +6,16 @@ interface OperationPermission {
id: string
source_id: string
operation: string
is_allowed: boolean
requires_citation: boolean
notes?: string
allowed: boolean
conditions?: string
}
interface SourceWithOperations {
id: string
domain: string
name: string
license: string
is_active: boolean
license?: string
active: boolean
operations: OperationPermission[]
}
@@ -44,11 +43,33 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
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 [sourcesRes, opsRes] = await Promise.all([
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()
setSources(data.sources || [])
const sourcesData = await sourcesRes.json()
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) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
@@ -58,28 +79,26 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
const togglePermission = async (
source: SourceWithOperations,
operationId: string,
field: 'is_allowed' | 'requires_citation'
operationId: string
) => {
// 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) {
if (operationId === 'training' && !permission.allowed) {
setError('Training mit externen Daten ist VERBOTEN und kann nicht aktiviert werden.')
return
}
const updateId = `${permission.id}-${field}`
const updateId = `${permission.id}-allowed`
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 }),
body: JSON.stringify({ allowed: !permission.allowed }),
})
if (!res.ok) {
@@ -174,7 +193,7 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
</tr>
) : (
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">
<div>
<div className="font-medium text-slate-800">{source.name}</div>
@@ -184,17 +203,17 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
{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`
const isAllowed = permission?.allowed ?? false
const hasConditions = !!permission?.conditions
const isUpdating = updating === `${permission?.id}-allowed`
return (
<td key={op.id} className="px-4 py-3 text-center">
<div className="flex flex-col items-center gap-2">
{/* Is Allowed Toggle */}
{/* Allowed Toggle */}
<button
onClick={() => togglePermission(source, op.id, 'is_allowed')}
disabled={isTraining || isUpdating || !source.is_active}
onClick={() => togglePermission(source, op.id)}
disabled={isTraining || isUpdating || !source.active}
className={`w-10 h-10 flex items-center justify-center rounded transition-colors ${
isTraining
? 'bg-slate-800 text-white cursor-not-allowed'
@@ -219,20 +238,14 @@ export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
)}
</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'}
{/* Conditions indicator (read-only) */}
{isAllowed && !isTraining && hasConditions && (
<span
className="px-2 py-1 text-xs rounded bg-amber-100 text-amber-700"
title={permission?.conditions || ''}
>
{requiresCitation ? 'Cite ✓' : 'Cite'}
</button>
Bedingung
</span>
)}
</div>
</td>

View File

@@ -5,19 +5,19 @@ import { useState, useEffect } from 'react'
interface PIIRule {
id: string
name: string
rule_type: string
pattern: string
severity: string
is_active: boolean
description?: string
pattern?: string
category: string
action: string
active: boolean
created_at: string
updated_at: string
}
interface PIIMatch {
rule_id: string
rule_name: string
rule_type: string
severity: string
category: string
action: string
match: string
start_index: number
end_index: number
@@ -27,7 +27,6 @@ interface PIITestResult {
has_pii: boolean
matches: PIIMatch[]
should_block: boolean
block_level: string
}
interface PIIRulesTabProps {
@@ -35,14 +34,20 @@ interface PIIRulesTabProps {
onUpdate?: () => void
}
const RULE_TYPES = [
{ value: 'regex', label: 'Regex (Muster)' },
{ value: 'keyword', label: 'Keyword (Stichwort)' },
const CATEGORIES = [
{ value: 'email', label: 'E-Mail-Adressen' },
{ 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: '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' },
]
@@ -64,10 +69,10 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
// New rule form
const [newRule, setNewRule] = useState({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
category: 'email',
action: 'block',
active: true,
})
useEffect(() => {
@@ -102,10 +107,10 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
setNewRule({
name: '',
rule_type: 'regex',
pattern: '',
severity: 'block',
is_active: true,
category: 'email',
action: 'block',
active: true,
})
setIsNewRule(false)
fetchRules()
@@ -162,7 +167,7 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
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 }),
body: JSON.stringify({ active: !rule.active }),
})
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
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 }),
})
setTesting(true)
const matches: PIIMatch[] = []
const activeRules = rules.filter((r) => r.active && r.pattern)
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)
for (const rule of activeRules) {
try {
const regex = new RegExp(rule.pattern!, 'gi')
let m: RegExpExecArray | null
while ((m = regex.exec(testText)) !== null) {
matches.push({
rule_id: rule.id,
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 config = SEVERITIES.find((s) => s.value === severity)
const getActionBadge = (action: string) => {
const config = ACTIONS.find((a) => a.value === action)
return (
<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>
)
}
@@ -288,7 +307,7 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<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)}
{getActionBadge(match.action)}
<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}
@@ -334,9 +353,9 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<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">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">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-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</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">
<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>
</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}
{rule.pattern && 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">{getActionBadge(rule.action)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggleRuleStatus(rule)}
className={`text-xs px-2 py-1 rounded ${
rule.is_active
rule.active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{rule.is_active ? 'Aktiv' : 'Inaktiv'}
{rule.active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
@@ -408,41 +427,41 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</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
value={newRule.rule_type}
onChange={(e) => setNewRule({ ...newRule, rule_type: e.target.value })}
value={newRule.category}
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"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</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
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'}
placeholder={'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...'}
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>
<label className="block text-sm font-medium text-slate-700 mb-1">Aktion *</label>
<select
value={newRule.severity}
onChange={(e) => setNewRule({ ...newRule, severity: e.target.value })}
value={newRule.action}
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"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
@@ -486,24 +505,24 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</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
value={editingRule.rule_type}
onChange={(e) => setEditingRule({ ...editingRule, rule_type: e.target.value })}
value={editingRule.category}
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"
>
{RULE_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</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
value={editingRule.pattern}
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"
@@ -511,15 +530,15 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</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
value={editingRule.severity}
onChange={(e) => setEditingRule({ ...editingRule, severity: e.target.value })}
value={editingRule.action}
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"
>
{SEVERITIES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
@@ -528,12 +547,12 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
<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 })}
id="edit_active"
checked={editingRule.active}
onChange={(e) => setEditingRule({ ...editingRule, active: e.target.checked })}
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
</label>
</div>

View File

@@ -4,16 +4,17 @@ import { useState, useEffect } from 'react'
interface AllowedSource {
id: string
policy_id: string
domain: string
name: string
license: string
description?: string
license?: string
legal_basis?: string
citation_template?: string
trust_boost: number
is_active: boolean
source_type: string
active: boolean
metadata?: Record<string, unknown>
created_at: string
updated_at: string
updated_at?: string
}
interface SourcesTabProps {
@@ -62,10 +63,8 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
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
active: true,
})
useEffect(() => {
@@ -107,10 +106,8 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
name: '',
license: 'DL-DE-BY-2.0',
legal_basis: '',
citation_template: '',
trust_boost: 0.5,
is_active: true,
policy_id: '',
active: true,
})
setIsNewSource(false)
fetchSources()
@@ -167,7 +164,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
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 }),
body: JSON.stringify({ active: !source.active }),
})
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
@@ -289,12 +286,12 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
<button
onClick={() => toggleSourceStatus(source)}
className={`text-xs px-2 py-1 rounded ${
source.is_active
source.active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{source.is_active ? 'Aktiv' : 'Inaktiv'}
{source.active ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
@@ -461,17 +458,6 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
/>
</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
@@ -491,12 +477,12 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
<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 })}
id="active"
checked={editingSource.active}
onChange={(e) => setEditingSource({ ...editingSource, active: e.target.checked })}
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
</label>
</div>