feat(admin): dedicated routes for user management (#37) #47
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppUser> 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<Void> deleteUser(@PathVariable UUID id) {
|
||||
|
||||
@@ -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<UUID> groupIds;
|
||||
}
|
||||
@@ -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<UUID> groupIds; // In welche Gruppen soll der User?
|
||||
private List<UUID> groupIds;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
private String contact;
|
||||
}
|
||||
|
||||
@@ -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<UserGroup> 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);
|
||||
|
||||
218
frontend/e2e/admin.spec.ts
Normal file
218
frontend/e2e/admin.spec.ts
Normal file
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
106
frontend/e2e/profile.spec.ts
Normal file
106
frontend/e2e/profile.spec.ts
Normal file
@@ -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('/');
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||
<div class="flex items-center justify-between border-b border-gray-100 p-6">
|
||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
|
||||
<a
|
||||
href="/admin/users/new"
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-brand-navy px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{m.admin_btn_new_user()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -88,200 +93,89 @@ function cancelEditGroup() {
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_login()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_full_name()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_groups()}</th
|
||||
>
|
||||
{#if editingUserId}
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_password()}</th
|
||||
>
|
||||
{/if}
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each data.users as user (user.id)}
|
||||
<tr class="group/row hover:bg-gray-50">
|
||||
{#if editingUserId === user.id}
|
||||
<!-- === EDIT MODE === -->
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
{user.username}
|
||||
<input
|
||||
type="hidden"
|
||||
name="username"
|
||||
value={user.username}
|
||||
form="edit-form-{user.id}"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<select
|
||||
name="groupIds"
|
||||
multiple
|
||||
form="edit-form-{user.id}"
|
||||
class="block min-h-[80px] w-full rounded border-brand-mint p-1 text-xs"
|
||||
>
|
||||
{#each data.groups as group (group.id)}
|
||||
<option
|
||||
value={group.id}
|
||||
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
|
||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
|
||||
{user.username}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
{#if user.firstName || user.lastName}
|
||||
{user.firstName ?? ''} {user.lastName ?? ''}
|
||||
{:else}
|
||||
<span class="text-gray-300 italic">–</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if user.groups && user.groups.length > 0}
|
||||
{#each user.groups as group (group.id)}
|
||||
<span
|
||||
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
|
||||
>
|
||||
{group.name}
|
||||
</option>
|
||||
</span>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint()}</p>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-right align-top whitespace-nowrap">
|
||||
<form
|
||||
id="edit-form-{user.id}"
|
||||
method="POST"
|
||||
action="?/createUser"
|
||||
use:enhance={() =>
|
||||
async ({ update }) => {
|
||||
await update();
|
||||
cancelEditUser();
|
||||
}}
|
||||
class="flex flex-col items-end gap-2"
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<a
|
||||
href="/admin/users/{user.id}"
|
||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={m.admin_password_placeholder()}
|
||||
class="w-32 rounded border border-brand-mint px-2 py-1 text-xs"
|
||||
/>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
|
||||
<div class="mt-1 flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-green-600 px-2 py-1 text-xs font-bold text-white uppercase hover:bg-green-700"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditUser}
|
||||
class="rounded bg-gray-200 px-2 py-1 text-xs font-bold text-gray-600 uppercase hover:bg-gray-300"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
{:else}
|
||||
<!-- === VIEW MODE === -->
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
{user.username}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if user.groups && user.groups.length > 0}
|
||||
{#each user.groups as group (group.id)}
|
||||
<span
|
||||
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteUser"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="flex items-center"
|
||||
>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<button
|
||||
onclick={() => startEditUser(user.id)}
|
||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||
title={m.admin_btn_delete_user_title()}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteUser"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="flex items-center"
|
||||
>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<button
|
||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||
title={m.admin_btn_delete_user_title()}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Create User Form -->
|
||||
<div class="border-t border-gray-200 bg-gray-50 p-6">
|
||||
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
|
||||
{m.admin_section_new_user()}
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createUser"
|
||||
use:enhance
|
||||
class="grid grid-cols-1 items-start gap-4 md:grid-cols-6"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="Login"
|
||||
required
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={m.admin_col_password()}
|
||||
required
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
/>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<select
|
||||
name="groupIds"
|
||||
multiple
|
||||
class="h-[42px] w-full rounded border-gray-300 py-1 text-sm"
|
||||
required
|
||||
title={m.admin_multiselect_hint_multi()}
|
||||
>
|
||||
{#each data.groups as group (group.id)}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint_full()}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="h-[42px] w-full rounded bg-brand-navy text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy"
|
||||
>{m.btn_create()}</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'tags'}
|
||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||
|
||||
82
frontend/src/routes/admin/page.svelte.spec.ts
Normal file
82
frontend/src/routes/admin/page.svelte.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
67
frontend/src/routes/admin/users/[id]/+page.server.ts
Normal file
67
frontend/src/routes/admin/users/[id]/+page.server.ts
Normal file
@@ -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 };
|
||||
}
|
||||
};
|
||||
233
frontend/src/routes/admin/users/[id]/+page.svelte
Normal file
233
frontend/src/routes/admin/users/[id]/+page.svelte
Normal file
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
function isoToGerman(iso: string | undefined): string {
|
||||
if (!iso) return '';
|
||||
const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return '';
|
||||
return `${match[3]}.${match[2]}.${match[1]}`;
|
||||
}
|
||||
|
||||
function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
let birthDateDisplay = $state(untrack(() => isoToGerman(data.editUser?.birthDate)));
|
||||
let birthDateIso = $state(untrack(() => data.editUser?.birthDate ?? ''));
|
||||
|
||||
function handleBirthDateInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let formatted: string;
|
||||
if (digits.length <= 2) {
|
||||
formatted = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
input.value = formatted;
|
||||
birthDateDisplay = formatted;
|
||||
birthDateIso = germanToIso(formatted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<a
|
||||
href="/admin"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">
|
||||
{m.admin_user_edit_heading({ username: data.editUser.username })}
|
||||
</h1>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||
{m.admin_user_updated()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance class="space-y-6">
|
||||
<!-- Profile card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.profile_section_personal()}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_first_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={data.editUser.firstName ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_last_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={data.editUser.lastName ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_birth_date()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={birthDateDisplay}
|
||||
oninput={handleBirthDateInput}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_email()}
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={data.editUser.email ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_contact()}
|
||||
</span>
|
||||
<textarea
|
||||
name="contact"
|
||||
rows="3"
|
||||
placeholder={m.profile_contact_placeholder()}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
>{data.editUser.contact ?? ''}</textarea
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.admin_col_groups()}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each data.groups as group (group.id)}
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="groupIds"
|
||||
value={group.id}
|
||||
checked={data.editUser.groups?.some((g: { id: string }) => g.id === group.id)}
|
||||
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
||||
/>
|
||||
{group.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.admin_label_new_password_optional()}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_new_password()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_new_password_confirm()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<a
|
||||
href="/admin"
|
||||
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
129
frontend/src/routes/admin/users/[id]/page.svelte.spec.ts
Normal file
129
frontend/src/routes/admin/users/[id]/page.svelte.spec.ts
Normal file
@@ -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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLTextAreaElement>('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<HTMLInputElement>(
|
||||
'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<HTMLInputElement>(
|
||||
'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<HTMLInputElement>('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();
|
||||
});
|
||||
});
|
||||
45
frontend/src/routes/admin/users/new/+page.server.ts
Normal file
45
frontend/src/routes/admin/users/new/+page.server.ts
Normal file
@@ -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');
|
||||
}
|
||||
};
|
||||
204
frontend/src/routes/admin/users/new/+page.svelte
Normal file
204
frontend/src/routes/admin/users/new/+page.svelte
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
let birthDateIso = $state('');
|
||||
|
||||
function handleBirthDateInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let formatted: string;
|
||||
if (digits.length <= 2) {
|
||||
formatted = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
input.value = formatted;
|
||||
birthDateIso = germanToIso(formatted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<a
|
||||
href="/admin"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.admin_user_new_heading()}</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<form method="POST" use:enhance class="space-y-5">
|
||||
<!-- Account -->
|
||||
<h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.admin_section_users()}
|
||||
</h2>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.admin_col_login()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.admin_label_initial_password()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Profile -->
|
||||
<h2 class="pt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.profile_section_personal()}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_first_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_last_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_birth_date()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="TT.MM.JJJJ"
|
||||
oninput={handleBirthDateInput}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_email()}
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_contact()}
|
||||
</span>
|
||||
<textarea
|
||||
name="contact"
|
||||
rows="3"
|
||||
placeholder={m.profile_contact_placeholder()}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<!-- Groups -->
|
||||
<h2 class="pt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.admin_col_groups()}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each data.groups as group (group.id)}
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="groupIds"
|
||||
value={group.id}
|
||||
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
||||
/>
|
||||
{group.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm"
|
||||
>
|
||||
<a
|
||||
href="/admin"
|
||||
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.btn_create()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
68
frontend/src/routes/admin/users/new/page.svelte.spec.ts
Normal file
68
frontend/src/routes/admin/users/new/page.svelte.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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') || '';
|
||||
|
||||
Reference in New Issue
Block a user