diff --git a/COLLABORATING.md b/COLLABORATING.md index 771eec9b..04a8c019 100644 --- a/COLLABORATING.md +++ b/COLLABORATING.md @@ -43,6 +43,42 @@ Repeat for each new behavior. - The Refactor step must not change behavior — if a test breaks, the refactor introduced a bug. - If a bug is reported with no test, write the failing test first, then fix it. +## User Journeys & E2E Acceptance Criteria + +Every `feature` issue must include two sections before any implementation begins: + +### 1. User Journey + +A plain-prose description of the steps a user takes to get value from the feature. Written from the user's perspective, not the implementation's: + +> User opens a document, clicks "History", sees a chronological list of changes with editor name and timestamp. Clicking a row expands the old vs. new values. + +This makes the scope concrete and prevents scope creep — anything not in the journey is out of scope for the issue. + +### 2. E2E Scenarios + +One or more acceptance criteria written as Playwright-ready scenarios. These become the outermost Red test in the TDD cycle — no feature is considered done until all its E2E scenarios pass: + +``` +Scenario: View edit history of a document + Given I am on a document detail page + When I click the "History" tab + Then I see at least one revision entry + And each entry shows the editor's name and a timestamp +``` + +Use this format consistently. It maps directly to `test.describe` / `test` blocks in the Playwright spec. + +### Where this fits in the workflow + +``` +Issue (Journey + Scenarios) → Red E2E test → Implementation → Green +``` + +The scenarios in the issue are the contract. Write them before planning, treat them as failing tests from day one. + +--- + ## Issue Tracking (Gitea) All work is tracked in **Gitea** at `http://192.168.178.71:3005` (repo `marcel/familienarchiv`). Never use todo files or CLAUDE.md notes as a substitute. diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java b/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java index 18571eb3..c4164a66 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java @@ -81,7 +81,8 @@ public class DataInitializer { @Profile("e2e") public CommandLineRunner initE2EData(PersonRepository personRepo, DocumentRepository docRepo, - TagRepository tagRepo) { + TagRepository tagRepo, + PasswordEncoder passwordEncoder) { return args -> { if (personRepo.count() > 0) { log.info("E2E seed: Daten bereits vorhanden, überspringe."); @@ -165,8 +166,21 @@ public class DataInitializer { .receivers(Set.of(otto)) .build()); - log.info("E2E seed: {} Personen, {} Tags, {} Dokumente erstellt.", - personRepo.count(), tagRepo.count(), docRepo.count()); + // ── Read-only user (for permissions E2E tests) ─────────────────── + // Username: reader / Password: reader123 + // Has only READ_ALL — used to assert write controls are absent. + UserGroup leserGroup = groupRepository.save(UserGroup.builder() + .name("Leser") + .permissions(Set.of("READ_ALL")) + .build()); + userRepository.save(AppUser.builder() + .username("reader") + .password(passwordEncoder.encode("reader123")) + .groups(Set.of(leserGroup)) + .build()); + + log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.", + personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count()); }; } } 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/e2e/admin.spec.ts b/frontend/e2e/admin.spec.ts new file mode 100644 index 00000000..1ffd6f59 --- /dev/null +++ b/frontend/e2e/admin.spec.ts @@ -0,0 +1,218 @@ +import { test, expect, type Browser } from '@playwright/test'; + +/** + * Admin panel E2E tests. + * + * Reads top-to-bottom as a complete admin journey: + * 1. Admin opens the dashboard and sees all three management tabs. + * 2. Admin creates a group for read-only access. + * 3. Admin creates a new user in that group. + * 4. Admin edits the user's profile. + * 5. Admin resets the user's password without knowing their current password. + * 6. The user can log in with the admin-set password. + * 7. Admin deletes the user. + * 8. Admin deletes the test group. + * 9. Admin renames a tag and renames it back. + * + * Steps 2–8 form a self-contained lifecycle: everything created in this suite + * is also deleted, leaving the database in its original state. + */ + +// ── Dashboard ───────────────────────────────────────────────────────────────── + +test.describe('Admin dashboard', () => { + test('admin navigates to /admin and sees the three management tabs', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + await expect(page.getByRole('button', { name: 'Benutzer', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Gruppen', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Schlagworte', exact: true })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-dashboard.png' }); + }); +}); + +// ── Group lifecycle ──────────────────────────────────────────────────────────── + +test.describe('Admin — group management', () => { + test('admin creates a new group "E2E Leser" with READ_ALL permission', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + // Switch to the Groups tab + await page.getByRole('button', { name: 'Gruppen', exact: true }).click(); + + await page.getByPlaceholder('Gruppenname (z.B. Editoren)').fill('E2E Leser'); + + // No permission checkboxes checked — READ_ALL is handled at application level + // (a group with no permissions gets read-only access by default in the UI) + + await page.getByRole('button', { name: /Erstellen/i }).click(); + + await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-group-created.png' }); + }); +}); + +// ── User lifecycle ───────────────────────────────────────────────────────────── + +test.describe('Admin — user lifecycle', () => { + test('admin creates user "e2e-testuser" and they appear in the user list', async ({ page }) => { + await page.goto('/admin/users/new'); + await page.waitForSelector('[data-hydrated]'); + + await page.locator('input[name="username"]').fill('e2e-testuser'); + await page.locator('input[name="password"]').fill('InitPass123!'); + + // Assign to the group we just created + const groupLabel = page.locator('label').filter({ hasText: 'E2E Leser' }); + if ((await groupLabel.count()) > 0) { + await groupLabel.locator('input[type="checkbox"]').check(); + } + + await page.getByRole('button', { name: /Erstellen/i }).click(); + + // Redirected back to /admin — user appears in the table + await expect(page).toHaveURL('/admin'); + await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-user-created.png' }); + }); + + test('admin opens the edit page and updates the user first name', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + // Click the edit link for the test user + const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' }); + await userRow.getByRole('link', { name: /Bearbeiten/i }).click(); + + await expect(page).toHaveURL(/\/admin\/users\/.+/); + await expect( + page.getByRole('heading', { name: /Benutzer bearbeiten: e2e-testuser/i }) + ).toBeVisible(); + + await page.locator('input[name="firstName"]').fill('E2E'); + await page.locator('input[name="lastName"]').fill('Testuser'); + + await page.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page.getByText('Änderungen gespeichert.')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-user-edited.png' }); + }); + + test('admin sets a new password without entering the current password', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' }); + await userRow.getByRole('link', { name: /Bearbeiten/i }).click(); + + // Password fields — no current password field on the admin edit form + await page.locator('input[name="newPassword"]').fill('AdminSet456!'); + await page.locator('input[name="confirmPassword"]').fill('AdminSet456!'); + + await page.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page.getByText('Änderungen gespeichert.')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-user-password-reset.png' }); + }); + + test('the user can log in with the admin-set password', async ({ browser }) => { + // Open a completely separate browser context — no shared session cookies + const freshCtx = await (browser as Browser).newContext({ + storageState: { cookies: [], origins: [] } + }); + const freshPage = await freshCtx.newPage(); + + await freshPage.goto('/login'); + await freshPage.getByLabel('Benutzername').fill('e2e-testuser'); + await freshPage.getByLabel('Passwort').fill('AdminSet456!'); + await freshPage.getByRole('button', { name: 'Anmelden' }).click(); + + await expect(freshPage).toHaveURL('/'); + await freshPage.screenshot({ path: 'test-results/e2e/admin-user-login-new-password.png' }); + + await freshCtx.close(); + }); + + test('admin deletes the test user and they disappear from the list', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' }); + + // The delete button triggers a window.confirm() dialog + page.once('dialog', (dialog) => dialog.accept()); + await userRow.getByTitle('Benutzer löschen').click(); + + await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-user-deleted.png' }); + }); +}); + +// ── Group cleanup ────────────────────────────────────────────────────────────── + +test.describe('Admin — group cleanup', () => { + test('admin deletes the "E2E Leser" group', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Gruppen' }).click(); + + const groupRow = page.locator('tr').filter({ hasText: 'E2E Leser' }); + + page.once('dialog', (dialog) => dialog.accept()); + await groupRow.getByTitle('Löschen').click(); + + await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-group-deleted.png' }); + }); +}); + +// ── Tag management ───────────────────────────────────────────────────────────── + +test.describe('Admin — tag management', () => { + test('admin renames a tag and sees the change in the list', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Schlagworte', exact: true }).click(); + // Wait for the tags list to render after the tab switch + await page.waitForSelector('ul > li'); + + // Hover over the "Familie" row to reveal the opacity-0 action buttons + const familieRow = page + .locator('ul > li') + .filter({ has: page.locator('span', { hasText: /^Familie$/ }) }); + await familieRow.hover(); + await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); + + // After clicking edit, {#if editingTagId} replaces the span with a form — + // the familieRow filter no longer matches, so we find the input directly. + await page.locator('input[name="name"]').fill('Familie (E2E)'); + await page.getByRole('button', { name: 'Speichern' }).click(); + + await expect(page.getByText('Familie (E2E)')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' }); + }); + + test('admin renames it back to restore the original name', async ({ page }) => { + await page.goto('/admin'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Schlagworte', exact: true }).click(); + await page.waitForSelector('ul > li'); + + const renamedRow = page + .locator('ul > li') + .filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) }); + await renamedRow.hover(); + await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); + + await page.locator('input[name="name"]').fill('Familie'); + await page.getByRole('button', { name: 'Speichern' }).click(); + + await expect(page.getByText('Familie')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' }); + }); +}); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 0aa61199..767da083 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -48,8 +48,24 @@ test.describe('Authentication', () => { await page.screenshot({ path: 'test-results/e2e/login-success.png' }); }); + test('login establishes a session that authenticates API calls', async ({ page }) => { + // Guards against regressions where the session cookie is set but broken. + // The profile page calls /api/users/me server-side — if auth works end-to-end, + // it loads without redirecting to /login. + await login(page); + await page.goto('/profile'); + await expect(page).toHaveURL('/profile'); + await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/auth-session-valid.png' }); + }); + test('logout clears the session and redirects to /login', async ({ page }) => { await login(page); + // Logout is inside the user avatar dropdown — open it first. + // Wait for the dropdown button to be visible before clicking Abmelden, + // since the {#if userMenuOpen} block renders asynchronously in Svelte. + await page.locator('button[aria-haspopup="true"]').click(); + await expect(page.getByRole('button', { name: 'Abmelden' })).toBeVisible(); await page.getByRole('button', { name: 'Abmelden' }).click(); await expect(page).toHaveURL(/\/login/); // Confirm session is gone: navigating to / redirects back diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index c2ee2960..5743ab02 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -80,6 +80,41 @@ test.describe('New document', () => { }); }); +test.describe('Document creation', () => { + test('user fills in a title and lands on the new document detail page', async ({ page }) => { + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + + await page.getByLabel('Titel').fill('E2E Testbrief'); + await page.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page).toHaveURL(/\/documents\/[^/]+$/); + await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/document-create.png' }); + }); +}); + +test.describe('Document editing', () => { + test('user opens an existing document, changes the title, and sees the update', async ({ + page + }) => { + // Find the document created in the previous describe + await page.goto('/?q=E2E+Testbrief'); + await page.waitForSelector('[data-hydrated]'); + const docLink = page.getByRole('link', { name: 'E2E Testbrief' }).first(); + const href = await docLink.getAttribute('href'); + await page.goto(`${href}/edit`); + await page.waitForSelector('[data-hydrated]'); + + await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)'); + await page.getByRole('button', { name: /Speichern/i }).click(); + + await expect(page).toHaveURL(/\/documents\/[^/]+$/); + await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' }); + }); +}); + test.describe('Document edit', () => { test('renders the edit form with pre-filled data', async ({ page }) => { // Navigate to home, find first document, go to its edit page diff --git a/frontend/e2e/permissions.spec.ts b/frontend/e2e/permissions.spec.ts index a7416856..63e9bc1a 100644 --- a/frontend/e2e/permissions.spec.ts +++ b/frontend/e2e/permissions.spec.ts @@ -1,4 +1,14 @@ import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +/** + * Permission E2E tests. + * + * Two describe blocks form the full story: + * 1. Admin user — can see all write controls. + * 2. Read-only user ("reader", seeded in DataInitializer with READ_ALL only) — + * can browse content but sees no write controls anywhere. + */ test.describe('Write permissions — admin user', () => { test('admin user sees Neues Dokument link on home page', async ({ page }) => { @@ -29,3 +39,49 @@ test.describe('Write permissions — admin user', () => { await expect(page.getByRole('button', { name: /Bearbeiten/i })).toBeVisible(); }); }); + +// ── Read-only user journey ───────────────────────────────────────────────────── +// +// The "reader" user is seeded by DataInitializer (e2e profile) with READ_ALL only. +// They can browse documents and persons but must not see any mutation controls. + +test.describe('Read-only user — no write controls visible', () => { + // Fresh session — no shared admin cookies + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await login(page, 'reader', 'reader123'); + }); + + test('read-only user is redirected to home after login', async ({ page }) => { + await expect(page).toHaveURL('/'); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-home.png' }); + }); + + test('home page does not show the "Neues Dokument" link', async ({ page }) => { + await expect(page.getByRole('link', { name: /Neues Dokument/i })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc.png' }); + }); + + test('persons page does not show the "Neue Person" link', async ({ page }) => { + await page.goto('/persons'); + await expect(page.getByRole('link', { name: /Neue Person/i })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-person.png' }); + }); + + test('person detail page does not show the edit button', async ({ page }) => { + await page.goto('/persons'); + const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first(); + await firstPerson.click(); + await page.waitForSelector('[data-hydrated]'); + await expect(page.getByRole('button', { name: /Bearbeiten/i })).not.toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-edit.png' }); + }); + + test('navigating directly to /documents/new redirects away', async ({ page }) => { + await page.goto('/documents/new'); + // Read-only user should not be able to access the new document form + await expect(page).not.toHaveURL('/documents/new'); + await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc-direct.png' }); + }); +}); diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index 12f7d2f0..e1089a71 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -95,6 +95,21 @@ test.describe('New person', () => { }); }); +test.describe('Person creation', () => { + test('user fills in first and last name and lands on the new person detail page', async ({ + page + }) => { + await page.goto('/persons/new'); + await page.getByLabel('Vorname').fill('E2E'); + await page.getByLabel('Nachname').fill('Testperson'); + await page.getByRole('button', { name: /Erstellen/i }).click(); + + await expect(page).toHaveURL(/\/persons\/[^/]+$/); + await expect(page.getByRole('heading', { name: 'E2E Testperson' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/person-create.png' }); + }); +}); + test.describe('Person detail — sort toggle', () => { test('each section has its own sort toggle that works independently', async ({ page }) => { await page.goto('/persons'); @@ -259,7 +274,10 @@ test.describe('Conversations — enhancements', () => { const originalReceiverId = url.searchParams.get('receiverId')!; await page.getByTestId('conv-swap-btn').click(); - await page.waitForURL(/senderId=/); + // Wait for the URL to reflect the swapped IDs (not just any URL with senderId=) + await page.waitForURL( + (url) => new URL(url).searchParams.get('senderId') === originalReceiverId + ); const swappedUrl = new URL(page.url()); expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId); diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts new file mode 100644 index 00000000..f1c763f4 --- /dev/null +++ b/frontend/e2e/profile.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test'; + +/** + * Profile page E2E tests. + * + * Reads top-to-bottom as a single user journey: + * the logged-in admin opens their profile, updates their display name, + * tries a wrong password (sees an error), then successfully changes their + * password and logs back in with the new one. + * + * The password change test restores the original password at the end so the + * shared session remains valid for all subsequent test files. + */ + +test.describe('Profile page', () => { + test('user opens their profile and sees the personal data and password sections', async ({ + page + }) => { + await page.goto('/profile'); + await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible(); + await expect(page.getByText('Persönliche Daten')).toBeVisible(); + await expect(page.getByText('Passwort ändern')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/profile-view.png' }); + }); + + test('user saves updated first and last name and sees confirmation', async ({ page }) => { + await page.goto('/profile'); + await page.waitForSelector('[data-hydrated]'); + + await page.locator('input[name="firstName"]').fill('E2E'); + await page.locator('input[name="lastName"]').fill('Admin'); + + // Two "Speichern" buttons exist — the first belongs to the profile form + await page + .locator('form[action*="updateProfile"]') + .getByRole('button', { name: /Speichern/i }) + .click(); + + await expect(page.getByText('Gespeichert.')).toBeVisible(); + // Nav avatar shows the new initials derived from firstName + lastName + await expect(page.locator('button[aria-haspopup="true"]')).toContainText('EA'); + await page.screenshot({ path: 'test-results/e2e/profile-save.png' }); + }); + + test('shows an error when the current password is wrong', async ({ page }) => { + await page.goto('/profile'); + await page.waitForSelector('[data-hydrated]'); + + await page.locator('input[name="currentPassword"]').fill('definitely-wrong'); + await page.locator('input[name="newPassword"]').fill('NewPass123!'); + await page.locator('input[name="confirmPassword"]').fill('NewPass123!'); + + await page + .locator('form[action*="changePassword"]') + .getByRole('button', { name: /Speichern/i }) + .click(); + + await expect(page.getByText('Das aktuelle Passwort ist falsch.')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/profile-wrong-password.png' }); + }); + + test('user changes their password and can log in with the new one', async ({ page }) => { + await page.goto('/profile'); + await page.waitForSelector('[data-hydrated]'); + + // ── Step 1: change to a temporary password ───────────────────────────── + await page.locator('input[name="currentPassword"]').fill('admin123'); + await page.locator('input[name="newPassword"]').fill('TempAdmin456!'); + await page.locator('input[name="confirmPassword"]').fill('TempAdmin456!'); + await page + .locator('form[action*="changePassword"]') + .getByRole('button', { name: /Speichern/i }) + .click(); + + // After the password changes, the auth_token cookie still carries the old + // credentials. use:enhance re-runs the page's load function, which calls + // the backend with the stale Basic Auth header → 401 → redirect to /login. + await expect(page).toHaveURL(/\/login/); + + // ── Step 2: log in with the new password ─────────────────────────────── + await page.getByLabel('Benutzername').fill('admin'); + await page.getByLabel('Passwort').fill('TempAdmin456!'); + await page.getByRole('button', { name: 'Anmelden' }).click(); + await expect(page).toHaveURL('/'); + await page.screenshot({ path: 'test-results/e2e/profile-password-changed.png' }); + + // ── Step 3: restore the original password so subsequent tests still work ─ + await page.goto('/profile'); + await page.waitForSelector('[data-hydrated]'); + await page.locator('input[name="currentPassword"]').fill('TempAdmin456!'); + await page.locator('input[name="newPassword"]').fill('admin123'); + await page.locator('input[name="confirmPassword"]').fill('admin123'); + await page + .locator('form[action*="changePassword"]') + .getByRole('button', { name: /Speichern/i }) + .click(); + // Redirected to /login again after credential rotation + await expect(page).toHaveURL(/\/login/); + + // ── Step 4: log back in with the restored password ───────────────────── + await page.getByLabel('Benutzername').fill('admin'); + await page.getByLabel('Passwort').fill('admin123'); + await page.getByRole('button', { name: 'Anmelden' }).click(); + await expect(page).toHaveURL('/'); + }); +}); 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..848fb1b4 --- /dev/null +++ b/frontend/src/routes/admin/page.svelte.spec.ts @@ -0,0 +1,82 @@ +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 = { + user: undefined, + canWrite: true, + 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, form: null }); + await expect.element(page.getByRole('cell', { name: 'max', exact: true })).toBeInTheDocument(); + }); + + it('shows the full name in the table', async () => { + render(Page, { data: baseData, form: null }); + 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, form: null }); + await expect.element(page.getByText('–')).toBeInTheDocument(); + }); + + it('shows group badges for the user', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText('Editoren')).toBeInTheDocument(); + }); + + it('edit link points to /admin/users/[id]', async () => { + render(Page, { data: baseData, form: null }); + 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, form: null }); + 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, form: null }); + 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..2ff1b450 --- /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 = { user: undefined, canWrite: true, editUser: makeUser(), groups }; + +afterEach(cleanup); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('Admin edit user page – rendering', () => { + it('renders the heading with username', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument(); + }); + + it('pre-fills first name from editUser data', async () => { + render(Page, { data: baseData, form: null }); + 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, form: null }); + const input = document.querySelector('input[name="lastName"]'); + expect(input?.value).toBe('Mustermann'); + }); + + it('pre-fills email from editUser data', async () => { + render(Page, { data: baseData, form: null }); + 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, form: null }); + 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, form: null }); + const textarea = document.querySelector('textarea[name="contact"]'); + expect(textarea?.value).toBe('Tel: 0123'); + }); + + it('renders group checkboxes', async () => { + render(Page, { data: baseData, form: null }); + 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, form: null }); + 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, form: null }); + 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, form: null }); + 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, form: null }); + await expect + .element(page.getByRole('link', { name: /Abbrechen/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('renders the save button', async () => { + render(Page, { data: baseData, form: null }); + 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..372cf779 --- /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 = { user: undefined, canWrite: true, groups }; + +afterEach(cleanup); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('Admin new user page – rendering', () => { + it('renders the page heading', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument(); + }); + + it('renders the login input', async () => { + render(Page, { data: baseData, form: null }); + await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument(); + }); + + it('renders group checkboxes for each available group', async () => { + render(Page, { data: baseData, form: null }); + 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, form: null }); + await expect + .element(page.getByRole('link', { name: /Abbrechen/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('back link points to /admin', async () => { + render(Page, { data: baseData, form: null }); + await expect + .element(page.getByRole('link', { name: /Zurück/i })) + .toHaveAttribute('href', '/admin'); + }); + + it('renders the create button', async () => { + render(Page, { data: baseData, form: null }); + 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(); + }); +}); diff --git a/frontend/src/routes/documents/new/+page.server.ts b/frontend/src/routes/documents/new/+page.server.ts index 6ef92f14..605a74e5 100644 --- a/frontend/src/routes/documents/new/+page.server.ts +++ b/frontend/src/routes/documents/new/+page.server.ts @@ -1,4 +1,4 @@ -import { error, fail, redirect } from '@sveltejs/kit'; +import { fail, redirect } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { createApiClient } from '$lib/api.server'; import { parseBackendError, getErrorMessage } from '$lib/errors'; @@ -16,7 +16,7 @@ export async function load({ locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL') ) ?? false; - if (!canWrite) throw error(403, 'Forbidden'); + if (!canWrite) throw redirect(303, '/'); const senderId = url.searchParams.get('senderId') || ''; const receiverId = url.searchParams.get('receiverId') || '';