feat: Anti-Fake-Evidence System (Phase 1-4b)
Implement full evidence integrity pipeline to prevent compliance theater: - Confidence levels (E0-E4), truth status tracking, assertion engine - Four-Eyes approval workflow, audit trail, reject endpoint - Evidence distribution dashboard, LLM audit routes - Traceability matrix (backend endpoint + Compliance Hub UI tab) - Anti-fake badges, control status machine, normative patterns - 2 migrations, 4 test suites, MkDocs documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
const badgeBase = "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confidence Level Badge (E0–E4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const confidenceColors: Record<string, string> = {
|
||||
E0: "bg-red-100 text-red-800",
|
||||
E1: "bg-yellow-100 text-yellow-800",
|
||||
E2: "bg-blue-100 text-blue-800",
|
||||
E3: "bg-green-100 text-green-800",
|
||||
E4: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
|
||||
const confidenceLabels: Record<string, string> = {
|
||||
E0: "E0 — Generiert",
|
||||
E1: "E1 — Manuell",
|
||||
E2: "E2 — Intern validiert",
|
||||
E3: "E3 — System-beobachtet",
|
||||
E4: "E4 — Extern auditiert",
|
||||
}
|
||||
|
||||
export function ConfidenceLevelBadge({ level }: { level?: string | null }) {
|
||||
if (!level) return null
|
||||
const color = confidenceColors[level] || "bg-gray-100 text-gray-800"
|
||||
const label = confidenceLabels[level] || level
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Truth Status Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const truthColors: Record<string, string> = {
|
||||
generated: "bg-violet-100 text-violet-800",
|
||||
uploaded: "bg-gray-100 text-gray-800",
|
||||
observed: "bg-blue-100 text-blue-800",
|
||||
validated: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
audited: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
|
||||
const truthLabels: Record<string, string> = {
|
||||
generated: "Generiert",
|
||||
uploaded: "Hochgeladen",
|
||||
observed: "Beobachtet",
|
||||
validated: "Validiert",
|
||||
rejected: "Abgelehnt",
|
||||
audited: "Auditiert",
|
||||
}
|
||||
|
||||
export function TruthStatusBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return null
|
||||
const color = truthColors[status] || "bg-gray-100 text-gray-800"
|
||||
const label = truthLabels[status] || status
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generation Mode Badge (sparkles icon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function GenerationModeBadge({ mode }: { mode?: string | null }) {
|
||||
if (!mode) return null
|
||||
return (
|
||||
<span className={`${badgeBase} bg-violet-100 text-violet-800`}>
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0v-1H3a1 1 0 010-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 7.512a1 1 0 010 1.976l-3.354.313-1.18 4.456a1 1 0 01-1.932 0l-1.18-4.456-3.354-.313a1 1 0 010-1.976l3.354-.313 1.18-4.456A1 1 0 0112 2z" />
|
||||
</svg>
|
||||
KI-generiert
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval Status Badge (Four-Eyes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const approvalColors: Record<string, string> = {
|
||||
none: "bg-gray-100 text-gray-600",
|
||||
pending_first: "bg-yellow-100 text-yellow-800",
|
||||
first_approved: "bg-blue-100 text-blue-800",
|
||||
approved: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
const approvalLabels: Record<string, string> = {
|
||||
none: "Kein Review",
|
||||
pending_first: "Warte auf 1. Review",
|
||||
first_approved: "1. Review OK",
|
||||
approved: "Genehmigt (4-Augen)",
|
||||
rejected: "Abgelehnt",
|
||||
}
|
||||
|
||||
export function ApprovalStatusBadge({
|
||||
status,
|
||||
requiresFourEyes,
|
||||
}: {
|
||||
status?: string | null
|
||||
requiresFourEyes?: boolean | null
|
||||
}) {
|
||||
if (!requiresFourEyes) return null
|
||||
const s = status || "none"
|
||||
const color = approvalColors[s] || "bg-gray-100 text-gray-600"
|
||||
const label = approvalLabels[s] || s
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
ConfidenceLevelBadge,
|
||||
TruthStatusBadge,
|
||||
GenerationModeBadge,
|
||||
ApprovalStatusBadge,
|
||||
} from './components/anti-fake-badges'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -28,6 +34,12 @@ interface DisplayEvidence {
|
||||
status: DisplayStatus
|
||||
fileSize: string
|
||||
fileUrl: string | null
|
||||
// Anti-Fake-Evidence Phase 2
|
||||
confidenceLevel: string | null
|
||||
truthStatus: string | null
|
||||
generationMode: string | null
|
||||
approvalStatus: string | null
|
||||
requiresFourEyes: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -162,7 +174,327 @@ const evidenceTemplates: EvidenceTemplate[] = [
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void }) {
|
||||
// =============================================================================
|
||||
// CONFIDENCE FILTER COLORS (matching anti-fake-badges)
|
||||
// =============================================================================
|
||||
|
||||
const confidenceFilterColors: Record<string, string> = {
|
||||
E0: 'bg-red-200 text-red-800',
|
||||
E1: 'bg-yellow-200 text-yellow-800',
|
||||
E2: 'bg-blue-200 text-blue-800',
|
||||
E3: 'bg-green-200 text-green-800',
|
||||
E4: 'bg-emerald-200 text-emerald-800',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REVIEW MODAL
|
||||
// =============================================================================
|
||||
|
||||
function ReviewModal({ evidence, onClose, onSuccess }: { evidence: DisplayEvidence; onClose: () => void; onSuccess: () => void }) {
|
||||
const [confidenceLevel, setConfidenceLevel] = useState(evidence.confidenceLevel || 'E1')
|
||||
const [truthStatus, setTruthStatus] = useState(evidence.truthStatus || 'uploaded')
|
||||
const [reviewedBy, setReviewedBy] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reviewedBy.trim()) { setError('Bitte E-Mail-Adresse angeben'); return }
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/evidence/${evidence.id}/review`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confidence_level: confidenceLevel, truth_status: truthStatus, reviewed_by: reviewedBy }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Review fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confidenceLevels = [
|
||||
{ value: 'E0', label: 'E0 — Generiert' },
|
||||
{ value: 'E1', label: 'E1 — Manuell' },
|
||||
{ value: 'E2', label: 'E2 — Intern validiert' },
|
||||
{ value: 'E3', label: 'E3 — System-beobachtet' },
|
||||
{ value: 'E4', label: 'E4 — Extern auditiert' },
|
||||
]
|
||||
|
||||
const truthStatuses = [
|
||||
{ value: 'generated', label: 'Generiert' },
|
||||
{ value: 'uploaded', label: 'Hochgeladen' },
|
||||
{ value: 'observed', label: 'Beobachtet' },
|
||||
{ value: 'validated', label: 'Validiert' },
|
||||
{ value: 'audited', label: 'Auditiert' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Evidence Reviewen</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">{evidence.name}</p>
|
||||
|
||||
{/* Current values */}
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Aktuelles Confidence-Level:</span>
|
||||
<span className="font-medium">{evidence.confidenceLevel || '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Aktueller Truth-Status:</span>
|
||||
<span className="font-medium">{evidence.truthStatus || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New confidence level */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Confidence-Level</label>
|
||||
<select value={confidenceLevel} onChange={e => setConfidenceLevel(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{confidenceLevels.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* New truth status */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Truth-Status</label>
|
||||
<select value={truthStatus} onChange={e => setTruthStatus(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{truthStatuses.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reviewed by */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Reviewer (E-Mail)</label>
|
||||
<input type="email" value={reviewedBy} onChange={e => setReviewedBy(e.target.value)}
|
||||
placeholder="reviewer@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Four-eyes warning */}
|
||||
{evidence.requiresFourEyes && evidence.approvalStatus !== 'approved' && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-yellow-600 mt-0.5 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>
|
||||
<div className="text-sm text-yellow-800">
|
||||
<p className="font-medium">4-Augen-Prinzip aktiv</p>
|
||||
<p>Dieser Nachweis erfordert eine zusaetzliche Freigabe durch einen zweiten Reviewer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50">
|
||||
{submitting ? 'Wird gespeichert...' : 'Review abschliessen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REJECT MODAL
|
||||
// =============================================================================
|
||||
|
||||
function RejectModal({ evidence, onClose, onSuccess }: { evidence: DisplayEvidence; onClose: () => void; onSuccess: () => void }) {
|
||||
const [reviewedBy, setReviewedBy] = useState('')
|
||||
const [rejectionReason, setRejectionReason] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reviewedBy.trim()) { setError('Bitte E-Mail-Adresse angeben'); return }
|
||||
if (!rejectionReason.trim()) { setError('Bitte Ablehnungsgrund angeben'); return }
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/evidence/${evidence.id}/reject`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reviewed_by: reviewedBy, rejection_reason: rejectionReason }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Ablehnung fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Evidence Ablehnen</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">{evidence.name}</p>
|
||||
|
||||
{/* Reviewed by */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Reviewer (E-Mail)</label>
|
||||
<input type="email" value={reviewedBy} onChange={e => setReviewedBy(e.target.value)}
|
||||
placeholder="reviewer@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-red-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Rejection reason */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ablehnungsgrund</label>
|
||||
<textarea value={rejectionReason} onChange={e => setRejectionReason(e.target.value)}
|
||||
placeholder="Bitte beschreiben Sie den Grund fuer die Ablehnung..."
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none" />
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50">
|
||||
{submitting ? 'Wird abgelehnt...' : 'Ablehnen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUDIT TRAIL PANEL
|
||||
// =============================================================================
|
||||
|
||||
function AuditTrailPanel({ evidenceId, onClose }: { evidenceId: string; onClose: () => void }) {
|
||||
const [entries, setEntries] = useState<{ id: string; action: string; actor: string; timestamp: string; details: Record<string, unknown> | null }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/compliance/audit-trail?entity_type=evidence&entity_id=${evidenceId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const mapped = (data.entries || []).map((e: Record<string, unknown>) => ({
|
||||
id: e.id as string,
|
||||
action: e.action as string,
|
||||
actor: (e.performed_by || 'System') as string,
|
||||
timestamp: (e.performed_at || '') as string,
|
||||
details: {
|
||||
...(e.field_changed ? { field: e.field_changed } : {}),
|
||||
...(e.old_value ? { old: e.old_value } : {}),
|
||||
...(e.new_value ? { new: e.new_value } : {}),
|
||||
...(e.change_summary ? { summary: e.change_summary } : {}),
|
||||
} as Record<string, unknown>,
|
||||
}))
|
||||
setEntries(mapped)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [evidenceId])
|
||||
|
||||
const actionLabels: Record<string, { label: string; color: string }> = {
|
||||
created: { label: 'Erstellt', color: 'bg-blue-100 text-blue-700' },
|
||||
uploaded: { label: 'Hochgeladen', color: 'bg-purple-100 text-purple-700' },
|
||||
reviewed: { label: 'Reviewed', color: 'bg-green-100 text-green-700' },
|
||||
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
|
||||
updated: { label: 'Aktualisiert', color: 'bg-yellow-100 text-yellow-700' },
|
||||
deleted: { label: 'Geloescht', color: 'bg-gray-100 text-gray-700' },
|
||||
approved: { label: 'Genehmigt', color: 'bg-emerald-100 text-emerald-700' },
|
||||
four_eyes_first: { label: '1. Review (4-Augen)', color: 'bg-blue-100 text-blue-700' },
|
||||
four_eyes_final: { label: 'Finale Freigabe (4-Augen)', color: 'bg-emerald-100 text-emerald-700' },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl mx-4 p-6 max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Audit-Trail</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">
|
||||
<p>Keine Audit-Trail-Eintraege vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry, idx) => {
|
||||
const meta = actionLabels[entry.action] || { label: entry.action, color: 'bg-gray-100 text-gray-700' }
|
||||
return (
|
||||
<div key={entry.id || idx} className="relative flex items-start gap-4 pl-10">
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-2.5 top-1.5 w-3 h-3 rounded-full bg-white border-2 border-purple-400" />
|
||||
|
||||
<div className="flex-1 bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${meta.color}`}>{meta.label}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{entry.timestamp ? new Date(entry.timestamp).toLocaleString('de-DE') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">{entry.actor || 'System'}</span>
|
||||
</div>
|
||||
{entry.details && Object.keys(entry.details).length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500 font-mono bg-white rounded p-2 border">
|
||||
{Object.entries(entry.details).map(([k, v]) => (
|
||||
<div key={k}><span className="text-gray-400">{k}:</span> {String(v)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EVIDENCE CARD
|
||||
// =============================================================================
|
||||
|
||||
function EvidenceCard({ evidence, onDelete, onView, onDownload, onReview, onReject, onShowHistory }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void; onReview: () => void; onReject: () => void; onShowHistory: () => void }) {
|
||||
const typeIcons = {
|
||||
document: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -221,9 +553,15 @@ function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: Di
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{evidence.name}</h3>
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
|
||||
{statusLabels[evidence.status]}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
|
||||
{statusLabels[evidence.status]}
|
||||
</span>
|
||||
<ConfidenceLevelBadge level={evidence.confidenceLevel} />
|
||||
<TruthStatusBadge status={evidence.truthStatus} />
|
||||
<GenerationModeBadge mode={evidence.generationMode} />
|
||||
<ApprovalStatusBadge status={evidence.approvalStatus} requiresFourEyes={evidence.requiresFourEyes} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
|
||||
|
||||
@@ -275,6 +613,31 @@ function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: Di
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
{/* Review button — visible when review is possible */}
|
||||
{(evidence.approvalStatus === 'none' || evidence.approvalStatus === 'pending_first' || evidence.approvalStatus === 'first_approved' || !evidence.approvalStatus) && evidence.approvalStatus !== 'approved' && evidence.approvalStatus !== 'rejected' && (
|
||||
<button
|
||||
onClick={onReview}
|
||||
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Reviewen
|
||||
</button>
|
||||
)}
|
||||
{/* Reject button — visible for four-eyes evidence that's not yet resolved */}
|
||||
{evidence.requiresFourEyes && evidence.approvalStatus !== 'rejected' && evidence.approvalStatus !== 'approved' && (
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="px-3 py-1 text-sm text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
)}
|
||||
{/* History button */}
|
||||
<button
|
||||
onClick={onShowHistory}
|
||||
className="px-3 py-1 text-sm text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Historie
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,6 +745,15 @@ export default function EvidencePage() {
|
||||
const [pageSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
// Anti-Fake-Evidence metadata (keyed by evidence ID)
|
||||
const [antiFakeMeta, setAntiFakeMeta] = useState<Record<string, {
|
||||
confidenceLevel: string | null
|
||||
truthStatus: string | null
|
||||
generationMode: string | null
|
||||
approvalStatus: string | null
|
||||
requiresFourEyes: boolean
|
||||
}>>({})
|
||||
|
||||
// Evidence Checks state
|
||||
const [checks, setChecks] = useState<EvidenceCheck[]>([])
|
||||
const [checksLoading, setChecksLoading] = useState(false)
|
||||
@@ -393,6 +765,13 @@ export default function EvidencePage() {
|
||||
const [coverageReport, setCoverageReport] = useState<CoverageReport | null>(null)
|
||||
const [seedingChecks, setSeedingChecks] = useState(false)
|
||||
|
||||
// Phase 3: Review/Reject/AuditTrail state
|
||||
const [reviewEvidence, setReviewEvidence] = useState<DisplayEvidence | null>(null)
|
||||
const [rejectEvidence, setRejectEvidence] = useState<DisplayEvidence | null>(null)
|
||||
const [auditTrailId, setAuditTrailId] = useState<string | null>(null)
|
||||
const [confidenceFilter, setConfidenceFilter] = useState<string | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
// Fetch evidence from backend on mount and when page changes
|
||||
useEffect(() => {
|
||||
const fetchEvidence = async () => {
|
||||
@@ -404,18 +783,30 @@ export default function EvidencePage() {
|
||||
if (data.total !== undefined) setTotal(data.total)
|
||||
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(),
|
||||
}))
|
||||
const metaMap: typeof antiFakeMeta = {}
|
||||
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => {
|
||||
const id = (e.id || '') as string
|
||||
metaMap[id] = {
|
||||
confidenceLevel: (e.confidence_level || null) as string | null,
|
||||
truthStatus: (e.truth_status || null) as string | null,
|
||||
generationMode: (e.generation_mode || null) as string | null,
|
||||
approvalStatus: (e.approval_status || null) as string | null,
|
||||
requiresFourEyes: !!e.requires_four_eyes,
|
||||
}
|
||||
return {
|
||||
id,
|
||||
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(),
|
||||
}
|
||||
})
|
||||
setAntiFakeMeta(metaMap)
|
||||
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
|
||||
setError(null)
|
||||
return
|
||||
@@ -463,12 +854,13 @@ export default function EvidencePage() {
|
||||
}
|
||||
|
||||
fetchEvidence()
|
||||
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, refreshKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Convert SDK evidence to display evidence
|
||||
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
|
||||
const template = evidenceTemplates.find(t => t.id === ev.id)
|
||||
|
||||
const meta = antiFakeMeta[ev.id]
|
||||
return {
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
@@ -485,12 +877,18 @@ export default function EvidencePage() {
|
||||
status: getEvidenceStatus(ev.validUntil),
|
||||
fileSize: template?.fileSize || 'Unbekannt',
|
||||
fileUrl: ev.fileUrl,
|
||||
confidenceLevel: meta?.confidenceLevel || null,
|
||||
truthStatus: meta?.truthStatus || null,
|
||||
generationMode: meta?.generationMode || null,
|
||||
approvalStatus: meta?.approvalStatus || null,
|
||||
requiresFourEyes: meta?.requiresFourEyes || false,
|
||||
}
|
||||
})
|
||||
|
||||
const filteredEvidence = filter === 'all'
|
||||
const filteredEvidence = (filter === 'all'
|
||||
? displayEvidence
|
||||
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
|
||||
).filter(e => !confidenceFilter || e.confidenceLevel === confidenceFilter)
|
||||
|
||||
const validCount = displayEvidence.filter(e => e.status === 'valid').length
|
||||
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
|
||||
@@ -803,6 +1201,20 @@ export default function EvidencePage() {
|
||||
f === 'certificate' ? 'Zertifikate' : 'Audit-Berichte'}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
{['E0', 'E1', 'E2', 'E3', 'E4'].map(level => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => setConfidenceFilter(confidenceFilter === level ? null : level)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
confidenceFilter === level
|
||||
? confidenceFilterColors[level]
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
@@ -818,6 +1230,9 @@ export default function EvidencePage() {
|
||||
onDelete={() => handleDelete(ev.id)}
|
||||
onView={() => handleView(ev)}
|
||||
onDownload={() => handleDownload(ev)}
|
||||
onReview={() => setReviewEvidence(ev)}
|
||||
onReject={() => setRejectEvidence(ev)}
|
||||
onShowHistory={() => setAuditTrailId(ev.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1106,6 +1521,28 @@ export default function EvidencePage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase 3 Modals */}
|
||||
{reviewEvidence && (
|
||||
<ReviewModal
|
||||
evidence={reviewEvidence}
|
||||
onClose={() => setReviewEvidence(null)}
|
||||
onSuccess={() => { setReviewEvidence(null); setRefreshKey(k => k + 1) }}
|
||||
/>
|
||||
)}
|
||||
{rejectEvidence && (
|
||||
<RejectModal
|
||||
evidence={rejectEvidence}
|
||||
onClose={() => setRejectEvidence(null)}
|
||||
onSuccess={() => { setRejectEvidence(null); setRefreshKey(k => k + 1) }}
|
||||
/>
|
||||
)}
|
||||
{auditTrailId && (
|
||||
<AuditTrailPanel
|
||||
evidenceId={auditTrailId}
|
||||
onClose={() => setAuditTrailId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user