diff --git a/frontend/src/routes/admin/+layout.server.ts b/frontend/src/routes/admin/+layout.server.ts new file mode 100644 index 00000000..19405345 --- /dev/null +++ b/frontend/src/routes/admin/+layout.server.ts @@ -0,0 +1,40 @@ +import { error } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +type UserGroup = { permissions: string[] }; + +function hasPerm(user: { groups?: UserGroup[] } | undefined, perm: string): boolean { + return user?.groups?.some((g) => g.permissions.includes(perm)) ?? false; +} + +function hasAnyAdminPerm(user: { groups?: UserGroup[] } | undefined): boolean { + return ( + hasPerm(user, 'ADMIN') || + hasPerm(user, 'ADMIN_USER') || + hasPerm(user, 'ADMIN_TAG') || + hasPerm(user, 'ADMIN_PERMISSION') + ); +} + +export async function load({ fetch, locals }) { + const user = locals.user; + if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN')); + + const api = createApiClient(fetch); + const [usersResult, groupsResult, tagsResult] = await Promise.all([ + api.GET('/api/users'), + api.GET('/api/groups'), + api.GET('/api/tags') + ]); + + return { + userCount: (usersResult.data ?? []).length, + groupCount: (groupsResult.data ?? []).length, + tagCount: (tagsResult.data ?? []).length, + canManageUsers: hasPerm(user, 'ADMIN_USER'), + canManageTags: hasPerm(user, 'ADMIN_TAG'), + canManageGroups: hasPerm(user, 'ADMIN_PERMISSION'), + canRunMaintenance: hasPerm(user, 'ADMIN') + }; +} diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 13f98a76..6c9558dd 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -1,10 +1,7 @@ diff --git a/frontend/src/routes/admin/GroupsTab.svelte b/frontend/src/routes/admin/GroupsTab.svelte index 6eb22e53..0f5da793 100644 --- a/frontend/src/routes/admin/GroupsTab.svelte +++ b/frontend/src/routes/admin/GroupsTab.svelte @@ -126,7 +126,7 @@ function cancelEditGroup() { ? 'border-red-100 bg-red-50 text-red-700' : 'border-line bg-muted text-ink-2'}" > - {perm} + {perm === 'ADMIN' ? '⚙ ' : ''}{perm} {/each} diff --git a/frontend/src/routes/admin/GroupsTab.svelte.spec.ts b/frontend/src/routes/admin/GroupsTab.svelte.spec.ts new file mode 100644 index 00000000..ad9cc66e --- /dev/null +++ b/frontend/src/routes/admin/GroupsTab.svelte.spec.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import GroupsTab from './GroupsTab.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +afterEach(cleanup); + +const makeGroups = () => [ + { id: 'g1', name: 'Administrators', permissions: ['ADMIN', 'WRITE_ALL'] }, + { id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] } +]; + +describe('GroupsTab — ADMIN permission badge (WCAG 1.4.1)', () => { + it('ADMIN badge has a non-color indicator so it is distinguishable without color', () => { + // The ADMIN badge must not rely on color alone (WCAG 1.4.1). + // It must contain a visible non-color indicator: a text prefix such as ⚙. + const { container } = render(GroupsTab, { groups: makeGroups() }); + + const adminBadge = Array.from(container.querySelectorAll('span')).find((el) => + el.textContent?.includes('ADMIN') + ); + expect(adminBadge).toBeDefined(); + // Badge text must include a non-color prefix character + expect(adminBadge!.textContent).toMatch(/[⚙★⚠!]/); + }); +}); diff --git a/frontend/src/routes/admin/TagsTab.svelte b/frontend/src/routes/admin/TagsTab.svelte index 01a1fec0..1b41558f 100644 --- a/frontend/src/routes/admin/TagsTab.svelte +++ b/frontend/src/routes/admin/TagsTab.svelte @@ -76,7 +76,7 @@ function cancelEditTag() { {tag.name} -
+