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} - + startEditTag(tag)} aria-label={m.admin_btn_edit_tag_label()} diff --git a/frontend/src/routes/admin/TagsTab.svelte.spec.ts b/frontend/src/routes/admin/TagsTab.svelte.spec.ts new file mode 100644 index 00000000..1f13f9c8 --- /dev/null +++ b/frontend/src/routes/admin/TagsTab.svelte.spec.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import TagsTab from './TagsTab.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +afterEach(cleanup); + +const makeTags = () => [ + { id: 't1', name: 'Familie' }, + { id: 't2', name: 'Urlaub' } +]; + +describe('TagsTab — action buttons', () => { + it('no element with opacity-0 class exists (regression guard: buttons must not be hover-only)', () => { + // Before fix: the action buttons container had opacity-0 group-hover:opacity-100 + // which hides buttons on touch devices. After fix: that class is gone entirely. + const { container } = render(TagsTab, { tags: makeTags() }); + + const hiddenElements = container.querySelectorAll('.opacity-0'); + expect(hiddenElements.length).toBe(0); + }); +}); diff --git a/frontend/src/routes/admin/layout.server.spec.ts b/frontend/src/routes/admin/layout.server.spec.ts new file mode 100644 index 00000000..eb5f39c8 --- /dev/null +++ b/frontend/src/routes/admin/layout.server.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { load } from './+layout.server'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); + +import { createApiClient } from '$lib/api.server'; + +function mockApi(users: unknown[], groups: unknown[], tags: unknown[]) { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ response: { ok: true }, data: users }) + .mockResolvedValueOnce({ response: { ok: true }, data: groups }) + .mockResolvedValueOnce({ response: { ok: true }, data: tags }) + } as ReturnType); +} + +const adminUser = { + groups: [{ permissions: ['ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'] }] +}; +const tagAdminUser = { groups: [{ permissions: ['ADMIN_TAG'] }] }; +const noPermUser = { groups: [{ permissions: ['READ_ALL'] }] }; + +beforeEach(() => vi.clearAllMocks()); + +describe('admin layout load — permission check', () => { + it('throws 403 when user has no admin permission', async () => { + await expect( + load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: noPermUser } }) + ).rejects.toMatchObject({ status: 403 }); + }); + + it('throws 403 when user is undefined', async () => { + await expect( + load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: undefined } }) + ).rejects.toMatchObject({ status: 403 }); + }); + + it('throws 403 when user has no groups', async () => { + await expect( + load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: { groups: [] } } }) + ).rejects.toMatchObject({ status: 403 }); + }); + + it('allows access for a user with ADMIN_TAG only', async () => { + mockApi([], [], []); + await expect( + load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: tagAdminUser } }) + ).resolves.toBeDefined(); + }); + + it('returns entity counts and permission flags for a full admin', async () => { + mockApi( + [{ id: 'u1' }, { id: 'u2' }], + [{ id: 'g1' }], + [{ id: 't1' }, { id: 't2' }, { id: 't3' }] + ); + + const result = await load({ + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: adminUser } + }); + + expect(result.userCount).toBe(2); + expect(result.groupCount).toBe(1); + expect(result.tagCount).toBe(3); + expect(result.canManageUsers).toBe(true); + expect(result.canManageTags).toBe(true); + expect(result.canManageGroups).toBe(true); + expect(result.canRunMaintenance).toBe(true); + }); +});