Remove "User Roles" admin page
This commit is contained in:
@@ -45,7 +45,6 @@ CRUD layer has been removed; see `docs/ADMIN_OVERHAUL.md` for the migration note
|
||||
| -------------------------------- | ------------------------ |
|
||||
| `/admin/users` | Name, Email, Role |
|
||||
| `/admin/profiles` | Reference implementation |
|
||||
| `/admin/user-roles` | |
|
||||
| `/admin/proclamations` | Super-only content edits |
|
||||
| `/admin/solfeggio-frequencies` | |
|
||||
| `/admin/proclamation-categories` | Draggable reorder |
|
||||
|
||||
@@ -3,21 +3,17 @@ import { createLocals, createFormRequest, mockUser } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
userRole: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
solfeggioFrequency: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
proclamationCategory: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
proclamationGroup: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { actions as userRoles } from './user-roles/+page.server';
|
||||
import { actions as frequencies } from './solfeggio-frequencies/+page.server';
|
||||
import { actions as categories } from './proclamation-categories/+page.server';
|
||||
import { actions as groups } from './proclamation-groups/+page.server';
|
||||
|
||||
const staff = () => createLocals(mockUser({ roleId: 3 }) as any);
|
||||
const admin = () => createLocals(mockUser({ roleId: 2 }) as any);
|
||||
const user = () => createLocals(mockUser({ roleId: 4 }) as any);
|
||||
const anonymous = () => createLocals();
|
||||
|
||||
@@ -27,7 +23,6 @@ type CrudActions = {
|
||||
};
|
||||
|
||||
const pages: Array<[string, CrudActions]> = [
|
||||
['user-roles', userRoles as unknown as CrudActions],
|
||||
['solfeggio-frequencies', frequencies as unknown as CrudActions],
|
||||
['proclamation-categories', categories as unknown as CrudActions],
|
||||
['proclamation-groups', groups as unknown as CrudActions]
|
||||
@@ -60,25 +55,3 @@ describe('admin CRUD action role guards', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('admin CRUD action role guards: authorized users still work', () => {
|
||||
it('staff can create a user role', async () => {
|
||||
(prisma.userRole.create as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 5 });
|
||||
const result = await userRoles.upsert({
|
||||
request: createFormRequest({ name: 'New Role' }),
|
||||
locals: staff()
|
||||
} as any);
|
||||
expect(result).toEqual({ success: true, action: 'upsert' });
|
||||
expect(prisma.userRole.create).toHaveBeenCalledWith({ data: { name: 'New Role' } });
|
||||
});
|
||||
|
||||
it('admin can delete a user role', async () => {
|
||||
(prisma.userRole.delete as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
const result = await userRoles.delete({
|
||||
request: createFormRequest({ id: '5' }),
|
||||
locals: admin()
|
||||
} as any);
|
||||
expect(result).toEqual({ success: true, action: 'delete' });
|
||||
expect(prisma.userRole.delete).toHaveBeenCalledWith({ where: { id: 5 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { isAdmin, isStaff } from '$lib/server/roles';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import { z } from 'zod';
|
||||
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
|
||||
|
||||
const UserRoleSchema = z.object({
|
||||
name: z.string().min(1, 'Required').max(100, 'Too long')
|
||||
});
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const records = await prisma.userRole.findMany({ orderBy: { id: 'asc' } });
|
||||
return { records: records as unknown as Record<string, unknown>[] };
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
upsert: async ({ request, locals }) => {
|
||||
if (!isStaff(locals)) {
|
||||
return { success: false, action: 'upsert', error: 'Not authorized' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const form = await superValidate(formData, zod4(UserRoleSchema));
|
||||
if (!form.valid) {
|
||||
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
return { success: false, action: 'upsert', error: formError, fieldErrors };
|
||||
}
|
||||
|
||||
const rawId = formData.get('id')?.toString();
|
||||
const data = { name: form.data.name };
|
||||
|
||||
try {
|
||||
if (rawId) {
|
||||
await prisma.userRole.update({ where: { id: Number(rawId) }, data });
|
||||
} else {
|
||||
await prisma.userRole.create({ data });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||
return { success: false, action: 'upsert', error: 'A role with that name already exists.' };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { success: true, action: 'upsert' };
|
||||
},
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!isAdmin(locals)) {
|
||||
return { success: false, error: 'Not authorized' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rawId = formData.get('id')?.toString();
|
||||
if (!rawId) return { success: false, error: 'Missing id' };
|
||||
|
||||
try {
|
||||
await prisma.userRole.delete({ where: { id: Number(rawId) } });
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
|
||||
return { success: false, error: 'Cannot delete: this role is assigned to users.' };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { success: true, action: 'delete' };
|
||||
}
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageProps } from './$types';
|
||||
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
|
||||
type ActionResult = {
|
||||
success?: boolean;
|
||||
action?: string;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ label: 'Id', value: (r) => r.id },
|
||||
{ label: 'Name', value: (r) => r.name }
|
||||
];
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
|
||||
let name = $state('');
|
||||
let formError = $state('');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let successMessage = $state('');
|
||||
let lastForm = $state.raw<ActionResult>(null);
|
||||
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function clearCloseTimer() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resetModalState() {
|
||||
clearCloseTimer();
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
successMessage = '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!form || form === lastForm) return;
|
||||
lastForm = form;
|
||||
|
||||
if (form.success) {
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
successMessage = editingRecord ? 'Role updated' : 'Role created';
|
||||
closeTimer = setTimeout(() => {
|
||||
modalOpen = false;
|
||||
successMessage = '';
|
||||
closeTimer = undefined;
|
||||
}, 1200);
|
||||
} else {
|
||||
successMessage = '';
|
||||
formError = form.error ?? '';
|
||||
fieldErrors = form.fieldErrors ?? {};
|
||||
}
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
resetModalState();
|
||||
editingRecord = null;
|
||||
name = '';
|
||||
lastForm = form;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
resetModalState();
|
||||
editingRecord = record;
|
||||
name = String(record.name ?? '');
|
||||
lastForm = form;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
clearCloseTimer();
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<DataTable
|
||||
title="User Roles"
|
||||
{columns}
|
||||
records={data.records}
|
||||
newLabel="New Role"
|
||||
searchPlaceholder="Search roles..."
|
||||
onRowClick={openEdit}
|
||||
onNew={openCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if modalOpen}
|
||||
<AdminModal
|
||||
title={editingRecord ? 'Edit User Role' : 'Create User Role'}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
{#if successMessage}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
|
||||
>
|
||||
{successMessage}
|
||||
</div>
|
||||
{/if}
|
||||
{#if formError}
|
||||
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
|
||||
{#if editingRecord}
|
||||
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
|
||||
<input
|
||||
id="field-name"
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
bind:value={name}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
{#if fieldErrors.name}
|
||||
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
|
||||
>
|
||||
{editingRecord ? 'Save Role' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminModal>
|
||||
{/if}
|
||||
@@ -17,7 +17,6 @@
|
||||
const adminModels = [
|
||||
{ slug: 'users', href: '/admin/users', displayName: 'Users' },
|
||||
{ slug: 'profiles', href: '/admin/profiles', displayName: 'Profiles' },
|
||||
{ slug: 'user-roles', href: '/admin/user-roles', displayName: 'User Roles' },
|
||||
{
|
||||
slug: 'proclamations',
|
||||
href: '/admin/proclamations',
|
||||
|
||||
Reference in New Issue
Block a user