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 InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const AuditLog = lazy(() => import('./pages/AuditLog'))
|
||||
const NotFound = lazy(() => import('./pages/NotFound'))
|
||||
|
||||
export default function AdminApp() {
|
||||
@@ -86,6 +87,7 @@ export default function AdminApp() {
|
||||
<Route path="invoices/new" element={<InvoiceCreate />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</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" />
|
||||
</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