diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 6726140..41bbcaf 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -163,6 +163,8 @@ export const adminApi = { listInvites: () => apiClient.get('/admin/invites').then(r => r.data), createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), + auditLog: (params?: { limit?: number; offset?: number }) => + apiClient.get('/admin/audit-log', { params }).then(r => r.data), } export const addonsApi = { diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx new file mode 100644 index 0000000..f36d69e --- /dev/null +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { adminApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { RefreshCw, ClipboardList } from 'lucide-react' + +interface AuditEntry { + id: number + created_at: string + user_id: number | null + username: string | null + user_email: string | null + action: string + resource: string | null + details: Record | null + ip: string | null +} + +export default function AuditLogPanel(): React.ReactElement { + const { t, locale } = useTranslation() + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [offset, setOffset] = useState(0) + const [loading, setLoading] = useState(true) + const limit = 100 + + const loadFirstPage = useCallback(async () => { + setLoading(true) + try { + const data = await adminApi.auditLog({ limit, offset: 0 }) as { + entries: AuditEntry[] + total: number + } + setEntries(data.entries || []) + setTotal(data.total ?? 0) + setOffset(0) + } catch { + setEntries([]) + setTotal(0) + setOffset(0) + } finally { + setLoading(false) + } + }, []) + + const loadMore = useCallback(async () => { + const nextOffset = offset + limit + setLoading(true) + try { + const data = await adminApi.auditLog({ limit, offset: nextOffset }) as { + entries: AuditEntry[] + total: number + } + setEntries((prev) => [...prev, ...(data.entries || [])]) + setTotal(data.total ?? 0) + setOffset(nextOffset) + } catch { + /* keep existing */ + } finally { + setLoading(false) + } + }, [offset]) + + useEffect(() => { + loadFirstPage() + }, [loadFirstPage]) + + const fmtTime = (iso: string) => { + try { + return new Date(iso).toLocaleString(locale, { + dateStyle: 'short', + timeStyle: 'medium', + }) + } catch { + return iso + } + } + + const fmtDetails = (d: Record | null) => { + if (!d || Object.keys(d).length === 0) return '—' + try { + return JSON.stringify(d) + } catch { + return '—' + } + } + + const userLabel = (e: AuditEntry) => { + if (e.username) return e.username + if (e.user_email) return e.user_email + if (e.user_id != null) return `#${e.user_id}` + return '—' + } + + return ( +
+
+
+

+ + {t('admin.tabs.audit')} +

+

{t('admin.audit.subtitle')}

+
+ +
+ +

+ {t('admin.audit.showing', { count: entries.length, total })} +

+ + {loading && entries.length === 0 ? ( +
{t('common.loading')}
+ ) : entries.length === 0 ? ( +
{t('admin.audit.empty')}
+ ) : ( +
+ + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + ))} + +
{t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')}
{fmtTime(e.created_at)}{userLabel(e)}{e.action}{e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)}
+
+ )} + + {entries.length < total && ( + + )} +
+ ) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index e4d5070..dda3574 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -276,6 +276,7 @@ const ar: Record = { 'admin.tabs.users': 'المستخدمون', 'admin.tabs.categories': 'الفئات', 'admin.tabs.backup': 'النسخ الاحتياطي', + 'admin.tabs.audit': 'سجل التدقيق', 'admin.tabs.settings': 'الإعدادات', 'admin.tabs.config': 'الإعدادات', 'admin.tabs.templates': 'قوالب التعبئة', @@ -419,6 +420,18 @@ const ar: Record = { 'admin.weather.locationHint': 'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.', // GitHub + 'admin.audit.subtitle': 'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).', + 'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.', + 'admin.audit.refresh': 'تحديث', + 'admin.audit.loadMore': 'تحميل المزيد', + 'admin.audit.showing': 'تم تحميل {count} · الإجمالي {total}', + 'admin.audit.col.time': 'الوقت', + 'admin.audit.col.user': 'المستخدم', + 'admin.audit.col.action': 'الإجراء', + 'admin.audit.col.resource': 'المورد', + 'admin.audit.col.ip': 'عنوان IP', + 'admin.audit.col.details': 'التفاصيل', + 'admin.github.title': 'سجل الإصدارات', 'admin.github.subtitle': 'آخر التحديثات من {repo}', 'admin.github.latest': 'الأحدث', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 969e8a0..5047a61 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -271,6 +271,7 @@ const br: Record = { 'admin.tabs.users': 'Usuários', 'admin.tabs.categories': 'Categorias', 'admin.tabs.backup': 'Backup', + 'admin.tabs.audit': 'Registro de auditoria', 'admin.stats.users': 'Usuários', 'admin.stats.trips': 'Viagens', 'admin.stats.places': 'Lugares', @@ -413,6 +414,18 @@ const br: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Eventos sensíveis de segurança e administração (backups, usuários, MFA, configurações).', + 'admin.audit.empty': 'Nenhum registro de auditoria ainda.', + 'admin.audit.refresh': 'Atualizar', + 'admin.audit.loadMore': 'Carregar mais', + 'admin.audit.showing': '{count} carregados · {total} no total', + 'admin.audit.col.time': 'Data/hora', + 'admin.audit.col.user': 'Usuário', + 'admin.audit.col.action': 'Ação', + 'admin.audit.col.resource': 'Recurso', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Detalhes', 'admin.github.title': 'Histórico de versões', 'admin.github.subtitle': 'Últimas atualizações de {repo}', 'admin.github.latest': 'Mais recente', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 840a4f2..3f0a2f8 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -271,6 +271,7 @@ const de: Record = { 'admin.tabs.users': 'Benutzer', 'admin.tabs.categories': 'Kategorien', 'admin.tabs.backup': 'Backup', + 'admin.tabs.audit': 'Audit-Protokoll', 'admin.stats.users': 'Benutzer', 'admin.stats.trips': 'Reisen', 'admin.stats.places': 'Orte', @@ -374,8 +375,6 @@ const de: Record = { 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.', - 'admin.addons.catalog.memories.name': 'Erinnerungen', - 'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise', 'admin.addons.catalog.packing.name': 'Packliste', 'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise', 'admin.addons.catalog.budget.name': 'Budget', @@ -415,6 +414,19 @@ const de: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).', + 'admin.audit.empty': 'Noch keine Audit-Einträge.', + 'admin.audit.refresh': 'Aktualisieren', + 'admin.audit.loadMore': 'Mehr laden', + 'admin.audit.showing': '{count} geladen · {total} gesamt', + 'admin.audit.col.time': 'Zeit', + 'admin.audit.col.user': 'Benutzer', + 'admin.audit.col.action': 'Aktion', + 'admin.audit.col.resource': 'Ressource', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', + 'admin.github.title': 'Update-Verlauf', 'admin.github.subtitle': 'Neueste Updates von {repo}', 'admin.github.latest': 'Aktuell', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 18d79ac..ee5f058 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -271,6 +271,7 @@ const en: Record = { 'admin.tabs.users': 'Users', 'admin.tabs.categories': 'Categories', 'admin.tabs.backup': 'Backup', + 'admin.tabs.audit': 'Audit log', 'admin.stats.users': 'Users', 'admin.stats.trips': 'Trips', 'admin.stats.places': 'Places', @@ -374,8 +375,6 @@ const en: Record = { 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.', - 'admin.addons.catalog.memories.name': 'Memories', - 'admin.addons.catalog.memories.description': 'Shared photo albums for each trip', 'admin.addons.catalog.packing.name': 'Packing', 'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip', 'admin.addons.catalog.budget.name': 'Budget', @@ -415,6 +414,18 @@ const en: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).', + 'admin.audit.empty': 'No audit entries yet.', + 'admin.audit.refresh': 'Refresh', + 'admin.audit.loadMore': 'Load more', + 'admin.audit.showing': '{count} loaded · {total} total', + 'admin.audit.col.time': 'Time', + 'admin.audit.col.user': 'User', + 'admin.audit.col.action': 'Action', + 'admin.audit.col.resource': 'Resource', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', 'admin.github.title': 'Release History', 'admin.github.subtitle': 'Latest updates from {repo}', 'admin.github.latest': 'Latest', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 3cb293f..34f6979 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -269,6 +269,7 @@ const es: Record = { 'admin.tabs.users': 'Usuarios', 'admin.tabs.categories': 'Categorías', 'admin.tabs.backup': 'Copia de seguridad', + 'admin.tabs.audit': 'Registro de auditoría', 'admin.stats.users': 'Usuarios', 'admin.stats.trips': 'Viajes', 'admin.stats.places': 'Lugares', @@ -393,6 +394,19 @@ const es: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Eventos sensibles de seguridad y administración (copias de seguridad, usuarios, MFA, ajustes).', + 'admin.audit.empty': 'Aún no hay entradas de auditoría.', + 'admin.audit.refresh': 'Actualizar', + 'admin.audit.loadMore': 'Cargar más', + 'admin.audit.showing': '{count} cargados · {total} en total', + 'admin.audit.col.time': 'Fecha y hora', + 'admin.audit.col.user': 'Usuario', + 'admin.audit.col.action': 'Acción', + 'admin.audit.col.resource': 'Recurso', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Detalles', + 'admin.github.title': 'Historial de versiones', 'admin.github.subtitle': 'Últimas novedades de {repo}', 'admin.github.latest': 'Última', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 28a7462..fef5c43 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -271,6 +271,7 @@ const fr: Record = { 'admin.tabs.users': 'Utilisateurs', 'admin.tabs.categories': 'Catégories', 'admin.tabs.backup': 'Sauvegarde', + 'admin.tabs.audit': 'Journal d\'audit', 'admin.stats.users': 'Utilisateurs', 'admin.stats.trips': 'Voyages', 'admin.stats.places': 'Lieux', @@ -412,6 +413,19 @@ const fr: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Événements liés à la sécurité et à l\'administration (sauvegardes, utilisateurs, MFA, paramètres).', + 'admin.audit.empty': 'Aucune entrée d\'audit pour le moment.', + 'admin.audit.refresh': 'Actualiser', + 'admin.audit.loadMore': 'Charger plus', + 'admin.audit.showing': '{count} chargés · {total} au total', + 'admin.audit.col.time': 'Date et heure', + 'admin.audit.col.user': 'Utilisateur', + 'admin.audit.col.action': 'Action', + 'admin.audit.col.resource': 'Ressource', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Détails', + 'admin.github.title': 'Historique des versions', 'admin.github.subtitle': 'Dernières mises à jour de {repo}', 'admin.github.latest': 'Dernière', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index add8dd6..8e35a4e 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -271,6 +271,7 @@ const nl: Record = { 'admin.tabs.users': 'Gebruikers', 'admin.tabs.categories': 'Categorieën', 'admin.tabs.backup': 'Back-up', + 'admin.tabs.audit': 'Auditlog', 'admin.stats.users': 'Gebruikers', 'admin.stats.trips': 'Reizen', 'admin.stats.places': 'Plaatsen', @@ -412,6 +413,19 @@ const nl: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Beveiligingsgevoelige en beheerdersgebeurtenissen (back-ups, gebruikers, MFA, instellingen).', + 'admin.audit.empty': 'Nog geen auditregistraties.', + 'admin.audit.refresh': 'Vernieuwen', + 'admin.audit.loadMore': 'Meer laden', + 'admin.audit.showing': '{count} geladen · {total} totaal', + 'admin.audit.col.time': 'Tijd', + 'admin.audit.col.user': 'Gebruiker', + 'admin.audit.col.action': 'Actie', + 'admin.audit.col.resource': 'Bron', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', + 'admin.github.title': 'Release-geschiedenis', 'admin.github.subtitle': 'Laatste updates van {repo}', 'admin.github.latest': 'Nieuwste', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 039f8dc..660bb29 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -271,6 +271,7 @@ const ru: Record = { 'admin.tabs.users': 'Пользователи', 'admin.tabs.categories': 'Категории', 'admin.tabs.backup': 'Резервная копия', + 'admin.tabs.audit': 'Журнал аудита', 'admin.stats.users': 'Пользователи', 'admin.stats.trips': 'Поездки', 'admin.stats.places': 'Места', @@ -412,6 +413,19 @@ const ru: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'События, связанные с безопасностью и администрированием (резервные копии, пользователи, MFA, настройки).', + 'admin.audit.empty': 'Записей аудита пока нет.', + 'admin.audit.refresh': 'Обновить', + 'admin.audit.loadMore': 'Загрузить ещё', + 'admin.audit.showing': 'Загружено: {count} · всего {total}', + 'admin.audit.col.time': 'Время', + 'admin.audit.col.user': 'Пользователь', + 'admin.audit.col.action': 'Действие', + 'admin.audit.col.resource': 'Объект', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Подробности', + 'admin.github.title': 'История релизов', 'admin.github.subtitle': 'Последние обновления из {repo}', 'admin.github.latest': 'Последний', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 6da7988..7be0f3d 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -271,6 +271,7 @@ const zh: Record = { 'admin.tabs.users': '用户', 'admin.tabs.categories': '分类', 'admin.tabs.backup': '备份', + 'admin.tabs.audit': '审计日志', 'admin.stats.users': '用户', 'admin.stats.trips': '旅行', 'admin.stats.places': '地点', @@ -412,6 +413,19 @@ const zh: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': '安全与管理员操作记录(备份、用户、MFA、设置)。', + 'admin.audit.empty': '暂无审计记录。', + 'admin.audit.refresh': '刷新', + 'admin.audit.loadMore': '加载更多', + 'admin.audit.showing': '已加载 {count} 条 · 共 {total} 条', + 'admin.audit.col.time': '时间', + 'admin.audit.col.user': '用户', + 'admin.audit.col.action': '操作', + 'admin.audit.col.resource': '资源', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': '详情', + 'admin.github.title': '版本历史', 'admin.github.subtitle': '{repo} 的最新更新', 'admin.github.latest': '最新', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index b63306f..5e2b007 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -13,6 +13,7 @@ import BackupPanel from '../components/Admin/BackupPanel' import GitHubPanel from '../components/Admin/GitHubPanel' import AddonManager from '../components/Admin/AddonManager' import PackingTemplateManager from '../components/Admin/PackingTemplateManager' +import AuditLogPanel from '../components/Admin/AuditLogPanel' import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' @@ -61,6 +62,7 @@ export default function AdminPage(): React.ReactElement { { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'backup', label: t('admin.tabs.backup') }, + { id: 'audit', label: t('admin.tabs.audit') }, { id: 'github', label: t('admin.tabs.github') }, ] @@ -923,6 +925,8 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'backup' && } + {activeTab === 'audit' && } + {activeTab === 'github' && } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 55078fd..7d59ef5 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -321,6 +321,20 @@ function runMigrations(db: Database.Database): void { UNIQUE(file_id, place_id) )`); }, + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 425a914..c3ebe23 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -380,6 +380,17 @@ function createTables(db: Database.Database): void { UNIQUE(assignment_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); `); } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index c7aaf2a..7879c78 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -7,6 +7,7 @@ import fs from 'fs'; import { db } from '../db/database'; import { authenticate, adminOnly } from '../middleware/auth'; import { AuthRequest, User, Addon } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; const router = express.Router(); @@ -52,6 +53,14 @@ router.post('/users', (req: Request, res: Response) => { 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' ).get(result.lastInsertRowid); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_create', + resource: String(result.lastInsertRowid), + ip: getClientIp(req), + details: { username: username.trim(), email: email.trim(), role: role || 'user' }, + }); res.status(201).json({ user }); }); @@ -90,6 +99,19 @@ router.put('/users/:id', (req: Request, res: Response) => { 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' ).get(req.params.id); + const authReq = req as AuthRequest; + const changed: string[] = []; + if (username) changed.push('username'); + if (email) changed.push('email'); + if (role) changed.push('role'); + if (password) changed.push('password'); + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_update', + resource: String(req.params.id), + ip: getClientIp(req), + details: { fields: changed }, + }); res.json({ user: updated }); }); @@ -103,6 +125,12 @@ router.delete('/users/:id', (req: Request, res: Response) => { if (!user) return res.status(404).json({ error: 'User not found' }); db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_delete', + resource: String(req.params.id), + ip: getClientIp(req), + }); res.json({ success: true }); }); @@ -115,6 +143,48 @@ router.get('/stats', (_req: Request, res: Response) => { res.json({ totalUsers, totalTrips, totalPlaces, totalFiles }); }); +router.get('/audit-log', (req: Request, res: Response) => { + const limitRaw = parseInt(String(req.query.limit || '100'), 10); + const offsetRaw = parseInt(String(req.query.offset || '0'), 10); + const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500); + const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0); + type Row = { + id: number; + created_at: string; + user_id: number | null; + username: string | null; + user_email: string | null; + action: string; + resource: string | null; + details: string | null; + ip: string | null; + }; + const rows = db.prepare(` + SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip + FROM audit_log a + LEFT JOIN users u ON u.id = a.user_id + ORDER BY a.id DESC + LIMIT ? OFFSET ? + `).all(limit, offset) as Row[]; + const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c; + res.json({ + entries: rows.map((r) => { + let details: Record | null = null; + if (r.details) { + try { + details = JSON.parse(r.details) as Record; + } catch { + details = { _parse_error: true }; + } + } + return { ...r, details }; + }), + total, + limit, + offset, + }); +}); + router.get('/oidc', (_req: Request, res: Response) => { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || ''; const secret = get('oidc_client_secret'); @@ -135,16 +205,25 @@ router.put('/oidc', (req: Request, res: Response) => { if (client_secret !== undefined) set('oidc_client_secret', client_secret); set('oidc_display_name', display_name); set('oidc_only', oidc_only ? 'true' : 'false'); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.oidc_update', + ip: getClientIp(req), + details: { oidc_only: !!oidc_only, issuer_set: !!issuer }, + }); res.json({ success: true }); }); -router.post('/save-demo-baseline', (_req: Request, res: Response) => { +router.post('/save-demo-baseline', (req: Request, res: Response) => { if (process.env.DEMO_MODE !== 'true') { return res.status(404).json({ error: 'Not found' }); } try { const { saveBaseline } = require('../demo/demo-reset'); saveBaseline(); + const authReq = req as AuthRequest; + writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' }); } catch (err: unknown) { console.error(err); @@ -201,7 +280,7 @@ router.get('/version-check', async (_req: Request, res: Response) => { } }); -router.post('/update', async (_req: Request, res: Response) => { +router.post('/update', async (req: Request, res: Response) => { const rootDir = path.resolve(__dirname, '../../..'); const serverDir = path.resolve(__dirname, '../..'); const clientDir = path.join(rootDir, 'client'); @@ -224,6 +303,13 @@ router.post('/update', async (_req: Request, res: Response) => { const { version: newVersion } = require('../../package.json'); steps.push({ step: 'version', version: newVersion }); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.system_update', + resource: newVersion, + ip: getClientIp(req), + }); res.json({ success: true, steps, restarting: true }); setTimeout(() => { @@ -260,24 +346,39 @@ router.post('/invites', (req: Request, res: Response) => { ? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString() : null; - db.prepare( + const ins = db.prepare( 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)' ).run(token, uses, expiresAt, authReq.user.id); + const inviteId = Number(ins.lastInsertRowid); const invite = db.prepare(` SELECT i.*, u.username as created_by_name FROM invite_tokens i JOIN users u ON i.created_by = u.id - WHERE i.id = last_insert_rowid() - `).get(); + WHERE i.id = ? + `).get(inviteId); + writeAudit({ + userId: authReq.user.id, + action: 'admin.invite_create', + resource: String(inviteId), + ip: getClientIp(req), + details: { max_uses: uses, expires_in_days: expires_in_days ?? null }, + }); res.status(201).json({ invite }); }); -router.delete('/invites/:id', (_req: Request, res: Response) => { - const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id); +router.delete('/invites/:id', (req: Request, res: Response) => { + const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id); if (!invite) return res.status(404).json({ error: 'Invite not found' }); - db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id); + db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.invite_delete', + resource: String(req.params.id), + ip: getClientIp(req), + }); res.json({ success: true }); }); @@ -291,6 +392,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => { router.put('/bag-tracking', (req: Request, res: Response) => { const { enabled } = req.body; db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false'); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.bag_tracking', + ip: getClientIp(req), + details: { enabled: !!enabled }, + }); res.json({ enabled: !!enabled }); }); @@ -337,10 +445,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => { res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) }); }); -router.delete('/packing-templates/:id', (_req: Request, res: Response) => { - const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id); +router.delete('/packing-templates/:id', (req: Request, res: Response) => { + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id); if (!template) return res.status(404).json({ error: 'Template not found' }); - db.prepare('DELETE FROM packing_templates WHERE id = ?').run(_req.params.id); + db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id); + const authReq = req as AuthRequest; + const t = template as { name?: string }; + writeAudit({ + userId: authReq.user.id, + action: 'admin.packing_template_delete', + resource: String(req.params.id), + ip: getClientIp(req), + details: { name: t.name }, + }); res.json({ success: true }); }); @@ -408,6 +525,14 @@ router.put('/addons/:id', (req: Request, res: Response) => { if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id); if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id); const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon; + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.addon_update', + resource: String(req.params.id), + ip: getClientIp(req), + details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined }, + }); res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } }); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index b5518ef..f752269 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -13,6 +13,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth'; import { JWT_SECRET } from '../config'; import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; import { AuthRequest, User } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; authenticator.options = { window: 1 }; @@ -518,6 +519,15 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => { if (allowed_file_types !== undefined) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types)); } + writeAudit({ + userId: authReq.user.id, + action: 'settings.app_update', + ip: getClientIp(req), + details: { + allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined, + allowed_file_types_changed: allowed_file_types !== undefined, + }, + }); res.json({ success: true }); }); @@ -673,6 +683,7 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => { authReq.user.id ); mfaSetupPending.delete(authReq.user.id); + writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); res.json({ success: true, mfa_enabled: true }); }); @@ -702,6 +713,7 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re authReq.user.id ); mfaSetupPending.delete(authReq.user.id); + writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); res.json({ success: true, mfa_enabled: false }); }); diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index e18190d..06a7ead 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -7,6 +7,10 @@ import fs from 'fs'; import { authenticate, adminOnly } from '../middleware/auth'; import * as scheduler from '../scheduler'; import { db, closeDb, reinitialize } from '../db/database'; +import { AuthRequest } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; + +type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string }; const router = express.Router(); @@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re }); const stat = fs.statSync(outputPath); + const authReq = _req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.create', + resource: filename, + ip: getClientIp(_req), + details: { size: stat.size }, + }); res.json({ success: true, backup: { @@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => { res.download(filePath, filename); }); -async function restoreFromZip(zipPath: string, res: Response) { +async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) { const extractDir = path.join(dataDir, `restore-${Date.now()}`); try { await fs.createReadStream(zipPath) @@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) { fs.rmSync(extractDir, { recursive: true, force: true }); + if (audit) { + writeAudit({ + userId: audit.userId, + action: audit.source, + resource: audit.label, + ip: audit.ip, + }); + } res.json({ success: true }); } catch (err: unknown) { console.error('Restore error:', err); @@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => { if (!fs.existsSync(zipPath)) { return res.status(404).json({ error: 'Backup not found' }); } - await restoreFromZip(zipPath, res); + const authReq = req as AuthRequest; + await restoreFromZip(zipPath, res, { + userId: authReq.user.id, + ip: getClientIp(req), + source: 'backup.restore', + label: filename, + }); }); const uploadTmp = multer({ @@ -206,7 +232,14 @@ const uploadTmp = multer({ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const zipPath = req.file.path; - await restoreFromZip(zipPath, res); + const authReq = req as AuthRequest; + const origName = req.file.originalname || 'upload.zip'; + await restoreFromZip(zipPath, res, { + userId: authReq.user.id, + ip: getClientIp(req), + source: 'backup.upload_restore', + label: origName, + }); if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); }); @@ -248,6 +281,13 @@ router.put('/auto-settings', (req: Request, res: Response) => { const settings = parseAutoBackupBody((req.body || {}) as Record); scheduler.saveSettings(settings); scheduler.start(); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.auto_settings', + ip: getClientIp(req), + details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days }, + }); res.json({ settings }); } catch (err: unknown) { console.error('[backup] PUT auto-settings:', err); @@ -272,6 +312,13 @@ router.delete('/:filename', (req: Request, res: Response) => { } fs.unlinkSync(filePath); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.delete', + resource: filename, + ip: getClientIp(req), + }); res.json({ success: true }); }); diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts new file mode 100644 index 0000000..ed78ad5 --- /dev/null +++ b/server/src/services/auditLog.ts @@ -0,0 +1,30 @@ +import { Request } from 'express'; +import { db } from '../db/database'; + +export function getClientIp(req: Request): string | null { + const xff = req.headers['x-forwarded-for']; + if (typeof xff === 'string') { + const first = xff.split(',')[0]?.trim(); + return first || null; + } + if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null; + return req.socket?.remoteAddress || null; +} + +/** Best-effort; never throws — failures are logged only. */ +export function writeAudit(entry: { + userId: number | null; + action: string; + resource?: string | null; + details?: Record; + ip?: string | null; +}): void { + try { + const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null; + db.prepare( + `INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)` + ).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null); + } catch (e) { + console.error('[audit] write failed:', e instanceof Error ? e.message : e); + } +}