Enforce roleId checks on all admin page actions

This commit is contained in:
NeilClack
2026-09-10 14:59:32 -05:00
parent 0cb4b2148a
commit 7596937f30
14 changed files with 217 additions and 112 deletions
@@ -2,12 +2,10 @@ import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { rotateApiKey } from '$lib/server/api-key';
import { isSuper } from '$lib/server/roles';
const SUPER = 1;
export const load: PageServerLoad = async ({ parent }) => {
const { adminUser } = await parent();
if (adminUser.roleId > SUPER) {
export const load: PageServerLoad = async ({ locals }) => {
if (!isSuper(locals)) {
throw error(403, 'Only Super admins can manage API keys');
}
@@ -39,18 +37,9 @@ export const load: PageServerLoad = async ({ parent }) => {
};
};
async function viewerRoleId(locals: App.Locals): Promise<number> {
if (!locals.user?.id) return 4;
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { roleId: true }
});
return user?.roleId ?? 4;
}
export const actions = {
rotate: async ({ locals }) => {
if ((await viewerRoleId(locals)) > SUPER) {
if (!isSuper(locals)) {
return { success: false, error: 'Not authorized' };
}
@@ -63,7 +52,7 @@ export const actions = {
};
},
rotateEmergency: async ({ locals }) => {
if ((await viewerRoleId(locals)) > SUPER) {
if (!isSuper(locals)) {
return { success: false, error: 'Not authorized' };
}
@@ -1,6 +1,7 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend';
import { isStaff } from '$lib/server/roles';
import { error } from '@sveltejs/kit';
function generateLicenseKey(): string {
@@ -18,7 +19,7 @@ export const load: PageServerLoad = async () => {
export const actions = {
createKey: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
if (!isStaff(locals)) throw error(403, 'Not authorized');
const formData = await request.formData();
const email = formData.get('email')?.toString()?.trim().toLowerCase();
@@ -60,7 +61,7 @@ export const actions = {
return { success: true, action: 'createKey' };
},
replace: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
if (!isStaff(locals)) throw error(403, 'Not authorized');
const formData = await request.formData();
const keyId = formData.get('id')?.toString();
@@ -48,11 +48,11 @@ describe('admin license-keys createKey action', () => {
const req = createFormRequest({ email: 'jane@example.com' });
const err = await actions.createKey({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns error when email is missing', async () => {
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({});
const result = await actions.createKey({ request: req, locals } as any);
@@ -65,7 +65,7 @@ describe('admin license-keys createKey action', () => {
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({ email: 'nobody@example.com' });
const result = await actions.createKey({ request: req, locals } as any);
@@ -86,7 +86,7 @@ describe('admin license-keys createKey action', () => {
});
it('creates a license key and sends email with license_key variable', async () => {
const user = mockUser();
const user = mockUser({ roleId: 3 });
const profile = { firstName: 'Jane' };
mockFindUnique.mockResolvedValue(user);
@@ -117,7 +117,7 @@ describe('admin license-keys createKey action', () => {
});
it('sends email with license_key even when profile is missing', async () => {
const user = mockUser();
const user = mockUser({ roleId: 3 });
mockFindUnique.mockResolvedValue(user);
mockProfileFindFirst.mockResolvedValue(null);
@@ -136,7 +136,7 @@ describe('admin license-keys createKey action', () => {
});
it('still returns success when email sending fails', async () => {
const user = mockUser();
const user = mockUser({ roleId: 3 });
mockFindUnique.mockResolvedValue(user);
mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' });
@@ -166,11 +166,11 @@ describe('admin license-keys replace action', () => {
const req = createFormRequest({ id: 'k1' });
const err = await actions.replace({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns error when id is missing', async () => {
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({});
const result = await actions.replace({ request: req, locals } as any);
@@ -180,7 +180,7 @@ describe('admin license-keys replace action', () => {
it('returns error when the key does not exist', async () => {
mockLicenseKeyFindUnique.mockResolvedValue(null);
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({ id: 'missing' });
const result = await actions.replace({ request: req, locals } as any);
@@ -197,7 +197,7 @@ describe('admin license-keys replace action', () => {
mockLicenseKeyDelete.mockResolvedValue({});
mockLicenseKeyCreate.mockResolvedValue({});
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({ id: 'k1' });
const result = await actions.replace({ request: req, locals } as any);
@@ -1,5 +1,6 @@
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';
@@ -17,7 +18,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
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(CategorySchema));
if (!form.valid) {
@@ -58,7 +63,11 @@ export const actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
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' };
@@ -77,7 +86,11 @@ export const actions = {
return { success: true, action: 'delete' };
},
reorder: async ({ request }) => {
reorder: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, error: 'Not authorized' };
}
const formData = await request.formData();
const raw = formData.get('ids')?.toString();
if (!raw) return { success: false, error: 'Missing ids' };
@@ -1,5 +1,6 @@
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';
@@ -17,7 +18,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
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(GroupSchema));
if (!form.valid) {
@@ -58,7 +63,11 @@ export const actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
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' };
@@ -77,7 +86,11 @@ export const actions = {
return { success: true, action: 'delete' };
},
reorder: async ({ request }) => {
reorder: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, error: 'Not authorized' };
}
const formData = await request.formData();
const raw = formData.get('ids')?.toString();
if (!raw) return { success: false, error: 'Missing ids' };
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { isSuper } from '$lib/server/roles';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
@@ -13,18 +14,6 @@ const ProclamationSchema = z.object({
content: z.string().optional().default('')
});
async function isSuper(locals: App.Locals): Promise<boolean> {
if (!locals.user?.id) return false;
// The Better Auth session user does not include `roleId`, so read the
// current role from the database.
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { roleId: true }
});
return (user?.roleId ?? 4) <= 1;
}
export const load: PageServerLoad = async ({ locals }) => {
const [proclamations, frequencies, categories, groups] = await Promise.all([
prisma.proclamation.findMany({
@@ -48,7 +37,7 @@ export const load: PageServerLoad = async ({ locals }) => {
const records = proclamations as unknown as Record<string, unknown>[];
// Strip content from non-Super users
if (!(await isSuper(locals))) {
if (!isSuper(locals)) {
for (const record of records) {
delete record.content;
}
@@ -63,7 +52,7 @@ export const actions = {
return { success: false, action: 'upsert', error: 'Unauthorized' };
}
if (!(await isSuper(locals))) {
if (!isSuper(locals)) {
return {
success: false,
action: 'upsert',
@@ -106,7 +95,7 @@ export const actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
if (!(await isSuper(locals))) {
if (!isSuper(locals)) {
return {
success: false,
action: 'delete',
@@ -18,9 +18,6 @@ vi.mock('$lib/db', () => ({
},
proclamationGroup: {
findMany: vi.fn()
},
user: {
findUnique: vi.fn()
}
}
}));
@@ -36,7 +33,6 @@ const mockProclamationDelete = prisma.proclamation.delete as ReturnType<typeof v
const mockFreqFindMany = prisma.solfeggioFrequency.findMany as ReturnType<typeof vi.fn>;
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
const mockGroupFindMany = prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const frequencies = [{ id: 1, name: 'UT', value: 396 }];
const categories = [{ id: 1, name: 'Healing' }];
@@ -56,8 +52,6 @@ function validRequest(overrides: Record<string, string> = {}) {
describe('proclamations admin page server', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default the caller to SUPER (1); override per test as needed.
mockUserFindUnique.mockResolvedValue({ roleId: 1 });
});
describe('load', () => {
@@ -74,7 +68,6 @@ describe('proclamations admin page server', () => {
});
it('strips content for non-Super users', async () => {
mockUserFindUnique.mockResolvedValue({ roleId: 4 });
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const result = await load({ locals } as any);
@@ -116,7 +109,6 @@ describe('proclamations admin page server', () => {
});
it('rejects non-Super users', async () => {
mockUserFindUnique.mockResolvedValue({ roleId: 4 });
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const req = createFormRequest({ name: 'test' });
@@ -157,21 +149,6 @@ describe('proclamations admin page server', () => {
});
});
it('allows a Super user whose session user has no roleId', async () => {
mockProclamationCreate.mockResolvedValue({ id: 'p1' });
// The real Better Auth session user does not carry `roleId`; the
// action must look the role up in the database instead.
const locals = createLocals(mockUser({ roleId: null }) as any);
const result = await actions.upsert({ request: validRequest(), locals } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
select: { roleId: true }
});
});
it('updates an existing proclamation for a Super user', async () => {
mockProclamationFindUnique.mockResolvedValue(mockProclamation());
mockProclamationUpdate.mockResolvedValue({});
@@ -223,7 +200,6 @@ describe('proclamations admin page server', () => {
describe('delete action', () => {
it('rejects non-Super users', async () => {
mockUserFindUnique.mockResolvedValue({ roleId: 4 });
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const req = createFormRequest({ id: '1' });
@@ -1,5 +1,6 @@
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';
@@ -45,7 +46,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
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(ProfileSchema));
if (!form.valid) {
@@ -112,14 +117,22 @@ export const actions = {
}
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
delete: async ({ request, locals }) => {
if (!isAdmin(locals)) {
return { success: false, action: 'delete', error: 'Not authorized' };
}
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
await prisma.profile.delete({ where: { id: rawId } });
return { success: true, action: 'delete' };
},
upsertAddress: async ({ request }) => {
upsertAddress: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, action: 'upsertAddress', addressError: 'Not authorized' };
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(AddressSchema));
if (!form.valid) {
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLocals, createFormRequest } from '$lib/test-utils';
import { createLocals, createFormRequest, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
@@ -41,6 +41,9 @@ function makeProfileRequest(overrides: Record<string, string> = {}) {
return createFormRequest({ ...validFields, ...overrides });
}
const staff = () => createLocals(mockUser({ roleId: 3 }) as any);
const admin = () => createLocals(mockUser({ roleId: 2 }) as any);
describe('profiles admin page server', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -73,7 +76,10 @@ describe('profiles admin page server', () => {
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email });
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
const result = await actions.upsert({
request: makeProfileRequest(),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } });
@@ -86,7 +92,10 @@ describe('profiles admin page server', () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
const result = await actions.upsert({
request: makeProfileRequest(),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields });
@@ -96,7 +105,10 @@ describe('profiles admin page server', () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockRejectedValue({ code: 'P2002' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
const result = await actions.upsert({
request: makeProfileRequest(),
locals: staff()
} as any);
expect(result).toEqual({
success: false,
@@ -119,7 +131,7 @@ describe('profiles admin page server', () => {
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ email: 'new@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserUpdate).toHaveBeenCalledWith({
@@ -142,7 +154,7 @@ describe('profiles admin page server', () => {
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindFirst).not.toHaveBeenCalled();
@@ -159,7 +171,7 @@ describe('profiles admin page server', () => {
mockUserFindFirst.mockResolvedValue({ id: 'user-2' });
const request = makeProfileRequest({ email: 'taken@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({
success: false,
@@ -181,7 +193,7 @@ describe('profiles admin page server', () => {
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileUpdate).toHaveBeenCalledWith({
@@ -194,7 +206,7 @@ describe('profiles admin page server', () => {
describe('upsert - validation', () => {
it('returns field errors for invalid input', async () => {
const request = makeProfileRequest({ email: 'not-an-email' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result.success).toBe(false);
expect(result.action).toBe('upsert');
@@ -205,7 +217,7 @@ describe('profiles admin page server', () => {
mockProfileFindUnique.mockResolvedValue(null);
const request = makeProfileRequest({ id: 'missing' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' });
});
@@ -215,7 +227,7 @@ describe('profiles admin page server', () => {
it('deletes the profile by id', async () => {
mockProfileDelete.mockResolvedValue({});
const locals = createLocals();
const locals = admin();
const req = createFormRequest({ id: 'p1' });
const result = await actions.delete({ request: req, locals } as any);
@@ -224,7 +236,7 @@ describe('profiles admin page server', () => {
});
it('returns an error when id is missing', async () => {
const locals = createLocals();
const locals = admin();
const result = await actions.delete({ request: createFormRequest({}), locals } as any);
expect(result).toEqual({ success: false, error: 'Missing id' });
@@ -0,0 +1,84 @@
import { describe, it, expect, vi } from 'vitest';
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();
type CrudActions = {
upsert: (event: unknown) => Promise<unknown>;
delete: (event: unknown) => Promise<unknown>;
};
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]
];
describe('admin CRUD action role guards', () => {
for (const [name, actions] of pages) {
it(`${name}: upsert rejects an anonymous request`, async () => {
const result = await actions.upsert({
request: createFormRequest({ name: 'x' }),
locals: anonymous()
});
expect(result).toEqual({ success: false, action: 'upsert', error: 'Not authorized' });
});
it(`${name}: upsert rejects a regular user`, async () => {
const result = await actions.upsert({
request: createFormRequest({ name: 'x' }),
locals: user()
});
expect(result).toEqual({ success: false, action: 'upsert', error: 'Not authorized' });
});
it(`${name}: delete rejects a staff (non-admin) user`, async () => {
const result = await actions.delete({
request: createFormRequest({ id: '1' }),
locals: staff()
});
expect(result).toEqual({ success: false, error: 'Not authorized' });
});
}
});
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,5 +1,6 @@
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';
@@ -16,7 +17,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
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(SolfeggioSchema));
if (!form.valid) {
@@ -48,7 +53,11 @@ export const actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
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' };
@@ -1,5 +1,6 @@
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';
@@ -15,7 +16,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
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) {
@@ -43,7 +48,11 @@ export const actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
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' };
@@ -1,6 +1,7 @@
import type { PageServerLoad, Actions } from './$types';
import { prisma } from '$lib/db';
import { sendRegistrationInvite } from '$lib/server/registration-invite';
import { isAdmin, isStaff, roleId as viewerRoleId } from '$lib/server/roles';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
@@ -12,16 +13,8 @@ const UserSchema = z.object({
roleId: z.coerce.number().int().min(1)
});
async function myRoleId(locals: App.Locals): Promise<number> {
const user = await prisma.user.findUnique({
where: { id: locals.user?.id },
select: { roleId: true }
});
return user?.roleId ?? 4;
}
export const load: PageServerLoad = async ({ locals }) => {
const roleId = await myRoleId(locals);
const myRole = viewerRoleId(locals);
const [users, roles] = await Promise.all([
prisma.user.findMany({
@@ -31,7 +24,7 @@ export const load: PageServerLoad = async ({ locals }) => {
prisma.userRole.findMany({ orderBy: { id: 'asc' } })
]);
const roleOptions = roles.filter((role) => role.id >= roleId);
const roleOptions = roles.filter((role) => role.id >= myRole);
return {
records: users as unknown as Record<string, unknown>[],
@@ -41,6 +34,10 @@ export const load: PageServerLoad = async ({ locals }) => {
export const actions: 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(UserSchema));
if (!form.valid) {
@@ -57,7 +54,7 @@ export const actions: Actions = {
roleId: form.data.roleId
};
const myRole = await myRoleId(locals);
const myRole = viewerRoleId(locals);
if (myRole > 1 && data.roleId < myRole) {
return {
success: false,
@@ -104,7 +101,11 @@ export const actions: Actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
delete: async ({ request, locals }) => {
if (!isAdmin(locals)) {
return { success: false, action: 'delete', error: 'Not authorized' };
}
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
@@ -25,7 +25,6 @@ vi.mock('$lib/server/registration-invite', () => ({
import { prisma } from '$lib/db';
import { actions } from './+page.server';
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const userCreate = prisma.user.create as ReturnType<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
@@ -44,14 +43,13 @@ function upsertRequest(overrides: Record<string, string> = {}) {
describe('users admin page server upsert', () => {
beforeEach(() => {
vi.clearAllMocks();
userFindUnique.mockResolvedValue({ roleId: 1 });
userCreate.mockResolvedValue({ id: 'new-user' });
userUpdate.mockResolvedValue({ id: 'user-1' });
sendRegistrationInvite.mockResolvedValue(undefined);
});
it('marks a created user verified and emails a set-password link', async () => {
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await invokeUpsert(upsertRequest(), locals);
expect(result).toEqual({ success: true, action: 'upsert' });
@@ -66,7 +64,7 @@ describe('users admin page server upsert', () => {
});
it('does not email a registration link when updating an existing user', async () => {
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await invokeUpsert(upsertRequest({ id: 'user-9' }), locals);
expect(result).toEqual({ success: true, action: 'upsert' });
@@ -78,7 +76,7 @@ describe('users admin page server upsert', () => {
it('still succeeds when the registration email fails to send', async () => {
sendRegistrationInvite.mockRejectedValue(new Error('resend down'));
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await invokeUpsert(upsertRequest(), locals);
expect(result).toEqual({ success: true, action: 'upsert' });
@@ -86,9 +84,7 @@ describe('users admin page server upsert', () => {
});
it('blocks assigning a role with more privileges than the actor', async () => {
userFindUnique.mockResolvedValue({ roleId: 2 });
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 2 }) as any);
const result = await invokeUpsert(upsertRequest({ roleId: '1' }), locals);
expect(result).toEqual({