diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts
deleted file mode 100644
index b58bf3c4..00000000
--- a/frontend/src/routes/admin/+page.server.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { error, fail } from '@sveltejs/kit';
-import { createApiClient } from '$lib/api.server';
-import { getErrorMessage } from '$lib/errors';
-
-type ApiResult = { response: Response; error?: unknown };
-
-function toActionResult(result: ApiResult) {
- if (!result.response.ok) {
- const code = (result.error as { code?: string } | undefined)?.code;
- return fail(result.response.status, { success: false, message: getErrorMessage(code) });
- }
- return { success: true };
-}
-
-export async function load({ fetch, locals }) {
- const user = locals.user;
- const hasAdmin = user?.groups?.some((g: { permissions: string[] }) =>
- g.permissions.includes('ADMIN')
- );
- if (!hasAdmin) 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 {
- users: usersResult.data ?? [],
- groups: groupsResult.data ?? [],
- tags: tagsResult.data ?? []
- };
-}
-
-export const actions = {
- deleteUser: async ({ request, fetch }) => {
- const data = await request.formData();
- const id = data.get('id') as string;
- const api = createApiClient(fetch);
-
- const result = await api.DELETE('/api/users/{id}', {
- params: { path: { id } }
- });
-
- return toActionResult(result);
- },
-
- updateTag: async ({ request, fetch }) => {
- const data = await request.formData();
- const id = data.get('id') as string;
- const api = createApiClient(fetch);
-
- const result = await api.PUT('/api/tags/{id}', {
- params: { path: { id } },
- body: { name: data.get('name') as string }
- });
-
- return toActionResult(result);
- },
-
- deleteTag: async ({ request, fetch }) => {
- const data = await request.formData();
- const id = data.get('id') as string;
- const api = createApiClient(fetch);
-
- const result = await api.DELETE('/api/tags/{id}', {
- params: { path: { id } }
- });
-
- return toActionResult(result);
- },
-
- createGroup: async ({ request, fetch }) => {
- const data = await request.formData();
- const api = createApiClient(fetch);
-
- const result = await api.POST('/api/groups', {
- body: {
- name: data.get('name') as string,
- permissions: data.getAll('permissions') as string[]
- }
- });
-
- return toActionResult(result);
- },
-
- updateGroup: async ({ request, fetch }) => {
- const data = await request.formData();
- const id = data.get('id') as string;
- const api = createApiClient(fetch);
-
- const result = await api.PATCH('/api/groups/{id}', {
- params: { path: { id } },
- body: {
- name: data.get('name') as string,
- permissions: data.getAll('permissions') as string[]
- }
- });
-
- return toActionResult(result);
- },
-
- deleteGroup: async ({ request, fetch }) => {
- const data = await request.formData();
- const id = data.get('id') as string;
- const api = createApiClient(fetch);
-
- const result = await api.DELETE('/api/groups/{id}', {
- params: { path: { id } }
- });
-
- return toActionResult(result);
- }
-};
diff --git a/frontend/src/routes/admin/GroupsTab.svelte b/frontend/src/routes/admin/GroupsTab.svelte
deleted file mode 100644
index 0f5da793..00000000
--- a/frontend/src/routes/admin/GroupsTab.svelte
+++ /dev/null
@@ -1,221 +0,0 @@
-
-
-
-
-
{m.admin_section_groups()}
-
-
-
-
-
- | {m.admin_col_name()} |
- {m.admin_col_permissions()} |
- {m.admin_col_actions()} |
-
-
-
- {#each groups as group (group.id)}
-
- {#if editingGroupId === group.id}
-
- |
-
- |
- {:else}
-
-
- {group.name}
- |
-
-
- {#each group.permissions as perm (perm)}
-
- {perm === 'ADMIN' ? '⚙ ' : ''}{perm}
-
- {/each}
-
- |
-
-
-
-
-
-
- |
- {/if}
-
- {/each}
-
-
-
-
-
-
- {m.admin_section_new_group()}
-
-
-
-
diff --git a/frontend/src/routes/admin/GroupsTab.svelte.spec.ts b/frontend/src/routes/admin/GroupsTab.svelte.spec.ts
deleted file mode 100644
index ad9cc66e..00000000
--- a/frontend/src/routes/admin/GroupsTab.svelte.spec.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-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/SystemTab.svelte b/frontend/src/routes/admin/SystemTab.svelte
deleted file mode 100644
index bc4301ac..00000000
--- a/frontend/src/routes/admin/SystemTab.svelte
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
{m.admin_system_backfill_heading()}
-
{m.admin_system_backfill_description()}
-
- {#if backfillResult !== null}
-
- {m.admin_system_backfill_success({ count: backfillResult })}
-
- {/if}
-
-
-
-
- {m.admin_system_backfill_hashes_heading()}
-
-
{m.admin_system_backfill_hashes_description()}
-
- {#if backfillHashesResult !== null}
-
- {m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
-
- {/if}
-
diff --git a/frontend/src/routes/admin/TagsTab.svelte b/frontend/src/routes/admin/TagsTab.svelte
deleted file mode 100644
index 1b41558f..00000000
--- a/frontend/src/routes/admin/TagsTab.svelte
+++ /dev/null
@@ -1,127 +0,0 @@
-
-
-
-
-
{m.admin_section_tags()}
-
- {m.admin_tags_warning()}
-
-
-
-
- {#each tags as tag (tag.id)}
- -
- {#if editingTagId === tag.id}
-
- {:else}
-
- {tag.name}
-
-
-
-
-
- {/if}
-
- {/each}
-
-
diff --git a/frontend/src/routes/admin/TagsTab.svelte.spec.ts b/frontend/src/routes/admin/TagsTab.svelte.spec.ts
deleted file mode 100644
index 1f13f9c8..00000000
--- a/frontend/src/routes/admin/TagsTab.svelte.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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/UsersTab.svelte b/frontend/src/routes/admin/UsersTab.svelte
deleted file mode 100644
index 32e4d5c6..00000000
--- a/frontend/src/routes/admin/UsersTab.svelte
+++ /dev/null
@@ -1,122 +0,0 @@
-
-
-
-
-
-
-
-
-
- | {m.admin_col_login()} |
- {m.admin_col_full_name()} |
- {m.admin_col_groups()} |
- {m.admin_col_actions()} |
-
-
-
- {#each users as user (user.id)}
-
- |
- {user.username}
- |
-
- {#if user.firstName || user.lastName}
- {user.firstName ?? ''} {user.lastName ?? ''}
- {:else}
- –
- {/if}
- |
-
-
- {#if user.groups && user.groups.length > 0}
- {#each user.groups as group (group.id)}
-
- {group.name}
-
- {/each}
- {:else}
- {m.admin_no_groups()}
- {/if}
-
- |
-
-
- |
-
- {/each}
-
-
-
-
diff --git a/frontend/src/routes/admin/page.server.spec.ts b/frontend/src/routes/admin/page.server.spec.ts
deleted file mode 100644
index a8edb053..00000000
--- a/frontend/src/routes/admin/page.server.spec.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { describe, expect, it, vi, beforeEach } from 'vitest';
-import { load } from './+page.server';
-
-vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
-
-import { createApiClient } from '$lib/api.server';
-
-const adminUser = { groups: [{ permissions: ['ADMIN'] }] };
-const readOnlyUser = { groups: [{ permissions: ['READ_ALL'] }] };
-
-function mockApiReturning(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);
-}
-
-beforeEach(() => vi.clearAllMocks());
-
-// ─── permission check ─────────────────────────────────────────────────────────
-
-describe('admin 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: readOnlyUser } })
- ).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 });
- });
-});
-
-// ─── happy path ───────────────────────────────────────────────────────────────
-
-describe('admin load — happy path', () => {
- it('returns users, groups, and tags for an admin user', async () => {
- mockApiReturning(
- [{ id: 'u1', username: 'alice' }],
- [{ id: 'g1', name: 'Editors' }],
- [{ id: 't1', name: 'Familie' }]
- );
-
- const result = await load({
- fetch: vi.fn() as unknown as typeof fetch,
- locals: { user: adminUser }
- });
-
- expect(result.users).toHaveLength(1);
- expect(result.groups).toHaveLength(1);
- expect(result.tags).toHaveLength(1);
- });
-
- it('returns empty arrays when API returns no data', async () => {
- mockApiReturning([], [], []);
-
- const result = await load({
- fetch: vi.fn() as unknown as typeof fetch,
- locals: { user: adminUser }
- });
-
- expect(result.users).toEqual([]);
- expect(result.groups).toEqual([]);
- expect(result.tags).toEqual([]);
- });
-});
diff --git a/frontend/src/routes/admin/users/[id]/+page.server.ts b/frontend/src/routes/admin/users/[id]/+page.server.ts
index 6d99c407..14c719b6 100644
--- a/frontend/src/routes/admin/users/[id]/+page.server.ts
+++ b/frontend/src/routes/admin/users/[id]/+page.server.ts
@@ -1,4 +1,4 @@
-import { error, fail } from '@sveltejs/kit';
+import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
@@ -63,5 +63,19 @@ export const actions: Actions = {
}
return { success: true };
+ },
+
+ delete: async ({ params, fetch }) => {
+ const api = createApiClient(fetch);
+ const result = await api.DELETE('/api/users/{id}', {
+ params: { path: { id: params.id } }
+ });
+
+ if (!result.response.ok) {
+ const code = (result.error as unknown as { code?: string })?.code;
+ return fail(result.response.status, { error: getErrorMessage(code) });
+ }
+
+ throw redirect(303, '/admin/users');
}
};
diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte
index e2201990..03ac297a 100644
--- a/frontend/src/routes/admin/users/[id]/+page.svelte
+++ b/frontend/src/routes/admin/users/[id]/+page.svelte
@@ -16,6 +16,25 @@ const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string })
{m.admin_user_edit_heading({ username: data.editUser.username })}
+
diff --git a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts
index 43fca9b1..53dce8b4 100644
--- a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts
+++ b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts
@@ -96,7 +96,7 @@ describe('Admin edit user page – rendering', () => {
it('includes pre-selected group ids in FormData at submit time (guards against groupIds being empty)', async () => {
render(Page, { data: baseData, form: null });
- const form = document.querySelector('form')!;
+ const form = document.querySelector('form#edit-user-form')!;
const formData = new FormData(form);
expect(formData.getAll('groupIds')).toContain('g1');
expect(formData.getAll('groupIds')).not.toContain('g2');