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',
|
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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user