feat(audit): admin audit log
Audit log - Add audit_log table (migration + schema) with index on created_at. - Add auditLog service (writeAudit, getClientIp) and record events for backups (create, restore, upload-restore, delete, auto-settings), admin actions (users, OIDC, invites, system update, demo baseline, bag tracking, packing template delete, addons), and auth (app settings, MFA enable/disable). - Add GET /api/admin/audit-log with pagination; fix invite insert row id lookup. - Add AuditLogPanel and Admin tab; adminApi.auditLog. - Add admin.tabs.audit and admin.audit.* strings in all locale files. Note: Rebase feature branches so new DB migrations stay after existing ones (e.g. file_links) when merging upstream.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -276,6 +276,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': 'قوالب التعبئة',
|
||||
@@ -419,6 +420,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': 'الأحدث',
|
||||
|
||||
@@ -271,6 +271,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -271,6 +271,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',
|
||||
@@ -374,8 +375,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',
|
||||
@@ -415,6 +414,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',
|
||||
|
||||
@@ -271,6 +271,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',
|
||||
@@ -374,8 +375,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',
|
||||
@@ -415,6 +414,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',
|
||||
|
||||
@@ -269,6 +269,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',
|
||||
@@ -393,6 +394,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',
|
||||
|
||||
@@ -271,6 +271,7 @@ const fr: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -271,6 +271,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',
|
||||
@@ -412,6 +413,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',
|
||||
|
||||
@@ -271,6 +271,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': 'Места',
|
||||
@@ -412,6 +413,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': 'Последний',
|
||||
|
||||
@@ -271,6 +271,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': '地点',
|
||||
@@ -412,6 +413,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') },
|
||||
]
|
||||
|
||||
@@ -923,6 +925,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'audit' && <AuditLogPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> | null = null;
|
||||
if (r.details) {
|
||||
try {
|
||||
details = JSON.parse(r.details) as Record<string, unknown>;
|
||||
} 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 || '{}') } });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
30
server/src/services/auditLog.ts
Normal file
30
server/src/services/auditLog.ts
Normal file
@@ -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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user