Files
BRETT/app/components/AuditLog.vue
T
kibiandClaude Opus 5.5 06ac414457 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>
2026-10-07 19:27:28 +02:00

276 lines
9.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>