420 lines
18 KiB
JavaScript
420 lines
18 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { getAuditLogs, getAuditStats, exportAuditLogs } from '../../services/auditLogs';
|
||
import './AuditLogs.css';
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||
|
||
const ACTION_LABELS = {
|
||
CREATE: 'Erstellen', UPDATE: 'Bearbeiten', DELETE: 'Löschen',
|
||
RESTORE: 'Wiederherstellen', LOGIN: 'Login', LOGOUT: 'Logout',
|
||
LOGIN_FAILED: 'Login fehlgeschlagen', IMPORT: 'Import', EXPORT: 'Export',
|
||
BULK_UPDATE: 'Massen-Update', BULK_DELETE: 'Massen-Löschung',
|
||
PASSWORD_RESET: 'Passwort-Reset', PASSWORD_CHANGE: 'Passwort-Änderung'
|
||
};
|
||
|
||
const ACTION_ICONS = {
|
||
CREATE: '➕', UPDATE: '✏️', DELETE: '🗑️', RESTORE: '↩️',
|
||
LOGIN: '🔓', LOGOUT: '🔒', LOGIN_FAILED: '🚫',
|
||
IMPORT: '📥', EXPORT: '📤', BULK_UPDATE: '✏️', BULK_DELETE: '🗑️',
|
||
PASSWORD_RESET: '🔑', PASSWORD_CHANGE: '🔑'
|
||
};
|
||
|
||
const ACTION_BADGE = {
|
||
CREATE: 'badge-create', UPDATE: 'badge-update', DELETE: 'badge-delete',
|
||
RESTORE: 'badge-restore', LOGIN: 'badge-login', LOGOUT: 'badge-logout',
|
||
LOGIN_FAILED: 'badge-login-failed', IMPORT: 'badge-import', EXPORT: 'badge-export',
|
||
BULK_UPDATE: 'badge-bulk-update', BULK_DELETE: 'badge-bulk-delete',
|
||
PASSWORD_RESET: 'badge-password', PASSWORD_CHANGE: 'badge-password'
|
||
};
|
||
|
||
const formatDate = (ds) =>
|
||
new Date(ds).toLocaleString('de-DE', {
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||
});
|
||
|
||
const parseUA = (ua) => {
|
||
if (!ua) return '—';
|
||
if (/iPhone|iPad/.test(ua)) return '📱 iOS';
|
||
if (/Android/.test(ua)) return '📱 Android';
|
||
if (/Firefox\/(\d+)/.test(ua)) return `🦊 Firefox ${ua.match(/Firefox\/(\d+)/)[1]}`;
|
||
if (/Edg\/(\d+)/.test(ua)) return `🔷 Edge ${ua.match(/Edg\/(\d+)/)[1]}`;
|
||
if (/Chrome\/(\d+)/.test(ua)) return `🌐 Chrome ${ua.match(/Chrome\/(\d+)/)[1]}`;
|
||
if (/Safari\/(\d+)/.test(ua)) return `🧭 Safari`;
|
||
return ua.slice(0, 60);
|
||
};
|
||
|
||
const statusBadgeClass = (code) => {
|
||
if (!code) return '';
|
||
if (code < 300) return 'status-ok';
|
||
if (code < 400) return 'status-redirect';
|
||
if (code < 500) return 'status-error';
|
||
return 'status-server-error';
|
||
};
|
||
|
||
// ── component ──────────────────────────────────────────────────────────────────
|
||
|
||
function AuditLogs() {
|
||
const [logs, setLogs] = useState([]);
|
||
const [stats, setStats] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [statsLoading, setStatsLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [expanded, setExpanded] = useState({});
|
||
const [showStats, setShowStats] = useState(true);
|
||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||
const [exporting, setExporting] = useState(false);
|
||
const [pagination, setPagination] = useState({});
|
||
const [statsDays, setStatsDays] = useState(30);
|
||
|
||
const [filters, setFilters] = useState({
|
||
action: '', resource: '', adminUsername: '',
|
||
success: '', startDate: '', endDate: '',
|
||
page: 1, limit: 25
|
||
});
|
||
|
||
const autoRefreshRef = useRef(null);
|
||
|
||
const loadLogs = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
const params = {};
|
||
Object.entries(filters).forEach(([k, v]) => { if (v !== '') params[k] = v; });
|
||
const result = await getAuditLogs(params);
|
||
if (result.success) {
|
||
setLogs(result.data);
|
||
setPagination(result.pagination);
|
||
} else {
|
||
setError(result.message);
|
||
}
|
||
setLoading(false);
|
||
}, [filters]);
|
||
|
||
const loadStats = useCallback(async () => {
|
||
setStatsLoading(true);
|
||
const result = await getAuditStats(statsDays);
|
||
if (result.success) setStats(result.data);
|
||
setStatsLoading(false);
|
||
}, [statsDays]);
|
||
|
||
useEffect(() => { loadLogs(); }, [loadLogs]);
|
||
useEffect(() => { loadStats(); }, [loadStats]);
|
||
|
||
// Auto-refresh
|
||
useEffect(() => {
|
||
if (autoRefresh) {
|
||
autoRefreshRef.current = setInterval(() => loadLogs(), 30000);
|
||
} else {
|
||
clearInterval(autoRefreshRef.current);
|
||
}
|
||
return () => clearInterval(autoRefreshRef.current);
|
||
}, [autoRefresh, loadLogs]);
|
||
|
||
const setFilter = (key, value) =>
|
||
setFilters(f => ({ ...f, [key]: value, page: 1 }));
|
||
|
||
const toggleExpand = (id) =>
|
||
setExpanded(e => ({ ...e, [id]: !e[id] }));
|
||
|
||
const handleExport = async () => {
|
||
setExporting(true);
|
||
const params = {};
|
||
Object.entries(filters).forEach(([k, v]) => {
|
||
if (v !== '' && k !== 'page' && k !== 'limit') params[k] = v;
|
||
});
|
||
await exportAuditLogs(params);
|
||
setExporting(false);
|
||
};
|
||
|
||
const resetFilters = () =>
|
||
setFilters({ action: '', resource: '', adminUsername: '', success: '', startDate: '', endDate: '', page: 1, limit: 25 });
|
||
|
||
// ── stats cards ──────────────────────────────────────────────────────────────
|
||
|
||
const renderStats = () => {
|
||
if (statsLoading) return <div className="stats-loading">Lade Statistiken…</div>;
|
||
if (!stats) return null;
|
||
const maxBar = Math.max(...(stats.dailyActivity?.map(d => d.count) || [1]), 1);
|
||
return (
|
||
<div className="audit-stats">
|
||
<div className="stats-row">
|
||
<div className="stat-card stat-total">
|
||
<div className="stat-number">{stats.total}</div>
|
||
<div className="stat-label">Aktionen gesamt</div>
|
||
</div>
|
||
<div className="stat-card stat-failed">
|
||
<div className="stat-number">{stats.failed}</div>
|
||
<div className="stat-label">Fehlgeschlagen</div>
|
||
</div>
|
||
<div className="stat-card stat-success">
|
||
<div className="stat-number">{stats.total - stats.failed}</div>
|
||
<div className="stat-label">Erfolgreich</div>
|
||
</div>
|
||
<div className="stat-card stat-period">
|
||
<div className="stat-number">{stats.days} Tage</div>
|
||
<div className="stat-label">Zeitraum</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="stats-details">
|
||
<div className="stats-col">
|
||
<h4>Nach Aktion</h4>
|
||
{stats.byAction.map(a => (
|
||
<div key={a.action} className="stat-bar-row">
|
||
<span className={`audit-badge ${ACTION_BADGE[a.action] || 'badge-default'}`}>
|
||
{ACTION_ICONS[a.action]} {ACTION_LABELS[a.action] || a.action}
|
||
</span>
|
||
<span className="stat-bar-count">{a.count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="stats-col">
|
||
<h4>Nach Admin</h4>
|
||
{stats.byAdmin.map(a => (
|
||
<div key={a.admin} className="stat-bar-row">
|
||
<span className="stat-bar-label">👤 {a.admin}</span>
|
||
<span className="stat-bar-count">{a.count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="stats-col stats-chart-col">
|
||
<h4>Aktivität (letzte {stats.days} Tage)</h4>
|
||
<div className="mini-chart">
|
||
{stats.dailyActivity.map(d => (
|
||
<div key={d.date} className="chart-bar-wrap" title={`${d.date}: ${d.count} (${d.failed} Fehler)`}>
|
||
<div className="chart-bar" style={{ height: `${Math.max(4, (d.count / maxBar) * 100)}%` }}>
|
||
{d.failed > 0 && (
|
||
<div className="chart-bar-failed" style={{ height: `${(d.failed / d.count) * 100}%` }} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── diff renderer ────────────────────────────────────────────────────────────
|
||
|
||
const renderDiff = (changes) => {
|
||
if (!changes || Object.keys(changes).length === 0) return null;
|
||
return (
|
||
<table className="diff-table">
|
||
<thead>
|
||
<tr><th>Feld</th><th>Vorher</th><th>Nachher</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{Object.entries(changes).map(([field, { before, after }]) => (
|
||
<tr key={field}>
|
||
<td className="diff-field">{field}</td>
|
||
<td className="diff-before">{JSON.stringify(before) ?? '—'}</td>
|
||
<td className="diff-after">{JSON.stringify(after) ?? '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
};
|
||
|
||
// ── log item ─────────────────────────────────────────────────────────────────
|
||
|
||
const renderLogItem = (log) => {
|
||
const isExpanded = expanded[log._id];
|
||
const hasDiff = log.changes && Object.keys(log.changes).length > 0;
|
||
const hasMeta = log.metadata && Object.keys(log.metadata).length > 0;
|
||
const hasDetail = hasDiff || hasMeta || log.errorMessage || log.userAgent || log.requestPath;
|
||
|
||
return (
|
||
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
|
||
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
|
||
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
|
||
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
|
||
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
|
||
</span>
|
||
<span className="audit-resource">{log.resource}</span>
|
||
{log.statusCode && (
|
||
<span className={`status-code ${statusBadgeClass(log.statusCode)}`}>{log.statusCode}</span>
|
||
)}
|
||
{log.duration != null && (
|
||
<span className="duration-badge">{log.duration} ms</span>
|
||
)}
|
||
<span className="audit-time">{formatDate(log.createdAt)}</span>
|
||
{hasDetail && (
|
||
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="audit-item-body">
|
||
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>
|
||
{log.resourceName && <span className="audit-resource-name">📌 {log.resourceName}</span>}
|
||
{log.ipAddress && <span className="audit-ip">🌐 {log.ipAddress}</span>}
|
||
{!log.success && <span className="audit-failed-badge">✗ Fehlgeschlagen</span>}
|
||
</div>
|
||
|
||
{isExpanded && (
|
||
<div className="audit-item-detail">
|
||
{log.requestMethod && log.requestPath && (
|
||
<div className="detail-row">
|
||
<span className="detail-label">Anfrage</span>
|
||
<code>{log.requestMethod} {log.requestPath}</code>
|
||
</div>
|
||
)}
|
||
{log.userAgent && (
|
||
<div className="detail-row">
|
||
<span className="detail-label">Browser</span>
|
||
<span>{parseUA(log.userAgent)}</span>
|
||
<span className="detail-ua-full" title={log.userAgent}>ⓘ</span>
|
||
</div>
|
||
)}
|
||
{log.errorMessage && (
|
||
<div className="detail-row detail-error">
|
||
<span className="detail-label">Fehler</span>
|
||
<span>{log.errorMessage}</span>
|
||
</div>
|
||
)}
|
||
{hasMeta && (
|
||
<div className="detail-row">
|
||
<span className="detail-label">Details</span>
|
||
<span>
|
||
{Object.entries(log.metadata).map(([k, v]) => (
|
||
<span key={k} className="meta-tag">{k}: <strong>{String(v)}</strong></span>
|
||
))}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{hasDiff && (
|
||
<div className="detail-diff">
|
||
<div className="detail-label">Änderungen</div>
|
||
{renderDiff(log.changes)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── main render ───────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<div className="audit-logs-container">
|
||
<div className="audit-header">
|
||
<div className="audit-header-row">
|
||
<div>
|
||
<h2>📋 Audit-Logs</h2>
|
||
<p className="audit-description">Vollständiges Protokoll aller Admin-Aktionen</p>
|
||
</div>
|
||
<div className="audit-header-actions">
|
||
<label className="auto-refresh-toggle">
|
||
<input type="checkbox" checked={autoRefresh}
|
||
onChange={e => setAutoRefresh(e.target.checked)} />
|
||
Auto-Refresh (30 s)
|
||
</label>
|
||
<button className="btn-export" onClick={handleExport} disabled={exporting}>
|
||
{exporting ? 'Exportiere…' : '⬇️ CSV exportieren'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="stats-toggle-bar">
|
||
<button className="btn-stats-toggle" onClick={() => setShowStats(s => !s)}>
|
||
{showStats ? '▲ Statistiken ausblenden' : '▼ Statistiken anzeigen'}
|
||
</button>
|
||
{showStats && (
|
||
<select value={statsDays} onChange={e => setStatsDays(Number(e.target.value))}
|
||
className="filter-select stats-days-select">
|
||
<option value={7}>7 Tage</option>
|
||
<option value={30}>30 Tage</option>
|
||
<option value={90}>90 Tage</option>
|
||
<option value={365}>365 Tage</option>
|
||
</select>
|
||
)}
|
||
</div>
|
||
{showStats && renderStats()}
|
||
</div>
|
||
|
||
<div className="audit-filters">
|
||
<select value={filters.action} onChange={e => setFilter('action', e.target.value)} className="filter-select">
|
||
<option value="">Alle Aktionen</option>
|
||
{Object.entries(ACTION_LABELS).map(([k, v]) => (
|
||
<option key={k} value={k}>{ACTION_ICONS[k]} {v}</option>
|
||
))}
|
||
</select>
|
||
|
||
<select value={filters.resource} onChange={e => setFilter('resource', e.target.value)} className="filter-select">
|
||
<option value="">Alle Ressourcen</option>
|
||
<option value="User">Benutzer</option>
|
||
<option value="Admin">Admin</option>
|
||
<option value="Config">Konfiguration</option>
|
||
<option value="Stoeberhundefuehrer">Stöberhundeführer</option>
|
||
</select>
|
||
|
||
<select value={filters.success} onChange={e => setFilter('success', e.target.value)} className="filter-select">
|
||
<option value="">Alle Status</option>
|
||
<option value="true">✓ Erfolgreich</option>
|
||
<option value="false">✗ Fehlgeschlagen</option>
|
||
</select>
|
||
|
||
<input type="text" placeholder="Admin-Name…" value={filters.adminUsername}
|
||
onChange={e => setFilter('adminUsername', e.target.value)} className="filter-input" />
|
||
|
||
<input type="date" value={filters.startDate}
|
||
onChange={e => setFilter('startDate', e.target.value)} className="filter-input filter-date"
|
||
title="Von Datum" />
|
||
<input type="date" value={filters.endDate}
|
||
onChange={e => setFilter('endDate', e.target.value)} className="filter-input filter-date"
|
||
title="Bis Datum" />
|
||
|
||
<button onClick={loadLogs} className="btn-refresh" disabled={loading}>
|
||
🔄 {loading ? 'Lädt…' : 'Aktualisieren'}
|
||
</button>
|
||
<button onClick={resetFilters} className="btn-reset">✕ Zurücksetzen</button>
|
||
</div>
|
||
|
||
{error && <div className="audit-error"><p>{error}</p></div>}
|
||
|
||
<div className="audit-results-bar">
|
||
{pagination.total != null && (
|
||
<span>{pagination.total} Einträge gefunden</span>
|
||
)}
|
||
<select value={filters.limit} onChange={e => setFilter('limit', Number(e.target.value))}
|
||
className="filter-select limit-select">
|
||
<option value={10}>10 pro Seite</option>
|
||
<option value={25}>25 pro Seite</option>
|
||
<option value={50}>50 pro Seite</option>
|
||
<option value={100}>100 pro Seite</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="audit-list">
|
||
{loading && logs.length === 0
|
||
? <div className="audit-empty">Lade Logs…</div>
|
||
: logs.length === 0
|
||
? <div className="audit-empty">Keine Logs gefunden</div>
|
||
: logs.map(renderLogItem)
|
||
}
|
||
</div>
|
||
|
||
{pagination.pages > 1 && (
|
||
<div className="audit-pagination">
|
||
<button onClick={() => setFilters(f => ({ ...f, page: 1 }))}
|
||
disabled={filters.page === 1} className="btn-page">«</button>
|
||
<button onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}
|
||
disabled={filters.page === 1} className="btn-page">‹ Zurück</button>
|
||
<span className="page-info">
|
||
Seite {filters.page} / {pagination.pages}
|
||
</span>
|
||
<button onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}
|
||
disabled={filters.page >= pagination.pages} className="btn-page">Weiter ›</button>
|
||
<button onClick={() => setFilters(f => ({ ...f, page: pagination.pages }))}
|
||
disabled={filters.page >= pagination.pages} className="btn-page">»</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default AuditLogs;
|