/** * ComplianceAuditPage — Compliance & AI-Audit dashboard. * * Tab A: AI Data-Flow Log — FormGeneratorTable + content view modal + download * Tab B: Security / GDPR Audit Log — FormGeneratorTable * Tab C: Aggregated AI-Audit Statistics with charts * Tab D: Neutralization Mappings — FormGeneratorTable + delete */ import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, BarChart, Bar, PieChart, Pie, Cell, } from 'recharts'; import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa'; import api from '../api'; import { useLanguage } from '../providers/language/LanguageContext'; import { useUserMandates } from '../hooks/useUserMandates'; import { useConfirm } from '../hooks/useConfirm'; import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable'; import styles from './ComplianceAuditPage.module.css'; const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32']; const _CATEGORY_COLORS: Record = { security: '#c62828', gdpr: '#6a1b9a', permission: '#e65100', access: '#1565c0', key: '#2e7d32', data: '#00897b', }; type TabId = 'ai-log' | 'audit-log' | 'stats' | 'neutralization'; function _tabLabel(tabId: TabId, t: (k: string) => string): string { switch (tabId) { case 'ai-log': return t('AI-Datenfluss'); case 'audit-log': return t('Audit-Log'); case 'stats': return t('Statistiken'); case 'neutralization': return t('Neutralisierung'); default: return tabId; } } // ─── Placeholder highlighting ─── const _PLACEHOLDER_RX = /\[([a-z]+)\.([a-f0-9-]{36})\]/g; const _PH_TYPE_COLORS: Record = { name: '#7c3aed', email: '#2563eb', phone: '#0891b2', address: '#059669', financial: '#d97706', id: '#dc2626', logic: '#be185d', company: '#4f46e5', product: '#7c3aed', location: '#059669', other: '#6b7280', }; interface NeutMapping { id: string; originalText: string; patternType: string; } function _renderHighlightedText( text: string, lookup: Map, ): React.ReactNode[] { const parts: React.ReactNode[] = []; let lastIdx = 0; const rx = new RegExp(_PLACEHOLDER_RX.source, 'g'); let match: RegExpExecArray | null; while ((match = rx.exec(text)) !== null) { if (match.index > lastIdx) { parts.push({text.slice(lastIdx, match.index)}); } const phType = match[1]; const phId = match[2]; const fullPh = match[0]; const mapping = lookup.get(phId); const color = _PH_TYPE_COLORS[phType] || _PH_TYPE_COLORS.other; parts.push( {fullPh} , ); lastIdx = match.index + match[0].length; } if (lastIdx < text.length) { parts.push({text.slice(lastIdx)}); } return parts; } // ─── Shared types ─── interface AuditStats { totalCalls: number; timeRangeDays: number; callsPerDay: Array<{ date: string; calls: number }>; costPerDay: Array<{ date: string; cost: number }>; callsByModel: Record; callsByFeature: Record; topUsers: Record; neutralizationPercent: number; } interface Mandate { id: string; name?: string; label?: string; } interface ContentModalData { row: any; contentInputFull?: string; contentOutputFull?: string; contentInputPreview?: string; contentOutputPreview?: string; neutralizationMappings: NeutMapping[]; } const _AI_LOG_PAGE_SIZE = 50; const _AUDIT_LOG_PAGE_SIZE = 100; const _NEUT_PAGE_SIZE = 100; export const ComplianceAuditPage: React.FC = () => { const { t } = useLanguage(); const { fetchMandates } = useUserMandates(); const { confirm, ConfirmDialog } = useConfirm(); const [mandates, setMandates] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); const [selectedMandateId, setSelectedMandateId] = useState(null); const [activeTab, setActiveTab] = useState('audit-log'); // ── Tab A: AI-Log state ── const [aiEntries, setAiEntries] = useState([]); const [aiPagination, setAiPagination] = useState(undefined); const [aiLoading, setAiLoading] = useState(false); // ── Tab B: Audit-Log state ── const [auditEntries, setAuditEntries] = useState([]); const [auditPagination, setAuditPagination] = useState(undefined); const [auditLoading, setAuditLoading] = useState(false); // ── Tab C state ── const [stats, setStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); const [statsRange, setStatsRange] = useState(30); // ── Tab D: Neutralization Mappings state ── const [neutEntries, setNeutEntries] = useState([]); const [neutPagination, setNeutPagination] = useState(undefined); const [neutLoading, setNeutLoading] = useState(false); // ── Content View Modal state ── const [contentModal, setContentModal] = useState(null); const [contentModalLoading, setContentModalLoading] = useState(false); const [contentModalTab, setContentModalTab] = useState<'input' | 'output'>('input'); // ── Mandate loader ── useEffect(() => { let cancelled = false; (async () => { setMandatesLoading(true); try { const data = await fetchMandates(); if (!cancelled) { const list = Array.isArray(data) ? data : []; setMandates(list); if (list.length === 1) setSelectedMandateId(list[0].id); } } catch { /* */ } finally { if (!cancelled) setMandatesLoading(false); } })(); return () => { cancelled = true; }; }, [fetchMandates]); function _mandateHeaders(): Record { return selectedMandateId ? { 'X-Mandate-Id': selectedMandateId } : {}; } // ── Tab A loader ── const _loadAiLog = useCallback(async (paginationParams?: any) => { if (!selectedMandateId) return; setAiLoading(true); try { const page = paginationParams?.page ?? 1; const pageSize = paginationParams?.pageSize ?? _AI_LOG_PAGE_SIZE; const offset = (page - 1) * pageSize; const params: any = { limit: pageSize, offset }; if (paginationParams?.sort?.length) params.sort = JSON.stringify(paginationParams.sort); if (paginationParams?.filters && Object.keys(paginationParams.filters).length) params.filters = JSON.stringify(paginationParams.filters); if (paginationParams?.search) params.search = paginationParams.search; const { data } = await api.get('/api/audit/ai-log', { params, headers: _mandateHeaders(), }); const items: any[] = data?.items ?? []; const totalItems = data?.totalItems ?? 0; setAiEntries(items); setAiPagination({ currentPage: page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)), }); } catch { /* */ } finally { setAiLoading(false); } }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps // ── Tab B loader ── const _loadAuditLog = useCallback(async (paginationParams?: any) => { if (!selectedMandateId) return; setAuditLoading(true); try { const page = paginationParams?.page ?? 1; const pageSize = paginationParams?.pageSize ?? _AUDIT_LOG_PAGE_SIZE; const offset = (page - 1) * pageSize; const params: any = { limit: pageSize, offset }; if (paginationParams?.sort?.length) params.sort = JSON.stringify(paginationParams.sort); if (paginationParams?.filters && Object.keys(paginationParams.filters).length) params.filters = JSON.stringify(paginationParams.filters); if (paginationParams?.search) params.search = paginationParams.search; const { data } = await api.get('/api/audit/log', { params, headers: _mandateHeaders(), }); const items: any[] = data?.items ?? []; const totalItems = data?.totalItems ?? items.length; setAuditEntries(items); setAuditPagination({ currentPage: page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)), }); } catch { /* */ } finally { setAuditLoading(false); } }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps // ── Tab C loader ── const _loadStats = useCallback(async (days = 30) => { if (!selectedMandateId) return; setStatsLoading(true); try { const { data } = await api.get('/api/audit/stats', { params: { timeRange: days }, headers: _mandateHeaders(), }); setStats(data ?? null); } catch { /* */ } finally { setStatsLoading(false); } }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps // ── Tab D loader ── const _loadNeutMappings = useCallback(async (paginationParams?: any) => { if (!selectedMandateId) return; setNeutLoading(true); try { const page = paginationParams?.page ?? 1; const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE; const offset = (page - 1) * pageSize; const neutParams: any = { limit: pageSize, offset }; if (paginationParams?.sort?.length) neutParams.sort = JSON.stringify(paginationParams.sort); if (paginationParams?.filters && Object.keys(paginationParams.filters).length) neutParams.filters = JSON.stringify(paginationParams.filters); if (paginationParams?.search) neutParams.search = paginationParams.search; const { data } = await api.get('/api/audit/neutralization-mappings', { params: neutParams, headers: _mandateHeaders(), }); const items: any[] = data?.items ?? []; const totalItems = data?.totalItems ?? 0; setNeutEntries(items); setNeutPagination({ currentPage: page, pageSize, totalItems, totalPages: Math.max(1, Math.ceil(totalItems / pageSize)), }); } catch { /* */ } finally { setNeutLoading(false); } }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps const _handleDeleteMapping = useCallback(async (row: any) => { if (!selectedMandateId || !row?.id) return; const ok = await confirm( t('Soll diese Zuordnung wirklich gelöscht werden?'), { confirmLabel: t('Löschen'), variant: 'danger' }, ); if (!ok) return; try { await api.delete(`/api/audit/neutralization-mappings/${row.id}`, { headers: _mandateHeaders(), }); void _loadNeutMappings(); } catch (err) { console.error('Delete mapping failed:', err); } }, [selectedMandateId, _loadNeutMappings, confirm, t]); // eslint-disable-line react-hooks/exhaustive-deps const _handleDeleteMappingsBatch = useCallback(async (rows: any[]) => { if (!selectedMandateId || rows.length === 0) return; const ok = await confirm( t('{n} Zuordnungen wirklich löschen?', { n: String(rows.length) }), { confirmLabel: t('Alle löschen'), variant: 'danger' }, ); if (!ok) return; try { await Promise.all( rows.map(row => api.delete(`/api/audit/neutralization-mappings/${row.id}`, { headers: _mandateHeaders(), })) ); void _loadNeutMappings(); } catch (err) { console.error('Batch delete failed:', err); } }, [selectedMandateId, _loadNeutMappings, confirm, t]); // eslint-disable-line react-hooks/exhaustive-deps // ── Auto-load on tab / mandate change ── useEffect(() => { if (!selectedMandateId) return; if (activeTab === 'ai-log') void _loadAiLog(); else if (activeTab === 'audit-log') void _loadAuditLog(); else if (activeTab === 'stats') void _loadStats(statsRange); else if (activeTab === 'neutralization') void _loadNeutMappings(); }, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps // ── Content view handler (modal) ── const _handleContentView = useCallback(async (row: any) => { if (!selectedMandateId || !row?.id) return; setContentModalLoading(true); setContentModalTab('input'); setContentModal({ row, neutralizationMappings: [] }); try { const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, { headers: _mandateHeaders(), }); setContentModal({ row, contentInputFull: data?.contentInputFull, contentOutputFull: data?.contentOutputFull, contentInputPreview: data?.contentInputPreview, contentOutputPreview: data?.contentOutputPreview, neutralizationMappings: data?.neutralizationMappings ?? [], }); } catch (err) { console.error('Content load failed:', err); } finally { setContentModalLoading(false); } }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps // ── Content download handler ── const _handleContentDownload = useCallback(async (row: any) => { if (!selectedMandateId || !row?.id) return; try { const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, { headers: _mandateHeaders(), }); const ts = row.timestamp ? new Date(row.timestamp * 1000).toISOString() : '–'; const text = [ `=== AI-Audit-Eintrag: ${row.id} ===`, `Zeitpunkt: ${ts}`, `Benutzer: ${row.username || row.userId || '–'}`, `Provider: ${row.aiProvider || '–'}`, `Modell: ${row.aiModel || '–'}`, `Typ: ${row.operationType || '–'}`, `Neutralisierung: ${row.neutralizationActive ? 'aktiv' : 'inaktiv'}`, `Status: ${row.success ? 'OK' : 'Fehler'}`, '', '=== Input (was an AI gesendet wurde) ===', data?.contentInputFull || data?.contentInputPreview || '(kein Input gespeichert)', '', '=== Output (AI-Antwort) ===', data?.contentOutputFull || data?.contentOutputPreview || '(kein Output gespeichert)', ].join('\n'); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ai-audit-${row.id.slice(0, 8)}.txt`; a.click(); URL.revokeObjectURL(url); } catch (err) { console.error('Content download failed:', err); } }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps // ── Mapping lookup for modal ── const _modalMappingLookup = useMemo(() => { const map = new Map(); if (contentModal?.neutralizationMappings) { for (const m of contentModal.neutralizationMappings) { map.set(m.id, m); } } return map; }, [contentModal?.neutralizationMappings]); // ── Column definitions ── const aiLogColumns: ColumnConfig[] = useMemo(() => [ { key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 }, { key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140, formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'), }, { key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, formatter: (val: any, row: any) => val || row?.featureCode || '–', }, { key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 }, { key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140, formatter: (val: any, row: any) => { const provider = val || '–'; const op = row?.operationType; return op ? `${provider} · ${op}` : provider; }, }, { key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110, formatter: (val: any) => val != null ? Number(val).toFixed(4) : '–', }, { key: 'neutralizationActive', label: t('Neutralisierung'), type: 'text' as any, sortable: true, width: 100, formatter: (val: any) => val ? '✓' : '–', }, { key: 'success', label: t('Status'), type: 'text' as any, sortable: true, filterable: true, width: 80, formatter: (val: any) => val ? t('OK') : t('Fehler'), cellClassName: (val: any) => val ? styles.statusOk : styles.statusError, }, ], [t]); const auditLogColumns: ColumnConfig[] = useMemo(() => [ { key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 }, { key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140, formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'), }, { key: 'category', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 110, cellClassName: (val: any) => { const color = _CATEGORY_COLORS[val as string]; return color ? styles[`cat_${val}`] || '' : ''; }, formatter: (val: any) => val || '–', }, { key: 'action', label: t('Aktion'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 140 }, { key: 'resourceType', label: t('Ressource'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'details', label: t('Details'), type: 'text' as any, searchable: true, width: 250 }, { key: 'success', label: t('Status'), type: 'text' as any, sortable: true, width: 70, formatter: (val: any) => val ? '✓' : '✗', cellClassName: (val: any) => val ? styles.statusOk : styles.statusError, }, { key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 }, ], [t]); const neutColumns: ColumnConfig[] = useMemo(() => [ { key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 }, { key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 }, { key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140, formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'), }, { key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'), }, { key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140, formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–', }, ], [t]); // ── fetchFilterValues for autofilter dropdowns ── const _makeFetchFilterValues = useCallback( (endpoint: string) => async (columnKey: string, crossFilters?: Record) => { if (!selectedMandateId) return []; try { const params: any = { mode: 'filterValues', column: columnKey }; if (crossFilters && Object.keys(crossFilters).length) { params.filters = JSON.stringify(crossFilters); } const { data } = await api.get(endpoint, { params, headers: _mandateHeaders() }); return Array.isArray(data) ? data : []; } catch { return []; } }, [selectedMandateId], // eslint-disable-line react-hooks/exhaustive-deps ); // ── hookData for FormGeneratorTable ── const aiLogHookData = useMemo(() => ({ refetch: _loadAiLog, pagination: aiPagination, fetchFilterValues: _makeFetchFilterValues('/api/audit/ai-log'), }), [_loadAiLog, aiPagination, _makeFetchFilterValues]); const auditLogHookData = useMemo(() => ({ refetch: _loadAuditLog, pagination: auditPagination, fetchFilterValues: _makeFetchFilterValues('/api/audit/log'), }), [_loadAuditLog, auditPagination, _makeFetchFilterValues]); const neutHookData = useMemo(() => ({ refetch: _loadNeutMappings, pagination: neutPagination, fetchFilterValues: _makeFetchFilterValues('/api/audit/neutralization-mappings'), }), [_loadNeutMappings, neutPagination, _makeFetchFilterValues]); // ── Render ── const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats']; return (

{t('Compliance & AI-Audit')}

{t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')}

{/* Mandate selector */}
{!selectedMandateId ? (

{t('Bitte wählen Sie einen Mandanten aus.')}

) : ( <> {/* Tab bar */}
{_tabs.map(tab => ( ))}
{/* ── Tab A: AI Data-Flow Log ── */} {activeTab === 'ai-log' && (
, onClick: _handleContentView, }, { id: 'downloadContent', title: t('Input/Output herunterladen'), icon: , onClick: _handleContentDownload, }, ]} />
)} {/* ── Tab B: Audit Log ── */} {activeTab === 'audit-log' && (
)} {/* ── Tab C: Statistics ── */} {activeTab === 'stats' && (
{[7, 30, 90].map(d => ( ))}
{statsLoading ? (

{t('Lade Statistiken…')}

) : !stats ? (

{t('Keine Daten verfügbar.')}

) : ( <> {/* KPIs */}

{stats.totalCalls}

{t('AI-Aufrufe')}

{stats.neutralizationPercent}%

{t('Neutralisierungsquote')}

{Object.keys(stats.callsByModel).length}

{t('Genutzte Modelle')}

{stats.costPerDay.reduce((s, d) => s + d.cost, 0).toFixed(2)}

{t('Gesamtkosten (CHF)')}

{/* Charts row 1: Calls/Day + Cost/Day */}

{t('AI-Aufrufe pro Tag')}

{stats.callsPerDay.length === 0 ? (

{t('Keine Daten')}

) : ( )}

{t('Kosten-Verlauf (CHF)')}

{stats.costPerDay.length === 0 ? (

{t('Keine Daten')}

) : ( )}
{/* Charts row 2: By Model (pie) + By Feature (bar) */}

{t('AI-Aufrufe nach Modell')}

{Object.keys(stats.callsByModel).length === 0 ? (

{t('Keine Daten')}

) : ( ({ name, value }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} > {Object.keys(stats.callsByModel).map((_, i) => ( ))} )}

{t('AI-Aufrufe nach Feature')}

{Object.keys(stats.callsByFeature).length === 0 ? (

{t('Keine Daten')}

) : ( ({ name, value }))}> )}
{/* Top Users */} {Object.keys(stats.topUsers).length > 0 && (

{t('Top-Nutzer nach AI-Aufrufen')}

({ name, value }))} layout="vertical" margin={{ left: 8, right: 16 }} >
)} )}
)} {/* ── Tab D: Neutralization Mappings ── */} {activeTab === 'neutralization' && (
, onClick: _handleDeleteMapping, }, ]} />
)} )} {/* ── Content View Modal ── */} {contentModal && (

{t('AI-Audit Inhalt')}

{contentModal.row?.username || contentModal.row?.userId?.slice(0, 8) || '–'} {' · '} {contentModal.row?.aiModel || '–'} {' · '} {contentModal.row?.timestamp ? new Date(contentModal.row.timestamp * 1000).toLocaleString() : '–'}
{contentModal.neutralizationMappings.length > 0 && (
{t('{n} Platzhalter aufgelöst', { n: String(contentModal.neutralizationMappings.length) })} {t('Hover über markierte Platzhalter für Originaltext')}
)}
{contentModalLoading ? (

{t('Lade Inhalt…')}

) : (
{contentModalTab === 'input' ? ( (() => { const text = contentModal.contentInputFull || contentModal.contentInputPreview || t('(kein Input gespeichert)'); return _modalMappingLookup.size > 0 ? _renderHighlightedText(text, _modalMappingLookup) : text; })() ) : ( (() => { const text = contentModal.contentOutputFull || contentModal.contentOutputPreview || t('(kein Output gespeichert)'); return _modalMappingLookup.size > 0 ? _renderHighlightedText(text, _modalMappingLookup) : text; })() )}
)}
)}
); };