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 @@
+
+
+
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 form?.success}
+
+ {m.admin_group_updated()}
+
+ {/if}
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+
+
+
+
+
+
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}
+
+
+
+
+
+
+