From fabb517d0bb962d4fb0ca8dac05968710c44afbb Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 30 Mar 2026 01:44:52 +0200 Subject: [PATCH] =?UTF-8?q?refactor(admin):=20phase=207=20=E2=80=94=20dele?= =?UTF-8?q?te=20old=20tab=20components=20and=20page.server.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove UsersTab, GroupsTab, TagsTab, SystemTab and their specs; delete the monolithic +page.server.ts with shared load + 6 form actions (all now handled by dedicated sub-route servers under users/, groups/, tags/). Add delete action and confirmation button to user edit panel. Fix test to query the edit form by id rather than the first form in DOM. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/+page.server.ts | 116 --------- frontend/src/routes/admin/GroupsTab.svelte | 221 ------------------ .../src/routes/admin/GroupsTab.svelte.spec.ts | 27 --- frontend/src/routes/admin/SystemTab.svelte | 72 ------ frontend/src/routes/admin/TagsTab.svelte | 127 ---------- .../src/routes/admin/TagsTab.svelte.spec.ts | 23 -- frontend/src/routes/admin/UsersTab.svelte | 122 ---------- frontend/src/routes/admin/page.server.spec.ts | 77 ------ .../routes/admin/users/[id]/+page.server.ts | 16 +- .../src/routes/admin/users/[id]/+page.svelte | 19 ++ .../admin/users/[id]/page.svelte.spec.ts | 2 +- 11 files changed, 35 insertions(+), 787 deletions(-) delete mode 100644 frontend/src/routes/admin/+page.server.ts delete mode 100644 frontend/src/routes/admin/GroupsTab.svelte delete mode 100644 frontend/src/routes/admin/GroupsTab.svelte.spec.ts delete mode 100644 frontend/src/routes/admin/SystemTab.svelte delete mode 100644 frontend/src/routes/admin/TagsTab.svelte delete mode 100644 frontend/src/routes/admin/TagsTab.svelte.spec.ts delete mode 100644 frontend/src/routes/admin/UsersTab.svelte delete mode 100644 frontend/src/routes/admin/page.server.spec.ts 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()}

-
- - - - - - - - - - - {#each groups as group (group.id)} - - {#if editingGroupId === group.id} - - - {:else} - - - - - {/if} - - {/each} - -
{m.admin_col_name()}{m.admin_col_permissions()}{m.admin_col_actions()}
-
- async ({ update }) => { - await update(); - cancelEditGroup(); - }} - class="flex w-full flex-col items-start gap-4 sm:flex-row" - > - - -
- -
- -
- {#each availablePermissions as perm (perm)} - - {/each} -
- -
- - -
-
-
- {group.name} - -
- {#each group.permissions as perm (perm)} - - {perm === 'ADMIN' ? '⚙ ' : ''}{perm} - - {/each} -
-
-
- - -
{ - if (!confirm(m.admin_group_delete_confirm())) { - cancel(); - } - return async ({ update }) => { - await update(); - }; - }} - > - - -
-
-
- - -
-

- {m.admin_section_new_group()} -

-
-
- -
- -
- {#each availablePermissions as perm (perm)} - - {/each} -
- - -
-
-
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} -
    - async ({ update }) => { - await update(); - cancelEditTag(); - }} - class="flex flex-1 items-center gap-2" - > - - - - -
    - {:else} - - {tag.name} - -
    - -
    { - if (!confirm(m.admin_tag_delete_confirm())) { - cancel(); - } - return async ({ update }) => { - await update(); - }; - }} - class="inline" - > - - -
    -
    - {/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_section_users()}

- - - - - {m.admin_btn_new_user()} - -
- -
- - - - - - - - - - - {#each users as user (user.id)} - - - - - - - {/each} - -
{m.admin_col_login()}{m.admin_col_full_name()}{m.admin_col_groups()}{m.admin_col_actions()}
- {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} -
-
-
- - {m.btn_edit()} - - -
{ - if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) { - cancel(); - } - return async ({ update }) => { - await update(); - }; - }} - class="flex items-center" - > - - -
-
-
-
-
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 })}

+
{ + if (!confirm(m.admin_user_delete_confirm({ username: data.editUser.username }))) { + cancel(); + } + return async ({ update }) => { + await update(); + }; + }} + > + +
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');