forked from BRETT/BRETT
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:
@@ -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>
|
||||
Reference in New Issue
Block a user