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>
|
||||||
@@ -331,6 +331,15 @@ async function saveMotd() {
|
|||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<p v-if="errorMessage" class="text-sm text-destructive lg:col-span-2">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+38
-1
@@ -212,7 +212,44 @@
|
|||||||
"site": { "manage": "Seiten-Einstellungen verwalten" },
|
"site": { "manage": "Seiten-Einstellungen verwalten" },
|
||||||
"comment": { "delete": "Kommentare löschen" },
|
"comment": { "delete": "Kommentare löschen" },
|
||||||
"upload": { "delete": "Uploads löschen" },
|
"upload": { "delete": "Uploads löschen" },
|
||||||
"tag": { "delete": "Tags löschen" }
|
"tag": { "delete": "Tags löschen" },
|
||||||
|
"audit": { "view": "Audit-Log einsehen" }
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"title": "Audit-Log",
|
||||||
|
"desc": "Protokoll aller Moderations- und Admin-Aktionen. Klick auf einen Namen filtert nach diesem Nutzer.",
|
||||||
|
"filterAction": "Nach Aktion filtern",
|
||||||
|
"allActions": "Alle Aktionen",
|
||||||
|
"filterUser": "Nur Aktionen von {name} anzeigen",
|
||||||
|
"clearUser": "Nutzerfilter entfernen",
|
||||||
|
"count": "{n} Einträge",
|
||||||
|
"refresh": "Aktualisieren",
|
||||||
|
"time": "Zeit",
|
||||||
|
"actor": "Nutzer",
|
||||||
|
"action": "Aktion",
|
||||||
|
"details": "Details",
|
||||||
|
"deletedUser": "gelöschter Nutzer",
|
||||||
|
"empty": "Keine Einträge."
|
||||||
|
},
|
||||||
|
"auditActions": {
|
||||||
|
"delete_upload": "Upload gelöscht",
|
||||||
|
"bulk_delete_uploads": "Uploads gesammelt gelöscht",
|
||||||
|
"delete_comment": "Kommentar gelöscht",
|
||||||
|
"remove_tags": "Tags entfernt",
|
||||||
|
"create_role": "Rolle angelegt",
|
||||||
|
"delete_role": "Rolle gelöscht",
|
||||||
|
"update_role_permissions": "Berechtigungen geändert",
|
||||||
|
"assign_role": "Rolle zugewiesen",
|
||||||
|
"remove_role": "Rolle entzogen",
|
||||||
|
"update_motd": "Ansage geändert"
|
||||||
|
},
|
||||||
|
"auditDetails": {
|
||||||
|
"deleteUpload": "{name} von {user}",
|
||||||
|
"bulkDelete": "{n} Uploads",
|
||||||
|
"deleteComment": "{user}: {content}",
|
||||||
|
"role": "{role} → {user}",
|
||||||
|
"noChanges": "keine Änderungen",
|
||||||
|
"motdCleared": "Ansage entfernt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"palette": {
|
"palette": {
|
||||||
|
|||||||
+38
-1
@@ -212,7 +212,44 @@
|
|||||||
"site": { "manage": "Manage site settings" },
|
"site": { "manage": "Manage site settings" },
|
||||||
"comment": { "delete": "Delete comments" },
|
"comment": { "delete": "Delete comments" },
|
||||||
"upload": { "delete": "Delete uploads" },
|
"upload": { "delete": "Delete uploads" },
|
||||||
"tag": { "delete": "Delete tags" }
|
"tag": { "delete": "Delete tags" },
|
||||||
|
"audit": { "view": "View audit log" }
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"title": "Audit log",
|
||||||
|
"desc": "Record of all moderation and admin actions. Click a name to filter by that user.",
|
||||||
|
"filterAction": "Filter by action",
|
||||||
|
"allActions": "All actions",
|
||||||
|
"filterUser": "Only show actions by {name}",
|
||||||
|
"clearUser": "Clear user filter",
|
||||||
|
"count": "{n} entries",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"time": "Time",
|
||||||
|
"actor": "User",
|
||||||
|
"action": "Action",
|
||||||
|
"details": "Details",
|
||||||
|
"deletedUser": "deleted user",
|
||||||
|
"empty": "No entries."
|
||||||
|
},
|
||||||
|
"auditActions": {
|
||||||
|
"delete_upload": "Upload deleted",
|
||||||
|
"bulk_delete_uploads": "Uploads bulk-deleted",
|
||||||
|
"delete_comment": "Comment deleted",
|
||||||
|
"remove_tags": "Tags removed",
|
||||||
|
"create_role": "Role created",
|
||||||
|
"delete_role": "Role deleted",
|
||||||
|
"update_role_permissions": "Permissions changed",
|
||||||
|
"assign_role": "Role assigned",
|
||||||
|
"remove_role": "Role revoked",
|
||||||
|
"update_motd": "Announcement changed"
|
||||||
|
},
|
||||||
|
"auditDetails": {
|
||||||
|
"deleteUpload": "{name} by {user}",
|
||||||
|
"bulkDelete": "{n} uploads",
|
||||||
|
"deleteComment": "{user}: {content}",
|
||||||
|
"role": "{role} → {user}",
|
||||||
|
"noChanges": "no changes",
|
||||||
|
"motdCleared": "Announcement removed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"palette": {
|
"palette": {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { and, count, desc, eq, type SQL } from 'drizzle-orm'
|
||||||
|
import { AUDIT_ACTIONS } from '../../../shared/utils/audit'
|
||||||
|
import { auditLog, users } from '../../db/schema'
|
||||||
|
|
||||||
|
const PER_PAGE = 25
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requirePermission(event, 'audit.view')
|
||||||
|
const q = getQuery(event)
|
||||||
|
const page = Math.max(1, Math.floor(Number(q.page)) || 1)
|
||||||
|
const action = String(q.action ?? '')
|
||||||
|
const actorId = Number(q.user)
|
||||||
|
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
if ((AUDIT_ACTIONS as readonly string[]).includes(action)) conditions.push(eq(auditLog.action, action))
|
||||||
|
if (Number.isInteger(actorId) && actorId > 0) conditions.push(eq(auditLog.userId, actorId))
|
||||||
|
const where = conditions.length ? and(...conditions) : undefined
|
||||||
|
|
||||||
|
const db = useDb()
|
||||||
|
const [totalRow, rows] = await Promise.all([
|
||||||
|
db.select({ count: count() }).from(auditLog).where(where),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: auditLog.id,
|
||||||
|
action: auditLog.action,
|
||||||
|
targetType: auditLog.targetType,
|
||||||
|
targetId: auditLog.targetId,
|
||||||
|
details: auditLog.details,
|
||||||
|
createdAt: auditLog.createdAt,
|
||||||
|
userId: auditLog.userId,
|
||||||
|
userName: users.name,
|
||||||
|
userColor: users.color,
|
||||||
|
userAvatar: users.avatar,
|
||||||
|
})
|
||||||
|
.from(auditLog)
|
||||||
|
.leftJoin(users, eq(auditLog.userId, users.id))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(auditLog.id))
|
||||||
|
.limit(PER_PAGE)
|
||||||
|
.offset((page - 1) * PER_PAGE),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total = totalRow[0]?.count ?? 0
|
||||||
|
return {
|
||||||
|
items: rows.map(({ userAvatar, ...r }) => ({ ...r, userAvatarUrl: avatarUrl(userAvatar) })),
|
||||||
|
total,
|
||||||
|
perPage: PER_PAGE,
|
||||||
|
pageCount: Math.max(1, Math.ceil(total / PER_PAGE)),
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -6,7 +6,7 @@ const bodySchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'site.manage')
|
const actor = await requirePermission(event, 'site.manage')
|
||||||
const { message } = await readValidatedBody(event, bodySchema.parse)
|
const { message } = await readValidatedBody(event, bodySchema.parse)
|
||||||
const db = useDb()
|
const db = useDb()
|
||||||
await db
|
await db
|
||||||
@@ -14,5 +14,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
.values({ key: 'motd', value: message })
|
.values({ key: 'motd', value: message })
|
||||||
.onDuplicateKeyUpdate({ set: { value: message } })
|
.onDuplicateKeyUpdate({ set: { value: message } })
|
||||||
broadcastToAll({ type: 'motd', message })
|
broadcastToAll({ type: 'motd', message })
|
||||||
|
await logAudit(actor.id, 'update_motd', null, { message })
|
||||||
return { message }
|
return { message }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ const bodySchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'roles.manage')
|
const actor = await requirePermission(event, 'roles.manage')
|
||||||
const { name } = await readValidatedBody(event, bodySchema.parse)
|
const { name } = await readValidatedBody(event, bodySchema.parse)
|
||||||
const id = await ensureRole(name)
|
const id = await ensureRole(name)
|
||||||
|
await logAudit(actor.id, 'create_role', { type: 'role', id }, { name })
|
||||||
return { id, name }
|
return { id, name }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { roles } from '../../../db/schema'
|
|||||||
import { SYSTEM_ROLES } from '../../../../shared/utils/permissions'
|
import { SYSTEM_ROLES } from '../../../../shared/utils/permissions'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'roles.manage')
|
const actor = await requirePermission(event, 'roles.manage')
|
||||||
const id = Number(getRouterParam(event, 'id'))
|
const id = Number(getRouterParam(event, 'id'))
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
||||||
@@ -17,5 +17,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, statusMessage: `Die ${existing[0].name}-Rolle kann nicht gelöscht werden` })
|
throw createError({ statusCode: 400, statusMessage: `Die ${existing[0].name}-Rolle kann nicht gelöscht werden` })
|
||||||
}
|
}
|
||||||
await db.delete(roles).where(eq(roles.id, id))
|
await db.delete(roles).where(eq(roles.id, id))
|
||||||
|
await logAudit(actor.id, 'delete_role', { type: 'role', id }, { name: existing[0].name })
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const bodySchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'roles.manage')
|
const actor = await requirePermission(event, 'roles.manage')
|
||||||
const id = Number(getRouterParam(event, 'id'))
|
const id = Number(getRouterParam(event, 'id'))
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
||||||
@@ -27,6 +27,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, statusMessage: 'Die Admin-Rolle hat immer alle Berechtigungen' })
|
throw createError({ statusCode: 400, statusMessage: 'Die Admin-Rolle hat immer alle Berechtigungen' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previous = await getRolePermissions().then(m => m.get(id) ?? [])
|
||||||
await db.delete(rolePermissions).where(eq(rolePermissions.roleId, id))
|
await db.delete(rolePermissions).where(eq(rolePermissions.roleId, id))
|
||||||
if (keys.length) {
|
if (keys.length) {
|
||||||
const rows = await db.select({ id: permissions.id }).from(permissions).where(inArray(permissions.key, keys))
|
const rows = await db.select({ id: permissions.id }).from(permissions).where(inArray(permissions.key, keys))
|
||||||
@@ -34,5 +35,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
await db.insert(rolePermissions).values(rows.map(r => ({ roleId: id, permissionId: r.id })))
|
await db.insert(rolePermissions).values(rows.map(r => ({ roleId: id, permissionId: r.id })))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await logAudit(actor.id, 'update_role_permissions', { type: 'role', id }, {
|
||||||
|
name: role.name,
|
||||||
|
added: keys.filter(k => !previous.includes(k)),
|
||||||
|
removed: previous.filter(k => !keys.includes(k)),
|
||||||
|
})
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const bodySchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'users.manage')
|
const actor = await requirePermission(event, 'users.manage')
|
||||||
const userId = Number(getRouterParam(event, 'id'))
|
const userId = Number(getRouterParam(event, 'id'))
|
||||||
if (!Number.isInteger(userId) || userId <= 0) {
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
||||||
@@ -15,15 +15,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
const { roleId } = await readValidatedBody(event, bodySchema.parse)
|
const { roleId } = await readValidatedBody(event, bodySchema.parse)
|
||||||
const db = useDb()
|
const db = useDb()
|
||||||
|
|
||||||
const [user] = await db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1)
|
const [user] = await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, userId)).limit(1)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
|
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
|
||||||
}
|
}
|
||||||
const [role] = await db.select({ id: roles.id }).from(roles).where(eq(roles.id, roleId)).limit(1)
|
const [role] = await db.select({ id: roles.id, name: roles.name }).from(roles).where(eq(roles.id, roleId)).limit(1)
|
||||||
if (!role) {
|
if (!role) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Rolle nicht gefunden' })
|
throw createError({ statusCode: 404, statusMessage: 'Rolle nicht gefunden' })
|
||||||
}
|
}
|
||||||
|
|
||||||
await assignRole(userId, roleId)
|
await assignRole(userId, roleId)
|
||||||
|
await logAudit(actor.id, 'assign_role', { type: 'user', id: userId }, { userName: user.name, role: role.name })
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { roles, userRoles } from '../../../../../db/schema'
|
import { roles, userRoles, users } from '../../../../../db/schema'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const admin = await requirePermission(event, 'users.manage')
|
const admin = await requirePermission(event, 'users.manage')
|
||||||
@@ -18,6 +18,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, statusMessage: 'Du kannst dir nicht selbst die Admin-Rolle entziehen' })
|
throw createError({ statusCode: 400, statusMessage: 'Du kannst dir nicht selbst die Admin-Rolle entziehen' })
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(userRoles).where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
|
const [target] = await db.select({ name: users.name }).from(users).where(eq(users.id, userId)).limit(1)
|
||||||
|
const result = await db.delete(userRoles).where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
|
||||||
|
if (result[0].affectedRows) {
|
||||||
|
await logAudit(admin.id, 'remove_role', { type: 'user', id: userId }, { userName: target?.name ?? null, role: role.name })
|
||||||
|
}
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { unlink } from 'node:fs/promises'
|
import { unlink } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
import { commentAttachments, comments, uploads } from '../../db/schema'
|
import { commentAttachments, comments, uploads, users } from '../../db/schema'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'comment.delete')
|
const actor = await requirePermission(event, 'comment.delete')
|
||||||
const id = Number(getRouterParam(event, 'id'))
|
const id = Number(getRouterParam(event, 'id'))
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
||||||
}
|
}
|
||||||
const db = useDb()
|
const db = useDb()
|
||||||
const comment = await db
|
const comment = await db
|
||||||
.select({ uploadId: comments.uploadId })
|
.select({ uploadId: comments.uploadId, content: comments.content, userId: comments.userId, userName: users.name })
|
||||||
.from(comments)
|
.from(comments)
|
||||||
|
.innerJoin(users, eq(comments.userId, users.id))
|
||||||
.where(eq(comments.id, id))
|
.where(eq(comments.id, id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.then(r => r[0])
|
.then(r => r[0])
|
||||||
@@ -30,5 +31,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
await unlink(join(process.cwd(), 'public', f.cover)).catch(() => {})
|
await unlink(join(process.cwd(), 'public', f.cover)).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (comment) {
|
||||||
|
await logAudit(actor.id, 'delete_comment', { type: 'comment', id }, {
|
||||||
|
uploadId: comment.uploadId,
|
||||||
|
authorId: comment.userId,
|
||||||
|
authorName: comment.userName,
|
||||||
|
content: comment.content.slice(0, 500),
|
||||||
|
attachments: files.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { unlink } from 'node:fs/promises'
|
import { unlink } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { commentAttachments, comments, uploads } from '../../db/schema'
|
import { commentAttachments, comments, uploads, users } from '../../db/schema'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'upload.delete')
|
const actor = await requirePermission(event, 'upload.delete')
|
||||||
const id = getRouterParam(event, 'id') ?? ''
|
const id = getRouterParam(event, 'id') ?? ''
|
||||||
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
|
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
|
||||||
@@ -12,8 +12,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
const db = useDb()
|
const db = useDb()
|
||||||
|
|
||||||
const [upload] = await db
|
const [upload] = await db
|
||||||
.select({ fileName: uploads.fileName, previewImage: uploads.previewImage })
|
.select({ fileName: uploads.fileName, previewImage: uploads.previewImage, type: uploads.type, originalName: uploads.originalName, userId: uploads.userId, userName: users.name })
|
||||||
.from(uploads)
|
.from(uploads)
|
||||||
|
.innerJoin(users, eq(uploads.userId, users.id))
|
||||||
.where(eq(uploads.id, id))
|
.where(eq(uploads.id, id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!upload) {
|
if (!upload) {
|
||||||
@@ -40,5 +41,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
broadcastToAll({ type: 'upload-deleted', ids: [id] })
|
broadcastToAll({ type: 'upload-deleted', ids: [id] })
|
||||||
|
await logAudit(actor.id, 'delete_upload', { type: 'upload', id }, {
|
||||||
|
uploadType: upload.type,
|
||||||
|
originalName: upload.originalName,
|
||||||
|
uploaderId: upload.userId,
|
||||||
|
uploaderName: upload.userName,
|
||||||
|
})
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,5 +60,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
await db.insert(uploadTags).values(tagRows.map(t => ({ uploadId: id, tagId: t.id })))
|
await db.insert(uploadTags).values(tagRows.map(t => ({ uploadId: id, tagId: t.id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (removed.length) {
|
||||||
|
await logAudit(user.id, 'remove_tags', { type: 'upload', id }, { removed, isUploader: upload.userId === user.id })
|
||||||
|
}
|
||||||
return { tags: tagRows.map(t => t.name).sort() }
|
return { tags: tagRows.map(t => t.name).sort() }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { commentAttachments, comments, uploads } from '../../db/schema'
|
|||||||
const ID_RE = /^[A-Za-z0-9]{1,16}$/
|
const ID_RE = /^[A-Za-z0-9]{1,16}$/
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'upload.delete')
|
const actor = await requirePermission(event, 'upload.delete')
|
||||||
const body = await readBody<{ ids?: unknown }>(event)
|
const body = await readBody<{ ids?: unknown }>(event)
|
||||||
const raw = Array.isArray(body?.ids) ? body.ids : []
|
const raw = Array.isArray(body?.ids) ? body.ids : []
|
||||||
const ids = [...new Set(raw.filter((x): x is string => typeof x === 'string' && ID_RE.test(x)))].slice(0, 200)
|
const ids = [...new Set(raw.filter((x): x is string => typeof x === 'string' && ID_RE.test(x)))].slice(0, 200)
|
||||||
@@ -45,5 +45,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
broadcastToAll({ type: 'upload-deleted', ids: found })
|
broadcastToAll({ type: 'upload-deleted', ids: found })
|
||||||
|
await logAudit(actor.id, 'bulk_delete_uploads', null, { ids: found, count: found.length })
|
||||||
return { ok: true, deleted: found.length }
|
return { ok: true, deleted: found.length }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `audit_log` (
|
||||||
|
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
|
||||||
|
`user_id` bigint unsigned,
|
||||||
|
`action` varchar(50) NOT NULL,
|
||||||
|
`target_type` varchar(32),
|
||||||
|
`target_id` varchar(64),
|
||||||
|
`details` json,
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
CONSTRAINT `audit_log_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `audit_log` ADD CONSTRAINT `audit_log_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `audit_log_action_idx` ON `audit_log` (`action`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1791040007768,
|
"when": 1791040007768,
|
||||||
"tag": "0025_rare_william_stryker",
|
"tag": "0025_rare_william_stryker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1791391067859,
|
||||||
|
"tag": "0026_sleepy_colonel_america",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+18
-1
@@ -1,5 +1,5 @@
|
|||||||
import { relations } from 'drizzle-orm'
|
import { relations } from 'drizzle-orm'
|
||||||
import { bigint, boolean, char, int, json, mysqlEnum, mysqlTable, primaryKey, text, timestamp, varchar } from 'drizzle-orm/mysql-core'
|
import { bigint, boolean, char, index, int, json, mysqlEnum, mysqlTable, primaryKey, text, timestamp, varchar } from 'drizzle-orm/mysql-core'
|
||||||
|
|
||||||
export const users = mysqlTable('users', {
|
export const users = mysqlTable('users', {
|
||||||
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
|
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
|
||||||
@@ -138,6 +138,19 @@ export const uploadTags = mysqlTable('upload_tags', {
|
|||||||
tagId: bigint('tag_id', { mode: 'number', unsigned: true }).notNull().references(() => tags.id, { onDelete: 'cascade' }),
|
tagId: bigint('tag_id', { mode: 'number', unsigned: true }).notNull().references(() => tags.id, { onDelete: 'cascade' }),
|
||||||
}, t => [primaryKey({ columns: [t.uploadId, t.tagId] })])
|
}, t => [primaryKey({ columns: [t.uploadId, t.tagId] })])
|
||||||
|
|
||||||
|
// Protokoll von Moderations- und Admin-Aktionen. Der Akteur bleibt beim
|
||||||
|
// Löschen des Nutzers als NULL erhalten, Details als JSON-Snapshot, damit
|
||||||
|
// Einträge auch nach dem Löschen des Ziels lesbar bleiben
|
||||||
|
export const auditLog = mysqlTable('audit_log', {
|
||||||
|
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
|
||||||
|
userId: bigint('user_id', { mode: 'number', unsigned: true }).references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
action: varchar('action', { length: 50 }).notNull(),
|
||||||
|
targetType: varchar('target_type', { length: 32 }),
|
||||||
|
targetId: varchar('target_id', { length: 64 }),
|
||||||
|
details: json('details'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
}, t => [index('audit_log_action_idx').on(t.action)])
|
||||||
|
|
||||||
// Generischer Key-Value-Store für Seiten-Einstellungen (z. B. MOTD)
|
// Generischer Key-Value-Store für Seiten-Einstellungen (z. B. MOTD)
|
||||||
export const settings = mysqlTable('settings', {
|
export const settings = mysqlTable('settings', {
|
||||||
key: varchar('key', { length: 100 }).primaryKey(),
|
key: varchar('key', { length: 100 }).primaryKey(),
|
||||||
@@ -212,6 +225,10 @@ export const threadSubscriptionsRelations = relations(threadSubscriptions, ({ on
|
|||||||
user: one(users, { fields: [threadSubscriptions.userId], references: [users.id] }),
|
user: one(users, { fields: [threadSubscriptions.userId], references: [users.id] }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [auditLog.userId], references: [users.id] }),
|
||||||
|
}))
|
||||||
|
|
||||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
user: one(users, { fields: [notifications.userId], references: [users.id] }),
|
user: one(users, { fields: [notifications.userId], references: [users.id] }),
|
||||||
actor: one(users, { fields: [notifications.actorId], references: [users.id] }),
|
actor: one(users, { fields: [notifications.actorId], references: [users.id] }),
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { AuditAction } from '../../shared/utils/audit'
|
||||||
|
import { auditLog } from '../db/schema'
|
||||||
|
|
||||||
|
// Fehler beim Protokollieren dürfen die eigentliche Aktion nicht scheitern
|
||||||
|
// lassen — die ist zu diesem Zeitpunkt bereits ausgeführt
|
||||||
|
export async function logAudit(
|
||||||
|
userId: number,
|
||||||
|
action: AuditAction,
|
||||||
|
target: { type: string, id: string | number } | null,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await useDb().insert(auditLog).values({
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
targetType: target?.type ?? null,
|
||||||
|
targetId: target ? String(target.id) : null,
|
||||||
|
details: details ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.warn('[audit] Eintrag fehlgeschlagen:', err instanceof Error ? err.message : err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Katalog der protokollierten Aktionen — Labels im Admin über admin.auditActions.<key>
|
||||||
|
export const AUDIT_ACTIONS = [
|
||||||
|
'delete_upload',
|
||||||
|
'bulk_delete_uploads',
|
||||||
|
'delete_comment',
|
||||||
|
'remove_tags',
|
||||||
|
'create_role',
|
||||||
|
'delete_role',
|
||||||
|
'update_role_permissions',
|
||||||
|
'assign_role',
|
||||||
|
'remove_role',
|
||||||
|
'update_motd',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number]
|
||||||
@@ -6,6 +6,7 @@ export const PERMISSIONS = [
|
|||||||
{ key: 'comment.delete', label: 'Kommentare löschen' },
|
{ key: 'comment.delete', label: 'Kommentare löschen' },
|
||||||
{ key: 'upload.delete', label: 'Uploads löschen' },
|
{ key: 'upload.delete', label: 'Uploads löschen' },
|
||||||
{ key: 'tag.delete', label: 'Tags löschen' },
|
{ key: 'tag.delete', label: 'Tags löschen' },
|
||||||
|
{ key: 'audit.view', label: 'Audit-Log einsehen' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type PermissionKey = (typeof PERMISSIONS)[number]['key']
|
export type PermissionKey = (typeof PERMISSIONS)[number]['key']
|
||||||
|
|||||||
Reference in New Issue
Block a user