Add admin Audit Log page

This commit is contained in:
NeilClack
2026-09-10 15:10:16 -05:00
parent d16965dab6
commit 10aad07e6d
4 changed files with 526 additions and 1 deletions
@@ -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
};
};
@@ -0,0 +1,306 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function formatJson(value: unknown): string {
if (value === null || value === undefined) return '—';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function formatWhen(value: string | Date): string {
return new Date(value).toLocaleString();
}
const first = $derived(data.total === 0 ? 0 : (data.page - 1) * data.pageSize + 1);
const last = $derived(Math.min(data.page * data.pageSize, data.total));
</script>
<div class="mx-auto w-full">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Audit Log</h1>
<p class="mt-1 text-sm text-slate-500">
Every database change, newest first. You can see activity from roles beneath your own.
</p>
</div>
<p class="text-sm text-slate-500">{first}–{last} of {data.total}</p>
</div>
<!-- Filters -->
<form method="GET" class="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="flex flex-col gap-1">
<label for="q" class="text-xs font-medium tracking-wide text-slate-500 uppercase"
>Search</label
>
<input
id="q"
name="q"
type="search"
value={data.filters.q}
placeholder="User, email, page, action…"
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1">
<label for="role" class="text-xs font-medium tracking-wide text-slate-500 uppercase"
>Role</label
>
<select
id="role"
name="role"
value={data.filters.role}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="">All roles</option>
{#each data.roleOptions as role (role.id)}
<option value={String(role.id)}>{role.name}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label for="action" class="text-xs font-medium tracking-wide text-slate-500 uppercase"
>Action</label
>
<select
id="action"
name="action"
value={data.filters.action}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="">All actions</option>
{#each data.actionOptions as action (action)}
<option value={action}>{action}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label for="entity" class="text-xs font-medium tracking-wide text-slate-500 uppercase"
>Entity</label
>
<select
id="entity"
name="entity"
value={data.filters.entity}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="">All entities</option>
{#each data.entityOptions as entity (entity)}
<option value={entity}>{entity}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label for="page" class="text-xs font-medium tracking-wide text-slate-500 uppercase"
>Page</label
>
<input
id="page"
name="page"
type="text"
value={data.filters.page}
placeholder="/admin/users"
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1">
<label for="from" class="text-xs font-medium tracking-wide text-slate-500 uppercase"
>From</label
>
<input
id="from"
name="from"
type="date"
value={data.filters.from}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1">
<label for="to" class="text-xs font-medium tracking-wide text-slate-500 uppercase">To</label
>
<input
id="to"
name="to"
type="date"
value={data.filters.to}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1">
<label for="sort" class="text-xs font-medium tracking-wide text-slate-500 uppercase"
>Order</label
>
<select
id="sort"
name="sort"
value={data.filters.sort}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-3">
<a
href={resolve('/admin/audit-log')}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Clear
</a>
<button
type="submit"
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Apply filters
</button>
</div>
</form>
<!-- Results -->
<div class="mt-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-sm">
<table class="w-full min-w-max text-left text-sm text-slate-700">
<thead>
<tr
class="border-b border-slate-200 bg-slate-50 text-xs font-semibold tracking-wide text-slate-500 uppercase"
>
<th scope="col" class="px-4 py-3">When</th>
<th scope="col" class="px-4 py-3">User</th>
<th scope="col" class="px-4 py-3">Email</th>
<th scope="col" class="px-4 py-3">Role</th>
<th scope="col" class="px-4 py-3">Page</th>
<th scope="col" class="px-4 py-3">Action</th>
<th scope="col" class="px-4 py-3">Entity</th>
<th scope="col" class="px-4 py-3">Previous</th>
<th scope="col" class="px-4 py-3">Post</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each data.records as record (record.id)}
<tr class="align-top hover:bg-slate-50">
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{formatWhen(record.createdAt)}
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-600">
{record.userId ?? '—'}
</td>
<td class="px-4 py-3 text-slate-700">{record.email ?? '—'}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700"
>
{record.roleName ?? (record.roleId != null ? `Role ${record.roleId}` : 'System')}
</span>
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-600">{record.page}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-xs font-medium text-indigo-700"
>
{record.action}
</span>
</td>
<td class="px-4 py-3 text-slate-700">
{record.entity ?? '—'}{#if record.entityId}<span class="text-slate-400"
>#{record.entityId}</span
>{/if}
</td>
<td class="max-w-xs px-4 py-3">
{#if record.before == null}
<span class="text-slate-400">—</span>
{:else}
<details>
<summary class="cursor-pointer text-xs text-indigo-600 hover:text-indigo-700"
>view</summary
>
<pre
class="mt-2 max-h-64 overflow-auto rounded-md bg-slate-50 p-2 text-xs whitespace-pre-wrap text-slate-700">{formatJson(
record.before
)}</pre>
</details>
{/if}
</td>
<td class="max-w-xs px-4 py-3">
{#if record.after == null}
<span class="text-slate-400">—</span>
{:else}
<details>
<summary class="cursor-pointer text-xs text-indigo-600 hover:text-indigo-700"
>view</summary
>
<pre
class="mt-2 max-h-64 overflow-auto rounded-md bg-slate-50 p-2 text-xs whitespace-pre-wrap text-slate-700">{formatJson(
record.after
)}</pre>
</details>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="9" class="px-4 py-12 text-center text-slate-500">No events found.</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.totalPages > 1}
<div class="mt-4 flex items-center justify-between">
<p class="text-sm text-slate-500">Page {data.page} of {data.totalPages}</p>
<form method="GET" action={resolve('/admin/audit-log')} class="flex gap-2">
{#if data.filters.q}<input type="hidden" name="q" value={data.filters.q} />{/if}
{#if data.filters.role}<input type="hidden" name="role" value={data.filters.role} />{/if}
{#if data.filters.action}<input
type="hidden"
name="action"
value={data.filters.action}
/>{/if}
{#if data.filters.entity}<input
type="hidden"
name="entity"
value={data.filters.entity}
/>{/if}
{#if data.filters.page}<input type="hidden" name="page" value={data.filters.page} />{/if}
{#if data.filters.from}<input type="hidden" name="from" value={data.filters.from} />{/if}
{#if data.filters.to}<input type="hidden" name="to" value={data.filters.to} />{/if}
{#if data.filters.sort === 'asc'}<input type="hidden" name="sort" value="asc" />{/if}
{#if data.page > 1}
<button
type="submit"
name="p"
value={data.page - 1}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Previous
</button>
{/if}
{#if data.page < data.totalPages}
<button
type="submit"
name="p"
value={data.page + 1}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Next
</button>
{/if}
</form>
</div>
{/if}
</div>
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, createUrl, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
auditLog: {
findMany: vi.fn(),
count: vi.fn(),
groupBy: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const findMany = prisma.auditLog.findMany as ReturnType<typeof vi.fn>;
const count = prisma.auditLog.count as ReturnType<typeof vi.fn>;
const groupBy = prisma.auditLog.groupBy as ReturnType<typeof vi.fn>;
const noAuth = () => load({ locals: createLocals(), url: createUrl('/admin/audit-log') } as any);
const loadAs = (roleId: number, params: Record<string, string> = {}) =>
load({
locals: createLocals(mockUser({ roleId }) as any),
url: createUrl('/admin/audit-log', params)
} as any);
describe('audit-log page server load', () => {
beforeEach(() => {
vi.clearAllMocks();
findMany.mockResolvedValue([]);
count.mockResolvedValue(0);
groupBy.mockResolvedValue([]);
});
it('rejects unauthenticated users with 403', async () => {
const err = await Promise.resolve(noAuth()).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('rejects regular users with 403', async () => {
const err = await Promise.resolve(loadAs(4)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('lets staff see only roles beneath them (users)', async () => {
await loadAs(3);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { AND: [{ roleId: { gt: 3 } }] } })
);
});
it('lets admins see staff and users', async () => {
await loadAs(2);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { AND: [{ roleId: { gt: 2 } }] } })
);
});
it('lets supers see everything, including other supers and system events', async () => {
await loadAs(1);
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { AND: [{}] } }));
});
it('applies search across the requested columns', async () => {
await loadAs(1, { q: 'jane' });
const arg = findMany.mock.calls[0][0] as { where: { AND: unknown[] } };
expect(arg.where.AND).toHaveLength(2);
expect(arg.where.AND[1]).toHaveProperty('OR');
});
it('applies column filters', async () => {
await loadAs(1, {
role: '4',
action: 'update',
entity: 'Profile',
page: '/admin/profiles',
from: '2026-01-01'
});
const arg = findMany.mock.calls[0][0] as { where: { AND: unknown[] } };
const combined = JSON.stringify(arg.where.AND);
expect(combined).toContain('"roleId":4');
expect(combined).toContain('"action":"update"');
expect(combined).toContain('"entity":"Profile"');
expect(combined).toContain('"/admin/profiles"');
expect(combined).toContain('"gte"');
});
it('paginates and reports totals', async () => {
findMany.mockResolvedValue([{ id: 'a' }]);
count.mockResolvedValue(120);
const result = (await loadAs(1, { p: '2' })) as {
page: number;
totalPages: number;
total: number;
};
expect(result.total).toBe(120);
expect(result.page).toBe(2);
expect(result.totalPages).toBe(3);
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 50, take: 50 }));
});
});
+2 -1
View File
@@ -38,7 +38,8 @@
href: '/admin/proclamation-groups', href: '/admin/proclamation-groups',
displayName: 'Proclamation Groups' displayName: 'Proclamation Groups'
}, },
{ slug: 'license-keys', href: '/admin/license-keys', displayName: 'License Keys' } { slug: 'license-keys', href: '/admin/license-keys', displayName: 'License Keys' },
{ slug: 'audit-log', href: '/admin/audit-log', displayName: 'Audit Log' }
] as const; ] as const;
const currentPath = $derived(page.url.pathname); const currentPath = $derived(page.url.pathname);