From cefcdf30725525c133da28a31740fbe6bdd725f9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 30 Mar 2026 01:10:51 +0200 Subject: [PATCH] feat(admin): add layout server auth guard and Phase 1 hotfixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - +layout.server.ts: auth guard (throws 403 for non-admin) with granular permission flags and entity counts for EntityNav - GroupsTab: add ⚙ prefix to ADMIN badge (WCAG 1.4.1, non-color indicator) - TagsTab: remove opacity-0 from action buttons (hidden on touch devices) - +layout.svelte: remove unused isSystem derived Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/+layout.server.ts | 40 +++++++++++ frontend/src/routes/admin/+layout.svelte | 3 - frontend/src/routes/admin/GroupsTab.svelte | 2 +- .../src/routes/admin/GroupsTab.svelte.spec.ts | 27 +++++++ frontend/src/routes/admin/TagsTab.svelte | 2 +- .../src/routes/admin/TagsTab.svelte.spec.ts | 23 ++++++ .../src/routes/admin/layout.server.spec.ts | 72 +++++++++++++++++++ 7 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 frontend/src/routes/admin/+layout.server.ts create mode 100644 frontend/src/routes/admin/GroupsTab.svelte.spec.ts create mode 100644 frontend/src/routes/admin/TagsTab.svelte.spec.ts create mode 100644 frontend/src/routes/admin/layout.server.spec.ts 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} -
+