From fb4f8e820c7a4a78e174cb05de15e507391c8190 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 16:33:50 +0100 Subject: [PATCH] feat(admin): add dedicated routes for admin user management (#37) - New GET /admin/users/new page: create user with all profile fields (login, password, firstName, lastName, birthDate, email, contact, groups) - New GET /admin/users/[id] page: edit user profile, groups, and optional password change without requiring current password - New PUT /api/users/{id} backend endpoint (ADMIN_USER permission) with AdminUpdateUserRequest DTO for admin-override user updates - Refactored admin users tab: replaced inline editing with edit links to dedicated routes; create button now links to /admin/users/new - Extended CreateUserRequest with profile fields so new users can be created with full profile data in a single request - Added 28 component tests across 3 new spec files (TDD) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UserController.java | 10 + .../dto/AdminUpdateUserRequest.java | 18 ++ .../familienarchiv/dto/CreateUserRequest.java | 7 +- .../familienarchiv/service/UserService.java | 38 +++ frontend/messages/de.json | 8 + frontend/messages/en.json | 8 + frontend/messages/es.json | 8 + frontend/src/routes/admin/+page.server.ts | 15 - frontend/src/routes/admin/+page.svelte | 260 ++++++------------ frontend/src/routes/admin/page.svelte.spec.ts | 80 ++++++ .../routes/admin/users/[id]/+page.server.ts | 67 +++++ .../src/routes/admin/users/[id]/+page.svelte | 233 ++++++++++++++++ .../admin/users/[id]/page.svelte.spec.ts | 129 +++++++++ .../routes/admin/users/new/+page.server.ts | 45 +++ .../src/routes/admin/users/new/+page.svelte | 204 ++++++++++++++ .../admin/users/new/page.svelte.spec.ts | 68 +++++ 16 files changed, 999 insertions(+), 199 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/AdminUpdateUserRequest.java create mode 100644 frontend/src/routes/admin/page.svelte.spec.ts create mode 100644 frontend/src/routes/admin/users/[id]/+page.server.ts create mode 100644 frontend/src/routes/admin/users/[id]/+page.svelte create mode 100644 frontend/src/routes/admin/users/[id]/page.svelte.spec.ts create mode 100644 frontend/src/routes/admin/users/new/+page.server.ts create mode 100644 frontend/src/routes/admin/users/new/+page.svelte create mode 100644 frontend/src/routes/admin/users/new/page.svelte.spec.ts diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java index 608debf0..92b0b0f1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; import org.raddatz.familienarchiv.dto.UpdateProfileDTO; @@ -79,6 +80,15 @@ public class UserController { return ResponseEntity.ok(userService.createUserOrUpdate(request)); } + @PutMapping("/users/{id}") + @RequirePermission(Permission.ADMIN_USER) + public ResponseEntity adminUpdateUser(@PathVariable UUID id, + @RequestBody AdminUpdateUserRequest dto) { + AppUser updated = userService.adminUpdateUser(id, dto); + updated.setPassword(null); + return ResponseEntity.ok(updated); + } + @DeleteMapping("/users/{id}") @RequirePermission(Permission.ADMIN_USER) public ResponseEntity deleteUser(@PathVariable UUID id) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/AdminUpdateUserRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/AdminUpdateUserRequest.java new file mode 100644 index 00000000..a92a8db2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/AdminUpdateUserRequest.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Data +public class AdminUpdateUserRequest { + private String firstName; + private String lastName; + private LocalDate birthDate; + private String email; + private String contact; + private String newPassword; + private List groupIds; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java index 3270a8ca..4e0b8705 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.dto; import lombok.Data; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @@ -11,5 +12,9 @@ public class CreateUserRequest { private String username; private String email; private String initialPassword; - private List groupIds; // In welche Gruppen soll der User? + private List groupIds; + private String firstName; + private String lastName; + private LocalDate birthDate; + private String contact; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index c50deea7..c1988b95 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; import org.raddatz.familienarchiv.dto.UpdateProfileDTO; @@ -54,6 +55,10 @@ public class UserService { .email(request.getEmail()) .password(passwordEncoder.encode(request.getInitialPassword())) .groups(groups) + .firstName(request.getFirstName()) + .lastName(request.getLastName()) + .birthDate(request.getBirthDate()) + .contact(request.getContact()) .enabled(true) .build(); } @@ -96,6 +101,39 @@ public class UserService { return userRepository.save(user); } + @Transactional + public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) { + AppUser user = getById(id); + + if (dto.getEmail() != null && !dto.getEmail().isBlank()) { + userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> { + if (!existing.getId().equals(id)) { + throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, + "E-Mail wird bereits von einem anderen Konto verwendet"); + } + }); + user.setEmail(dto.getEmail().trim()); + } else if (dto.getEmail() != null && dto.getEmail().isBlank()) { + user.setEmail(null); + } + + user.setFirstName(dto.getFirstName()); + user.setLastName(dto.getLastName()); + user.setBirthDate(dto.getBirthDate()); + user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim()); + + if (dto.getNewPassword() != null && !dto.getNewPassword().isBlank()) { + user.setPassword(passwordEncoder.encode(dto.getNewPassword())); + } + + if (dto.getGroupIds() != null) { + Set groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds())); + user.setGroups(groups); + } + + return userRepository.save(user); + } + @Transactional public void changePassword(UUID userId, ChangePasswordDTO dto) { AppUser user = getById(userId); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index b26158df..7267aa79 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -159,6 +159,14 @@ "admin_section_new_group": "Neue Gruppe anlegen", "admin_group_name_placeholder": "Gruppenname (z.B. Editoren)", "admin_user_delete_confirm": "Benutzer {username} wirklich löschen?", + "admin_btn_new_user": "Neuer Benutzer", + "admin_user_new_heading": "Neuen Benutzer anlegen", + "admin_user_edit_heading": "Benutzer bearbeiten: {username}", + "admin_user_created": "Benutzer wurde erstellt.", + "admin_user_updated": "Änderungen gespeichert.", + "admin_col_full_name": "Name", + "admin_label_new_password_optional": "Neues Passwort (optional)", + "admin_label_initial_password": "Passwort", "doc_file_error_preview": "Vorschau konnte nicht geladen werden.", "doc_download_title": "Herunterladen", "doc_tag_filter_title": "Nach {name} filtern", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 86f97be0..93a39f72 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -159,6 +159,14 @@ "admin_section_new_group": "Create new group", "admin_group_name_placeholder": "Group name (e.g. Editors)", "admin_user_delete_confirm": "Really delete user {username}?", + "admin_btn_new_user": "New User", + "admin_user_new_heading": "Create new user", + "admin_user_edit_heading": "Edit user: {username}", + "admin_user_created": "User has been created.", + "admin_user_updated": "Changes saved.", + "admin_col_full_name": "Name", + "admin_label_new_password_optional": "New password (optional)", + "admin_label_initial_password": "Password", "doc_file_error_preview": "Could not load preview.", "doc_download_title": "Download", "doc_tag_filter_title": "Filter by {name}", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1e6ba43f..211e84fc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -159,6 +159,14 @@ "admin_section_new_group": "Crear nuevo grupo", "admin_group_name_placeholder": "Nombre del grupo (p.ej. Editores)", "admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?", + "admin_btn_new_user": "Nuevo usuario", + "admin_user_new_heading": "Crear nuevo usuario", + "admin_user_edit_heading": "Editar usuario: {username}", + "admin_user_created": "Usuario creado.", + "admin_user_updated": "Cambios guardados.", + "admin_col_full_name": "Nombre", + "admin_label_new_password_optional": "Nueva contraseña (opcional)", + "admin_label_initial_password": "Contraseña", "doc_file_error_preview": "No se pudo cargar la vista previa.", "doc_download_title": "Descargar", "doc_tag_filter_title": "Filtrar por {name}", diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts index e75c3128..b58bf3c4 100644 --- a/frontend/src/routes/admin/+page.server.ts +++ b/frontend/src/routes/admin/+page.server.ts @@ -35,21 +35,6 @@ export async function load({ fetch, locals }) { } export const actions = { - createUser: async ({ request, fetch }) => { - const data = await request.formData(); - const api = createApiClient(fetch); - - const result = await api.POST('/api/users', { - body: { - username: data.get('username') as string, - initialPassword: data.get('password') as string, - groupIds: data.getAll('groupIds') as string[] - } - }); - - return toActionResult(result); - }, - deleteUser: async ({ request, fetch }) => { const data = await request.formData(); const id = data.get('id') as string; diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index c395269c..841476cc 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -8,7 +8,6 @@ let { data, form } = $props(); let activeTab = $state('users'); let editingTagId: string | null = $state(null); let editingTagName = $state(''); -let editingUserId: string | null = $state(null); let editingGroupId: string | null = $state(null); const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION']; @@ -23,14 +22,6 @@ function cancelEditTag() { editingTagName = ''; } -function startEditUser(id: string) { - editingUserId = id; -} - -function cancelEditUser() { - editingUserId = null; -} - function startEditGroup(id: string) { editingGroupId = id; } @@ -80,6 +71,20 @@ function cancelEditGroup() {

{m.admin_section_users()}

+ + + + + {m.admin_btn_new_user()} +
@@ -88,200 +93,89 @@ function cancelEditGroup() { + - {#if editingUserId} - - {/if} + {#each data.users as user (user.id)} - {#if editingUserId === user.id} - - - - + + - - + - {:else} - - - - - {/if} + + + {/each}
{m.admin_col_login()}{m.admin_col_full_name()} {m.admin_col_groups()}{m.admin_col_password()}{m.admin_col_actions()}
- {user.username} - - - + {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} - -

{m.admin_multiselect_hint()}

-
-
- async ({ update }) => { - await update(); - cancelEditUser(); - }} - class="flex flex-col items-end gap-2" + {:else} + {m.admin_no_groups()} + {/if} + +
+
+ - + {m.btn_edit()} + -
- - -
- -
- {user.username} - -
- {#if user.groups && user.groups.length > 0} - {#each user.groups as group (group.id)} - - {group.name} - - {/each} - {:else} - {m.admin_no_groups()} - {/if} -
-
-
+
{ + if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) { + cancel(); + } + return async ({ update }) => { + await update(); + }; + }} + class="flex items-center" + > + - - { - if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) { - cancel(); - } - return async ({ update }) => { - await update(); - }; - }} - class="flex items-center" - > - - -
-
-
- - -
-

- {m.admin_section_new_user()} -

-
- - - -
- -

{m.admin_multiselect_hint_full()}

-
- - -
-
{:else if activeTab === 'tags'}
diff --git a/frontend/src/routes/admin/page.svelte.spec.ts b/frontend/src/routes/admin/page.svelte.spec.ts new file mode 100644 index 00000000..12ca0f09 --- /dev/null +++ b/frontend/src/routes/admin/page.svelte.spec.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +const makeGroup = (overrides = {}) => ({ + id: 'g1', + name: 'Editoren', + permissions: ['WRITE_ALL'], + ...overrides +}); + +const makeUser = (overrides = {}) => ({ + id: 'u1', + username: 'max', + firstName: 'Max', + lastName: 'Mustermann', + email: 'max@example.com', + birthDate: undefined, + contact: undefined, + enabled: true, + groups: [makeGroup()], + createdAt: '2024-01-01T00:00:00Z', + ...overrides +}); + +const baseData = { + users: [makeUser()], + groups: [makeGroup()], + tags: [] +}; + +afterEach(cleanup); + +// ─── Users tab ──────────────────────────────────────────────────────────────── + +describe('Admin page – users tab', () => { + it('shows the username in the table', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByRole('cell', { name: 'max', exact: true })).toBeInTheDocument(); + }); + + it('shows the full name in the table', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Max Mustermann/)).toBeInTheDocument(); + }); + + it('shows a dash when user has no name set', async () => { + const data = { ...baseData, users: [makeUser({ firstName: undefined, lastName: undefined })] }; + render(Page, { data }); + await expect.element(page.getByText('–')).toBeInTheDocument(); + }); + + it('shows group badges for the user', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText('Editoren')).toBeInTheDocument(); + }); + + it('edit link points to /admin/users/[id]', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Bearbeiten/i })) + .toHaveAttribute('href', '/admin/users/u1'); + }); + + it('new user button links to /admin/users/new', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Neuer Benutzer/i })) + .toHaveAttribute('href', '/admin/users/new'); + }); + + it('shows "no groups" label when user has no groups', async () => { + const data = { ...baseData, users: [makeUser({ groups: [] })] }; + render(Page, { data }); + await expect.element(page.getByText(/Keine Gruppen/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/admin/users/[id]/+page.server.ts b/frontend/src/routes/admin/users/[id]/+page.server.ts new file mode 100644 index 00000000..6d99c407 --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/+page.server.ts @@ -0,0 +1,67 @@ +import { error, fail } 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, 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 [userResult, groupsResult] = await Promise.all([ + api.GET('/api/users/{id}', { params: { path: { id: params.id } } }), + api.GET('/api/groups') + ]); + + if (!userResult.response.ok) throw error(404, getErrorMessage('USER_NOT_FOUND')); + + return { + editUser: userResult.data!, + groups: groupsResult.data ?? [] + }; +}; + +export const actions: Actions = { + default: async ({ params, request, fetch }) => { + const data = await request.formData(); + + const newPassword = data.get('newPassword') as string; + const confirmPassword = data.get('confirmPassword') as string; + if (newPassword && newPassword !== confirmPassword) { + return fail(400, { error: getErrorMessage('PASSWORDS_DO_NOT_MATCH') }); + } + + const birthDateRaw = data.get('birthDate') as string; + const body = { + firstName: (data.get('firstName') as string) || null, + lastName: (data.get('lastName') as string) || null, + birthDate: birthDateRaw || null, + email: (data.get('email') as string) || null, + contact: (data.get('contact') as string) || null, + newPassword: newPassword || null, + groupIds: data.getAll('groupIds') as string[] + }; + + const res = await fetch(`/api/users/${params.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + let code: string | undefined; + try { + const json = await res.json(); + code = json?.code; + } catch { + // ignore + } + return fail(res.status, { error: getErrorMessage(code) }); + } + + return { success: true }; + } +}; diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte new file mode 100644 index 00000000..1dd717fc --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -0,0 +1,233 @@ + + +
+ + + + + {m.btn_back_to_overview()} + + +

+ {m.admin_user_edit_heading({ username: data.editUser.username })} +

+ + {#if form?.success} +
+ {m.admin_user_updated()} +
+ {/if} + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ +
+

+ {m.profile_section_personal()} +

+ +
+
+ + + +
+ + + + + + +
+
+ + +
+

+ {m.admin_col_groups()} +

+ +
+ {#each data.groups as group (group.id)} + + {/each} +
+
+ + +
+

+ {m.admin_label_new_password_optional()} +

+ +
+ + + +
+
+ + +
+ + {m.btn_cancel()} + + +
+
+
diff --git a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts new file mode 100644 index 00000000..870b7610 --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +const groups = [ + { id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] }, + { id: 'g2', name: 'Admins', permissions: ['ADMIN'] } +]; + +const makeUser = (overrides = {}) => ({ + id: 'u1', + username: 'max', + firstName: 'Max', + lastName: 'Mustermann', + email: 'max@example.com', + birthDate: '1985-03-22', + contact: 'Tel: 0123', + enabled: true, + groups: [{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] }], + createdAt: '2024-01-01T00:00:00Z', + ...overrides +}); + +const baseData = { editUser: makeUser(), groups }; + +afterEach(cleanup); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('Admin edit user page – rendering', () => { + it('renders the heading with username', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument(); + }); + + it('pre-fills first name from editUser data', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('input[name="firstName"]'); + expect(input?.value).toBe('Max'); + }); + + it('pre-fills last name from editUser data', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('input[name="lastName"]'); + expect(input?.value).toBe('Mustermann'); + }); + + it('pre-fills email from editUser data', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('input[name="email"]'); + expect(input?.value).toBe('max@example.com'); + }); + + it('pre-fills birth date in German format (dd.mm.yyyy)', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('input[placeholder="TT.MM.JJJJ"]'); + expect(input?.value).toBe('22.03.1985'); + }); + + it('pre-fills contact field', async () => { + render(Page, { data: baseData }); + const textarea = document.querySelector('textarea[name="contact"]'); + expect(textarea?.value).toBe('Tel: 0123'); + }); + + it('renders group checkboxes', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText('Editoren')).toBeInTheDocument(); + await expect.element(page.getByText('Admins')).toBeInTheDocument(); + }); + + it('pre-selects the groups the user already belongs to', async () => { + render(Page, { data: baseData }); + const checkbox = document.querySelector( + 'input[type="checkbox"][name="groupIds"][value="g1"]' + ); + expect(checkbox?.checked).toBe(true); + }); + + it('does not pre-select groups the user does not belong to', async () => { + render(Page, { data: baseData }); + const checkbox = document.querySelector( + 'input[type="checkbox"][name="groupIds"][value="g2"]' + ); + expect(checkbox?.checked).toBe(false); + }); + + it('password fields are empty by default', async () => { + render(Page, { data: baseData }); + const passwordInputs = document.querySelectorAll('input[type="password"]'); + passwordInputs.forEach((input) => { + expect(input.value).toBe(''); + }); + }); + + it('cancel link points to /admin', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Abbrechen/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('renders the save button', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByRole('button', { name: /Speichern/i })).toBeInTheDocument(); + }); +}); + +// ─── Feedback messages ──────────────────────────────────────────────────────── + +describe('Admin edit user page – feedback', () => { + it('shows success message when form.success is true', async () => { + render(Page, { data: baseData, form: { success: true } }); + await expect.element(page.getByText(/Änderungen gespeichert/i)).toBeInTheDocument(); + }); + + it('shows error message when form.error is set', async () => { + render(Page, { data: baseData, form: { error: 'Ungültige Eingabe.' } }); + await expect.element(page.getByText('Ungültige Eingabe.')).toBeInTheDocument(); + }); + + it('does not show success message when form is null', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText(/Änderungen gespeichert/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/admin/users/new/+page.server.ts b/frontend/src/routes/admin/users/new/+page.server.ts new file mode 100644 index 00000000..256e4f89 --- /dev/null +++ b/frontend/src/routes/admin/users/new/+page.server.ts @@ -0,0 +1,45 @@ +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 ({ 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 groupsResult = await api.GET('/api/groups'); + + return { groups: groupsResult.data ?? [] }; +}; + +export const actions: Actions = { + default: async ({ request, fetch }) => { + const data = await request.formData(); + const api = createApiClient(fetch); + + const birthDateRaw = data.get('birthDate') as string; + const result = await api.POST('/api/users', { + body: { + username: data.get('username') as string, + initialPassword: data.get('password') as string, + email: (data.get('email') as string) || undefined, + groupIds: data.getAll('groupIds') as string[], + firstName: (data.get('firstName') as string) || null, + lastName: (data.get('lastName') as string) || null, + birthDate: birthDateRaw || null, + contact: (data.get('contact') as string) || null + } + }); + + 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'); + } +}; diff --git a/frontend/src/routes/admin/users/new/+page.svelte b/frontend/src/routes/admin/users/new/+page.svelte new file mode 100644 index 00000000..8567ffd6 --- /dev/null +++ b/frontend/src/routes/admin/users/new/+page.svelte @@ -0,0 +1,204 @@ + + +
+ + + + + {m.btn_back_to_overview()} + + +

{m.admin_user_new_heading()}

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

+ {m.admin_section_users()} +

+ + + + + + +

+ {m.profile_section_personal()} +

+ +
+ + + +
+ + + + + + + + +

+ {m.admin_col_groups()} +

+ +
+ {#each data.groups as group (group.id)} + + {/each} +
+ + +
+ + {m.btn_cancel()} + + +
+
+
+
diff --git a/frontend/src/routes/admin/users/new/page.svelte.spec.ts b/frontend/src/routes/admin/users/new/page.svelte.spec.ts new file mode 100644 index 00000000..ab48e816 --- /dev/null +++ b/frontend/src/routes/admin/users/new/page.svelte.spec.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +const groups = [ + { id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] }, + { id: 'g2', name: 'Admins', permissions: ['ADMIN'] } +]; + +const baseData = { groups }; + +afterEach(cleanup); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('Admin new user page – rendering', () => { + it('renders the page heading', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument(); + }); + + it('renders the login input', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument(); + }); + + it('renders group checkboxes for each available group', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText('Editoren')).toBeInTheDocument(); + await expect.element(page.getByText('Admins')).toBeInTheDocument(); + }); + + it('cancel link points to /admin', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Abbrechen/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('back link points to /admin', async () => { + render(Page, { data: baseData }); + await expect + .element(page.getByRole('link', { name: /Zurück/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('renders the create button', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByRole('button', { name: /Erstellen/i })).toBeInTheDocument(); + }); +}); + +// ─── Error display ──────────────────────────────────────────────────────────── + +describe('Admin new user page – error display', () => { + it('shows the error message when form has an error', async () => { + render(Page, { data: baseData, form: { error: 'Ein Fehler ist aufgetreten.' } }); + await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).toBeInTheDocument(); + }); + + it('does not show error section when form is null', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument(); + }); +});