'use client'
import React, { useState, useEffect, useRef } from 'react'
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
type DisplayEvidenceType = 'document' | 'screenshot' | 'log' | 'audit-report' | 'certificate'
type DisplayFormat = 'pdf' | 'image' | 'text' | 'json'
type DisplayStatus = 'valid' | 'expired' | 'pending-review'
interface DisplayEvidence {
id: string
name: string
description: string
displayType: DisplayEvidenceType
format: DisplayFormat
controlId: string
linkedRequirements: string[]
linkedControls: string[]
uploadedBy: string
uploadedAt: Date
validFrom: Date
validUntil: Date | null
status: DisplayStatus
fileSize: string
fileUrl: string | null
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType {
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 {
if (!validUntil) return 'pending-review'
const now = new Date()
if (validUntil < now) return 'expired'
return 'valid'
}
// =============================================================================
// FALLBACK TEMPLATES
// =============================================================================
interface EvidenceTemplate {
id: string
name: string
description: string
type: EvidenceType
displayType: DisplayEvidenceType
format: DisplayFormat
controlId: string
linkedRequirements: string[]
linkedControls: string[]
uploadedBy: string
validityDays: number
fileSize: string
}
const evidenceTemplates: EvidenceTemplate[] = [
{
id: 'ev-dse-001',
name: 'Datenschutzerklaerung v2.3',
description: 'Aktuelle Datenschutzerklaerung fuer Website und App',
type: 'DOCUMENT',
displayType: 'document',
format: 'pdf',
controlId: 'ctrl-org-001',
linkedRequirements: ['req-gdpr-13', 'req-gdpr-14'],
linkedControls: ['ctrl-org-001'],
uploadedBy: 'DSB',
validityDays: 365,
fileSize: '245 KB',
},
{
id: 'ev-pentest-001',
name: 'Penetrationstest Report Q4/2024',
description: 'Externer Penetrationstest durch Security-Partner',
type: 'AUDIT_REPORT',
displayType: 'audit-report',
format: 'pdf',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-gdpr-32', 'req-iso-a12'],
linkedControls: ['ctrl-tom-001', 'ctrl-tom-002', 'ctrl-det-001'],
uploadedBy: 'IT Security Team',
validityDays: 365,
fileSize: '2.1 MB',
},
{
id: 'ev-iso-cert',
name: 'ISO 27001 Zertifikat',
description: 'Zertifizierung des ISMS',
type: 'CERTIFICATE',
displayType: 'certificate',
format: 'pdf',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-iso-4.1', 'req-iso-5.1'],
linkedControls: [],
uploadedBy: 'QM Abteilung',
validityDays: 365,
fileSize: '156 KB',
},
{
id: 'ev-schulung-001',
name: 'Schulungsnachweis Datenschutz 2024',
description: 'Teilnehmerliste und Schulungsinhalt',
type: 'DOCUMENT',
displayType: 'document',
format: 'pdf',
controlId: 'ctrl-org-001',
linkedRequirements: ['req-gdpr-39'],
linkedControls: ['ctrl-org-001'],
uploadedBy: 'HR Team',
validityDays: 365,
fileSize: '890 KB',
},
{
id: 'ev-rbac-001',
name: 'Access Control Screenshot',
description: 'Nachweis der RBAC-Konfiguration',
type: 'SCREENSHOT',
displayType: 'screenshot',
format: 'image',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-gdpr-32'],
linkedControls: ['ctrl-tom-001'],
uploadedBy: 'Admin',
validityDays: 0,
fileSize: '1.2 MB',
},
{
id: 'ev-log-001',
name: 'Audit Log Export',
description: 'Monatlicher Audit-Log Export',
type: 'LOG',
displayType: 'log',
format: 'json',
controlId: 'ctrl-det-001',
linkedRequirements: ['req-gdpr-32'],
linkedControls: ['ctrl-det-001'],
uploadedBy: 'System',
validityDays: 90,
fileSize: '4.5 MB',
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void }) {
const typeIcons = {
document: (
),
screenshot: (
),
log: (
),
'audit-report': (
),
certificate: (
),
}
const statusColors = {
valid: 'bg-green-100 text-green-700 border-green-200',
expired: 'bg-red-100 text-red-700 border-red-200',
'pending-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
}
const statusLabels = {
valid: 'Gueltig',
expired: 'Abgelaufen',
'pending-review': 'Pruefung ausstehend',
}
return (
{typeIcons[evidence.displayType]}
{evidence.name}
{statusLabels[evidence.status]}
{evidence.description}
Hochgeladen: {evidence.uploadedAt.toLocaleDateString('de-DE')}
{evidence.validUntil && (
Gueltig bis: {evidence.validUntil.toLocaleDateString('de-DE')}
)}
{evidence.fileSize}
{evidence.linkedRequirements.map(req => (
{req}
))}
{evidence.linkedControls.map(ctrl => (
{ctrl}
))}
Hochgeladen von: {evidence.uploadedBy}
)
}
function LoadingSkeleton() {
return (
{[1, 2, 3].map(i => (
))}
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
// =============================================================================
// EVIDENCE CHECK TYPES
// =============================================================================
interface EvidenceCheck {
id: string
check_code: string
title: string
description: string | null
check_type: string
target_url: string | null
frequency: string
is_active: boolean
last_run_at: string | null
next_run_at: string | null
}
interface CheckResult {
id: string
check_id: string
run_status: string
summary: string | null
findings_count: number
critical_findings: number
duration_ms: number
run_at: string
}
interface EvidenceMapping {
id: string
evidence_id: string
control_code: string
mapping_type: string
verified_at: string | null
verified_by: string | null
notes: string | null
}
interface CoverageReport {
total_controls: number
controls_with_evidence: number
controls_without_evidence: number
coverage_percent: number
}
type EvidenceTabKey = 'evidence' | 'checks' | 'mapping' | 'report'
const CHECK_TYPE_LABELS: Record = {
tls_scan: { label: 'TLS-Scan', color: 'bg-blue-100 text-blue-700' },
header_check: { label: 'Header-Check', color: 'bg-green-100 text-green-700' },
certificate_check: { label: 'Zertifikat', color: 'bg-yellow-100 text-yellow-700' },
dns_check: { label: 'DNS-Check', color: 'bg-purple-100 text-purple-700' },
api_scan: { label: 'API-Scan', color: 'bg-indigo-100 text-indigo-700' },
config_scan: { label: 'Config-Scan', color: 'bg-orange-100 text-orange-700' },
port_scan: { label: 'Port-Scan', color: 'bg-red-100 text-red-700' },
}
const RUN_STATUS_LABELS: Record = {
running: { label: 'Laeuft...', color: 'bg-blue-100 text-blue-700' },
passed: { label: 'Bestanden', color: 'bg-green-100 text-green-700' },
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
warning: { label: 'Warnung', color: 'bg-yellow-100 text-yellow-700' },
error: { label: 'Fehler', color: 'bg-red-100 text-red-700' },
}
const CHECK_API = '/api/sdk/v1/compliance/evidence-checks'
export default function EvidencePage() {
const { state, dispatch } = useSDK()
const [activeTab, setActiveTab] = useState('evidence')
const [filter, setFilter] = useState('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef(null)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
// Evidence Checks state
const [checks, setChecks] = useState([])
const [checksLoading, setChecksLoading] = useState(false)
const [runningCheckId, setRunningCheckId] = useState(null)
const [checkResults, setCheckResults] = useState>({})
// Mappings state
const [mappings, setMappings] = useState([])
const [coverageReport, setCoverageReport] = useState(null)
const [seedingChecks, setSeedingChecks] = useState(false)
// Fetch evidence from backend on mount and when page changes
useEffect(() => {
const fetchEvidence = async () => {
try {
setLoading(true)
const res = await fetch(`/api/sdk/v1/compliance/evidence?page=${page}&limit=${pageSize}`)
if (res.ok) {
const data = await res.json()
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) => ({
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 =>
state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id))
)
const now = new Date()
relevantEvidence.forEach(template => {
const validFrom = new Date(now)
validFrom.setMonth(validFrom.getMonth() - 1)
const validUntil = template.validityDays > 0
? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000)
: null
const sdkEvidence: SDKEvidence = {
id: template.id,
controlId: template.controlId,
type: template.type,
name: template.name,
description: template.description,
fileUrl: null,
validFrom,
validUntil,
uploadedBy: template.uploadedBy,
uploadedAt: validFrom,
}
dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence })
})
}
fetchEvidence()
}, [page, pageSize]) // 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)
return {
id: ev.id,
name: ev.name,
description: ev.description,
displayType: mapEvidenceTypeToDisplay(ev.type),
format: template?.format || 'pdf',
controlId: ev.controlId,
linkedRequirements: template?.linkedRequirements || [],
linkedControls: template?.linkedControls || [ev.controlId],
uploadedBy: ev.uploadedBy,
uploadedAt: ev.uploadedAt,
validFrom: ev.validFrom,
validUntil: ev.validUntil,
status: getEvidenceStatus(ev.validUntil),
fileSize: template?.fileSize || 'Unbekannt',
fileUrl: ev.fileUrl,
}
})
const filteredEvidence = filter === 'all'
? displayEvidence
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
const validCount = displayEvidence.filter(e => e.status === 'valid').length
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length
const handleDelete = async (evidenceId: string) => {
if (!confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) return
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 handleView = (ev: DisplayEvidence) => {
if (ev.fileUrl) {
window.open(ev.fileUrl, '_blank')
} else {
alert('Keine Datei vorhanden')
}
}
const handleDownload = (ev: DisplayEvidence) => {
if (!ev.fileUrl) return
const a = document.createElement('a')
a.href = ev.fileUrl
a.download = ev.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
e.target.value = '' // Reset input
}
}
// Load checks when tab changes
const loadChecks = async () => {
setChecksLoading(true)
try {
const res = await fetch(`${CHECK_API}?limit=50`)
if (res.ok) {
const data = await res.json()
setChecks(data.checks || [])
}
} catch { /* silent */ }
finally { setChecksLoading(false) }
}
const runCheck = async (checkId: string) => {
setRunningCheckId(checkId)
try {
const res = await fetch(`${CHECK_API}/${checkId}/run`, { method: 'POST' })
if (res.ok) {
const result = await res.json()
setCheckResults(prev => ({
...prev,
[checkId]: [result, ...(prev[checkId] || [])].slice(0, 5),
}))
loadChecks() // refresh last_run_at
}
} catch { /* silent */ }
finally { setRunningCheckId(null) }
}
const loadCheckResults = async (checkId: string) => {
try {
const res = await fetch(`${CHECK_API}/${checkId}/results?limit=5`)
if (res.ok) {
const data = await res.json()
setCheckResults(prev => ({ ...prev, [checkId]: data.results || [] }))
}
} catch { /* silent */ }
}
const seedChecks = async () => {
setSeedingChecks(true)
try {
await fetch(`${CHECK_API}/seed`, { method: 'POST' })
loadChecks()
} catch { /* silent */ }
finally { setSeedingChecks(false) }
}
const loadMappings = async () => {
try {
const res = await fetch(`${CHECK_API}/mappings`)
if (res.ok) {
const data = await res.json()
setMappings(data.mappings || [])
}
} catch { /* silent */ }
}
const loadCoverageReport = async () => {
try {
const res = await fetch(`${CHECK_API}/mappings/report`)
if (res.ok) setCoverageReport(await res.json())
} catch { /* silent */ }
}
useEffect(() => {
if (activeTab === 'checks' && checks.length === 0) loadChecks()
if (activeTab === 'mapping') { loadMappings(); loadCoverageReport() }
if (activeTab === 'report') loadCoverageReport()
}, [activeTab]) // eslint-disable-line react-hooks/exhaustive-deps
const stepInfo = STEP_EXPLANATIONS['evidence']
const evidenceTabs: { key: EvidenceTabKey; label: string }[] = [
{ key: 'evidence', label: 'Nachweise' },
{ key: 'checks', label: 'Automatische Checks' },
{ key: 'mapping', label: 'Control-Mapping' },
{ key: 'report', label: 'Report' },
]
return (
{/* Hidden file input */}
{/* Step Header */}
{/* Tab Navigation */}
{evidenceTabs.map(tab => (
))}
{/* Error Banner */}
{error && (
{error}
)}
{/* ============================================================ */}
{/* TAB: Nachweise (existing content) */}
{/* ============================================================ */}
{activeTab === 'evidence' && <>
{/* Controls Alert */}
{state.controls.length === 0 && !loading && (
Keine Kontrollen definiert
Bitte definieren Sie zuerst Kontrollen, um die zugehoerigen Nachweise zu laden.
)}
{/* Stats */}
Gesamt
{displayEvidence.length}
Abgelaufen
{expiredCount}
Pruefung ausstehend
{pendingCount}
{/* Filter */}
Filter:
{['all', 'valid', 'expired', 'pending-review', 'document', 'certificate', 'audit-report'].map(f => (
))}
{/* Loading State */}
{loading &&
}
{/* Evidence List */}
{!loading && (
{filteredEvidence.map(ev => (
handleDelete(ev.id)}
onView={() => handleView(ev)}
onDownload={() => handleDownload(ev)}
/>
))}
)}
{/* Pagination */}
{!loading && total > pageSize && (
Zeige {((page - 1) * pageSize) + 1}–{Math.min(page * pageSize, total)} von {total} Nachweisen
Seite {page} von {Math.ceil(total / pageSize)}
)}
{!loading && filteredEvidence.length === 0 && state.controls.length > 0 && (
Keine Nachweise gefunden
Passen Sie den Filter an oder laden Sie neue Nachweise hoch.
)}
>}
{/* ============================================================ */}
{/* TAB: Automatische Checks */}
{/* ============================================================ */}
{activeTab === 'checks' && (
<>
{/* Seed if empty */}
{!checksLoading && checks.length === 0 && (
Keine automatischen Checks vorhanden
Laden Sie ca. 15 Standard-Checks (TLS, Header, Zertifikate, etc.).
)}
{checksLoading ? (
) : (
{checks.map(check => {
const typeMeta = CHECK_TYPE_LABELS[check.check_type] || { label: check.check_type, color: 'bg-gray-100 text-gray-700' }
const results = checkResults[check.id] || []
const lastResult = results[0]
const isRunning = runningCheckId === check.id
return (
{check.title}
{typeMeta.label}
{!check.is_active && (
Deaktiviert
)}
{check.description &&
{check.description}
}
Code: {check.check_code}
{check.target_url && Ziel: {check.target_url}}
Frequenz: {check.frequency}
{check.last_run_at && Letzter Lauf: {new Date(check.last_run_at).toLocaleDateString('de-DE')}}
{lastResult && (
{RUN_STATUS_LABELS[lastResult.run_status]?.label || lastResult.run_status}
)}
{/* Results */}
{results.length > 0 && (
Letzte Ergebnisse
{results.slice(0, 3).map(r => (
{RUN_STATUS_LABELS[r.run_status]?.label || r.run_status}
{new Date(r.run_at).toLocaleString('de-DE')}
{r.duration_ms}ms
{r.findings_count > 0 && (
{r.findings_count} Findings ({r.critical_findings} krit.)
)}
{r.summary && {r.summary}}
))}
)}
)
})}
)}
>
)}
{/* ============================================================ */}
{/* TAB: Control-Mapping */}
{/* ============================================================ */}
{activeTab === 'mapping' && (
<>
{coverageReport && (
Gesamt Controls
{coverageReport.total_controls}
Mit Nachweis
{coverageReport.controls_with_evidence}
Ohne Nachweis
{coverageReport.controls_without_evidence}
Abdeckung
{coverageReport.coverage_percent.toFixed(0)}%
)}
Evidence-Control-Verknuepfungen ({mappings.length})
{mappings.length === 0 ? (
Noch keine Verknuepfungen erstellt.
Fuehren Sie automatische Checks aus, um Nachweise automatisch mit Controls zu verknuepfen.
) : (
| Control |
Evidence |
Typ |
Verifiziert |
{mappings.map(m => (
| {m.control_code} |
{m.evidence_id.slice(0, 8)}... |
{m.mapping_type}
|
{m.verified_at ? `${new Date(m.verified_at).toLocaleDateString('de-DE')} von ${m.verified_by || '—'}` : 'Ausstehend'}
|
))}
)}
>
)}
{/* ============================================================ */}
{/* TAB: Report */}
{/* ============================================================ */}
{activeTab === 'report' && (
Evidence Coverage Report
{!coverageReport ? (
) : (
<>
{/* Coverage Bar */}
Gesamt-Abdeckung
= 80 ? 'text-green-600' :
coverageReport.coverage_percent >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>
{coverageReport.coverage_percent.toFixed(1)}%
= 80 ? 'bg-green-500' :
coverageReport.coverage_percent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${coverageReport.coverage_percent}%` }}
/>
{/* Summary */}
{coverageReport.total_controls}
Controls gesamt
{coverageReport.controls_with_evidence}
Mit Nachweis belegt
{coverageReport.controls_without_evidence}
Ohne Nachweis
{/* Check Summary */}
Automatische Checks
{checks.length} Check-Definitionen
{checks.filter(c => c.is_active).length} aktiv
{checks.filter(c => c.last_run_at).length} mindestens 1x ausgefuehrt
{/* Evidence Summary */}
Nachweise
{displayEvidence.length} Nachweise gesamt
{validCount} gueltig
{expiredCount} abgelaufen
{pendingCount} ausstehend
>
)}
)}
)
}