feat: admin audit log — merged PR #118
Audit logging for admin actions, backups, auth events. New AuditLogPanel in Admin tab with pagination. Dockerfile security: run as non-root user. i18n keys for all 9 languages. Thanks @fgbona for the implementation!
This commit is contained in:
@@ -168,6 +168,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 = {
|
||||
|
||||
166
client/src/components/Admin/AuditLogPanel.tsx
Normal file
166
client/src/components/Admin/AuditLogPanel.tsx
Normal file
@@ -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<string, unknown> | null
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
export default function AuditLogPanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
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<string, unknown> | 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
||||
<ClipboardList size={20} />
|
||||
{t('admin.tabs.audit')}
|
||||
</h2>
|
||||
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.audit.showing', { count: entries.length, total })}
|
||||
</p>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length < total && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -320,6 +320,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'المستخدمون',
|
||||
'admin.tabs.categories': 'الفئات',
|
||||
'admin.tabs.backup': 'النسخ الاحتياطي',
|
||||
'admin.tabs.audit': 'سجل التدقيق',
|
||||
'admin.tabs.settings': 'الإعدادات',
|
||||
'admin.tabs.config': 'الإعدادات',
|
||||
'admin.tabs.templates': 'قوالب التعبئة',
|
||||
@@ -463,6 +464,18 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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': 'الأحدث',
|
||||
|
||||
@@ -315,6 +315,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
@@ -418,8 +419,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
@@ -459,6 +458,19 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -315,6 +315,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
@@ -418,8 +419,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
@@ -459,6 +458,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -313,6 +313,7 @@ const es: Record<string, string> = {
|
||||
'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',
|
||||
@@ -437,6 +438,19 @@ const es: Record<string, string> = {
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -315,6 +315,7 @@ const nl: Record<string, string> = {
|
||||
'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',
|
||||
@@ -456,6 +457,19 @@ const nl: Record<string, string> = {
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -315,6 +315,7 @@ const ru: Record<string, string> = {
|
||||
'admin.tabs.users': 'Пользователи',
|
||||
'admin.tabs.categories': 'Категории',
|
||||
'admin.tabs.backup': 'Резервная копия',
|
||||
'admin.tabs.audit': 'Журнал аудита',
|
||||
'admin.stats.users': 'Пользователи',
|
||||
'admin.stats.trips': 'Поездки',
|
||||
'admin.stats.places': 'Места',
|
||||
@@ -456,6 +457,19 @@ const ru: Record<string, string> = {
|
||||
|
||||
// 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': 'Последний',
|
||||
|
||||
@@ -315,6 +315,7 @@ const zh: Record<string, string> = {
|
||||
'admin.tabs.users': '用户',
|
||||
'admin.tabs.categories': '分类',
|
||||
'admin.tabs.backup': '备份',
|
||||
'admin.tabs.audit': '审计日志',
|
||||
'admin.stats.users': '用户',
|
||||
'admin.stats.trips': '旅行',
|
||||
'admin.stats.places': '地点',
|
||||
@@ -456,6 +457,19 @@ const zh: Record<string, string> = {
|
||||
|
||||
// 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': '最新',
|
||||
|
||||
@@ -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') },
|
||||
]
|
||||
|
||||
@@ -978,6 +980,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'audit' && <AuditLogPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user