Compare commits

...

3 Commits

Author SHA1 Message Date
Marcel
fb4f8e820c feat(admin): add dedicated routes for admin user management (#37)
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m4s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Failing after 18m4s
CI / Unit & Component Tests (pull_request) Successful in 2m2s
CI / Backend Unit Tests (pull_request) Successful in 2m0s
CI / E2E Tests (pull_request) Failing after 16m10s
- New GET /admin/users/new page: create user with all profile fields
  (login, password, firstName, lastName, birthDate, email, contact, groups)
- New GET /admin/users/[id] page: edit user profile, groups, and
  optional password change without requiring current password
- New PUT /api/users/{id} backend endpoint (ADMIN_USER permission)
  with AdminUpdateUserRequest DTO for admin-override user updates
- Refactored admin users tab: replaced inline editing with edit links
  to dedicated routes; create button now links to /admin/users/new
- Extended CreateUserRequest with profile fields so new users can be
  created with full profile data in a single request
- Added 28 component tests across 3 new spec files (TDD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 16:33:50 +01:00
Marcel
9731afb776 fix(auth): pass through explicit Authorization header in handleFetch
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m9s
CI / Backend Unit Tests (pull_request) Successful in 2m2s
CI / E2E Tests (pull_request) Failing after 17m15s
CI / Unit & Component Tests (push) Successful in 2m4s
CI / Backend Unit Tests (push) Successful in 2m0s
CI / E2E Tests (push) Failing after 16m27s
The login action sends Basic auth via an explicit Authorization header.
handleFetch was intercepting this request and returning 401 because no
auth_token cookie exists yet (the user isn't logged in), never forwarding
the credentials to the backend.

Fix: if the outgoing request already has an Authorization header, pass it
through unchanged. Only inject the cookie-based token for requests that
don't provide their own auth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:38:01 +01:00
Marcel
f6634f1d00 fix(tests): fix Svelte 5 event delegation not firing via Playwright locator clicks
Replace Playwright locator .click() calls with native DOM element.click()
for all tests that trigger Svelte 5 delegated onclick handlers ($.delegated).
Playwright's CDP-based synthetic events don't propagate through Svelte 5's
document-level handle_event_propagation delegation mechanism, while native
DOM .click() does.

Also replace locator.click() with element.focus() for onfocus handler tests,
and add cleanup() to afterEach in all spec files missing it to prevent test
pollution between runs. Fix TagInput.svelte to use untrack() when reading
bindable state after an await to avoid track_reactivity_loss errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:34:56 +01:00
27 changed files with 1152 additions and 255 deletions

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -211,3 +211,84 @@ test.describe('Conversations', () => {
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
});
});
test.describe('Conversations — enhancements', () => {
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
// Navigate directly by URL so the test doesn't rely on typeahead interaction
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
// Resolve person IDs from the persons list
await page.goto('/persons');
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
const hansHref = await hansLink.getAttribute('href');
const hansId = hansHref!.split('/').pop()!;
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
const annaHref = await annaLink.getAttribute('href');
const annaId = annaHref!.split('/').pop()!;
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
await page.waitForURL(/senderId=/);
}
test('shows document count and year range summary when both persons are selected', async ({
page
}) => {
await loadHansAnnaConversation(page);
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 19231965
await expect(page.getByTestId('conv-summary')).toContainText('2');
await expect(page.getByTestId('conv-summary')).toContainText('1923');
await expect(page.getByTestId('conv-summary')).toContainText('1965');
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
});
test('shows year dividers between documents from different years', async ({ page }) => {
await loadHansAnnaConversation(page);
// Expect at least two year dividers (1923 and 1965)
await expect(page.getByTestId('year-divider').first()).toBeVisible();
const dividers = page.getByTestId('year-divider');
const texts = await dividers.allTextContents();
expect(texts.some((t) => t.includes('1923'))).toBe(true);
expect(texts.some((t) => t.includes('1965'))).toBe(true);
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
});
test('swap button switches sender and receiver and reloads', async ({ page }) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const originalSenderId = url.searchParams.get('senderId')!;
const originalReceiverId = url.searchParams.get('receiverId')!;
await page.getByTestId('conv-swap-btn').click();
await page.waitForURL(/senderId=/);
const swappedUrl = new URL(page.url());
expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);
expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
});
test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
page
}) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const senderId = url.searchParams.get('senderId')!;
const receiverId = url.searchParams.get('receiverId')!;
const link = page.getByTestId('conv-new-doc-link');
await expect(link).toBeVisible();
const href = await link.getAttribute('href');
expect(href).toContain(`senderId=${senderId}`);
expect(href).toContain(`receiverId=${receiverId}`);
await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
});
test('does not show swap button or new document link when only one person is selected', async ({
page
}) => {
await page.goto('/conversations');
await page.waitForURL('/conversations');
await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
});
});

View File

@@ -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",

View File

@@ -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}",

View File

@@ -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}",

View File

@@ -65,6 +65,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
if (isApi) {
// If the request already carries an explicit Authorization header (e.g. the
// login action sends Basic auth), pass it through unchanged.
if (request.headers.has('Authorization')) {
return fetch(request);
}
const token = event.cookies.get('auth_token');
if (!token) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonMultiSelect from './PersonMultiSelect.svelte';
@@ -29,6 +29,7 @@ function receiverInputs() {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
@@ -21,7 +22,7 @@ let {
onchange
}: Props = $props();
let searchTerm = $derived(initialName);
let searchTerm = $state(initialName);
let results: Person[] = $state([]);
let showDropdown = $state(false);
@@ -38,22 +39,24 @@ function handleInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const term = untrack(() => searchTerm);
const correspondentsOf = untrack(() => restrictToCorrespondentsOf);
loading = true;
try {
let url: string;
if (restrictToCorrespondentsOf) {
if (searchTerm.length >= 1) {
url = `/api/persons/${restrictToCorrespondentsOf}/correspondents?q=${encodeURIComponent(searchTerm)}`;
if (correspondentsOf) {
if (term.length >= 1) {
url = `/api/persons/${correspondentsOf}/correspondents?q=${encodeURIComponent(term)}`;
} else {
url = `/api/persons/${restrictToCorrespondentsOf}/correspondents`;
url = `/api/persons/${correspondentsOf}/correspondents`;
}
} else {
if (searchTerm.length < 1) {
if (term.length < 1) {
results = [];
loading = false;
return;
}
url = `/api/persons?q=${encodeURIComponent(searchTerm)}`;
url = `/api/persons?q=${encodeURIComponent(term)}`;
}
const res = await fetch(url);
results = res.ok ? await res.json() : [];
@@ -66,20 +69,22 @@ function handleInput() {
}, 300);
}
async function handleFocus() {
updateDropdownPosition();
function handleFocus() {
showDropdown = true;
if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!;
loading = true;
try {
const res = await fetch(`/api/persons/${restrictToCorrespondentsOf}/correspondents`);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
(async () => {
try {
const res = await fetch(`/api/persons/${personId}/correspondents`);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
})();
}
}
@@ -90,15 +95,6 @@ function selectPerson(person: Person) {
onchange?.(person.id!);
}
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
@@ -114,15 +110,12 @@ function clickOutside(node: HTMLElement) {
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
<div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
<input type="hidden" name={name} bind:value={value} />
<input
bind:this={inputEl}
type="text"
id="{name}-search"
autocomplete="off"
@@ -135,8 +128,7 @@ function clickOutside(node: HTMLElement) {
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
>
{#if loading}
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonTypeahead from './PersonTypeahead.svelte';
@@ -30,6 +30,7 @@ function hiddenInput(name: string) {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
@@ -117,9 +118,12 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
@@ -129,7 +133,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await tick();
expect(hiddenInput('senderId')?.value).toBe('1');
});
@@ -141,7 +146,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
});
@@ -151,7 +157,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
});
});
@@ -167,7 +174,8 @@ describe('PersonTypeahead clearing a selection', () => {
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
onchange.mockClear();
@@ -190,7 +198,7 @@ describe('PersonTypeahead correspondent mode', () => {
restrictToCorrespondentsOf: 'person-a-id'
});
await page.getByPlaceholder('Namen tippen...').click();
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
@@ -207,7 +215,7 @@ describe('PersonTypeahead correspondent mode', () => {
restrictToCorrespondentsOf: 'person-a-id'
});
await page.getByPlaceholder('Namen tippen...').click();
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
interface Props {
@@ -23,7 +24,8 @@ async function fetchSuggestions(query: string) {
if (res.ok) {
const data = await res.json();
const names: string[] = data.map((t: { name: string }) => t.name);
suggestions = names.filter((t) => !tags.includes(t));
const currentTags = untrack(() => tags);
suggestions = names.filter((t) => !currentTags.includes(t));
showSuggestions = true;
}
} catch (e) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TagInput from './TagInput.svelte';
@@ -24,6 +24,7 @@ function mockFetchEmpty() {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
@@ -113,8 +114,8 @@ describe('TagInput removing tags', () => {
it('removes a chip when its × button is clicked', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
// The × buttons have aria-label="Schlagwort entfernen"
const removeButtons = page.getByRole('button', { name: 'Schlagwort entfernen' });
await removeButtons.first().click();
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
await tick();
await expect.element(page.getByText('Familie')).not.toBeInTheDocument();
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-after-remove.png' });
@@ -122,8 +123,7 @@ describe('TagInput removing tags', () => {
it('removes the last tag on Backspace when the input is empty', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
const input = page.getByRole('textbox');
await input.click();
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
await userEvent.keyboard('{Backspace}');
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
await expect.element(page.getByText('Familie')).toBeInTheDocument();
@@ -177,7 +177,8 @@ describe('TagInput autocomplete', () => {
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
await page.getByRole('option', { name: 'Familie' }).click();
document.querySelector<HTMLElement>('[role="option"]')!.click();
await tick();
await expect.element(page.getByText('Familie')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestion-selected.png' });

View File

@@ -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;

View File

@@ -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>

View File

@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
const makeGroup = (overrides = {}) => ({
id: 'g1',
name: 'Editoren',
permissions: ['WRITE_ALL'],
...overrides
});
const makeUser = (overrides = {}) => ({
id: 'u1',
username: 'max',
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@example.com',
birthDate: undefined,
contact: undefined,
enabled: true,
groups: [makeGroup()],
createdAt: '2024-01-01T00:00:00Z',
...overrides
});
const baseData = {
users: [makeUser()],
groups: [makeGroup()],
tags: []
};
afterEach(cleanup);
// ─── Users tab ────────────────────────────────────────────────────────────────
describe('Admin page users tab', () => {
it('shows the username in the table', async () => {
render(Page, { data: baseData });
await expect.element(page.getByRole('cell', { name: 'max', exact: true })).toBeInTheDocument();
});
it('shows the full name in the table', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Max Mustermann/)).toBeInTheDocument();
});
it('shows a dash when user has no name set', async () => {
const data = { ...baseData, users: [makeUser({ firstName: undefined, lastName: undefined })] };
render(Page, { data });
await expect.element(page.getByText('')).toBeInTheDocument();
});
it('shows group badges for the user', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
});
it('edit link points to /admin/users/[id]', async () => {
render(Page, { data: baseData });
await expect
.element(page.getByRole('link', { name: /Bearbeiten/i }))
.toHaveAttribute('href', '/admin/users/u1');
});
it('new user button links to /admin/users/new', async () => {
render(Page, { data: baseData });
await expect
.element(page.getByRole('link', { name: /Neuer Benutzer/i }))
.toHaveAttribute('href', '/admin/users/new');
});
it('shows "no groups" label when user has no groups', async () => {
const data = { ...baseData, users: [makeUser({ groups: [] })] };
render(Page, { data });
await expect.element(page.getByText(/Keine Gruppen/i)).toBeInTheDocument();
});
});

View 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 };
}
};

View 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>

View 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 = { editUser: makeUser(), groups };
afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin edit user page rendering', () => {
it('renders the heading with username', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument();
});
it('pre-fills first name from editUser data', async () => {
render(Page, { data: baseData });
const input = document.querySelector<HTMLInputElement>('input[name="firstName"]');
expect(input?.value).toBe('Max');
});
it('pre-fills last name from editUser data', async () => {
render(Page, { data: baseData });
const input = document.querySelector<HTMLInputElement>('input[name="lastName"]');
expect(input?.value).toBe('Mustermann');
});
it('pre-fills email from editUser data', async () => {
render(Page, { data: baseData });
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 });
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 });
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[name="contact"]');
expect(textarea?.value).toBe('Tel: 0123');
});
it('renders group checkboxes', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
await expect.element(page.getByText('Admins')).toBeInTheDocument();
});
it('pre-selects the groups the user already belongs to', async () => {
render(Page, { data: baseData });
const checkbox = document.querySelector<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 });
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 });
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 });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin');
});
it('renders the save button', async () => {
render(Page, { data: baseData });
await expect.element(page.getByRole('button', { name: /Speichern/i })).toBeInTheDocument();
});
});
// ─── Feedback messages ────────────────────────────────────────────────────────
describe('Admin edit user page feedback', () => {
it('shows success message when form.success is true', async () => {
render(Page, { data: baseData, form: { success: true } });
await expect.element(page.getByText(/Änderungen gespeichert/i)).toBeInTheDocument();
});
it('shows error message when form.error is set', async () => {
render(Page, { data: baseData, form: { error: 'Ungültige Eingabe.' } });
await expect.element(page.getByText('Ungültige Eingabe.')).toBeInTheDocument();
});
it('does not show success message when form is null', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Änderungen gespeichert/i)).not.toBeInTheDocument();
});
});

View 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');
}
};

View 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>

View 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 = { groups };
afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin new user page rendering', () => {
it('renders the page heading', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument();
});
it('renders the login input', async () => {
render(Page, { data: baseData });
await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument();
});
it('renders group checkboxes for each available group', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
await expect.element(page.getByText('Admins')).toBeInTheDocument();
});
it('cancel link points to /admin', async () => {
render(Page, { data: baseData });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin');
});
it('back link points to /admin', async () => {
render(Page, { data: baseData });
await expect
.element(page.getByRole('link', { name: /Zurück/i }))
.toHaveAttribute('href', '/admin');
});
it('renders the create button', async () => {
render(Page, { data: baseData });
await expect.element(page.getByRole('button', { name: /Erstellen/i })).toBeInTheDocument();
});
});
// ─── Error display ────────────────────────────────────────────────────────────
describe('Admin new user page error display', () => {
it('shows the error message when form has an error', async () => {
render(Page, { data: baseData, form: { error: 'Ein Fehler ist aufgetreten.' } });
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).toBeInTheDocument();
});
it('does not show error section when form is null', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument();
});
});

View File

@@ -85,7 +85,7 @@ describe('Conversations page swap button', () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
render(Page, { data: withPersons });
await page.getByTestId('conv-swap-btn').click();
document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]')!.click();
expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
});

View File

@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import LoginPage from './+page.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
afterEach(cleanup);
describe('Login page rendering', () => {
it('renders the page title', async () => {
render(LoginPage, {});

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
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';
@@ -13,6 +13,8 @@ vi.stubGlobal(
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
afterEach(cleanup);
// ─── Test data ────────────────────────────────────────────────────────────────
const emptyData = {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
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';
@@ -17,6 +17,8 @@ const makePerson = (overrides = {}) => ({
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Persons page rendering', () => {