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">
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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">
|
||||
{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()}
|
||||
|
||||
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