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 @@ + + +
+
+
+

Audit Log

+

+ Every database change, newest first. You can see activity from roles beneath your own. +

+
+

{first}–{last} of {data.total}

+
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Clear + + +
+
+ + +
+ + + + + + + + + + + + + + + + {#each data.records as record (record.id)} + + + + + + + + + + + + {:else} + + + + {/each} + +
WhenUserEmailRolePageActionEntityPreviousPost
+ {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} +
+ view +
{formatJson(
+											record.before
+										)}
+
+ {/if} +
+ {#if record.after == null} + — + {:else} +
+ view +
{formatJson(
+											record.after
+										)}
+
+ {/if} +
No events found.
+
+ + + {#if data.totalPages > 1} +
+

Page {data.page} of {data.totalPages}

+
+ {#if data.filters.q}{/if} + {#if data.filters.role}{/if} + {#if data.filters.action}{/if} + {#if data.filters.entity}{/if} + {#if data.filters.page}{/if} + {#if data.filters.from}{/if} + {#if data.filters.to}{/if} + {#if data.filters.sort === 'asc'}{/if} + {#if data.page > 1} + + {/if} + {#if data.page < data.totalPages} + + {/if} +
+
+ {/if} +
diff --git a/src/routes/(protected)/admin/audit-log/page.server.spec.ts b/src/routes/(protected)/admin/audit-log/page.server.spec.ts new file mode 100644 index 0000000..24813ca --- /dev/null +++ b/src/routes/(protected)/admin/audit-log/page.server.spec.ts @@ -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; +const count = prisma.auditLog.count as ReturnType; +const groupBy = prisma.auditLog.groupBy as ReturnType; + +const noAuth = () => load({ locals: createLocals(), url: createUrl('/admin/audit-log') } as any); + +const loadAs = (roleId: number, params: Record = {}) => + 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 })); + }); +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5b145a0..5043627 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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);