diff --git a/api/admin/audit-log.php b/api/admin/audit-log.php new file mode 100644 index 0000000..e55de0a --- /dev/null +++ b/api/admin/audit-log.php @@ -0,0 +1,62 @@ + $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"; +} diff --git a/migrations/002_audit_permission.sql b/migrations/002_audit_permission.sql new file mode 100644 index 0000000..3f5d3c9 --- /dev/null +++ b/migrations/002_audit_permission.sql @@ -0,0 +1 @@ +INSERT INTO permissions (name, description) VALUES ('settings.audit', 'Zobrazení audit logu') ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/src/admin/AdminApp.jsx b/src/admin/AdminApp.jsx index acfc312..324308e 100644 --- a/src/admin/AdminApp.jsx +++ b/src/admin/AdminApp.jsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/src/admin/components/Sidebar.jsx b/src/admin/components/Sidebar.jsx index cf113be..1549bfb 100644 --- a/src/admin/components/Sidebar.jsx +++ b/src/admin/components/Sidebar.jsx @@ -264,6 +264,20 @@ const menuSections = [ ) + }, + { + path: '/audit-log', + label: 'Audit log', + permission: 'settings.audit', + icon: ( + + + + + + + + ) } ] } diff --git a/src/admin/pages/AuditLog.jsx b/src/admin/pages/AuditLog.jsx new file mode 100644 index 0000000..980563c --- /dev/null +++ b/src/admin/pages/AuditLog.jsx @@ -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 + } + + 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) => ( + + {Array.from({ length: 6 }, (_, j) => ( +
+ ))} + + )) + ) + + return ( + +
+

Audit log

+
+ +
+
+
+ + handleFilterChange('search', e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + handleFilterChange('date_from', e.target.value)} + /> +
+
+ + handleFilterChange('date_to', e.target.value)} + /> +
+
+
+ +
+
+ + + + + + + + + + + + + {loading && renderSkeletonRows()} + {!loading && logs.length === 0 && ( + + + + )} + {!loading && logs.map((log) => ( + + + + + + + + + ))} + +
ČasUživatelAkceTyp entityPopisIP
+ Žádné záznamy k zobrazení +
{formatDatetime(log.created_at)}{log.username || '-'} + + {ACTION_LABELS[log.action] || log.action} + + {ENTITY_TYPE_LABELS[log.entity_type] || log.entity_type || '-'}{log.description || '-'}{log.user_ip || '-'}
+
+ + +
+
+ ) +}