feat: P6 operacni viditelnost - audit log prohlizec, cleanup script
- audit-log.php: API endpoint s filtrovanim (akce, entita, datum, hledani) a stranovanim - AuditLog.jsx: stranka s tabulkou, filtry, pagination, skeleton loading - Sidebar: polozka "Audit log" pod Systemem (settings.audit permission) - cleanup.php: CLI script - maze rate limit soubory >24h a audit log >90 dni - Migrace: settings.audit permission Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
62
api/admin/audit-log.php
Normal file
62
api/admin/audit-log.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit Log API - prohlížení audit logu
|
||||||
|
*
|
||||||
|
* GET /api/admin/audit-log.php
|
||||||
|
* ?page=1&per_page=50&search=&action=&entity_type=&date_from=&date_to=
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
|
||||||
|
setCorsHeaders();
|
||||||
|
setSecurityHeaders();
|
||||||
|
setNoCacheHeaders();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
requirePermission($authData, 'settings.audit');
|
||||||
|
|
||||||
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||||
|
$perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50)));
|
||||||
|
|
||||||
|
$filters = [];
|
||||||
|
|
||||||
|
if (!empty($_GET['search'])) {
|
||||||
|
$filters['search'] = (string) $_GET['search'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['action'])) {
|
||||||
|
$filters['action'] = (string) $_GET['action'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['entity_type'])) {
|
||||||
|
$filters['entity_type'] = (string) $_GET['entity_type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['date_from'])) {
|
||||||
|
$filters['date_from'] = (string) $_GET['date_from'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['date_to'])) {
|
||||||
|
$filters['date_to'] = (string) $_GET['date_to'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = AuditLog::getLogs($filters, $page, $perPage);
|
||||||
|
|
||||||
|
successResponse($result);
|
||||||
58
api/cleanup.php
Normal file
58
api/cleanup.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup CLI script - maze stare rate limit soubory a audit log zaznamy.
|
||||||
|
*
|
||||||
|
* Pouziti: php api/cleanup.php
|
||||||
|
* Doporuceny cron: 0 3 * * * php /path/to/api/cleanup.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
echo 'Pouze CLI';
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
$rateLimitDir = __DIR__ . '/rate_limits';
|
||||||
|
$rateLimitMaxAge = 24 * 60 * 60; // 24 hodin
|
||||||
|
$auditLogMaxDays = 90;
|
||||||
|
|
||||||
|
$deleted = 0;
|
||||||
|
$errors = 0;
|
||||||
|
|
||||||
|
// Rate limit soubory starsi 24h
|
||||||
|
if (is_dir($rateLimitDir)) {
|
||||||
|
$now = time();
|
||||||
|
$files = glob($rateLimitDir . '/*.json');
|
||||||
|
if ($files !== false) {
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$age = $now - filemtime($file);
|
||||||
|
if ($age > $rateLimitMaxAge) {
|
||||||
|
if (unlink($file)) {
|
||||||
|
$deleted++;
|
||||||
|
} else {
|
||||||
|
$errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Rate limits: smazano {$deleted} souboru" . ($errors > 0 ? " ({$errors} chyb)" : '') . "\n";
|
||||||
|
|
||||||
|
// Audit log zaznamy starsi 90 dni
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'DELETE FROM audit_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$auditLogMaxDays]);
|
||||||
|
$auditDeleted = $stmt->rowCount();
|
||||||
|
echo "Audit log: smazano {$auditDeleted} zaznamu starsich {$auditLogMaxDays} dni\n";
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo "Audit log: chyba - {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
1
migrations/002_audit_permission.sql
Normal file
1
migrations/002_audit_permission.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
INSERT INTO permissions (name, description) VALUES ('settings.audit', 'Zobrazení audit logu') ON DUPLICATE KEY UPDATE description = VALUES(description);
|
||||||
@@ -45,6 +45,7 @@ const Invoices = lazy(() => import('./pages/Invoices'))
|
|||||||
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
|
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
|
||||||
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
|
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
|
||||||
const Settings = lazy(() => import('./pages/Settings'))
|
const Settings = lazy(() => import('./pages/Settings'))
|
||||||
|
const AuditLog = lazy(() => import('./pages/AuditLog'))
|
||||||
const NotFound = lazy(() => import('./pages/NotFound'))
|
const NotFound = lazy(() => import('./pages/NotFound'))
|
||||||
|
|
||||||
export default function AdminApp() {
|
export default function AdminApp() {
|
||||||
@@ -86,6 +87,7 @@ export default function AdminApp() {
|
|||||||
<Route path="invoices/new" element={<InvoiceCreate />} />
|
<Route path="invoices/new" element={<InvoiceCreate />} />
|
||||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
|
<Route path="audit-log" element={<AuditLog />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -264,6 +264,20 @@ const menuSections = [
|
|||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/audit-log',
|
||||||
|
label: 'Audit log',
|
||||||
|
permission: 'settings.audit',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
273
src/admin/pages/AuditLog.jsx
Normal file
273
src/admin/pages/AuditLog.jsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useAlert } from '../context/AlertContext'
|
||||||
|
import Forbidden from '../components/Forbidden'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import apiFetch from '../utils/api'
|
||||||
|
|
||||||
|
const API_BASE = '/api/admin'
|
||||||
|
|
||||||
|
const ACTION_LABELS = {
|
||||||
|
create: 'Vytvoření',
|
||||||
|
update: 'Úprava',
|
||||||
|
delete: 'Smazání',
|
||||||
|
login: 'Přihlášení',
|
||||||
|
login_failed: 'Neúspěšné přihlášení',
|
||||||
|
logout: 'Odhlášení',
|
||||||
|
view: 'Zobrazení',
|
||||||
|
activate: 'Aktivace',
|
||||||
|
deactivate: 'Deaktivace',
|
||||||
|
password_change: 'Změna hesla',
|
||||||
|
permission_change: 'Změna oprávnění',
|
||||||
|
access_denied: 'Přístup odepřen',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_BADGE_CLASS = {
|
||||||
|
create: 'admin-badge-success',
|
||||||
|
update: 'admin-badge-info',
|
||||||
|
delete: 'admin-badge-warning',
|
||||||
|
login: 'admin-badge-secondary',
|
||||||
|
login_failed: 'admin-badge-warning',
|
||||||
|
logout: 'admin-badge-secondary',
|
||||||
|
view: 'admin-badge-info',
|
||||||
|
activate: 'admin-badge-success',
|
||||||
|
deactivate: 'admin-badge-warning',
|
||||||
|
password_change: 'admin-badge-info',
|
||||||
|
permission_change: 'admin-badge-info',
|
||||||
|
access_denied: 'admin-badge-warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_TYPE_LABELS = {
|
||||||
|
user: 'Uživatel',
|
||||||
|
attendance: 'Docházka',
|
||||||
|
leave_request: 'Žádost o nepřítomnost',
|
||||||
|
offers_quotation: 'Nabídka',
|
||||||
|
offers_customer: 'Zákazník',
|
||||||
|
offers_item_template: 'Šablona položky',
|
||||||
|
offers_scope_template: 'Šablona rozsahu',
|
||||||
|
offers_settings: 'Nastavení nabídek',
|
||||||
|
orders_order: 'Objednávka',
|
||||||
|
invoices_invoice: 'Faktura',
|
||||||
|
projects_project: 'Projekt',
|
||||||
|
role: 'Role',
|
||||||
|
trips: 'Jízda',
|
||||||
|
vehicles: 'Vozidlo',
|
||||||
|
bank_account: 'Bankovní účet',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
|
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
|
export default function AuditLog() {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const alert = useAlert()
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [pagination, setPagination] = useState(null)
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
search: '',
|
||||||
|
action: '',
|
||||||
|
entity_type: '',
|
||||||
|
date_from: '',
|
||||||
|
date_to: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async (page = 1, perPage = 50) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
params.set('search', filters.search)
|
||||||
|
}
|
||||||
|
if (filters.action) {
|
||||||
|
params.set('action', filters.action)
|
||||||
|
}
|
||||||
|
if (filters.entity_type) {
|
||||||
|
params.set('entity_type', filters.entity_type)
|
||||||
|
}
|
||||||
|
if (filters.date_from) {
|
||||||
|
params.set('date_from', filters.date_from)
|
||||||
|
}
|
||||||
|
if (filters.date_to) {
|
||||||
|
params.set('date_to', filters.date_to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(`${API_BASE}/audit-log.php?${params.toString()}`)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setLogs(data.data.logs || [])
|
||||||
|
setPagination({
|
||||||
|
total: data.data.total,
|
||||||
|
page: data.data.page,
|
||||||
|
per_page: data.data.per_page,
|
||||||
|
total_pages: data.data.pages,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
alert.error(data.error || 'Nepodařilo se načíst audit log')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert.error('Chyba připojení')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [filters]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs()
|
||||||
|
}, [fetchLogs])
|
||||||
|
|
||||||
|
if (!hasPermission('settings.audit')) {
|
||||||
|
return <Forbidden />
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterChange = (key, value) => {
|
||||||
|
setFilters(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (newPage) => {
|
||||||
|
fetchLogs(newPage, pagination?.per_page || 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePerPageChange = (newPerPage) => {
|
||||||
|
fetchLogs(1, newPerPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDatetime = (dateString) => {
|
||||||
|
if (!dateString) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return new Date(dateString).toLocaleString('cs-CZ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSkeletonRows = () => (
|
||||||
|
Array.from({ length: 10 }, (_, i) => (
|
||||||
|
<tr key={`skeleton-${i}`}>
|
||||||
|
{Array.from({ length: 6 }, (_, j) => (
|
||||||
|
<td key={j}><div className="admin-skeleton" style={{ height: '16px', width: `${60 + Math.random() * 40}%` }} /></td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="admin-page-header">
|
||||||
|
<h1>Audit log</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card" style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ flex: '1 1 200px', minWidth: '150px' }}>
|
||||||
|
<label className="admin-form-label">Hledat</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="admin-form-input"
|
||||||
|
placeholder="Popis, uživatel..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0 1 180px', minWidth: '140px' }}>
|
||||||
|
<label className="admin-form-label">Akce</label>
|
||||||
|
<select
|
||||||
|
className="admin-form-input"
|
||||||
|
value={filters.action}
|
||||||
|
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Všechny akce</option>
|
||||||
|
{ACTION_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0 1 180px', minWidth: '140px' }}>
|
||||||
|
<label className="admin-form-label">Typ entity</label>
|
||||||
|
<select
|
||||||
|
className="admin-form-input"
|
||||||
|
value={filters.entity_type}
|
||||||
|
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Všechny typy</option>
|
||||||
|
{ENTITY_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0 1 160px', minWidth: '130px' }}>
|
||||||
|
<label className="admin-form-label">Od</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="admin-form-input"
|
||||||
|
value={filters.date_from}
|
||||||
|
onChange={(e) => handleFilterChange('date_from', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0 1 160px', minWidth: '130px' }}>
|
||||||
|
<label className="admin-form-label">Do</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="admin-form-input"
|
||||||
|
value={filters.date_to}
|
||||||
|
onChange={(e) => handleFilterChange('date_to', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-table-wrapper">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Čas</th>
|
||||||
|
<th>Uživatel</th>
|
||||||
|
<th>Akce</th>
|
||||||
|
<th>Typ entity</th>
|
||||||
|
<th>Popis</th>
|
||||||
|
<th>IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && renderSkeletonRows()}
|
||||||
|
{!loading && logs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="6" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
|
Žádné záznamy k zobrazení
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!loading && logs.map((log) => (
|
||||||
|
<tr key={log.id}>
|
||||||
|
<td className="admin-mono">{formatDatetime(log.created_at)}</td>
|
||||||
|
<td style={{ fontWeight: 500 }}>{log.username || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}>
|
||||||
|
{ACTION_LABELS[log.action] || log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{ENTITY_TYPE_LABELS[log.entity_type] || log.entity_type || '-'}</td>
|
||||||
|
<td>{log.description || '-'}</td>
|
||||||
|
<td className="admin-mono">{log.user_ip || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPerPageChange={handlePerPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user