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:
@@ -166,7 +166,7 @@ export default function SDKRootLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<SDKProvider>
|
||||
<SDKProvider enableBackendSync={true}>
|
||||
<SDKInnerLayout>{children}</SDKInnerLayout>
|
||||
</SDKProvider>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
42
admin-compliance/app/api/sdk/v1/import/route.ts
Normal file
42
admin-compliance/app/api/sdk/v1/import/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
42
admin-compliance/app/api/sdk/v1/screening/route.ts
Normal file
42
admin-compliance/app/api/sdk/v1/screening/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user