Files
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

348 lines
12 KiB
Vue

<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { PERMISSIONS, SYSTEM_ROLES } from '~~/shared/utils/permissions'
import type { DataTableFeatures } from '@/lib/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
import { Textarea } from '@/components/ui/textarea'
definePageMeta({ middleware: 'admin' })
interface Role {
id: number
name: string
memberCount: number
permissions: string[]
}
interface AdminUser {
id: number
name: string
email: string | null
roles: { id: number, name: string }[]
}
const { user, can } = useAuth()
const { t } = useI18n()
const { data: rolesList, refresh: refreshRoles } = await useFetch<Role[]>('/api/admin/roles', { default: () => [] })
const { data: usersList, refresh: refreshUsers } = await useFetch<AdminUser[]>('/api/admin/users', { default: () => [] })
const newRoleName = ref('')
const motdDraft = ref('')
const motdSaving = ref(false)
const { data: motdData } = await useFetch<{ message: string }>('/api/motd', { default: () => ({ message: '' }) })
motdDraft.value = motdData.value.message
const errorMessage = ref('')
const selectedRole = ref<Record<number, number | undefined>>({})
const editedPermissions = ref<Record<number, string[]>>({})
const savingRole = ref<number | null>(null)
const userColumns = computed<ColumnDef<DataTableFeatures, AdminUser>[]>(() => [
{ accessorKey: 'name', header: t('common.name') },
{ accessorKey: 'email', header: t('common.email'), cell: ({ getValue }) => getValue() ?? '—' },
{ id: 'roles', header: t('admin.rolesCol'), enableSorting: false, enableGlobalFilter: false },
{ id: 'actions', header: t('admin.actionCol'), enableSorting: false, enableGlobalFilter: false },
])
function userSearch(u: AdminUser, query: string) {
const q = query.toLowerCase()
return u.name.toLowerCase().includes(q) || (u.email ?? '').toLowerCase().includes(q)
}
function errText(err: unknown) {
const e = err as { data?: { statusMessage?: string, message?: string } }
return e.data?.statusMessage ?? e.data?.message ?? t('admin.actionFailed')
}
async function refreshAll() {
await Promise.all([refreshRoles(), refreshUsers()])
}
async function createRole() {
if (!newRoleName.value.trim()) return
errorMessage.value = ''
try {
await $fetch('/api/admin/roles', { method: 'POST', body: { name: newRoleName.value.trim() } })
newRoleName.value = ''
await refreshRoles()
}
catch (err) {
errorMessage.value = errText(err)
}
}
async function deleteRole(roleId: number) {
errorMessage.value = ''
try {
await $fetch(`/api/admin/roles/${roleId}`, { method: 'DELETE' })
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
}
async function addRole(userId: number) {
const roleId = selectedRole.value[userId]
if (!roleId) return
errorMessage.value = ''
try {
await $fetch(`/api/admin/users/${userId}/roles`, { method: 'POST', body: { roleId } })
selectedRole.value[userId] = undefined
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
}
async function removeRole(userId: number, roleId: number) {
errorMessage.value = ''
try {
await $fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE' })
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
}
function permissionsDraft(role: Role) {
return editedPermissions.value[role.id] ?? role.permissions
}
function isDirty(role: Role) {
const draft = editedPermissions.value[role.id]
return !!draft && (draft.length !== role.permissions.length || draft.some(k => !role.permissions.includes(k)))
}
function togglePermission(role: Role, key: string, checked: boolean | 'indeterminate') {
const current = permissionsDraft(role)
editedPermissions.value[role.id] = checked === true
? [...new Set([...current, key])]
: current.filter(k => k !== key)
}
async function savePermissions(role: Role) {
const draft = editedPermissions.value[role.id]
if (!draft) return
errorMessage.value = ''
savingRole.value = role.id
try {
await $fetch(`/api/admin/roles/${role.id}/permissions`, { method: 'PUT', body: { permissions: draft } })
delete editedPermissions.value[role.id]
await refreshRoles()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
savingRole.value = null
}
}
function resetPermissions(role: Role) {
delete editedPermissions.value[role.id]
}
async function saveMotd() {
motdSaving.value = true
errorMessage.value = ''
try {
const res = await $fetch<{ message: string }>('/api/admin/motd', {
method: 'PUT',
body: { message: motdDraft.value },
})
motdDraft.value = res.message
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
motdSaving.value = false
}
}
</script>
<template>
<div class="grid gap-6 px-4 lg:grid-cols-2 lg:px-6">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.roles') }}</CardTitle>
<CardDescription>{{ $t('admin.rolesDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<ul class="space-y-2">
<li v-for="role in rolesList" :key="role.id" class="rounded-md border px-3 py-2">
<div class="flex items-center gap-3">
<span class="font-medium">{{ role.name }}</span>
<span class="text-sm text-muted-foreground">{{ $t('admin.memberCount', { n: role.memberCount }) }}</span>
<Button
v-if="!SYSTEM_ROLES.includes(role.name as 'admin' | 'moderator') && can('roles.manage')"
variant="ghost"
size="icon-sm"
class="ml-auto text-muted-foreground"
@click="deleteRole(role.id)"
>
<Icon name="ph:trash-fill" class="size-4" />
<span class="sr-only">{{ $t('admin.deleteRole') }}</span>
</Button>
</div>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
<label
v-for="perm in PERMISSIONS"
:key="perm.key"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="role.name === 'admin' || permissionsDraft(role).includes(perm.key)"
:disabled="role.name === 'admin' || !can('roles.manage') || savingRole === role.id"
@update:model-value="togglePermission(role, perm.key, $event)"
/>
{{ $t(`admin.perms.${perm.key}`) }}
</label>
</div>
<div v-if="isDirty(role)" class="mt-2 flex items-center gap-2">
<Button
size="sm"
:disabled="savingRole === role.id"
@click="savePermissions(role)"
>
<Spinner v-if="savingRole === role.id" />
{{ savingRole === role.id ? $t('common.saving') : $t('common.save') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="savingRole === role.id"
@click="resetPermissions(role)"
>
{{ $t('common.reset') }}
</Button>
</div>
</li>
</ul>
<form v-if="can('roles.manage')" class="flex gap-2" @submit.prevent="createRole">
<Input v-model="newRoleName" :placeholder="$t('admin.newRole')" maxlength="50" />
<Button type="submit" :disabled="!newRoleName.trim()">
{{ $t('admin.create') }}
</Button>
</form>
</CardContent>
</Card>
<Card v-if="can('users.manage')">
<CardHeader>
<CardTitle>{{ $t('admin.users') }}</CardTitle>
<CardDescription>{{ $t('admin.usersDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<DataTable
:columns="userColumns"
:data="usersList"
:search-placeholder="$t('admin.searchUsers')"
:search-fn="userSearch"
>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.original.name }}</span>
</template>
<template #cell-roles="{ row }">
<div class="flex flex-wrap items-center gap-1.5">
<Badge v-for="r in row.original.roles" :key="r.id" variant="secondary" class="gap-1">
{{ r.name }}
<button
v-if="!(r.name === 'admin' && row.original.id === user?.id)"
type="button"
class="opacity-60 hover:opacity-100"
@click="removeRole(row.original.id, r.id)"
>
<Icon name="ph:x-fill" class="size-3" />
<span class="sr-only">{{ $t('admin.removeRole', { name: r.name }) }}</span>
</button>
</Badge>
<span v-if="!row.original.roles.length" class="text-xs text-muted-foreground">{{ $t('admin.noRoles') }}</span>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2">
<Select
:model-value="selectedRole[row.original.id]"
:disabled="!rolesList.some(r => !row.original.roles.some(ur => ur.id === r.id))"
@update:model-value="selectedRole[row.original.id] = $event === undefined ? undefined : Number($event)"
>
<SelectTrigger size="sm" class="w-40" :aria-label="$t('admin.chooseRole')">
<SelectValue :placeholder="$t('admin.chooseRole')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="role in rolesList.filter(r => !row.original.roles.some(ur => ur.id === r.id))"
:key="role.id"
:value="role.id"
>
{{ role.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
:disabled="!selectedRole[row.original.id]"
@click="addRole(row.original.id)"
>
{{ $t('admin.assign') }}
</Button>
</div>
</template>
</DataTable>
</CardContent>
</Card>
<Card v-if="can('site.manage')" class="lg:col-span-2">
<CardHeader>
<CardTitle>{{ $t('admin.motd') }}</CardTitle>
<CardDescription>{{ $t('admin.motdDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<form class="flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="saveMotd">
<Textarea
v-model="motdDraft"
:placeholder="$t('admin.motdPlaceholder')"
maxlength="500"
class="min-h-16"
/>
<Button type="submit" class="shrink-0" :disabled="motdSaving">
<Spinner v-if="motdSaving" />
{{ motdSaving ? $t('common.saving') : $t('common.save') }}
</Button>
</form>
</CardContent>
</Card>
<Card v-if="can('audit.view')" class="lg:col-span-2">
<CardHeader>
<CardTitle>{{ $t('admin.audit.title') }}</CardTitle>
<CardDescription>{{ $t('admin.audit.desc') }}</CardDescription>
</CardHeader>
<CardContent>
<AuditLog />
</CardContent>
</Card>
<p v-if="errorMessage" class="text-sm text-destructive lg:col-span-2">
{{ errorMessage }}
</p>
</div>
</template>