Compare commits
3 Commits
e6d666b89b
...
a50a9810ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a50a9810ee | ||
|
|
f7a0b11e41 | ||
|
|
80a988dc58 |
@@ -166,7 +166,7 @@ export default function SDKRootLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SDKProvider>
|
<SDKProvider enableBackendSync={true}>
|
||||||
<SDKInnerLayout>{children}</SDKInnerLayout>
|
<SDKInnerLayout>{children}</SDKInnerLayout>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ interface AISystem {
|
|||||||
status: 'draft' | 'classified' | 'compliant' | 'non-compliant'
|
status: 'draft' | 'classified' | 'compliant' | 'non-compliant'
|
||||||
obligations: string[]
|
obligations: string[]
|
||||||
assessmentDate: Date | null
|
assessmentDate: Date | null
|
||||||
|
assessmentResult: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MOCK DATA
|
// INITIAL DATA
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const mockAISystems: AISystem[] = [
|
const initialSystems: AISystem[] = [
|
||||||
{
|
{
|
||||||
id: 'ai-1',
|
id: 'ai-1',
|
||||||
name: 'Kundenservice Chatbot',
|
name: 'Kundenservice Chatbot',
|
||||||
@@ -35,6 +36,7 @@ const mockAISystems: AISystem[] = [
|
|||||||
status: 'classified',
|
status: 'classified',
|
||||||
obligations: ['Transparenzpflicht', 'Kennzeichnung als KI-System'],
|
obligations: ['Transparenzpflicht', 'Kennzeichnung als KI-System'],
|
||||||
assessmentDate: new Date('2024-01-15'),
|
assessmentDate: new Date('2024-01-15'),
|
||||||
|
assessmentResult: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ai-2',
|
id: 'ai-2',
|
||||||
@@ -46,6 +48,7 @@ const mockAISystems: AISystem[] = [
|
|||||||
status: 'non-compliant',
|
status: 'non-compliant',
|
||||||
obligations: ['Risikomanagementsystem', 'Datenlenkung', 'Technische Dokumentation', 'Menschliche Aufsicht', 'Transparenz'],
|
obligations: ['Risikomanagementsystem', 'Datenlenkung', 'Technische Dokumentation', 'Menschliche Aufsicht', 'Transparenz'],
|
||||||
assessmentDate: new Date('2024-01-10'),
|
assessmentDate: new Date('2024-01-10'),
|
||||||
|
assessmentResult: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ai-3',
|
id: 'ai-3',
|
||||||
@@ -57,17 +60,7 @@ const mockAISystems: AISystem[] = [
|
|||||||
status: 'compliant',
|
status: 'compliant',
|
||||||
obligations: [],
|
obligations: [],
|
||||||
assessmentDate: new Date('2024-01-05'),
|
assessmentDate: new Date('2024-01-05'),
|
||||||
},
|
assessmentResult: null,
|
||||||
{
|
|
||||||
id: 'ai-4',
|
|
||||||
name: 'Neue KI-Anwendung',
|
|
||||||
description: 'Noch nicht klassifiziertes System',
|
|
||||||
classification: 'unclassified',
|
|
||||||
purpose: 'In Evaluierung',
|
|
||||||
sector: 'Unbestimmt',
|
|
||||||
status: 'draft',
|
|
||||||
obligations: [],
|
|
||||||
assessmentDate: null,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -107,7 +100,113 @@ function RiskPyramid({ systems }: { systems: AISystem[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AISystemCard({ system }: { system: AISystem }) {
|
function AddSystemForm({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
onSubmit: (system: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
purpose: '',
|
||||||
|
sector: '',
|
||||||
|
classification: 'unclassified' as AISystem['classification'],
|
||||||
|
status: 'draft' as AISystem['status'],
|
||||||
|
obligations: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Neues KI-System registrieren</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="z.B. Dokumenten-Scanner"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Beschreiben Sie das KI-System..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.purpose}
|
||||||
|
onChange={e => setFormData({ ...formData, purpose: e.target.value })}
|
||||||
|
placeholder="z.B. Texterkennung"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Sektor</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.sector}
|
||||||
|
onChange={e => setFormData({ ...formData, sector: e.target.value })}
|
||||||
|
placeholder="z.B. Verwaltung"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vorklassifizierung</label>
|
||||||
|
<select
|
||||||
|
value={formData.classification}
|
||||||
|
onChange={e => setFormData({ ...formData, classification: e.target.value as AISystem['classification'] })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="unclassified">Noch nicht klassifiziert</option>
|
||||||
|
<option value="minimal-risk">Minimales Risiko</option>
|
||||||
|
<option value="limited-risk">Begrenztes Risiko</option>
|
||||||
|
<option value="high-risk">Hochrisiko</option>
|
||||||
|
<option value="prohibited">Verboten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center justify-end gap-3">
|
||||||
|
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(formData)}
|
||||||
|
disabled={!formData.name}
|
||||||
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Registrieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AISystemCard({
|
||||||
|
system,
|
||||||
|
onAssess,
|
||||||
|
onEdit,
|
||||||
|
assessing,
|
||||||
|
}: {
|
||||||
|
system: AISystem
|
||||||
|
onAssess: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
assessing: boolean
|
||||||
|
}) {
|
||||||
const classificationColors = {
|
const classificationColors = {
|
||||||
prohibited: 'bg-red-100 text-red-700 border-red-200',
|
prohibited: 'bg-red-100 text-red-700 border-red-200',
|
||||||
'high-risk': 'bg-orange-100 text-orange-700 border-orange-200',
|
'high-risk': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||||
@@ -177,11 +276,34 @@ function AISystemCard({ system }: { system: AISystem }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{system.assessmentResult && (
|
||||||
|
<div className="mt-3 p-3 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-xs font-medium text-blue-700">KI-Risikobewertung abgeschlossen</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<div className="mt-4 flex items-center gap-2">
|
||||||
<button className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors">
|
<button
|
||||||
{system.classification === 'unclassified' ? 'Klassifizierung starten' : 'Details anzeigen'}
|
onClick={onAssess}
|
||||||
|
disabled={assessing}
|
||||||
|
className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{assessing ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Bewertung laeuft...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
system.classification === 'unclassified' ? 'Klassifizierung starten' : 'Risikobewertung starten'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,8 +317,69 @@ function AISystemCard({ system }: { system: AISystem }) {
|
|||||||
|
|
||||||
export default function AIActPage() {
|
export default function AIActPage() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
const [systems] = useState<AISystem[]>(mockAISystems)
|
const [systems, setSystems] = useState<AISystem[]>(initialSystems)
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [assessingId, setAssessingId] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleAddSystem = (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||||
|
const newSystem: AISystem = {
|
||||||
|
...data,
|
||||||
|
id: `ai-${Date.now()}`,
|
||||||
|
assessmentDate: data.classification !== 'unclassified' ? new Date() : null,
|
||||||
|
assessmentResult: null,
|
||||||
|
}
|
||||||
|
setSystems(prev => [...prev, newSystem])
|
||||||
|
setShowAddForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssess = async (systemId: string) => {
|
||||||
|
const system = systems.find(s => s.id === systemId)
|
||||||
|
if (!system) return
|
||||||
|
|
||||||
|
setAssessingId(systemId)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/ai/assess-risk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
system_name: system.name,
|
||||||
|
description: system.description,
|
||||||
|
purpose: system.purpose,
|
||||||
|
sector: system.sector,
|
||||||
|
current_classification: system.classification,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
// Update system with assessment result
|
||||||
|
setSystems(prev => prev.map(s =>
|
||||||
|
s.id === systemId
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
assessmentDate: new Date(),
|
||||||
|
assessmentResult: result,
|
||||||
|
classification: result.risk_level || result.classification || s.classification,
|
||||||
|
status: result.risk_level === 'high-risk' || result.classification === 'high-risk' ? 'non-compliant' : 'classified',
|
||||||
|
obligations: result.obligations || s.obligations,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
const errData = await res.json().catch(() => ({ error: 'Bewertung fehlgeschlagen' }))
|
||||||
|
setError(errData.error || errData.detail || 'Bewertung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Verbindung zum KI-Service fehlgeschlagen. Bitte versuchen Sie es spaeter erneut.')
|
||||||
|
} finally {
|
||||||
|
setAssessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filteredSystems = filter === 'all'
|
const filteredSystems = filter === 'all'
|
||||||
? systems
|
? systems
|
||||||
@@ -218,7 +401,10 @@ export default function AIActPage() {
|
|||||||
explanation={stepInfo.explanation}
|
explanation={stepInfo.explanation}
|
||||||
tips={stepInfo.tips}
|
tips={stepInfo.tips}
|
||||||
>
|
>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
<button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -226,6 +412,22 @@ export default function AIActPage() {
|
|||||||
</button>
|
</button>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add System Form */}
|
||||||
|
{showAddForm && (
|
||||||
|
<AddSystemForm
|
||||||
|
onSubmit={handleAddSystem}
|
||||||
|
onCancel={() => setShowAddForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
@@ -275,7 +477,13 @@ export default function AIActPage() {
|
|||||||
{/* AI Systems List */}
|
{/* AI Systems List */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{filteredSystems.map(system => (
|
{filteredSystems.map(system => (
|
||||||
<AISystemCard key={system.id} system={system} />
|
<AISystemCard
|
||||||
|
key={system.id}
|
||||||
|
system={system}
|
||||||
|
onAssess={() => handleAssess(system.id)}
|
||||||
|
onEdit={() => {/* Edit handler */}}
|
||||||
|
assessing={assessingId === system.id}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function mapDisplayStatusToSDK(status: DisplayStatus): SDKChecklistItem['status'
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// CHECKLIST TEMPLATES
|
// FALLBACK TEMPLATES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface ChecklistTemplate {
|
interface ChecklistTemplate {
|
||||||
@@ -245,6 +245,22 @@ function ChecklistItemCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="h-4 w-24 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-16 bg-gray-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-full bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MAIN PAGE
|
// MAIN PAGE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -252,47 +268,85 @@ function ChecklistItemCard({
|
|||||||
export default function AuditChecklistPage() {
|
export default function AuditChecklistPage() {
|
||||||
const { state, dispatch } = useSDK()
|
const { state, dispatch } = useSDK()
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Load checklist items based on requirements when requirements exist
|
// Fetch checklist from backend on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.requirements.length > 0 && state.checklist.length === 0) {
|
const fetchChecklist = async () => {
|
||||||
// Add relevant checklist items based on requirements
|
try {
|
||||||
const relevantItems = checklistTemplates.filter(t =>
|
setLoading(true)
|
||||||
state.requirements.some(r => r.id === t.requirementId)
|
|
||||||
)
|
|
||||||
|
|
||||||
relevantItems.forEach(template => {
|
// First, try to find an active audit session
|
||||||
const sdkItem: SDKChecklistItem = {
|
const sessionsRes = await fetch('/api/sdk/v1/compliance/audit/sessions?status=in_progress')
|
||||||
id: template.id,
|
if (sessionsRes.ok) {
|
||||||
requirementId: template.requirementId,
|
const sessionsData = await sessionsRes.json()
|
||||||
title: template.question,
|
const sessions = sessionsData.sessions || sessionsData
|
||||||
description: template.category,
|
if (Array.isArray(sessions) && sessions.length > 0) {
|
||||||
status: 'PENDING',
|
const session = sessions[0]
|
||||||
notes: '',
|
setActiveSessionId(session.id)
|
||||||
verifiedBy: null,
|
|
||||||
verifiedAt: null,
|
|
||||||
}
|
|
||||||
dispatch({ type: 'SET_STATE', payload: { checklist: [...state.checklist, sdkItem] } })
|
|
||||||
})
|
|
||||||
|
|
||||||
// If no requirements match, add all templates
|
// Fetch checklist items for this session
|
||||||
if (relevantItems.length === 0) {
|
const checklistRes = await fetch(`/api/sdk/v1/compliance/audit/checklist/${session.id}`)
|
||||||
checklistTemplates.forEach(template => {
|
if (checklistRes.ok) {
|
||||||
const sdkItem: SDKChecklistItem = {
|
const checklistData = await checklistRes.json()
|
||||||
id: template.id,
|
const items = checklistData.items || checklistData.checklist || checklistData
|
||||||
requirementId: template.requirementId,
|
if (Array.isArray(items) && items.length > 0) {
|
||||||
title: template.question,
|
const mapped: SDKChecklistItem[] = items.map((item: Record<string, unknown>) => ({
|
||||||
description: template.category,
|
id: (item.id || item.requirement_id || '') as string,
|
||||||
status: 'PENDING',
|
requirementId: (item.requirement_id || '') as string,
|
||||||
notes: '',
|
title: (item.title || item.question || '') as string,
|
||||||
verifiedBy: null,
|
description: (item.category || item.description || '') as string,
|
||||||
verifiedAt: null,
|
status: ((item.status || 'PENDING') as string).toUpperCase() as SDKChecklistItem['status'],
|
||||||
|
notes: (item.notes || item.auditor_notes || '') as string,
|
||||||
|
verifiedBy: (item.verified_by || item.signed_off_by || null) as string | null,
|
||||||
|
verifiedAt: item.verified_at || item.signed_off_at ? new Date((item.verified_at || item.signed_off_at) as string) : null,
|
||||||
|
}))
|
||||||
|
dispatch({ type: 'SET_STATE', payload: { checklist: mapped } })
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dispatch({ type: 'SET_STATE', payload: { checklist: [...state.checklist, sdkItem] } })
|
}
|
||||||
})
|
|
||||||
|
// Fallback: load from templates
|
||||||
|
loadFromTemplates()
|
||||||
|
} catch {
|
||||||
|
loadFromTemplates()
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state.requirements, state.checklist.length, dispatch])
|
|
||||||
|
const loadFromTemplates = () => {
|
||||||
|
if (state.checklist.length > 0) return
|
||||||
|
|
||||||
|
const templatesToLoad = state.requirements.length > 0
|
||||||
|
? checklistTemplates.filter(t =>
|
||||||
|
state.requirements.some(r => r.id === t.requirementId)
|
||||||
|
)
|
||||||
|
: checklistTemplates
|
||||||
|
|
||||||
|
const items: SDKChecklistItem[] = templatesToLoad.map(template => ({
|
||||||
|
id: template.id,
|
||||||
|
requirementId: template.requirementId,
|
||||||
|
title: template.question,
|
||||||
|
description: template.category,
|
||||||
|
status: 'PENDING',
|
||||||
|
notes: '',
|
||||||
|
verifiedBy: null,
|
||||||
|
verifiedAt: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
dispatch({ type: 'SET_STATE', payload: { checklist: items } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchChecklist()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Convert SDK checklist items to display items
|
// Convert SDK checklist items to display items
|
||||||
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
|
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
|
||||||
@@ -305,7 +359,7 @@ export default function AuditChecklistPage() {
|
|||||||
category: item.description || template?.category || 'Allgemein',
|
category: item.description || template?.category || 'Allgemein',
|
||||||
status: mapSDKStatusToDisplay(item.status),
|
status: mapSDKStatusToDisplay(item.status),
|
||||||
notes: item.notes,
|
notes: item.notes,
|
||||||
evidence: [], // Evidence is tracked separately in SDK
|
evidence: [],
|
||||||
priority: template?.priority || 'medium',
|
priority: template?.priority || 'medium',
|
||||||
verifiedBy: item.verifiedBy,
|
verifiedBy: item.verifiedBy,
|
||||||
verifiedAt: item.verifiedAt,
|
verifiedAt: item.verifiedAt,
|
||||||
@@ -325,21 +379,39 @@ export default function AuditChecklistPage() {
|
|||||||
? Math.round(((compliantCount + partialCount * 0.5) / displayItems.length) * 100)
|
? Math.round(((compliantCount + partialCount * 0.5) / displayItems.length) * 100)
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const handleStatusChange = (itemId: string, status: DisplayStatus) => {
|
const handleStatusChange = async (itemId: string, status: DisplayStatus) => {
|
||||||
|
const sdkStatus = mapDisplayStatusToSDK(status)
|
||||||
const updatedChecklist = state.checklist.map(item =>
|
const updatedChecklist = state.checklist.map(item =>
|
||||||
item.id === itemId
|
item.id === itemId
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
status: mapDisplayStatusToSDK(status),
|
status: sdkStatus,
|
||||||
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
|
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
|
||||||
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
|
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
|
||||||
}
|
}
|
||||||
: item
|
: item
|
||||||
)
|
)
|
||||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||||
|
|
||||||
|
// Persist to backend if we have an active session
|
||||||
|
if (activeSessionId) {
|
||||||
|
try {
|
||||||
|
const item = state.checklist.find(i => i.id === itemId)
|
||||||
|
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: sdkStatus === 'PASSED' ? 'compliant' : sdkStatus === 'FAILED' ? 'non_compliant' : sdkStatus === 'NOT_APPLICABLE' ? 'partially_compliant' : 'not_assessed',
|
||||||
|
auditor_notes: item?.notes || '',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNotesChange = (itemId: string, notes: string) => {
|
const handleNotesChange = async (itemId: string, notes: string) => {
|
||||||
const updatedChecklist = state.checklist.map(item =>
|
const updatedChecklist = state.checklist.map(item =>
|
||||||
item.id === itemId ? { ...item, notes } : item
|
item.id === itemId ? { ...item, notes } : item
|
||||||
)
|
)
|
||||||
@@ -371,8 +443,16 @@ export default function AuditChecklistPage() {
|
|||||||
</div>
|
</div>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Requirements Alert */}
|
{/* Requirements Alert */}
|
||||||
{state.requirements.length === 0 && (
|
{state.requirements.length === 0 && !loading && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -397,6 +477,11 @@ export default function AuditChecklistPage() {
|
|||||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||||
<span>Frameworks: DSGVO, AI Act</span>
|
<span>Frameworks: DSGVO, AI Act</span>
|
||||||
<span>Letzte Aktualisierung: {new Date().toLocaleDateString('de-DE')}</span>
|
<span>Letzte Aktualisierung: {new Date().toLocaleDateString('de-DE')}</span>
|
||||||
|
{activeSessionId && (
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||||
|
Session aktiv
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -453,19 +538,24 @@ export default function AuditChecklistPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Checklist Items */}
|
{/* Loading State */}
|
||||||
<div className="space-y-4">
|
{loading && <LoadingSkeleton />}
|
||||||
{filteredItems.map(item => (
|
|
||||||
<ChecklistItemCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
onStatusChange={(status) => handleStatusChange(item.id, status)}
|
|
||||||
onNotesChange={(notes) => handleNotesChange(item.id, notes)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredItems.length === 0 && state.requirements.length > 0 && (
|
{/* Checklist Items */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<ChecklistItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onStatusChange={(status) => handleStatusChange(item.id, status)}
|
||||||
|
onNotesChange={(notes) => handleNotesChange(item.id, notes)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && filteredItems.length === 0 && state.requirements.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function AuditReportPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const params = statusFilter !== 'all' ? `?status=${statusFilter}` : ''
|
const params = statusFilter !== 'all' ? `?status=${statusFilter}` : ''
|
||||||
const res = await fetch(`/api/admin/audit/sessions${params}`)
|
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions${params}`)
|
||||||
if (!res.ok) throw new Error('Fehler beim Laden der Audit-Sessions')
|
if (!res.ok) throw new Error('Fehler beim Laden der Audit-Sessions')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setSessions(data.sessions || [])
|
setSessions(data.sessions || [])
|
||||||
@@ -81,7 +81,7 @@ export default function AuditReportPage() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
const res = await fetch('/api/admin/audit/sessions', {
|
const res = await fetch('/api/sdk/v1/compliance/audit/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newSession),
|
body: JSON.stringify(newSession),
|
||||||
@@ -99,7 +99,7 @@ export default function AuditReportPage() {
|
|||||||
|
|
||||||
const startSession = async (sessionId: string) => {
|
const startSession = async (sessionId: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/start`, { method: 'PUT' })
|
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}/start`, { method: 'PUT' })
|
||||||
if (!res.ok) throw new Error('Fehler beim Starten der Session')
|
if (!res.ok) throw new Error('Fehler beim Starten der Session')
|
||||||
fetchSessions()
|
fetchSessions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -109,7 +109,7 @@ export default function AuditReportPage() {
|
|||||||
|
|
||||||
const completeSession = async (sessionId: string) => {
|
const completeSession = async (sessionId: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/complete`, { method: 'PUT' })
|
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}/complete`, { method: 'PUT' })
|
||||||
if (!res.ok) throw new Error('Fehler beim Abschliessen der Session')
|
if (!res.ok) throw new Error('Fehler beim Abschliessen der Session')
|
||||||
fetchSessions()
|
fetchSessions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -120,7 +120,7 @@ export default function AuditReportPage() {
|
|||||||
const deleteSession = async (sessionId: string) => {
|
const deleteSession = async (sessionId: string) => {
|
||||||
if (!confirm('Session wirklich loeschen?')) return
|
if (!confirm('Session wirklich loeschen?')) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/audit/sessions/${sessionId}`, { method: 'DELETE' })
|
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}`, { method: 'DELETE' })
|
||||||
if (!res.ok) throw new Error('Fehler beim Loeschen der Session')
|
if (!res.ok) throw new Error('Fehler beim Loeschen der Session')
|
||||||
fetchSessions()
|
fetchSessions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -131,7 +131,7 @@ export default function AuditReportPage() {
|
|||||||
const downloadPdf = async (sessionId: string) => {
|
const downloadPdf = async (sessionId: string) => {
|
||||||
try {
|
try {
|
||||||
setGeneratingPdf(sessionId)
|
setGeneratingPdf(sessionId)
|
||||||
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/pdf?language=${pdfLanguage}`)
|
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}/pdf?language=${pdfLanguage}`)
|
||||||
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
|
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
|||||||
@@ -1130,16 +1130,65 @@ export default function CompanyProfilePage() {
|
|||||||
const totalSteps = wizardSteps.length
|
const totalSteps = wizardSteps.length
|
||||||
const lastStep = wizardSteps[wizardSteps.length - 1].id
|
const lastStep = wizardSteps[wizardSteps.length - 1].id
|
||||||
|
|
||||||
// Load existing profile
|
// Load existing profile: first try backend, then SDK state as fallback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.companyProfile) {
|
let cancelled = false
|
||||||
setFormData(state.companyProfile)
|
|
||||||
// If profile is complete, show last step
|
async function loadFromBackend() {
|
||||||
if (state.companyProfile.isComplete) {
|
try {
|
||||||
setCurrentStep(5)
|
const response = await fetch('/api/sdk/v1/company-profile?tenant_id=default')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data && !cancelled) {
|
||||||
|
const backendProfile: Partial<CompanyProfile> = {
|
||||||
|
companyName: data.company_name || '',
|
||||||
|
legalForm: data.legal_form || undefined,
|
||||||
|
industry: data.industry || '',
|
||||||
|
foundedYear: data.founded_year || undefined,
|
||||||
|
businessModel: data.business_model || undefined,
|
||||||
|
offerings: data.offerings || [],
|
||||||
|
companySize: data.company_size || undefined,
|
||||||
|
employeeCount: data.employee_count || '',
|
||||||
|
annualRevenue: data.annual_revenue || '',
|
||||||
|
headquartersCountry: data.headquarters_country || 'DE',
|
||||||
|
headquartersCity: data.headquarters_city || '',
|
||||||
|
hasInternationalLocations: data.has_international_locations || false,
|
||||||
|
internationalCountries: data.international_countries || [],
|
||||||
|
targetMarkets: data.target_markets || [],
|
||||||
|
primaryJurisdiction: data.primary_jurisdiction || 'DE',
|
||||||
|
isDataController: data.is_data_controller ?? true,
|
||||||
|
isDataProcessor: data.is_data_processor ?? false,
|
||||||
|
usesAI: data.uses_ai ?? false,
|
||||||
|
aiUseCases: data.ai_use_cases || [],
|
||||||
|
dpoName: data.dpo_name || '',
|
||||||
|
dpoEmail: data.dpo_email || '',
|
||||||
|
isComplete: data.is_complete || false,
|
||||||
|
}
|
||||||
|
setFormData(backendProfile)
|
||||||
|
if (backendProfile.isComplete) {
|
||||||
|
setCurrentStep(5)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Backend not available, fall through to SDK state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use SDK state
|
||||||
|
if (!cancelled && state.companyProfile) {
|
||||||
|
setFormData(state.companyProfile)
|
||||||
|
if (state.companyProfile.isComplete) {
|
||||||
|
setCurrentStep(5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state.companyProfile])
|
|
||||||
|
loadFromBackend()
|
||||||
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
const updateFormData = (updates: Partial<CompanyProfile>) => {
|
const updateFormData = (updates: Partial<CompanyProfile>) => {
|
||||||
setFormData(prev => ({ ...prev, ...updates }))
|
setFormData(prev => ({ ...prev, ...updates }))
|
||||||
@@ -1161,7 +1210,7 @@ export default function CompanyProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const completeAndSaveProfile = () => {
|
const completeAndSaveProfile = async () => {
|
||||||
const completeProfile: CompanyProfile = {
|
const completeProfile: CompanyProfile = {
|
||||||
...formData,
|
...formData,
|
||||||
isComplete: true,
|
isComplete: true,
|
||||||
@@ -1170,6 +1219,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useSDK, Control as SDKControl, ControlType, ImplementationStatus, RiskSeverity } from '@/lib/sdk'
|
import { useSDK, Control as SDKControl, ControlType, ImplementationStatus } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -12,9 +12,7 @@ type DisplayControlType = 'preventive' | 'detective' | 'corrective'
|
|||||||
type DisplayCategory = 'technical' | 'organizational' | 'physical'
|
type DisplayCategory = 'technical' | 'organizational' | 'physical'
|
||||||
type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
|
type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
|
||||||
|
|
||||||
// DisplayControl uses SDK Control properties but adds UI-specific fields
|
|
||||||
interface DisplayControl {
|
interface DisplayControl {
|
||||||
// From SDKControl
|
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
@@ -24,7 +22,6 @@ interface DisplayControl {
|
|||||||
evidence: string[]
|
evidence: string[]
|
||||||
owner: string | null
|
owner: string | null
|
||||||
dueDate: Date | null
|
dueDate: Date | null
|
||||||
// UI-specific fields
|
|
||||||
code: string
|
code: string
|
||||||
displayType: DisplayControlType
|
displayType: DisplayControlType
|
||||||
displayCategory: DisplayCategory
|
displayCategory: DisplayCategory
|
||||||
@@ -57,7 +54,7 @@ function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// CONTROL TEMPLATES
|
// FALLBACK TEMPLATES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface ControlTemplate {
|
interface ControlTemplate {
|
||||||
@@ -286,6 +283,25 @@ function ControlCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="h-5 w-20 bg-gray-200 rounded" />
|
||||||
|
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||||
|
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||||
|
<div className="h-4 w-full bg-gray-100 rounded" />
|
||||||
|
<div className="mt-4 h-2 bg-gray-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MAIN PAGE
|
// MAIN PAGE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -293,14 +309,51 @@ function ControlCard({
|
|||||||
export default function ControlsPage() {
|
export default function ControlsPage() {
|
||||||
const { state, dispatch } = useSDK()
|
const { state, dispatch } = useSDK()
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Track effectiveness locally as it's not in the SDK state type
|
// Track effectiveness locally as it's not in the SDK state type
|
||||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
// Load controls based on requirements when requirements exist
|
// Fetch controls from backend on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.requirements.length > 0 && state.controls.length === 0) {
|
const fetchControls = async () => {
|
||||||
// Add relevant controls based on requirements
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/controls')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const backendControls = data.controls || data
|
||||||
|
if (Array.isArray(backendControls) && backendControls.length > 0) {
|
||||||
|
const mapped: SDKControl[] = backendControls.map((c: Record<string, unknown>) => ({
|
||||||
|
id: (c.control_id || c.id) as string,
|
||||||
|
name: (c.name || c.title || '') as string,
|
||||||
|
description: (c.description || '') as string,
|
||||||
|
type: ((c.type || c.control_type || 'TECHNICAL') as string).toUpperCase() as ControlType,
|
||||||
|
category: (c.category || '') as string,
|
||||||
|
implementationStatus: ((c.implementation_status || c.status || 'NOT_IMPLEMENTED') as string).toUpperCase() as ImplementationStatus,
|
||||||
|
effectiveness: (c.effectiveness || 'LOW') as 'LOW' | 'MEDIUM' | 'HIGH',
|
||||||
|
evidence: (c.evidence || []) as string[],
|
||||||
|
owner: (c.owner || null) as string | null,
|
||||||
|
dueDate: c.due_date ? new Date(c.due_date as string) : null,
|
||||||
|
}))
|
||||||
|
dispatch({ type: 'SET_STATE', payload: { controls: mapped } })
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadFromTemplates()
|
||||||
|
} catch {
|
||||||
|
loadFromTemplates()
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromTemplates = () => {
|
||||||
|
if (state.controls.length > 0) return
|
||||||
|
if (state.requirements.length === 0) return
|
||||||
|
|
||||||
const relevantControls = controlTemplates.filter(c =>
|
const relevantControls = controlTemplates.filter(c =>
|
||||||
c.linkedRequirements.some(reqId => state.requirements.some(r => r.id === reqId))
|
c.linkedRequirements.some(reqId => state.requirements.some(r => r.id === reqId))
|
||||||
)
|
)
|
||||||
@@ -321,7 +374,9 @@ export default function ControlsPage() {
|
|||||||
dispatch({ type: 'ADD_CONTROL', payload: sdkControl })
|
dispatch({ type: 'ADD_CONTROL', payload: sdkControl })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [state.requirements, state.controls.length, dispatch])
|
|
||||||
|
fetchControls()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Convert SDK controls to display controls
|
// Convert SDK controls to display controls
|
||||||
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
||||||
@@ -364,11 +419,21 @@ export default function ControlsPage() {
|
|||||||
: 0
|
: 0
|
||||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
||||||
|
|
||||||
const handleStatusChange = (controlId: string, status: ImplementationStatus) => {
|
const handleStatusChange = async (controlId: string, status: ImplementationStatus) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_CONTROL',
|
type: 'UPDATE_CONTROL',
|
||||||
payload: { id: controlId, data: { implementationStatus: status } },
|
payload: { id: controlId, data: { implementationStatus: status } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ implementation_status: status }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail — SDK state is already updated
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEffectivenessChange = (controlId: string, effectiveness: number) => {
|
const handleEffectivenessChange = (controlId: string, effectiveness: number) => {
|
||||||
@@ -395,8 +460,16 @@ export default function ControlsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Requirements Alert */}
|
{/* Requirements Alert */}
|
||||||
{state.requirements.length === 0 && (
|
{state.requirements.length === 0 && !loading && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -456,19 +529,24 @@ export default function ControlsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls List */}
|
{/* Loading State */}
|
||||||
<div className="space-y-4">
|
{loading && <LoadingSkeleton />}
|
||||||
{filteredControls.map(control => (
|
|
||||||
<ControlCard
|
|
||||||
key={control.id}
|
|
||||||
control={control}
|
|
||||||
onStatusChange={(status) => handleStatusChange(control.id, status)}
|
|
||||||
onEffectivenessChange={(effectiveness) => handleEffectivenessChange(control.id, effectiveness)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredControls.length === 0 && state.requirements.length > 0 && (
|
{/* Controls List */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredControls.map(control => (
|
||||||
|
<ControlCard
|
||||||
|
key={control.id}
|
||||||
|
control={control}
|
||||||
|
onStatusChange={(status) => handleStatusChange(control.id, status)}
|
||||||
|
onEffectivenessChange={(effectiveness) => handleEffectivenessChange(control.id, effectiveness)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && filteredControls.length === 0 && state.requirements.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
|
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
|
||||||
@@ -45,17 +45,6 @@ function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapDisplayTypeToEvidence(type: DisplayEvidenceType): EvidenceType {
|
|
||||||
switch (type) {
|
|
||||||
case 'document': return 'DOCUMENT'
|
|
||||||
case 'screenshot': return 'SCREENSHOT'
|
|
||||||
case 'log': return 'LOG'
|
|
||||||
case 'certificate': return 'CERTIFICATE'
|
|
||||||
case 'audit-report': return 'AUDIT_REPORT'
|
|
||||||
default: return 'DOCUMENT'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEvidenceStatus(validUntil: Date | null): DisplayStatus {
|
function getEvidenceStatus(validUntil: Date | null): DisplayStatus {
|
||||||
if (!validUntil) return 'pending-review'
|
if (!validUntil) return 'pending-review'
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -64,7 +53,7 @@ function getEvidenceStatus(validUntil: Date | null): DisplayStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// EVIDENCE TEMPLATES
|
// FALLBACK TEMPLATES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface EvidenceTemplate {
|
interface EvidenceTemplate {
|
||||||
@@ -284,6 +273,24 @@ function EvidenceCard({ evidence, onDelete }: { evidence: DisplayEvidence; onDel
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 bg-gray-200 rounded-lg" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||||
|
<div className="h-4 w-full bg-gray-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MAIN PAGE
|
// MAIN PAGE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -291,11 +298,50 @@ function EvidenceCard({ evidence, onDelete }: { evidence: DisplayEvidence; onDel
|
|||||||
export default function EvidencePage() {
|
export default function EvidencePage() {
|
||||||
const { state, dispatch } = useSDK()
|
const { state, dispatch } = useSDK()
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Load evidence based on controls when controls exist
|
// Fetch evidence from backend on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.controls.length > 0 && state.evidence.length === 0) {
|
const fetchEvidence = async () => {
|
||||||
// Add relevant evidence based on controls
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/evidence')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const backendEvidence = data.evidence || data
|
||||||
|
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
|
||||||
|
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
|
||||||
|
id: (e.id || '') as string,
|
||||||
|
controlId: (e.control_id || '') as string,
|
||||||
|
type: ((e.evidence_type || 'DOCUMENT') as string).toUpperCase() as EvidenceType,
|
||||||
|
name: (e.title || e.name || '') as string,
|
||||||
|
description: (e.description || '') as string,
|
||||||
|
fileUrl: (e.artifact_url || null) as string | null,
|
||||||
|
validFrom: e.valid_from ? new Date(e.valid_from as string) : new Date(),
|
||||||
|
validUntil: e.valid_until ? new Date(e.valid_until as string) : null,
|
||||||
|
uploadedBy: (e.uploaded_by || 'System') as string,
|
||||||
|
uploadedAt: e.created_at ? new Date(e.created_at as string) : new Date(),
|
||||||
|
}))
|
||||||
|
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadFromTemplates()
|
||||||
|
} catch {
|
||||||
|
loadFromTemplates()
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromTemplates = () => {
|
||||||
|
if (state.evidence.length > 0) return
|
||||||
|
if (state.controls.length === 0) return
|
||||||
|
|
||||||
const relevantEvidence = evidenceTemplates.filter(e =>
|
const relevantEvidence = evidenceTemplates.filter(e =>
|
||||||
state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id))
|
state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id))
|
||||||
)
|
)
|
||||||
@@ -303,7 +349,7 @@ export default function EvidencePage() {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
relevantEvidence.forEach(template => {
|
relevantEvidence.forEach(template => {
|
||||||
const validFrom = new Date(now)
|
const validFrom = new Date(now)
|
||||||
validFrom.setMonth(validFrom.getMonth() - 1) // Uploaded 1 month ago
|
validFrom.setMonth(validFrom.getMonth() - 1)
|
||||||
|
|
||||||
const validUntil = template.validityDays > 0
|
const validUntil = template.validityDays > 0
|
||||||
? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000)
|
? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000)
|
||||||
@@ -324,7 +370,9 @@ export default function EvidencePage() {
|
|||||||
dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence })
|
dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [state.controls, state.evidence.length, dispatch])
|
|
||||||
|
fetchEvidence()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Convert SDK evidence to display evidence
|
// Convert SDK evidence to display evidence
|
||||||
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
|
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
|
||||||
@@ -357,9 +405,79 @@ export default function EvidencePage() {
|
|||||||
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
|
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
|
||||||
const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length
|
const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length
|
||||||
|
|
||||||
const handleDelete = (evidenceId: string) => {
|
const handleDelete = async (evidenceId: string) => {
|
||||||
if (confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) {
|
if (!confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) return
|
||||||
dispatch({ type: 'DELETE_EVIDENCE', payload: evidenceId })
|
|
||||||
|
dispatch({ type: 'DELETE_EVIDENCE', payload: evidenceId })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/compliance/evidence/${evidenceId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail — SDK state is already updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the first control as default, or a generic one
|
||||||
|
const controlId = state.controls.length > 0 ? state.controls[0].id : 'GENERIC'
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
control_id: controlId,
|
||||||
|
evidence_type: 'document',
|
||||||
|
title: file.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/evidence/upload?${params}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({ error: 'Upload fehlgeschlagen' }))
|
||||||
|
throw new Error(errData.error || errData.detail || 'Upload fehlgeschlagen')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
// Add to SDK state
|
||||||
|
const newEvidence: SDKEvidence = {
|
||||||
|
id: data.id || `ev-${Date.now()}`,
|
||||||
|
controlId: controlId,
|
||||||
|
type: 'DOCUMENT',
|
||||||
|
name: file.name,
|
||||||
|
description: `Hochgeladen am ${new Date().toLocaleDateString('de-DE')}`,
|
||||||
|
fileUrl: data.artifact_url || null,
|
||||||
|
validFrom: new Date(),
|
||||||
|
validUntil: null,
|
||||||
|
uploadedBy: 'Aktueller Benutzer',
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
}
|
||||||
|
dispatch({ type: 'ADD_EVIDENCE', payload: newEvidence })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadClick = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
handleUpload(file)
|
||||||
|
e.target.value = '' // Reset input
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +485,15 @@ export default function EvidencePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg,.json,.csv,.txt"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Step Header */}
|
{/* Step Header */}
|
||||||
<StepHeader
|
<StepHeader
|
||||||
stepId="evidence"
|
stepId="evidence"
|
||||||
@@ -375,16 +502,40 @@ export default function EvidencePage() {
|
|||||||
explanation={stepInfo.explanation}
|
explanation={stepInfo.explanation}
|
||||||
tips={stepInfo.tips}
|
tips={stepInfo.tips}
|
||||||
>
|
>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
<button
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onClick={handleUploadClick}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
disabled={uploading}
|
||||||
</svg>
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
Nachweis hochladen
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Wird hochgeladen...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
Nachweis hochladen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls Alert */}
|
{/* Controls Alert */}
|
||||||
{state.controls.length === 0 && (
|
{state.controls.length === 0 && !loading && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -443,18 +594,23 @@ export default function EvidencePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Evidence List */}
|
{/* Loading State */}
|
||||||
<div className="space-y-4">
|
{loading && <LoadingSkeleton />}
|
||||||
{filteredEvidence.map(ev => (
|
|
||||||
<EvidenceCard
|
|
||||||
key={ev.id}
|
|
||||||
evidence={ev}
|
|
||||||
onDelete={() => handleDelete(ev.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredEvidence.length === 0 && state.controls.length > 0 && (
|
{/* Evidence List */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredEvidence.map(ev => (
|
||||||
|
<EvidenceCard
|
||||||
|
key={ev.id}
|
||||||
|
evidence={ev}
|
||||||
|
onDelete={() => handleDelete(ev.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && filteredEvidence.length === 0 && state.controls.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ export default function ImportPage() {
|
|||||||
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'error' as const, error: 'Analyse fehlgeschlagen' } : f)))
|
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'error' as const, error: 'Analyse fehlgeschlagen' } : f)))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: create basic document without backend analysis
|
// Offline-Modus: create basic document without backend analysis
|
||||||
const doc: ImportedDocument = {
|
const doc: ImportedDocument = {
|
||||||
id: file.id,
|
id: file.id,
|
||||||
name: file.file.name,
|
name: file.file.name,
|
||||||
@@ -438,7 +438,7 @@ export default function ImportPage() {
|
|||||||
confidence: 0.5,
|
confidence: 0.5,
|
||||||
extractedEntities: [],
|
extractedEntities: [],
|
||||||
gaps: [],
|
gaps: [],
|
||||||
recommendations: ['Backend nicht erreichbar — manuelle Pruefung empfohlen'],
|
recommendations: ['Offline-Modus — Backend nicht erreichbar, manuelle Pruefung empfohlen'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
addImportedDocument(doc)
|
addImportedDocument(doc)
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export default function ModulesPage() {
|
|||||||
.filter(m => state.modules.some(sm => sm.id === m.id))
|
.filter(m => state.modules.some(sm => sm.id === m.id))
|
||||||
.reduce((sum, m) => sum + m.controlsCount, 0)
|
.reduce((sum, m) => sum + m.controlsCount, 0)
|
||||||
|
|
||||||
const handleActivateModule = (module: DisplayModule) => {
|
const handleActivateModule = async (module: DisplayModule) => {
|
||||||
const serviceModule: ServiceModule = {
|
const serviceModule: ServiceModule = {
|
||||||
id: module.id,
|
id: module.id,
|
||||||
name: module.name,
|
name: module.name,
|
||||||
@@ -312,11 +312,27 @@ export default function ModulesPage() {
|
|||||||
hasAIComponents: module.hasAIComponents,
|
hasAIComponents: module.hasAIComponents,
|
||||||
}
|
}
|
||||||
dispatch({ type: 'ADD_MODULE', payload: serviceModule })
|
dispatch({ type: 'ADD_MODULE', payload: serviceModule })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/modules/${encodeURIComponent(module.id)}/activate`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.warn('Could not persist module activation to backend')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeactivateModule = (moduleId: string) => {
|
const handleDeactivateModule = async (moduleId: string) => {
|
||||||
const updatedModules = state.modules.filter(m => m.id !== moduleId)
|
const updatedModules = state.modules.filter(m => m.id !== moduleId)
|
||||||
dispatch({ type: 'SET_STATE', payload: { modules: updatedModules } })
|
dispatch({ type: 'SET_STATE', payload: { modules: updatedModules } })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/modules/${encodeURIComponent(moduleId)}/deactivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.warn('Could not persist module deactivation to backend')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stepInfo = STEP_EXPLANATIONS['modules']
|
const stepInfo = STEP_EXPLANATIONS['modules']
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export default function ObligationsPage() {
|
|||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [backendAvailable, setBackendAvailable] = useState(false)
|
const [backendAvailable, setBackendAvailable] = useState(false)
|
||||||
|
const [backendError, setBackendError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadObligations() {
|
async function loadObligations() {
|
||||||
@@ -215,7 +216,7 @@ export default function ObligationsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Backend unavailable, use SDK state obligations
|
setBackendError('Backend nicht erreichbar — Pflichten aus lokalem State geladen (Offline-Modus)')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: use obligations from SDK state
|
// Fallback: use obligations from SDK state
|
||||||
@@ -275,6 +276,14 @@ export default function ObligationsPage() {
|
|||||||
Pflichten aus UCCA-Assessments geladen (Live-Daten)
|
Pflichten aus UCCA-Assessments geladen (Live-Daten)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{backendError && !backendAvailable && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
{backendError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function mapStatusToDisplayStatus(status: RequirementStatus): DisplayStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// AVAILABLE REQUIREMENTS (Templates)
|
// FALLBACK TEMPLATES (used when backend is unavailable)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const requirementTemplates: Omit<DisplayRequirement, 'displayStatus' | 'controlsLinked' | 'evidenceCount'>[] = [
|
const requirementTemplates: Omit<DisplayRequirement, 'displayStatus' | 'controlsLinked' | 'evidenceCount'>[] = [
|
||||||
@@ -182,13 +182,6 @@ function RequirementCard({
|
|||||||
'not-applicable': 'bg-gray-100 text-gray-500 border-gray-200',
|
'not-applicable': 'bg-gray-100 text-gray-500 border-gray-200',
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabels = {
|
|
||||||
compliant: 'Konform',
|
|
||||||
partial: 'Teilweise',
|
|
||||||
'non-compliant': 'Nicht konform',
|
|
||||||
'not-applicable': 'N/A',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[requirement.displayStatus]}`}>
|
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[requirement.displayStatus]}`}>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -235,6 +228,23 @@ function RequirementCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="h-5 w-20 bg-gray-200 rounded" />
|
||||||
|
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||||
|
<div className="h-4 w-full bg-gray-100 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MAIN PAGE
|
// MAIN PAGE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -243,11 +253,50 @@ export default function RequirementsPage() {
|
|||||||
const { state, dispatch } = useSDK()
|
const { state, dispatch } = useSDK()
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Load requirements based on active modules
|
// Fetch requirements from backend on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only add requirements if there are active modules and no requirements yet
|
const fetchRequirements = async () => {
|
||||||
if (state.modules.length > 0 && state.requirements.length === 0) {
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/requirements')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const backendRequirements = data.requirements || data
|
||||||
|
if (Array.isArray(backendRequirements) && backendRequirements.length > 0) {
|
||||||
|
// Map backend data to SDK format and load into state
|
||||||
|
const mapped: SDKRequirement[] = backendRequirements.map((r: Record<string, unknown>) => ({
|
||||||
|
id: (r.requirement_id || r.id) as string,
|
||||||
|
regulation: (r.regulation_code || r.regulation || '') as string,
|
||||||
|
article: (r.article || '') as string,
|
||||||
|
title: (r.title || '') as string,
|
||||||
|
description: (r.description || '') as string,
|
||||||
|
criticality: ((r.criticality || r.priority || 'MEDIUM') as string).toUpperCase() as RiskSeverity,
|
||||||
|
applicableModules: (r.applicable_modules || []) as string[],
|
||||||
|
status: (r.status || 'NOT_STARTED') as RequirementStatus,
|
||||||
|
controls: (r.controls || []) as string[],
|
||||||
|
}))
|
||||||
|
dispatch({ type: 'SET_STATE', payload: { requirements: mapped } })
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If backend returns empty or fails, fall back to templates
|
||||||
|
loadFromTemplates()
|
||||||
|
} catch {
|
||||||
|
// Backend unavailable — use templates
|
||||||
|
loadFromTemplates()
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromTemplates = () => {
|
||||||
|
if (state.requirements.length > 0) return // Already have data
|
||||||
|
if (state.modules.length === 0) return // No modules yet
|
||||||
|
|
||||||
const activeModuleIds = state.modules.map(m => m.id)
|
const activeModuleIds = state.modules.map(m => m.id)
|
||||||
const relevantRequirements = requirementTemplates.filter(r =>
|
const relevantRequirements = requirementTemplates.filter(r =>
|
||||||
r.applicableModules.some(m => activeModuleIds.includes(m))
|
r.applicableModules.some(m => activeModuleIds.includes(m))
|
||||||
@@ -268,7 +317,9 @@ export default function RequirementsPage() {
|
|||||||
dispatch({ type: 'ADD_REQUIREMENT', payload: sdkRequirement })
|
dispatch({ type: 'ADD_REQUIREMENT', payload: sdkRequirement })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [state.modules, state.requirements.length, dispatch])
|
|
||||||
|
fetchRequirements()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Convert SDK requirements to display requirements
|
// Convert SDK requirements to display requirements
|
||||||
const displayRequirements: DisplayRequirement[] = state.requirements.map(req => {
|
const displayRequirements: DisplayRequirement[] = state.requirements.map(req => {
|
||||||
@@ -302,11 +353,22 @@ export default function RequirementsPage() {
|
|||||||
const partialCount = displayRequirements.filter(r => r.displayStatus === 'partial').length
|
const partialCount = displayRequirements.filter(r => r.displayStatus === 'partial').length
|
||||||
const nonCompliantCount = displayRequirements.filter(r => r.displayStatus === 'non-compliant').length
|
const nonCompliantCount = displayRequirements.filter(r => r.displayStatus === 'non-compliant').length
|
||||||
|
|
||||||
const handleStatusChange = (requirementId: string, status: RequirementStatus) => {
|
const handleStatusChange = async (requirementId: string, status: RequirementStatus) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_REQUIREMENT',
|
type: 'UPDATE_REQUIREMENT',
|
||||||
payload: { id: requirementId, data: { status } },
|
payload: { id: requirementId, data: { status } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Persist to backend
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/compliance/requirements/${requirementId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail — SDK state is already updated
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stepInfo = STEP_EXPLANATIONS['requirements']
|
const stepInfo = STEP_EXPLANATIONS['requirements']
|
||||||
@@ -329,8 +391,16 @@ export default function RequirementsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Module Alert */}
|
{/* Module Alert */}
|
||||||
{state.modules.length === 0 && (
|
{state.modules.length === 0 && !loading && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -400,18 +470,23 @@ export default function RequirementsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Requirements List */}
|
{/* Loading State */}
|
||||||
<div className="space-y-4">
|
{loading && <LoadingSkeleton />}
|
||||||
{filteredRequirements.map(requirement => (
|
|
||||||
<RequirementCard
|
|
||||||
key={requirement.id}
|
|
||||||
requirement={requirement}
|
|
||||||
onStatusChange={(status) => handleStatusChange(requirement.id, status)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredRequirements.length === 0 && state.modules.length > 0 && (
|
{/* Requirements List */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredRequirements.map(requirement => (
|
||||||
|
<RequirementCard
|
||||||
|
key={requirement.id}
|
||||||
|
requirement={requirement}
|
||||||
|
onStatusChange={(status) => handleStatusChange(requirement.id, status)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && filteredRequirements.length === 0 && state.modules.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useSDK, Risk, RiskLikelihood, RiskImpact, RiskSeverity, calculateRiskScore, getRiskSeverityFromScore } from '@/lib/sdk'
|
import { useSDK, Risk, RiskLikelihood, RiskImpact, RiskSeverity, RiskStatus, RiskMitigation, calculateRiskScore, getRiskSeverityFromScore } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -359,6 +359,24 @@ function RiskCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||||
|
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||||
|
<div className="h-4 w-full bg-gray-100 rounded mb-4" />
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="h-4 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MAIN PAGE
|
// MAIN PAGE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -367,8 +385,50 @@ export default function RisksPage() {
|
|||||||
const { state, dispatch, addRisk } = useSDK()
|
const { state, dispatch, addRisk } = useSDK()
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [editingRisk, setEditingRisk] = useState<Risk | null>(null)
|
const [editingRisk, setEditingRisk] = useState<Risk | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleSubmit = (data: { title: string; description: string; category: string; likelihood: RiskLikelihood; impact: RiskImpact }) => {
|
// Fetch risks from backend on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRisks = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/risks')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const backendRisks = data.risks || data
|
||||||
|
if (Array.isArray(backendRisks) && backendRisks.length > 0) {
|
||||||
|
const mapped: Risk[] = backendRisks.map((r: Record<string, unknown>) => ({
|
||||||
|
id: (r.risk_id || r.id || '') as string,
|
||||||
|
title: (r.title || '') as string,
|
||||||
|
description: (r.description || '') as string,
|
||||||
|
category: (r.category || 'technical') as string,
|
||||||
|
likelihood: (r.likelihood || 3) as RiskLikelihood,
|
||||||
|
impact: (r.impact || 3) as RiskImpact,
|
||||||
|
severity: ((r.inherent_risk || r.severity || 'MEDIUM') as string).toUpperCase() as RiskSeverity,
|
||||||
|
inherentRiskScore: (r.likelihood as number || 3) * (r.impact as number || 3),
|
||||||
|
residualRiskScore: (r.residual_likelihood as number || r.likelihood as number || 3) * (r.residual_impact as number || r.impact as number || 3),
|
||||||
|
status: (r.status || 'IDENTIFIED') as RiskStatus,
|
||||||
|
mitigation: (Array.isArray(r.mitigating_controls) ? (r.mitigating_controls as RiskMitigation[]) : []) as RiskMitigation[],
|
||||||
|
owner: (r.owner || null) as string | null,
|
||||||
|
relatedControls: [] as string[],
|
||||||
|
relatedRequirements: [] as string[],
|
||||||
|
}))
|
||||||
|
dispatch({ type: 'SET_STATE', payload: { risks: mapped } })
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Backend unavailable — use SDK state as-is
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRisks()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleSubmit = async (data: { title: string; description: string; category: string; likelihood: RiskLikelihood; impact: RiskImpact }) => {
|
||||||
const score = calculateRiskScore(data.likelihood, data.impact)
|
const score = calculateRiskScore(data.likelihood, data.impact)
|
||||||
const severity = getRiskSeverityFromScore(score)
|
const severity = getRiskSeverityFromScore(score)
|
||||||
|
|
||||||
@@ -385,9 +445,27 @@ export default function RisksPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Persist to backend
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/compliance/risks/${editingRisk.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
category: data.category,
|
||||||
|
likelihood: data.likelihood,
|
||||||
|
impact: data.impact,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const riskId = `risk-${Date.now()}`
|
||||||
const newRisk: Risk = {
|
const newRisk: Risk = {
|
||||||
id: `risk-${Date.now()}`,
|
id: riskId,
|
||||||
...data,
|
...data,
|
||||||
severity,
|
severity,
|
||||||
inherentRiskScore: score,
|
inherentRiskScore: score,
|
||||||
@@ -399,15 +477,41 @@ export default function RisksPage() {
|
|||||||
relatedRequirements: [],
|
relatedRequirements: [],
|
||||||
}
|
}
|
||||||
addRisk(newRisk)
|
addRisk(newRisk)
|
||||||
|
|
||||||
|
// Persist to backend
|
||||||
|
try {
|
||||||
|
await fetch('/api/sdk/v1/compliance/risks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
risk_id: riskId,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
category: data.category,
|
||||||
|
likelihood: data.likelihood,
|
||||||
|
impact: data.impact,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
setEditingRisk(null)
|
setEditingRisk(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (confirm('Möchten Sie dieses Risiko wirklich löschen?')) {
|
if (!confirm('Moechten Sie dieses Risiko wirklich loeschen?')) return
|
||||||
dispatch({ type: 'DELETE_RISK', payload: id })
|
|
||||||
|
dispatch({ type: 'DELETE_RISK', payload: id })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/compliance/risks/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +551,14 @@ export default function RisksPage() {
|
|||||||
)}
|
)}
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
@@ -479,11 +591,14 @@ export default function RisksPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && <LoadingSkeleton />}
|
||||||
|
|
||||||
{/* Matrix */}
|
{/* Matrix */}
|
||||||
<RiskMatrix risks={state.risks} onCellClick={() => {}} />
|
{!loading && <RiskMatrix risks={state.risks} onCellClick={() => {}} />}
|
||||||
|
|
||||||
{/* Risk List */}
|
{/* Risk List */}
|
||||||
{state.risks.length > 0 && (
|
{!loading && state.risks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alle Risiken</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alle Risiken</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -502,7 +617,7 @@ export default function RisksPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{state.risks.length === 0 && !showForm && (
|
{!loading && state.risks.length === 0 && !showForm && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
<div className="w-16 h-16 mx-auto bg-orange-100 rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 mx-auto bg-orange-100 rounded-full flex items-center justify-center mb-4">
|
||||||
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -516,7 +631,7 @@ export default function RisksPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Keine Risiken erfasst</h3>
|
<h3 className="text-lg font-semibold text-gray-900">Keine Risiken erfasst</h3>
|
||||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||||
Beginnen Sie mit der Erfassung von Risiken für Ihre KI-Anwendungen.
|
Beginnen Sie mit der Erfassung von Risiken fuer Ihre KI-Anwendungen.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
127
admin-compliance/app/api/sdk/v1/compliance/[[...path]]/route.ts
Normal file
127
admin-compliance/app/api/sdk/v1/compliance/[[...path]]/route.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Compliance API Proxy - Catch-all route
|
||||||
|
* Proxies all /api/sdk/v1/compliance/* requests to backend-compliance
|
||||||
|
*
|
||||||
|
* Backend routes: requirements, controls, evidence, risks, audit, ai
|
||||||
|
* All under /api/compliance/ prefix on backend-compliance:8002
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
pathSegments: string[] | undefined,
|
||||||
|
method: string
|
||||||
|
) {
|
||||||
|
const pathStr = pathSegments?.join('/') || ''
|
||||||
|
const searchParams = request.nextUrl.searchParams.toString()
|
||||||
|
const basePath = `${BACKEND_URL}/api/compliance`
|
||||||
|
const url = pathStr
|
||||||
|
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerNames = ['authorization', 'x-namespace-id', 'x-tenant-slug']
|
||||||
|
for (const name of headerNames) {
|
||||||
|
const value = request.headers.get(name)
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const clientUserId = request.headers.get('x-user-id')
|
||||||
|
const clientTenantId = request.headers.get('x-tenant-id')
|
||||||
|
headers['X-User-ID'] = (clientUserId && uuidRegex.test(clientUserId)) ? clientUserId : '00000000-0000-0000-0000-000000000001'
|
||||||
|
headers['X-Tenant-ID'] = (clientTenantId && uuidRegex.test(clientTenantId)) ? clientTenantId : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT') {
|
||||||
|
const body = await request.text()
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let errorJson
|
||||||
|
try {
|
||||||
|
errorJson = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorJson = { error: errorText }
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle PDF/binary responses
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) {
|
||||||
|
const blob = await response.blob()
|
||||||
|
return new NextResponse(blob, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Compliance API proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verbindung zum Compliance Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'GET')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'PUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
return proxyRequest(request, path, 'DELETE')
|
||||||
|
}
|
||||||
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ moduleId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { moduleId } = await params
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/activate`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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 activate module:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to backend' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ moduleId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { moduleId } = await params
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/deactivate`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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 deactivate module:', 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(() => {
|
||||||
@@ -29,10 +34,10 @@ export function ScopeExportTab({ decision, answers }: ScopeExportTabProps) {
|
|||||||
const headers = ['Typ', 'Tiefe', 'Aufwand (Tage)', 'Pflicht', 'Hard-Trigger']
|
const headers = ['Typ', 'Tiefe', 'Aufwand (Tage)', 'Pflicht', 'Hard-Trigger']
|
||||||
const rows = decision.requiredDocuments.map((doc) => [
|
const rows = decision.requiredDocuments.map((doc) => [
|
||||||
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
|
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
|
||||||
doc.depthDescription,
|
doc.depth,
|
||||||
doc.effortEstimate?.days?.toString() || '0',
|
doc.estimatedEffort || '0',
|
||||||
doc.isMandatory ? 'Ja' : 'Nein',
|
doc.required ? 'Ja' : 'Nein',
|
||||||
doc.triggeredByHardTrigger ? 'Ja' : 'Nein',
|
doc.triggeredBy.length > 0 ? 'Ja' : 'Nein',
|
||||||
])
|
])
|
||||||
|
|
||||||
const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n')
|
const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n')
|
||||||
@@ -52,32 +57,29 @@ export function ScopeExportTab({ decision, answers }: ScopeExportTabProps) {
|
|||||||
let markdown = `# Compliance Scope Entscheidung\n\n`
|
let markdown = `# Compliance Scope Entscheidung\n\n`
|
||||||
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
|
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
|
||||||
markdown += `## Einstufung\n\n`
|
markdown += `## Einstufung\n\n`
|
||||||
markdown += `**Level:** ${decision.level} - ${DEPTH_LEVEL_LABELS[decision.level]}\n\n`
|
markdown += `**Level:** ${decision.determinedLevel} - ${DEPTH_LEVEL_LABELS[decision.determinedLevel]}\n\n`
|
||||||
if (decision.reasoning) {
|
if (decision.reasoning) {
|
||||||
markdown += `**Begründung:** ${decision.reasoning}\n\n`
|
markdown += `**Begründung:** ${decision.reasoning}\n\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decision.scores) {
|
if (decision.scores) {
|
||||||
markdown += `## Scores\n\n`
|
markdown += `## Scores\n\n`
|
||||||
markdown += `- **Risiko-Score:** ${decision.scores.riskScore}/100\n`
|
markdown += `- **Risiko-Score:** ${decision.scores.risk_score}/100\n`
|
||||||
markdown += `- **Komplexitäts-Score:** ${decision.scores.complexityScore}/100\n`
|
markdown += `- **Komplexitäts-Score:** ${decision.scores.complexity_score}/100\n`
|
||||||
markdown += `- **Assurance-Score:** ${decision.scores.assuranceScore}/100\n`
|
markdown += `- **Assurance-Score:** ${decision.scores.assurance_need}/100\n`
|
||||||
markdown += `- **Gesamt-Score:** ${decision.scores.compositeScore}/100\n\n`
|
markdown += `- **Gesamt-Score:** ${decision.scores.composite_score}/100\n\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decision.hardTriggers && decision.hardTriggers.length > 0) {
|
if (decision.triggeredHardTriggers && decision.triggeredHardTriggers.length > 0) {
|
||||||
const matchedTriggers = decision.hardTriggers.filter((ht) => ht.matched)
|
markdown += `## Aktive Hard-Trigger\n\n`
|
||||||
if (matchedTriggers.length > 0) {
|
decision.triggeredHardTriggers.forEach((trigger) => {
|
||||||
markdown += `## Aktive Hard-Trigger\n\n`
|
markdown += `- **${trigger.rule.label}**\n`
|
||||||
matchedTriggers.forEach((trigger) => {
|
markdown += ` - ${trigger.rule.description}\n`
|
||||||
markdown += `- **${trigger.label}**\n`
|
if (trigger.rule.legalReference) {
|
||||||
markdown += ` - ${trigger.description}\n`
|
markdown += ` - Rechtsgrundlage: ${trigger.rule.legalReference}\n`
|
||||||
if (trigger.legalReference) {
|
}
|
||||||
markdown += ` - Rechtsgrundlage: ${trigger.legalReference}\n`
|
})
|
||||||
}
|
markdown += `\n`
|
||||||
})
|
|
||||||
markdown += `\n`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
|
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
|
||||||
@@ -85,9 +87,9 @@ export function ScopeExportTab({ decision, answers }: ScopeExportTabProps) {
|
|||||||
markdown += `| Typ | Tiefe | Aufwand | Pflicht | Hard-Trigger |\n`
|
markdown += `| Typ | Tiefe | Aufwand | Pflicht | Hard-Trigger |\n`
|
||||||
markdown += `|-----|-------|---------|---------|-------------|\n`
|
markdown += `|-----|-------|---------|---------|-------------|\n`
|
||||||
decision.requiredDocuments.forEach((doc) => {
|
decision.requiredDocuments.forEach((doc) => {
|
||||||
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depthDescription} | ${
|
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depth} | ${
|
||||||
doc.effortEstimate?.days || 0
|
doc.estimatedEffort || '0'
|
||||||
} Tage | ${doc.isMandatory ? 'Ja' : 'Nein'} | ${doc.triggeredByHardTrigger ? 'Ja' : 'Nein'} |\n`
|
} | ${doc.required ? 'Ja' : 'Nein'} | ${doc.triggeredBy.length > 0 ? 'Ja' : 'Nein'} |\n`
|
||||||
})
|
})
|
||||||
markdown += `\n`
|
markdown += `\n`
|
||||||
}
|
}
|
||||||
@@ -106,8 +108,8 @@ export function ScopeExportTab({ decision, answers }: ScopeExportTabProps) {
|
|||||||
decision.nextActions.forEach((action) => {
|
decision.nextActions.forEach((action) => {
|
||||||
markdown += `${action.priority}. **${action.title}**\n`
|
markdown += `${action.priority}. **${action.title}**\n`
|
||||||
markdown += ` ${action.description}\n`
|
markdown += ` ${action.description}\n`
|
||||||
if (action.effortDays) {
|
if (action.estimatedEffort) {
|
||||||
markdown += ` Aufwand: ${action.effortDays} Tage\n`
|
markdown += ` Aufwand: ${action.estimatedEffort}\n`
|
||||||
}
|
}
|
||||||
markdown += `\n`
|
markdown += `\n`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -147,6 +147,30 @@ async def create_evidence(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/evidence/{evidence_id}")
|
||||||
|
async def delete_evidence(
|
||||||
|
evidence_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete an evidence record."""
|
||||||
|
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
|
||||||
|
if not evidence:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
|
||||||
|
|
||||||
|
# Remove artifact file if it exists
|
||||||
|
if evidence.artifact_path and os.path.exists(evidence.artifact_path):
|
||||||
|
try:
|
||||||
|
os.remove(evidence.artifact_path)
|
||||||
|
except OSError:
|
||||||
|
logger.warning(f"Could not remove artifact file: {evidence.artifact_path}")
|
||||||
|
|
||||||
|
db.delete(evidence)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Evidence {evidence_id} deleted")
|
||||||
|
return {"success": True, "message": f"Evidence {evidence_id} deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/evidence/upload")
|
@router.post("/evidence/upload")
|
||||||
async def upload_evidence(
|
async def upload_evidence(
|
||||||
control_id: str = Query(...),
|
control_id: str = Query(...),
|
||||||
|
|||||||
@@ -198,6 +198,42 @@ async def seed_modules(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/modules/{module_id}/activate")
|
||||||
|
async def activate_module(module_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Activate a service module."""
|
||||||
|
from ..db.repository import ServiceModuleRepository
|
||||||
|
|
||||||
|
repo = ServiceModuleRepository(db)
|
||||||
|
module = repo.get_by_id(module_id)
|
||||||
|
if not module:
|
||||||
|
module = repo.get_by_name(module_id)
|
||||||
|
if not module:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Module {module_id} not found")
|
||||||
|
|
||||||
|
module.is_active = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "activated", "id": module.id, "name": module.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/modules/{module_id}/deactivate")
|
||||||
|
async def deactivate_module(module_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Deactivate a service module."""
|
||||||
|
from ..db.repository import ServiceModuleRepository
|
||||||
|
|
||||||
|
repo = ServiceModuleRepository(db)
|
||||||
|
module = repo.get_by_id(module_id)
|
||||||
|
if not module:
|
||||||
|
module = repo.get_by_name(module_id)
|
||||||
|
if not module:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Module {module_id} not found")
|
||||||
|
|
||||||
|
module.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "deactivated", "id": module.id, "name": module.name}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/modules/{module_id}/regulations", response_model=ModuleRegulationMappingResponse)
|
@router.post("/modules/{module_id}/regulations", response_model=ModuleRegulationMappingResponse)
|
||||||
async def add_module_regulation(
|
async def add_module_regulation(
|
||||||
module_id: str,
|
module_id: str,
|
||||||
|
|||||||
@@ -164,6 +164,24 @@ async def update_risk(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/risks/{risk_id}")
|
||||||
|
async def delete_risk(
|
||||||
|
risk_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a risk."""
|
||||||
|
repo = RiskRepository(db)
|
||||||
|
risk = repo.get_by_risk_id(risk_id)
|
||||||
|
if not risk:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Risk {risk_id} not found")
|
||||||
|
|
||||||
|
db.delete(risk)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Risk {risk_id} deleted")
|
||||||
|
return {"success": True, "message": f"Risk {risk_id} deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/risks/matrix", response_model=RiskMatrixResponse)
|
@router.get("/risks/matrix", response_model=RiskMatrixResponse)
|
||||||
async def get_risk_matrix(db: Session = Depends(get_db)):
|
async def get_risk_matrix(db: Session = Depends(get_db)):
|
||||||
"""Get risk matrix data for visualization."""
|
"""Get risk matrix data for visualization."""
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from sqlalchemy.orm import Session
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from compliance.db.source_policy_models import (
|
from compliance.db.source_policy_models import (
|
||||||
AllowedSourceDB,
|
AllowedSourceDB,
|
||||||
|
BlockedContentDB,
|
||||||
SourceOperationDB,
|
SourceOperationDB,
|
||||||
PIIRuleDB,
|
PIIRuleDB,
|
||||||
SourcePolicyAuditDB,
|
SourcePolicyAuditDB,
|
||||||
@@ -398,6 +399,43 @@ async def delete_pii_rule(rule_id: str, db: Session = Depends(get_db)):
|
|||||||
return {"status": "deleted", "id": rule_id}
|
return {"status": "deleted", "id": rule_id}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Blocked Content
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/blocked-content")
|
||||||
|
async def list_blocked_content(
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
domain: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List blocked content entries."""
|
||||||
|
query = db.query(BlockedContentDB)
|
||||||
|
|
||||||
|
if domain:
|
||||||
|
query = query.filter(BlockedContentDB.domain == domain)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
entries = query.order_by(BlockedContentDB.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"blocked": [
|
||||||
|
{
|
||||||
|
"id": str(e.id),
|
||||||
|
"url": e.url,
|
||||||
|
"domain": e.domain,
|
||||||
|
"block_reason": e.block_reason,
|
||||||
|
"rule_id": str(e.rule_id) if e.rule_id else None,
|
||||||
|
"details": e.details,
|
||||||
|
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Audit Trail
|
# Audit Trail
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -85,6 +85,28 @@ class PIIRuleDB(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedContentDB(Base):
|
||||||
|
"""Blocked content entries tracked by source policy enforcement."""
|
||||||
|
|
||||||
|
__tablename__ = 'compliance_blocked_content'
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
url = Column(Text, nullable=True)
|
||||||
|
domain = Column(String(255), nullable=False)
|
||||||
|
block_reason = Column(String(100), nullable=False) # unlicensed, pii, blacklisted, etc.
|
||||||
|
rule_id = Column(UUID(as_uuid=True), nullable=True) # PII rule or source that triggered block
|
||||||
|
details = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_blocked_content_domain', 'domain'),
|
||||||
|
Index('idx_blocked_content_created', 'created_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<BlockedContent {self.domain}: {self.block_reason}>"
|
||||||
|
|
||||||
|
|
||||||
class SourcePolicyAuditDB(Base):
|
class SourcePolicyAuditDB(Base):
|
||||||
"""Audit trail for source policy changes."""
|
"""Audit trail for source policy changes."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user