diff --git a/src/routes/(protected)/admin/audit-log/+page.server.ts b/src/routes/(protected)/admin/audit-log/+page.server.ts new file mode 100644 index 0000000..0351f06 --- /dev/null +++ b/src/routes/(protected)/admin/audit-log/+page.server.ts @@ -0,0 +1,110 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { prisma } from '$lib/db'; +import { SUPER, isStaff, roleId as viewerRoleId } from '$lib/server/roles'; +import type { Prisma } from '$lib/generated/prisma/client'; + +const PAGE_SIZE = 50; + +function parseDate(value: string): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +export const load: PageServerLoad = async ({ locals, url }) => { + if (!isStaff(locals)) { + throw error(403, 'not authorized'); + } + + const viewerRole = viewerRoleId(locals); + const params = url.searchParams; + + const q = params.get('q')?.trim() ?? ''; + const role = params.get('role')?.trim() ?? ''; + const action = params.get('action')?.trim() ?? ''; + const entity = params.get('entity')?.trim() ?? ''; + const page = params.get('page')?.trim() ?? ''; + const from = params.get('from')?.trim() ?? ''; + const to = params.get('to')?.trim() ?? ''; + const sort = params.get('sort') === 'asc' ? 'asc' : 'desc'; + const requestedPage = Math.max(1, Number.parseInt(params.get('p') ?? '1', 10) || 1); + + // Visibility: a viewer sees activity from roles strictly beneath their own. + // Supers see everything (including other supers and system/API events), + // otherwise nobody could audit a super. + const visibility: Prisma.AuditLogWhereInput = + viewerRole > SUPER ? { roleId: { gt: viewerRole } } : {}; + + const filters: Prisma.AuditLogWhereInput[] = []; + if (q) { + filters.push({ + OR: [ + { email: { contains: q, mode: 'insensitive' } }, + { userId: { contains: q, mode: 'insensitive' } }, + { roleName: { contains: q, mode: 'insensitive' } }, + { page: { contains: q, mode: 'insensitive' } }, + { action: { contains: q, mode: 'insensitive' } }, + { entity: { contains: q, mode: 'insensitive' } }, + { entityId: { contains: q, mode: 'insensitive' } } + ] + }); + } + if (role) filters.push({ roleId: Number(role) }); + if (action) filters.push({ action }); + if (entity) filters.push({ entity }); + if (page) filters.push({ page: { contains: page, mode: 'insensitive' } }); + if (from) { + const date = parseDate(from); + if (date) filters.push({ createdAt: { gte: date } }); + } + if (to) { + const date = parseDate(to); + if (date) { + const end = new Date(date); + end.setHours(23, 59, 59, 999); + filters.push({ createdAt: { lte: end } }); + } + } + + const where: Prisma.AuditLogWhereInput = { AND: [visibility, ...filters] }; + + const [records, total, actionFacets, entityFacets, roleFacets] = await Promise.all([ + prisma.auditLog.findMany({ + where, + orderBy: { createdAt: sort }, + skip: (requestedPage - 1) * PAGE_SIZE, + take: PAGE_SIZE + }), + prisma.auditLog.count({ where }), + prisma.auditLog.groupBy({ by: ['action'], where: visibility, orderBy: { action: 'asc' } }), + prisma.auditLog.groupBy({ by: ['entity'], where: visibility, orderBy: { entity: 'asc' } }), + prisma.auditLog.groupBy({ + by: ['roleId', 'roleName'], + where: visibility, + orderBy: { roleId: 'asc' } + }) + ]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return { + records, + total, + page: Math.min(requestedPage, totalPages), + totalPages, + pageSize: PAGE_SIZE, + filters: { q, role, action, entity, page, from, to, sort }, + actionOptions: actionFacets.map((facet) => facet.action), + entityOptions: entityFacets + .map((facet) => facet.entity) + .filter((value): value is string => Boolean(value)), + roleOptions: roleFacets + .filter((facet) => facet.roleId !== null) + .map((facet) => ({ + id: facet.roleId as number, + name: facet.roleName ?? `Role ${facet.roleId}` + })), + viewerRole + }; +}; diff --git a/src/routes/(protected)/admin/audit-log/+page.svelte b/src/routes/(protected)/admin/audit-log/+page.svelte new file mode 100644 index 0000000..bd59d84 --- /dev/null +++ b/src/routes/(protected)/admin/audit-log/+page.svelte @@ -0,0 +1,306 @@ + + +
+ Every database change, newest first. You can see activity from roles beneath your own. +
+{first}–{last} of {data.total}
+| When | +User | +Role | +Page | +Action | +Entity | +Previous | +Post | +|
|---|---|---|---|---|---|---|---|---|
| + {formatWhen(record.createdAt)} + | ++ {record.userId ?? '—'} + | +{record.email ?? '—'} | ++ + {record.roleName ?? (record.roleId != null ? `Role ${record.roleId}` : 'System')} + + | +{record.page} | ++ + {record.action} + + | ++ {record.entity ?? '—'}{#if record.entityId}#{record.entityId}{/if} + | +
+ {#if record.before == null}
+ —
+ {:else}
+
+
+ {/if}
+ view+{formatJson(
+ record.before
+ )}
+ |
+
+ {#if record.after == null}
+ —
+ {:else}
+
+
+ {/if}
+ view+{formatJson(
+ record.after
+ )}
+ |
+
| No events found. | +||||||||
Page {data.page} of {data.totalPages}
+ +