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)}
+ />
+
+
+
+
+
+
+
+
+
+ | Čas |
+ Uživatel |
+ Akce |
+ Typ entity |
+ Popis |
+ IP |
+
+
+
+ {loading && renderSkeletonRows()}
+ {!loading && logs.length === 0 && (
+
+ |
+ Žádné záznamy k zobrazení
+ |
+
+ )}
+ {!loading && logs.map((log) => (
+
+ | {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 || '-'} |
+
+ ))}
+
+
+
+
+
+
+
+ )
+}