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:
40
frontend/src/routes/admin/+layout.server.ts
Normal file
40
frontend/src/routes/admin/+layout.server.ts
Normal 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')
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
|
||||||
import EntityNav from './EntityNav.svelte';
|
import EntityNav from './EntityNav.svelte';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
const isSystem = $derived(page.url.pathname.startsWith('/admin/system'));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ function cancelEditGroup() {
|
|||||||
? 'border-red-100 bg-red-50 text-red-700'
|
? 'border-red-100 bg-red-50 text-red-700'
|
||||||
: 'border-line bg-muted text-ink-2'}"
|
: 'border-line bg-muted text-ink-2'}"
|
||||||
>
|
>
|
||||||
{perm}
|
{perm === 'ADMIN' ? '⚙ ' : ''}{perm}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
27
frontend/src/routes/admin/GroupsTab.svelte.spec.ts
Normal file
27
frontend/src/routes/admin/GroupsTab.svelte.spec.ts
Normal 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(/[⚙★⚠!]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,7 +76,7 @@ function cancelEditTag() {
|
|||||||
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={() => startEditTag(tag)}
|
onclick={() => startEditTag(tag)}
|
||||||
aria-label={m.admin_btn_edit_tag_label()}
|
aria-label={m.admin_btn_edit_tag_label()}
|
||||||
|
|||||||
23
frontend/src/routes/admin/TagsTab.svelte.spec.ts
Normal file
23
frontend/src/routes/admin/TagsTab.svelte.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
frontend/src/routes/admin/layout.server.spec.ts
Normal file
72
frontend/src/routes/admin/layout.server.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user