forked from BRETT/BRETT
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>
276 lines
9.2 KiB
Vue
276 lines
9.2 KiB
Vue
<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>
|