feat(admin): add layout server auth guard and Phase 1 hotfixes

- +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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-30 01:10:51 +02:00
committed by marcel
parent 9cacc6079e
commit cefcdf3072
7 changed files with 164 additions and 5 deletions

View File

@@ -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')
};
}

View File

@@ -1,10 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import EntityNav from './EntityNav.svelte';
let { data, children } = $props();
const isSystem = $derived(page.url.pathname.startsWith('/admin/system'));
</script>
<svelte:head>

View File

@@ -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}
</span>
{/each}
</div>

View File

@@ -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(/[⚙★⚠!]/);
});
});

View File

@@ -76,7 +76,7 @@ function cancelEditTag() {
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
{tag.name}
</span>
<div class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex items-center gap-2">
<button
onclick={() => startEditTag(tag)}
aria-label={m.admin_btn_edit_tag_label()}

View File

@@ -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);
});
});

View File

@@ -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<typeof createApiClient>);
}
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);
});
});