jagd-apps/stoeberhunde/frontend/src/components/admin/AuditLogs.js

420 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;