Add admin Audit Log page
This commit is contained in:
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,8 @@
|
||||
href: '/admin/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;
|
||||
const currentPath = $derived(page.url.pathname);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user