Add audit log for moderation and admin actions

New audit_log table records actor, action, target and a JSON snapshot
of the details, so entries stay readable after the target is deleted;
the actor is set to NULL when the user is removed. logAudit() is called
from upload delete (single and bulk), comment delete, tag removal, role
create/delete, role permission changes, role assign/revoke and MOTD
updates. Logging failures only warn and never fail the action itself.

The admin page gets an audit card behind the new audit.view permission
with action filter, click-to-filter by user, readable summaries per
action and server-side pagination. Action catalog lives in
shared/utils/audit.ts, labels under admin.auditActions (de/en).

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:27:28 +02:00
co-authored by Claude Opus 5.5
parent 983c37f1db
commit 06ac414457
22 changed files with 1939 additions and 19 deletions
+275
View File
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { AUDIT_ACTIONS } from '~~/shared/utils/audit'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
interface AuditEntry {
id: number
action: string
targetType: string | null
targetId: string | null
details: Record<string, unknown> | null
createdAt: string
userId: number | null
userName: string | null
userColor: string | null
userAvatarUrl: string | null
}
interface AuditList {
items: AuditEntry[]
total: number
perPage: number
pageCount: number
}
const { t, locale } = useI18n()
const timeAgo = useTimeAgo()
const page = ref(1)
const action = ref('all')
const actor = ref<{ id: number, name: string } | null>(null)
const query = computed(() => ({
page: page.value,
...(action.value !== 'all' && { action: action.value }),
...(actor.value && { user: actor.value.id }),
}))
const { data, pending, refresh } = await useFetch<AuditList>('/api/admin/audit', {
query,
server: false,
})
watch([action, actor], () => {
page.value = 1
})
function filterByActor(entry: AuditEntry) {
if (entry.userId === null || !entry.userName) return
actor.value = { id: entry.userId, name: entry.userName }
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
}
const str = (v: unknown) => (typeof v === 'string' ? v : '')
const list = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
const permLabel = (key: string) => t(`admin.perms.${key}`)
// Lesbare Zusammenfassung pro Aktion aus dem Details-Snapshot
function summary(entry: AuditEntry): string {
const d = entry.details ?? {}
switch (entry.action) {
case 'delete_upload':
return t('admin.auditDetails.deleteUpload', { name: str(d.originalName) || entry.targetId, user: str(d.uploaderName) || '?' })
case 'bulk_delete_uploads':
return t('admin.auditDetails.bulkDelete', { n: Number(d.count) || list(d.ids).length })
case 'delete_comment':
return t('admin.auditDetails.deleteComment', { user: str(d.authorName) || '?', content: str(d.content).slice(0, 120) })
case 'remove_tags':
return list(d.removed).join(', ')
case 'create_role':
case 'delete_role':
return str(d.name)
case 'update_role_permissions': {
const added = list(d.added).map(k => `+ ${permLabel(k)}`)
const removed = list(d.removed).map(k => `− ${permLabel(k)}`)
return [str(d.name), [...added, ...removed].join(', ') || t('admin.auditDetails.noChanges')].join(': ')
}
case 'assign_role':
case 'remove_role':
return t('admin.auditDetails.role', { role: str(d.role), user: str(d.userName) || `#${entry.targetId}` })
case 'update_motd':
return str(d.message) || t('admin.auditDetails.motdCleared')
default:
return ''
}
}
// Verlinkbares Ziel — gelöschte Uploads führen ins Leere und bleiben Text
function targetLink(entry: AuditEntry): string | null {
const d = entry.details ?? {}
if (entry.action === 'delete_comment' && str(d.uploadId)) return `/t/${str(d.uploadId)}`
if (entry.action === 'remove_tags' && entry.targetId) return `/t/${entry.targetId}`
if (entry.targetType === 'user' && entry.targetId) return `/user/${entry.targetId}`
return null
}
const bulkIds = (entry: AuditEntry) => (entry.action === 'bulk_delete_uploads' ? list(entry.details?.ids) : [])
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="action">
<SelectTrigger size="sm" class="w-56" :aria-label="$t('admin.audit.filterAction')">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">
{{ $t('admin.audit.allActions') }}
</SelectItem>
<SelectItem v-for="a in AUDIT_ACTIONS" :key="a" :value="a">
{{ $t(`admin.auditActions.${a}`) }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Badge v-if="actor" variant="secondary" class="gap-1">
{{ actor.name }}
<button
type="button"
class="cursor-pointer opacity-60 hover:opacity-100"
:aria-label="$t('admin.audit.clearUser')"
@click="actor = null"
>
<Icon name="ph:x-fill" class="size-3" />
</button>
</Badge>
<span v-if="data" class="text-sm text-muted-foreground">{{ $t('admin.audit.count', { n: data.total }) }}</span>
<Button
variant="ghost"
size="icon-sm"
class="ml-auto text-muted-foreground"
:disabled="pending"
:aria-label="$t('admin.audit.refresh')"
@click="refresh()"
>
<Icon name="ph:arrow-clockwise-bold" class="size-4" />
</Button>
</div>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-32">
{{ $t('admin.audit.time') }}
</TableHead>
<TableHead class="w-40">
{{ $t('admin.audit.actor') }}
</TableHead>
<TableHead class="w-48">
{{ $t('admin.audit.action') }}
</TableHead>
<TableHead>{{ $t('admin.audit.details') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-if="pending && !data">
<TableRow v-for="i in 5" :key="i">
<TableCell v-for="j in 4" :key="j">
<Skeleton class="h-4 w-full rounded-sm" />
</TableCell>
</TableRow>
</template>
<template v-else-if="data?.items.length">
<TableRow v-for="entry in data.items" :key="entry.id">
<TableCell class="text-xs text-muted-foreground">
<Tooltip>
<TooltipTrigger as-child>
<span>{{ timeAgo(entry.createdAt) }}</span>
</TooltipTrigger>
<TooltipContent>{{ formatDate(entry.createdAt) }}</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<button
v-if="entry.userName"
type="button"
class="flex cursor-pointer items-center gap-2 text-sm font-medium hover:underline"
:style="entry.userColor ? { color: entry.userColor } : undefined"
:title="$t('admin.audit.filterUser', { name: entry.userName })"
@click="filterByActor(entry)"
>
<NuxtImg
v-if="entry.userAvatarUrl"
:src="entry.userAvatarUrl"
:alt="entry.userName"
width="20"
height="20"
fit="cover"
class="size-5 shrink-0 rounded-sm"
/>
{{ entry.userName }}
</button>
<span v-else class="text-sm text-muted-foreground italic">{{ $t('admin.audit.deletedUser') }}</span>
</TableCell>
<TableCell>
<Badge variant="outline">
{{ $t(`admin.auditActions.${entry.action}`) }}
</Badge>
</TableCell>
<TableCell class="max-w-md text-sm whitespace-normal break-words">
<NuxtLink v-if="targetLink(entry)" :to="targetLink(entry)!" class="hover:underline">
{{ summary(entry) }}
</NuxtLink>
<span v-else>{{ summary(entry) }}</span>
<span v-if="bulkIds(entry).length" class="mt-0.5 block font-mono text-xs text-muted-foreground">
{{ bulkIds(entry).join(' ') }}
</span>
</TableCell>
</TableRow>
</template>
<TableEmpty v-else :colspan="4">
{{ $t('admin.audit.empty') }}
</TableEmpty>
</TableBody>
</Table>
</div>
<Pagination
v-if="data && data.pageCount > 1"
v-model:page="page"
:items-per-page="data.perPage"
:total="data.total"
:sibling-count="1"
show-edges
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, i) in items" :key="i">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else />
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
</div>
</template>