diff --git a/src/lib/svelte-admin/index.ts b/src/lib/svelte-admin/index.ts index f20f9bd..b3cce53 100644 --- a/src/lib/svelte-admin/index.ts +++ b/src/lib/svelte-admin/index.ts @@ -3,4 +3,5 @@ export { ROLES, hasRole, canAccessAdmin } from './roles'; export { fieldNameToLabel, resolveFieldValue } from './introspect'; export { getDelegate, parseId } from './prisma-delegate'; export { extractFormData } from './form-data'; -export { getModelConfig, getAllModelConfigs } from './registry'; +export { getModelConfig, getAllModelConfigs, getSerializableModelConfigs } from './registry'; +export type { SerializableModelConfig } from './registry'; diff --git a/src/lib/svelte-admin/registry.ts b/src/lib/svelte-admin/registry.ts index b4b895a..dfe200f 100644 --- a/src/lib/svelte-admin/registry.ts +++ b/src/lib/svelte-admin/registry.ts @@ -267,3 +267,24 @@ export function getModelConfig(slug: string): AdminModelConfig | undefined { export function getAllModelConfigs(): AdminModelConfig[] { return models; } + +export type SerializableModelConfig = { + slug: string; + displayName: string; + listFields: string[]; + accessFilterResult: Record | undefined; +}; + +export function getSerializableModelConfigs( + viewerRoleId: number, + excludeSlugs?: string[] +): SerializableModelConfig[] { + return models + .filter((m) => !excludeSlugs?.includes(m.slug)) + .map((m) => ({ + slug: m.slug, + displayName: m.displayName, + listFields: m.listFields, + accessFilterResult: m.accessFilter?.(viewerRoleId) + })); +} diff --git a/src/routes/(protected)/admin/+error.svelte b/src/routes/(protected)/admin/+error.svelte new file mode 100644 index 0000000..52c7b81 --- /dev/null +++ b/src/routes/(protected)/admin/+error.svelte @@ -0,0 +1,25 @@ + + + +
+

+ Admin Error ({$page.status}) +

+

+ {#if $page.error?.message} + {$page.error.message} + {:else} + An unexpected error occurred while loading this admin page. + {/if} +

+ + Back to Admin + +
+
diff --git a/src/routes/(protected)/admin/+page.server.ts b/src/routes/(protected)/admin/+page.server.ts index 4c57c8e..20a8a46 100644 --- a/src/routes/(protected)/admin/+page.server.ts +++ b/src/routes/(protected)/admin/+page.server.ts @@ -1,7 +1,8 @@ import type { PageServerLoad } from './$types'; -import { getAllModelConfigs } from '$lib/svelte-admin/registry'; +import { getSerializableModelConfigs } from '$lib/svelte-admin/registry'; -export const load: PageServerLoad = async () => { - const models = getAllModelConfigs().filter((m) => m.slug !== 'license-keys'); +export const load: PageServerLoad = async ({ locals }) => { + const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4; + const models = getSerializableModelConfigs(viewerRoleId, ['license-keys']); return { models }; }; diff --git a/src/routes/(protected)/admin/page.server.spec.ts b/src/routes/(protected)/admin/page.server.spec.ts new file mode 100644 index 0000000..08e1883 --- /dev/null +++ b/src/routes/(protected)/admin/page.server.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getSerializableModelConfigs } from '$lib/svelte-admin/registry'; +import { createLocals, mockUser } from '$lib/test-utils'; + +vi.mock('$lib/db', () => ({})); + +import { load } from './+page.server'; + +function localsWithRole(roleId: number) { + return createLocals(mockUser({ roleId }) as any); +} + +describe('getSerializableModelConfigs', () => { + it('returns only serializable fields (no functions)', () => { + const models = getSerializableModelConfigs(1); + const serialized = JSON.stringify(models); + expect(serialized).toBeDefined(); + + for (const model of models) { + expect(typeof model.slug).toBe('string'); + expect(typeof model.displayName).toBe('string'); + expect(Array.isArray(model.listFields)).toBe(true); + expect( + typeof model.accessFilterResult === 'object' || model.accessFilterResult === undefined + ).toBe(true); + } + }); + + it('evaluates accessFilter for SUPER (roleId 1) — returns undefined for users', () => { + const models = getSerializableModelConfigs(1); + const users = models.find((m) => m.slug === 'users')!; + expect(users.accessFilterResult).toBeUndefined(); + }); + + it('evaluates accessFilter for ADMIN (roleId 2) — restricts users to STAFF+', () => { + const models = getSerializableModelConfigs(2); + const users = models.find((m) => m.slug === 'users')!; + expect(users.accessFilterResult).toEqual({ roleId: { gte: 3 } }); + }); + + it('evaluates accessFilter for STAFF (roleId 3) — restricts users to USER-only', () => { + const models = getSerializableModelConfigs(3); + const users = models.find((m) => m.slug === 'users')!; + expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } }); + }); + + it('models without accessFilter have undefined accessFilterResult', () => { + const models = getSerializableModelConfigs(1); + const roles = models.find((m) => m.slug === 'user-roles')!; + expect(roles.accessFilterResult).toBeUndefined(); + }); + + it('excludes specified slugs', () => { + const models = getSerializableModelConfigs(1, ['license-keys', 'users']); + expect(models.find((m) => m.slug === 'license-keys')).toBeUndefined(); + expect(models.find((m) => m.slug === 'users')).toBeUndefined(); + expect(models.length).toBeGreaterThan(0); + }); +}); + +describe('admin index page load', () => { + it('returns models with evaluated accessFilter results', async () => { + const result = (await load({ locals: localsWithRole(1) } as any)) as any; + expect(result.models).toBeDefined(); + expect(Array.isArray(result.models)).toBe(true); + + const users = result.models.find((m: any) => m.slug === 'users'); + expect(users).toBeDefined(); + expect(users.accessFilterResult).toBeUndefined(); + }); + + it('returns different accessFilter results per role', async () => { + const superResult = (await load({ locals: localsWithRole(1) } as any)) as any; + const adminResult = (await load({ locals: localsWithRole(2) } as any)) as any; + const staffResult = (await load({ locals: localsWithRole(3) } as any)) as any; + + const superUsers = superResult.models.find((m: any) => m.slug === 'users'); + const adminUsers = adminResult.models.find((m: any) => m.slug === 'users'); + const staffUsers = staffResult.models.find((m: any) => m.slug === 'users'); + + expect(superUsers.accessFilterResult).toBeUndefined(); + expect(adminUsers.accessFilterResult).toEqual({ roleId: { gte: 3 } }); + expect(staffUsers.accessFilterResult).toEqual({ roleId: { gt: 3 } }); + }); + + it('excludes license-keys model', async () => { + const result = (await load({ locals: localsWithRole(1) } as any)) as any; + const licenseKeys = result.models.find((m: any) => m.slug === 'license-keys'); + expect(licenseKeys).toBeUndefined(); + }); + + it('returns fully JSON-serializable data (no functions)', async () => { + const result = (await load({ locals: localsWithRole(2) } as any)) as any; + const serialized = JSON.stringify(result); + const parsed = JSON.parse(serialized); + expect(parsed.models).toBeDefined(); + expect(parsed.models.length).toBeGreaterThan(0); + }); + + it('defaults to roleId 4 when user has no roleId', async () => { + const result = (await load({ + locals: createLocals(mockUser({ roleId: null }) as any) + } as any)) as any; + const users = result.models.find((m: any) => m.slug === 'users'); + expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } }); + }); +});