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:
2026-03-12 19:01:33 +01:00
parent ec44895f3d
commit f88ae25057
6 changed files with 410 additions and 0 deletions

62
api/admin/audit-log.php Normal file
View 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
View 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";
}

View File

@@ -0,0 +1 @@
INSERT INTO permissions (name, description) VALUES ('settings.audit', 'Zobrazení audit logu') ON DUPLICATE KEY UPDATE description = VALUES(description);

View File

@@ -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>

View File

@@ -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>
)
} }
] ]
} }

View 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>
)
}