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 (
|
return (
|
||||||
<SDKProvider>
|
<SDKProvider enableBackendSync={true}>
|
||||||
<SDKInnerLayout>{children}</SDKInnerLayout>
|
<SDKInnerLayout>{children}</SDKInnerLayout>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
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'
|
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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user