From 8197db2c14eafa6aac4bd59636dc21d56fa1f41b Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 30 Mar 2026 01:26:45 +0200 Subject: [PATCH] feat(admin/groups): add groups entity with master-detail sub-routes Creates the full groups section under /admin/groups/: - +layout.server.ts: loads groups list via GET /api/groups - GroupsListPanel.svelte: left list panel (name + permission count, active state) - +layout.svelte: composes list panel + children slot - +page.svelte: empty selection prompt - [id]/+page.server.ts: update (PATCH) and delete actions - [id]/+page.svelte: edit detail panel with Standard/Administrative permission sections - new/+page.svelte and +page.server.ts: create group form Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 11 ++ frontend/messages/en.json | 11 ++ frontend/messages/es.json | 11 ++ .../src/routes/admin/groups/+layout.server.ts | 8 + .../src/routes/admin/groups/+layout.svelte | 12 ++ frontend/src/routes/admin/groups/+page.svelte | 7 + .../admin/groups/GroupsListPanel.svelte | 64 ++++++++ .../routes/admin/groups/[id]/+page.server.ts | 47 ++++++ .../src/routes/admin/groups/[id]/+page.svelte | 144 ++++++++++++++++++ .../routes/admin/groups/layout.server.spec.ts | 41 +++++ .../routes/admin/groups/layout.svelte.spec.ts | 79 ++++++++++ .../routes/admin/groups/new/+page.server.ts | 25 +++ .../src/routes/admin/groups/new/+page.svelte | 117 ++++++++++++++ 13 files changed, 577 insertions(+) create mode 100644 frontend/src/routes/admin/groups/+layout.server.ts create mode 100644 frontend/src/routes/admin/groups/+layout.svelte create mode 100644 frontend/src/routes/admin/groups/+page.svelte create mode 100644 frontend/src/routes/admin/groups/GroupsListPanel.svelte create mode 100644 frontend/src/routes/admin/groups/[id]/+page.server.ts create mode 100644 frontend/src/routes/admin/groups/[id]/+page.svelte create mode 100644 frontend/src/routes/admin/groups/layout.server.spec.ts create mode 100644 frontend/src/routes/admin/groups/layout.svelte.spec.ts create mode 100644 frontend/src/routes/admin/groups/new/+page.server.ts create mode 100644 frontend/src/routes/admin/groups/new/+page.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index ccaecdb2..04be49cb 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -171,6 +171,17 @@ "admin_users_search_placeholder": "Benutzer suchen\u2026", "admin_users_empty": "Keine Benutzer vorhanden.", "admin_users_select_prompt": "W\u00e4hle einen Benutzer aus der Liste.", + "admin_btn_new_group": "Neue Gruppe", + "admin_groups_list_title": "Alle Gruppen", + "admin_groups_empty": "Keine Gruppen vorhanden.", + "admin_groups_select_prompt": "W\u00e4hle eine Gruppe aus der Liste.", + "admin_groups_permission_count": "{count} Berechtigungen", + "admin_group_new_heading": "Neue Gruppe anlegen", + "admin_group_edit_heading": "Gruppe: {name}", + "admin_group_updated": "Gruppe gespeichert.", + "admin_group_created": "Gruppe erstellt.", + "admin_groups_section_standard": "Standard", + "admin_groups_section_administrative": "Administrativ", "admin_user_new_heading": "Neuen Benutzer anlegen", "admin_user_edit_heading": "Benutzer bearbeiten: {username}", "admin_user_created": "Benutzer wurde erstellt.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c95a0303..9346ec74 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -171,6 +171,17 @@ "admin_users_search_placeholder": "Search users\u2026", "admin_users_empty": "No users found.", "admin_users_select_prompt": "Select a user from the list.", + "admin_btn_new_group": "New Group", + "admin_groups_list_title": "All Groups", + "admin_groups_empty": "No groups found.", + "admin_groups_select_prompt": "Select a group from the list.", + "admin_groups_permission_count": "{count} permissions", + "admin_group_new_heading": "Create new group", + "admin_group_edit_heading": "Group: {name}", + "admin_group_updated": "Group saved.", + "admin_group_created": "Group created.", + "admin_groups_section_standard": "Standard", + "admin_groups_section_administrative": "Administrative", "admin_user_new_heading": "Create new user", "admin_user_edit_heading": "Edit user: {username}", "admin_user_created": "User has been created.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 48bebf1b..d5850122 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -171,6 +171,17 @@ "admin_users_search_placeholder": "Buscar usuarios\u2026", "admin_users_empty": "No hay usuarios.", "admin_users_select_prompt": "Selecciona un usuario de la lista.", + "admin_btn_new_group": "Nuevo grupo", + "admin_groups_list_title": "Todos los grupos", + "admin_groups_empty": "No hay grupos.", + "admin_groups_select_prompt": "Selecciona un grupo de la lista.", + "admin_groups_permission_count": "{count} permisos", + "admin_group_new_heading": "Crear nuevo grupo", + "admin_group_edit_heading": "Grupo: {name}", + "admin_group_updated": "Grupo guardado.", + "admin_group_created": "Grupo creado.", + "admin_groups_section_standard": "Est\u00e1ndar", + "admin_groups_section_administrative": "Administrativo", "admin_user_new_heading": "Crear nuevo usuario", "admin_user_edit_heading": "Editar usuario: {username}", "admin_user_created": "Usuario creado.", diff --git a/frontend/src/routes/admin/groups/+layout.server.ts b/frontend/src/routes/admin/groups/+layout.server.ts new file mode 100644 index 00000000..d9cf130a --- /dev/null +++ b/frontend/src/routes/admin/groups/+layout.server.ts @@ -0,0 +1,8 @@ +import { createApiClient } from '$lib/api.server'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ fetch }) => { + const api = createApiClient(fetch); + const result = await api.GET('/api/groups'); + return { groups: result.data ?? [] }; +}; diff --git a/frontend/src/routes/admin/groups/+layout.svelte b/frontend/src/routes/admin/groups/+layout.svelte new file mode 100644 index 00000000..1313962a --- /dev/null +++ b/frontend/src/routes/admin/groups/+layout.svelte @@ -0,0 +1,12 @@ + + + + + +
+ {@render children()} +
diff --git a/frontend/src/routes/admin/groups/+page.svelte b/frontend/src/routes/admin/groups/+page.svelte new file mode 100644 index 00000000..d98bacd4 --- /dev/null +++ b/frontend/src/routes/admin/groups/+page.svelte @@ -0,0 +1,7 @@ + + +
+

{m.admin_groups_select_prompt()}

+
diff --git a/frontend/src/routes/admin/groups/GroupsListPanel.svelte b/frontend/src/routes/admin/groups/GroupsListPanel.svelte new file mode 100644 index 00000000..e025bdcc --- /dev/null +++ b/frontend/src/routes/admin/groups/GroupsListPanel.svelte @@ -0,0 +1,64 @@ + + +
+ +
+ + {m.admin_groups_list_title()} + + + + {m.admin_btn_new_group()} + +
+ + +
+ {#if groups.length === 0} +

+ {m.admin_groups_empty()} +

+ {:else} + {#each groups as group (group.id)} + {@const isActive = page.url.pathname.startsWith('/admin/groups/' + group.id)} + +
{group.name}
+
+ {m.admin_groups_permission_count({ count: group.permissions.length })} +
+
+ {/each} + {/if} +
+
diff --git a/frontend/src/routes/admin/groups/[id]/+page.server.ts b/frontend/src/routes/admin/groups/[id]/+page.server.ts new file mode 100644 index 00000000..b3441c04 --- /dev/null +++ b/frontend/src/routes/admin/groups/[id]/+page.server.ts @@ -0,0 +1,47 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export const load: PageServerLoad = async ({ params, parent }) => { + const { groups } = await parent(); + const group = groups.find((g: { id: string }) => g.id === params.id); + if (!group) throw error(404, getErrorMessage('GROUP_NOT_FOUND')); + return { group }; +}; + +export const actions: Actions = { + update: async ({ params, request, fetch }) => { + const data = await request.formData(); + const api = createApiClient(fetch); + + const result = await api.PATCH('/api/groups/{id}', { + params: { path: { id: params.id } }, + body: { + name: data.get('name') as string, + permissions: data.getAll('permissions') as string[] + } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + + return { success: true }; + }, + + delete: async ({ params, fetch }) => { + const api = createApiClient(fetch); + const result = await api.DELETE('/api/groups/{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/groups'); + } +}; diff --git a/frontend/src/routes/admin/groups/[id]/+page.svelte b/frontend/src/routes/admin/groups/[id]/+page.svelte new file mode 100644 index 00000000..377617d3 --- /dev/null +++ b/frontend/src/routes/admin/groups/[id]/+page.svelte @@ -0,0 +1,144 @@ + + +
+ +
+

+ {m.admin_group_edit_heading({ name: data.group.name })} +

+
{ + if (!confirm(m.admin_group_delete_confirm())) cancel(); + return async ({ update }) => { + await update(); + }; + }} + > + +
+
+ + +
+ {#if form?.success} +
+ {m.admin_group_updated()} +
+ {/if} + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ +
+

+ {m.admin_col_name()} +

+ +
+ + +
+

+ {m.admin_groups_section_standard()} +

+
+ {#each STANDARD_PERMISSIONS as perm (perm.value)} + + {/each} +
+
+ + +
+

+ {m.admin_groups_section_administrative()} +

+
+ {#each ADMIN_PERMISSIONS as perm (perm.value)} + + {/each} +
+
+
+
+ + +
+ + {m.btn_cancel()} + + +
+
diff --git a/frontend/src/routes/admin/groups/layout.server.spec.ts b/frontend/src/routes/admin/groups/layout.server.spec.ts new file mode 100644 index 00000000..a3fdc6e9 --- /dev/null +++ b/frontend/src/routes/admin/groups/layout.server.spec.ts @@ -0,0 +1,41 @@ +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(groups: unknown[]) { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValueOnce({ response: { ok: true }, data: groups }) + } as ReturnType); +} + +beforeEach(() => vi.clearAllMocks()); + +describe('admin/groups layout load', () => { + it('returns the groups list', async () => { + mockApi([ + { id: 'g1', name: 'Admins', permissions: ['ADMIN'] }, + { id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] } + ]); + const result = await load({ fetch: vi.fn() as unknown as typeof fetch }); + expect(result.groups).toHaveLength(2); + expect(result.groups[0].name).toBe('Admins'); + }); + + it('returns an empty array when the API returns nothing', async () => { + mockApi([]); + const result = await load({ fetch: vi.fn() as unknown as typeof fetch }); + expect(result.groups).toEqual([]); + }); + + it('calls GET /api/groups', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true }, data: [] }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + await load({ fetch: vi.fn() as unknown as typeof fetch }); + expect(mockGet).toHaveBeenCalledWith('/api/groups'); + }); +}); diff --git a/frontend/src/routes/admin/groups/layout.svelte.spec.ts b/frontend/src/routes/admin/groups/layout.svelte.spec.ts new file mode 100644 index 00000000..4496e243 --- /dev/null +++ b/frontend/src/routes/admin/groups/layout.svelte.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import GroupsListPanel from './GroupsListPanel.svelte'; + +vi.mock('$app/state', () => ({ + page: { url: { pathname: '/admin/groups/g1' } } +})); + +afterEach(cleanup); + +const groups = [ + { id: 'g1', name: 'Administrators', permissions: ['ADMIN', 'WRITE_ALL'] }, + { id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] }, + { id: 'g3', name: 'Readers', permissions: [] } +]; + +describe('GroupsListPanel — header', () => { + it('renders the panel title', async () => { + render(GroupsListPanel, { groups }); + await expect.element(page.getByText(/Alle Gruppen/i)).toBeInTheDocument(); + }); + + it('renders a new-group link pointing to /admin/groups/new', async () => { + render(GroupsListPanel, { groups }); + await expect + .element(page.getByRole('link', { name: /neue gruppe/i })) + .toHaveAttribute('href', '/admin/groups/new'); + }); +}); + +describe('GroupsListPanel — group items', () => { + it('renders each group name', async () => { + render(GroupsListPanel, { groups }); + await expect.element(page.getByRole('link', { name: /administrators/i })).toBeInTheDocument(); + await expect.element(page.getByRole('link', { name: /editors/i })).toBeInTheDocument(); + }); + + it('each group links to /admin/groups/[id]', async () => { + const { container } = render(GroupsListPanel, { groups }); + const links = container.querySelectorAll('a[href^="/admin/groups/g"]'); + expect(links.length).toBe(3); + expect(links[0].getAttribute('href')).toBe('/admin/groups/g1'); + }); + + it('shows permission count as subtitle', async () => { + render(GroupsListPanel, { groups }); + // Administrators has 2 permissions + await expect.element(page.getByText(/2 Berechtigungen/i)).toBeInTheDocument(); + }); + + it('shows "no permissions" for a group with zero permissions', async () => { + render(GroupsListPanel, { groups }); + await expect.element(page.getByText(/0 Berechtigungen/i)).toBeInTheDocument(); + }); +}); + +describe('GroupsListPanel — active state', () => { + it('marks the active group link with aria-current=page', async () => { + render(GroupsListPanel, { groups }); + await expect + .element(page.getByRole('link', { name: /administrators/i })) + .toHaveAttribute('aria-current', 'page'); + }); + + it('does not mark inactive group links with aria-current', async () => { + render(GroupsListPanel, { groups }); + await expect + .element(page.getByRole('link', { name: /editors/i })) + .not.toHaveAttribute('aria-current'); + }); +}); + +describe('GroupsListPanel — empty state', () => { + it('shows empty state when groups array is empty', async () => { + render(GroupsListPanel, { groups: [] }); + await expect.element(page.getByText(/keine gruppen/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/admin/groups/new/+page.server.ts b/frontend/src/routes/admin/groups/new/+page.server.ts new file mode 100644 index 00000000..1e6d7086 --- /dev/null +++ b/frontend/src/routes/admin/groups/new/+page.server.ts @@ -0,0 +1,25 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export const actions: Actions = { + default: 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[] + } + }); + + 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/groups'); + } +}; diff --git a/frontend/src/routes/admin/groups/new/+page.svelte b/frontend/src/routes/admin/groups/new/+page.svelte new file mode 100644 index 00000000..bdeb445f --- /dev/null +++ b/frontend/src/routes/admin/groups/new/+page.svelte @@ -0,0 +1,117 @@ + + +
+ +
+

+ {m.admin_group_new_heading()} +

+
+ + +
+ {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ +
+

+ {m.admin_col_name()} +

+ +
+ + +
+

+ {m.admin_groups_section_standard()} +

+
+ {#each availableStandard as perm (perm.value)} + + {/each} +
+
+ + +
+

+ {m.admin_groups_section_administrative()} +

+
+ {#each availableAdmin as perm (perm.value)} + + {/each} +
+
+
+
+ + +
+ + {m.btn_cancel()} + + +
+