Compare commits

...

9 Commits

Author SHA1 Message Date
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
Marcel
18601db4f8 fix(profile): use dd.mm.yyyy date input for birth date field
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m4s
CI / Backend Unit Tests (pull_request) Successful in 1m57s
CI / E2E Tests (pull_request) Failing after 13m53s
CI / Unit & Component Tests (push) Successful in 2m35s
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Replace the browser-native type="date" picker with a text input using
the same german format (dd.mm.yyyy with auto-dot insertion) as the
document date fields. A hidden input sends the ISO value to the server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:18:40 +01:00
Marcel
a65c69b0ce fix(tests): fix type errors in spec files after adding user to App.PageData
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 1m57s
CI / Backend Unit Tests (pull_request) Successful in 1m55s
CI / E2E Tests (pull_request) Failing after 14m6s
Add user: undefined to baseData in conversations and documents/new specs.
Change null to undefined for filePath/transcription in makeDoc fixture.
Add form: null to render calls missing it.
Fix birthYear conversion from string to number in persons/[id] server action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:05:08 +01:00
Marcel
8f5c13f162 fix(frontend): fix handleFetch skipping auth for /api/users/me endpoints and regenerate API types
The handleFetch hook previously skipped auth headers for all URLs
containing /api/users/me. Since the hook's own user-load call uses
globalThis.fetch (bypassing handleFetch), it is safe to remove this
exception — enabling profile update and password change actions to
authenticate properly.

Also regenerates API types with new profile endpoints and AppUser fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:04:37 +01:00
Marcel
168225d67c feat(frontend): add profile page and public user profile page
/profile: two-card layout with personal info form (name, birth date,
email, contact) and password change form, each with independent actions.
/users/[id]: read-only public view showing name, username, email, contact
with avatar circle initials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:04:10 +01:00
Marcel
401a1f359f feat(frontend): replace logout button with user avatar dropdown in nav
Show user initials (e.g. MM) in a circular button when name is set,
or a fallback person icon. Clicking opens a dropdown with links to
/profile and a logout form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:03:42 +01:00
Marcel
82c8401167 feat(frontend): add i18n messages and error codes for profile feature
Add profile_* message keys for the profile page forms in de/en/es.
Add EMAIL_ALREADY_IN_USE and WRONG_CURRENT_PASSWORD to ErrorCode type and
getErrorMessage switch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:03:19 +01:00
Marcel
2f803b2740 feat(backend): add user profile fields and profile/password endpoints
Add firstName, lastName, birthDate, contact to AppUser via V7 migration.
Add PUT /api/users/me and POST /api/users/me/password endpoints.
Add GET /api/users/{id} for public profile lookup.
Add EMAIL_ALREADY_IN_USE and WRONG_CURRENT_PASSWORD error codes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:02:55 +01:00
35 changed files with 1255 additions and 199 deletions

View File

@@ -4,7 +4,9 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
@@ -16,8 +18,10 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import lombok.AllArgsConstructor;
@@ -33,13 +37,32 @@ public class UserController {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Fetch full user object from DB to get latest permissions/groups
AppUser user = userService.findByUsername(authentication.getName());
// Security: Remove password before sending
user.setPassword(null);
return ResponseEntity.ok(user);
}
@PutMapping("users/me")
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
@RequestBody UpdateProfileDTO dto) {
AppUser current = userService.findByUsername(authentication.getName());
AppUser updated = userService.updateProfile(current.getId(), dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
}
@PostMapping("users/me/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void changePassword(Authentication authentication,
@RequestBody ChangePasswordDTO dto) {
AppUser current = userService.findByUsername(authentication.getName());
userService.changePassword(current.getId(), dto);
}
@GetMapping("users/{id}")
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {
AppUser user = userService.getById(id);
user.setPassword(null);
return ResponseEntity.ok(user);
}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class ChangePasswordDTO {
private String currentPassword;
private String newPassword;
}

View File

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
import java.time.LocalDate;
@Data
public class UpdateProfileDTO {
private String firstName;
private String lastName;
private LocalDate birthDate;
private String email;
private String contact;
}

View File

@@ -43,6 +43,10 @@ public class DomainException extends RuntimeException {
return new DomainException(code, HttpStatus.CONFLICT, message);
}
public static DomainException badRequest(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.BAD_REQUEST, message);
}
public static DomainException internal(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
}

View File

@@ -21,6 +21,10 @@ public enum ErrorCode {
// --- Users ---
/** A user with the given ID or username does not exist. 404 */
USER_NOT_FOUND,
/** The supplied email address is already used by another account. 409 */
EMAIL_ALREADY_IN_USE,
/** The supplied current password does not match the stored hash. 400 */
WRONG_CURRENT_PASSWORD,
// --- Import ---
/** A mass import is already in progress; only one can run at a time. 409 */

View File

@@ -10,6 +10,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@@ -36,8 +37,16 @@ public class AppUser {
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; // Wird verschlüsselt gespeichert (BCrypt)
private String firstName;
private String lastName;
private LocalDate birthDate;
@Column(unique = true)
private String email;
@Column(columnDefinition = "TEXT")
private String contact;
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen

View File

@@ -11,4 +11,5 @@ import java.util.UUID;
@Repository
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsername(String username);
Optional<AppUser> findByEmail(String email);
}

View File

@@ -3,7 +3,9 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -66,6 +68,45 @@ public class UserService {
userRepository.delete(user);
}
public AppUser getById(UUID id) {
return userRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
}
@Transactional
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
AppUser user = getById(userId);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
if (!existing.getId().equals(userId)) {
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());
return userRepository.save(user);
}
@Transactional
public void changePassword(UUID userId, ChangePasswordDTO dto) {
AppUser user = getById(userId);
if (!passwordEncoder.matches(dto.getCurrentPassword(), user.getPassword())) {
throw DomainException.badRequest(ErrorCode.WRONG_CURRENT_PASSWORD,
"Das aktuelle Passwort ist falsch");
}
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
userRepository.save(user);
}
public AppUser findByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username));

View File

@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN last_name VARCHAR(100);
ALTER TABLE users ADD COLUMN birth_date DATE;
ALTER TABLE users ADD COLUMN contact TEXT;
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);

View File

@@ -5,7 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.repository.AppUserRepository;
@@ -20,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.argThat;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@@ -109,6 +112,110 @@ class UserServiceTest {
verify(userRepository, times(1)).save(existing);
}
// ─── getById ──────────────────────────────────────────────────────────────
@Test
void getById_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(userRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.getById(id))
.isInstanceOf(DomainException.class);
}
@Test
void getById_returnsUser_whenFound() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
assertThat(userService.getById(id)).isEqualTo(user);
}
// ─── updateProfile ────────────────────────────────────────────────────────
@Test
void updateProfile_updatesFields() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setFirstName("Max"); dto.setLastName("Müller"); dto.setEmail("max@example.com");
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getFirstName()).isEqualTo("Max");
assertThat(result.getLastName()).isEqualTo("Müller");
assertThat(result.getEmail()).isEqualTo("max@example.com");
}
@Test
void updateProfile_throwsConflict_whenEmailTakenByAnotherUser() {
UUID id = UUID.randomUUID();
UUID otherId = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail("taken@example.com");
assertThatThrownBy(() -> userService.updateProfile(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("E-Mail");
}
@Test
void updateProfile_allowsSameEmailForSameUser() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("max@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail("max@example.com");
dto.setFirstName("Max");
assertThat(userService.updateProfile(id, dto).getEmail()).isEqualTo("max@example.com");
}
// ─── changePassword ───────────────────────────────────────────────────────
@Test
void changePassword_throwsBadRequest_whenCurrentPasswordWrong() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong", "hashed")).thenReturn(false);
ChangePasswordDTO dto = new ChangePasswordDTO();
dto.setCurrentPassword("wrong"); dto.setNewPassword("newpass");
assertThatThrownBy(() -> userService.changePassword(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("Passwort");
}
@Test
void changePassword_updatesHash_whenCurrentPasswordCorrect() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("correct", "hashed")).thenReturn(true);
when(passwordEncoder.encode("newpass")).thenReturn("newHash");
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
ChangePasswordDTO dto = new ChangePasswordDTO();
dto.setCurrentPassword("correct"); dto.setNewPassword("newpass");
userService.changePassword(id, dto);
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
}
// ─── getGroupById ─────────────────────────────────────────────────────────
@Test

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

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
@@ -11,13 +10,11 @@
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"error_validation_error": "Die Eingabe ist ungültig.",
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
"nav_documents": "Dokumente",
"nav_persons": "Personen",
"nav_conversations": "Konversationen",
"nav_admin": "Admin",
"nav_logout": "Abmelden",
"btn_save": "Speichern",
"btn_cancel": "Abbrechen",
"btn_edit": "Bearbeiten",
@@ -26,7 +23,6 @@
"btn_back_to_overview": "Zurück zur Übersicht",
"btn_back": "Zurück",
"btn_back_to_document": "Zurück zum Dokument",
"form_label_first_name": "Vorname",
"form_label_last_name": "Nachname",
"form_label_alias": "Rufname / Alias",
@@ -47,12 +43,10 @@
"form_label_archive_location": "Aufbewahrungsort",
"form_placeholder_archive_location": "z.B. Schrank 3, Mappe B",
"form_helper_archive_location": "Wo befindet sich das Originaldokument?",
"login_heading": "Anmelden",
"login_label_username": "Benutzername",
"login_label_password": "Passwort",
"login_btn_submit": "Anmelden",
"docs_search_placeholder": "Suche in Titel, Inhalt, Ort...",
"docs_btn_filter": "Filter",
"docs_btn_reset_title": "Filter zurücksetzen",
@@ -68,7 +62,6 @@
"docs_list_from": "Von",
"docs_list_to": "An",
"docs_list_unknown": "Unbekannt",
"doc_section_who_when": "Wer & Wann",
"doc_section_description": "Beschreibung",
"doc_section_file": "Datei",
@@ -79,7 +72,6 @@
"doc_current_file_label": "Aktuelle Datei:",
"doc_new_heading": "Neues Dokument",
"doc_edit_heading": "Bearbeiten",
"doc_section_details": "Details",
"doc_label_document_date": "Dokumentendatum",
"doc_label_creation_location": "Erstellungsort",
@@ -92,17 +84,14 @@
"doc_loading": "Lade Dokument...",
"doc_download_link": "Direkter Download versuchen",
"doc_no_scan": "Kein Scan vorhanden",
"persons_heading": "Personenverzeichnis",
"persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.",
"persons_btn_new": "Neue Person",
"persons_search_placeholder": "Namen suchen...",
"persons_empty_heading": "Keine Personen gefunden.",
"persons_empty_text": "Versuchen Sie einen anderen Suchbegriff.",
"persons_new_heading": "Neue Person",
"persons_section_details": "Angaben zur Person",
"person_edit_heading": "Person bearbeiten",
"person_label_full_name": "Voller Name",
"person_merge_heading": "Person zusammenführen",
@@ -126,7 +115,6 @@
"person_role_receiver": "Empfangen",
"person_co_correspondents_heading": "Häufige Korrespondenten",
"person_show_more": "+ {count} weitere anzeigen",
"conv_heading": "Konversationen",
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
"conv_label_person_a": "Person A (Absender)",
@@ -143,7 +131,6 @@
"conv_swap_btn": "Personen tauschen",
"conv_summary": "{count} Dokumente · {yearFrom}{yearTo}",
"conv_new_doc_link": "Neues Dokument in dieser Korrespondenz",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer",
"admin_tab_groups": "Gruppen",
@@ -172,7 +159,6 @@
"admin_section_new_group": "Neue Gruppe anlegen",
"admin_group_name_placeholder": "Gruppenname (z.B. Editoren)",
"admin_user_delete_confirm": "Benutzer {username} wirklich löschen?",
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
"doc_download_title": "Herunterladen",
"doc_tag_filter_title": "Nach {name} filtern",
@@ -180,9 +166,7 @@
"doc_preview_iframe_title": "Dokumentvorschau",
"doc_image_alt": "Original-Scan",
"doc_no_date": "Kein Datum",
"person_merge_will_be_deleted": "wird gelöscht.",
"comp_typeahead_placeholder": "Namen tippen...",
"comp_typeahead_loading": "Suche...",
"comp_multiselect_placeholder": "Namen tippen...",
@@ -191,5 +175,24 @@
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen",
"comp_taginput_create_hint": "Enter drücken um Schlagwort zu erstellen."
"comp_taginput_create_hint": "Enter drücken um Schlagwort zu erstellen.",
"error_email_already_in_use": "Diese E-Mail-Adresse wird bereits von einem anderen Konto verwendet.",
"error_wrong_current_password": "Das aktuelle Passwort ist falsch.",
"nav_profile": "Profil",
"profile_heading": "Mein Profil",
"profile_section_personal": "Persönliche Daten",
"profile_label_first_name": "Vorname",
"profile_label_last_name": "Nachname",
"profile_label_birth_date": "Geburtsdatum",
"profile_label_email": "E-Mail-Adresse",
"profile_label_contact": "Kontaktdaten",
"profile_contact_placeholder": "Telefon, Adresse oder sonstige Hinweise...",
"profile_section_password": "Passwort ändern",
"profile_label_current_password": "Aktuelles Passwort",
"profile_label_new_password": "Neues Passwort",
"profile_label_new_password_confirm": "Neues Passwort (Wiederholung)",
"profile_password_mismatch": "Die neuen Passwörter stimmen nicht überein.",
"profile_saved": "Gespeichert.",
"profile_password_changed": "Passwort erfolgreich geändert.",
"user_profile_heading": "Profil von"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_document_not_found": "Document not found.",
"error_document_no_file": "No file is associated with this document.",
"error_file_not_found": "The file could not be found in storage.",
@@ -11,13 +10,11 @@
"error_forbidden": "You do not have permission for this action.",
"error_validation_error": "The input is invalid.",
"error_internal_error": "An unexpected error occurred.",
"nav_documents": "Documents",
"nav_persons": "Persons",
"nav_conversations": "Conversations",
"nav_admin": "Admin",
"nav_logout": "Sign out",
"btn_save": "Save",
"btn_cancel": "Cancel",
"btn_edit": "Edit",
@@ -26,7 +23,6 @@
"btn_back_to_overview": "Back to overview",
"btn_back": "Back",
"btn_back_to_document": "Back to document",
"form_label_first_name": "First name",
"form_label_last_name": "Last name",
"form_label_alias": "Nickname / Alias",
@@ -47,12 +43,10 @@
"form_label_archive_location": "Storage location",
"form_placeholder_archive_location": "e.g. Cabinet 3, Folder B",
"form_helper_archive_location": "Where is the original document stored?",
"login_heading": "Sign in",
"login_label_username": "Username",
"login_label_password": "Password",
"login_btn_submit": "Sign in",
"docs_search_placeholder": "Search in title, content, location...",
"docs_btn_filter": "Filter",
"docs_btn_reset_title": "Reset filter",
@@ -68,7 +62,6 @@
"docs_list_from": "From",
"docs_list_to": "To",
"docs_list_unknown": "Unknown",
"doc_section_who_when": "Who & When",
"doc_section_description": "Description",
"doc_section_file": "File",
@@ -79,7 +72,6 @@
"doc_current_file_label": "Current file:",
"doc_new_heading": "New document",
"doc_edit_heading": "Edit",
"doc_section_details": "Details",
"doc_label_document_date": "Document date",
"doc_label_creation_location": "Place of creation",
@@ -92,17 +84,14 @@
"doc_loading": "Loading document...",
"doc_download_link": "Try direct download",
"doc_no_scan": "No scan available",
"persons_heading": "Person directory",
"persons_subtitle": "Browse the index of all recorded persons in the family archive.",
"persons_btn_new": "New person",
"persons_search_placeholder": "Search names...",
"persons_empty_heading": "No persons found.",
"persons_empty_text": "Try a different search term.",
"persons_new_heading": "New person",
"persons_section_details": "Person details",
"person_edit_heading": "Edit person",
"person_label_full_name": "Full name",
"person_merge_heading": "Merge person",
@@ -126,7 +115,6 @@
"person_role_receiver": "Received",
"person_co_correspondents_heading": "Frequent correspondents",
"person_show_more": "+ {count} more",
"conv_heading": "Conversations",
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
"conv_label_person_a": "Person A (Sender)",
@@ -143,7 +131,6 @@
"conv_swap_btn": "Swap persons",
"conv_summary": "{count} documents · {yearFrom}{yearTo}",
"conv_new_doc_link": "New document in this correspondence",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Users",
"admin_tab_groups": "Groups",
@@ -172,7 +159,6 @@
"admin_section_new_group": "Create new group",
"admin_group_name_placeholder": "Group name (e.g. Editors)",
"admin_user_delete_confirm": "Really delete user {username}?",
"doc_file_error_preview": "Could not load preview.",
"doc_download_title": "Download",
"doc_tag_filter_title": "Filter by {name}",
@@ -180,9 +166,7 @@
"doc_preview_iframe_title": "Document Preview",
"doc_image_alt": "Original scan",
"doc_no_date": "No date",
"person_merge_will_be_deleted": "will be deleted.",
"comp_typeahead_placeholder": "Type a name...",
"comp_typeahead_loading": "Searching...",
"comp_multiselect_placeholder": "Type a name...",
@@ -191,5 +175,24 @@
"comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag",
"comp_taginput_create_hint": "Press Enter to create tag."
"comp_taginput_create_hint": "Press Enter to create tag.",
"error_email_already_in_use": "This email address is already used by another account.",
"error_wrong_current_password": "The current password is incorrect.",
"nav_profile": "Profile",
"profile_heading": "My Profile",
"profile_section_personal": "Personal Information",
"profile_label_first_name": "First name",
"profile_label_last_name": "Last name",
"profile_label_birth_date": "Date of birth",
"profile_label_email": "Email address",
"profile_label_contact": "Contact details",
"profile_contact_placeholder": "Phone, address or other notes...",
"profile_section_password": "Change password",
"profile_label_current_password": "Current password",
"profile_label_new_password": "New password",
"profile_label_new_password_confirm": "New password (repeat)",
"profile_password_mismatch": "The new passwords do not match.",
"profile_saved": "Saved.",
"profile_password_changed": "Password changed successfully.",
"user_profile_heading": "Profile of"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_document_not_found": "Documento no encontrado.",
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
@@ -11,13 +10,11 @@
"error_forbidden": "No tiene permiso para realizar esta acción.",
"error_validation_error": "La entrada no es válida.",
"error_internal_error": "Se ha producido un error inesperado.",
"nav_documents": "Documentos",
"nav_persons": "Personas",
"nav_conversations": "Conversaciones",
"nav_admin": "Admin",
"nav_logout": "Cerrar sesión",
"btn_save": "Guardar",
"btn_cancel": "Cancelar",
"btn_edit": "Editar",
@@ -26,7 +23,6 @@
"btn_back_to_overview": "Volver al resumen",
"btn_back": "Volver",
"btn_back_to_document": "Volver al documento",
"form_label_first_name": "Nombre",
"form_label_last_name": "Apellido",
"form_label_alias": "Apodo / Alias",
@@ -47,12 +43,10 @@
"form_label_archive_location": "Ubicación de almacenamiento",
"form_placeholder_archive_location": "p.ej. Armario 3, Carpeta B",
"form_helper_archive_location": "¿Dónde se encuentra el documento original?",
"login_heading": "Iniciar sesión",
"login_label_username": "Usuario",
"login_label_password": "Contraseña",
"login_btn_submit": "Iniciar sesión",
"docs_search_placeholder": "Buscar en título, contenido, lugar...",
"docs_btn_filter": "Filtrar",
"docs_btn_reset_title": "Restablecer filtro",
@@ -68,7 +62,6 @@
"docs_list_from": "De",
"docs_list_to": "Para",
"docs_list_unknown": "Desconocido",
"doc_section_who_when": "Quién & Cuándo",
"doc_section_description": "Descripción",
"doc_section_file": "Archivo",
@@ -79,7 +72,6 @@
"doc_current_file_label": "Archivo actual:",
"doc_new_heading": "Nuevo documento",
"doc_edit_heading": "Editar",
"doc_section_details": "Detalles",
"doc_label_document_date": "Fecha del documento",
"doc_label_creation_location": "Lugar de creación",
@@ -92,17 +84,14 @@
"doc_loading": "Cargando documento...",
"doc_download_link": "Intentar descarga directa",
"doc_no_scan": "No hay escaneo disponible",
"persons_heading": "Directorio de personas",
"persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.",
"persons_btn_new": "Nueva persona",
"persons_search_placeholder": "Buscar nombres...",
"persons_empty_heading": "No se encontraron personas.",
"persons_empty_text": "Pruebe con otro término de búsqueda.",
"persons_new_heading": "Nueva persona",
"persons_section_details": "Datos de la persona",
"person_edit_heading": "Editar persona",
"person_label_full_name": "Nombre completo",
"person_merge_heading": "Fusionar persona",
@@ -126,7 +115,6 @@
"person_role_receiver": "Recibido",
"person_co_correspondents_heading": "Corresponsales frecuentes",
"person_show_more": "+ {count} más",
"conv_heading": "Conversaciones",
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
"conv_label_person_a": "Persona A (Remitente)",
@@ -143,7 +131,6 @@
"conv_swap_btn": "Intercambiar personas",
"conv_summary": "{count} documentos · {yearFrom}{yearTo}",
"conv_new_doc_link": "Nuevo documento en esta correspondencia",
"admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios",
"admin_tab_groups": "Grupos",
@@ -172,7 +159,6 @@
"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}?",
"doc_file_error_preview": "No se pudo cargar la vista previa.",
"doc_download_title": "Descargar",
"doc_tag_filter_title": "Filtrar por {name}",
@@ -180,9 +166,7 @@
"doc_preview_iframe_title": "Vista previa del documento",
"doc_image_alt": "Escaneado original",
"doc_no_date": "Sin fecha",
"person_merge_will_be_deleted": "será eliminado.",
"comp_typeahead_placeholder": "Escriba un nombre...",
"comp_typeahead_loading": "Buscando...",
"comp_multiselect_placeholder": "Escriba un nombre...",
@@ -191,5 +175,24 @@
"comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta",
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta."
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta.",
"error_email_already_in_use": "Esta dirección de correo ya está en uso por otra cuenta.",
"error_wrong_current_password": "La contraseña actual es incorrecta.",
"nav_profile": "Perfil",
"profile_heading": "Mi perfil",
"profile_section_personal": "Información personal",
"profile_label_first_name": "Nombre",
"profile_label_last_name": "Apellido",
"profile_label_birth_date": "Fecha de nacimiento",
"profile_label_email": "Correo electrónico",
"profile_label_contact": "Datos de contacto",
"profile_contact_placeholder": "Teléfono, dirección u otras notas...",
"profile_section_password": "Cambiar contraseña",
"profile_label_current_password": "Contraseña actual",
"profile_label_new_password": "Nueva contraseña",
"profile_label_new_password_confirm": "Nueva contraseña (repetir)",
"profile_password_mismatch": "Las nuevas contraseñas no coinciden.",
"profile_saved": "Guardado.",
"profile_password_changed": "Contraseña cambiada con éxito.",
"user_profile_heading": "Perfil de"
}

View File

@@ -6,10 +6,17 @@ declare global {
interface User {
id: string;
username: string;
firstName?: string;
lastName?: string;
birthDate?: string;
email?: string;
contact?: string;
groups: {
name: string;
permissions: string[];
}[];
enabled: boolean;
createdAt: string;
}
interface Locals {

View File

@@ -63,9 +63,14 @@ const userGroup: Handle = async ({ event, resolve }) => {
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
const isNotLoginTest = !request.url.includes('/api/users/me');
if (isApi && isNotLoginTest) {
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

@@ -10,6 +10,8 @@ export type ErrorCode =
| 'FILE_NOT_FOUND'
| 'FILE_UPLOAD_FAILED'
| 'USER_NOT_FOUND'
| 'EMAIL_ALREADY_IN_USE'
| 'WRONG_CURRENT_PASSWORD'
| 'IMPORT_ALREADY_RUNNING'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
@@ -50,6 +52,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_file_upload_failed();
case 'USER_NOT_FOUND':
return m.error_user_not_found();
case 'EMAIL_ALREADY_IN_USE':
return m.error_email_already_in_use();
case 'WRONG_CURRENT_PASSWORD':
return m.error_wrong_current_password();
case 'IMPORT_ALREADY_RUNNING':
return m.error_import_already_running();
case 'UNAUTHORIZED':

View File

@@ -4,6 +4,22 @@
*/
export interface paths {
"/api/users/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getCurrentUser"];
put: operations["updateProfile"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags/{id}": {
parameters: {
query?: never;
@@ -68,6 +84,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/me/password": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["changePassword"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons": {
parameters: {
query?: never;
@@ -164,17 +196,17 @@ export interface paths {
patch: operations["updateGroup"];
trace?: never;
};
"/api/users/me": {
"/api/users/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getCurrentUser"];
get: operations["getUser"];
put?: never;
post?: never;
delete?: never;
delete: operations["deleteUser"];
options?: never;
head?: never;
patch?: never;
@@ -228,6 +260,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/correspondents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getCorrespondents"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{id}/file": {
parameters: {
query?: never;
@@ -292,31 +340,55 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteUser"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
UpdateProfileDTO: {
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
contact?: string;
};
AppUser: {
/** Format: uuid */
id: string;
username: string;
password?: string;
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
contact?: string;
enabled: boolean;
groups: components["schemas"]["UserGroup"][];
/** Format: date-time */
createdAt: string;
};
UserGroup: {
/** Format: uuid */
id: string;
name: string;
permissions: string[];
};
Tag: {
/** Format: uuid */
id: string;
name: string;
};
PersonUpdateDTO: {
firstName?: string;
lastName?: string;
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
};
Person: {
/** Format: uuid */
id: string;
@@ -373,22 +445,9 @@ export interface components {
initialPassword?: string;
groupIds?: string[];
};
AppUser: {
/** Format: uuid */
id: string;
username: string;
password?: string;
email?: string;
enabled: boolean;
groups: components["schemas"]["UserGroup"][];
/** Format: date-time */
createdAt: string;
};
UserGroup: {
/** Format: uuid */
id: string;
name: string;
permissions: string[];
ChangePasswordDTO: {
currentPassword?: string;
newPassword?: string;
};
GroupDTO: {
name?: string;
@@ -412,6 +471,50 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
getCurrentUser: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
updateProfile: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateProfileDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
updateTag: {
parameters: {
query?: never;
@@ -493,9 +596,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": {
[key: string]: string;
};
"application/json": components["schemas"]["PersonUpdateDTO"];
};
};
responses: {
@@ -602,6 +703,28 @@ export interface operations {
};
};
};
changePassword: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChangePasswordDTO"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getPersons: {
parameters: {
query?: {
@@ -810,11 +933,13 @@ export interface operations {
};
};
};
getCurrentUser: {
getUser: {
parameters: {
query?: never;
header?: never;
path?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
@@ -830,6 +955,26 @@ export interface operations {
};
};
};
deleteUser: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
searchTags: {
parameters: {
query?: {
@@ -896,6 +1041,30 @@ export interface operations {
};
};
};
getCorrespondents: {
parameters: {
query?: {
q?: string;
};
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"][];
};
};
};
};
getDocumentFile: {
parameters: {
query?: never;
@@ -991,24 +1160,4 @@ export interface operations {
};
};
};
deleteUser: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@@ -6,14 +6,14 @@ import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { setLocale, getLocale } from '$lib/paraglide/runtime';
let { children } = $props();
let { children, data } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
const isAdmin = $derived(
page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
);
// Set after client-side hydration completes. Used by E2E tests to know the
@@ -22,6 +22,27 @@ let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
let userMenuOpen = $state(false);
const userInitials = $derived.by(() => {
const first = data?.user?.firstName?.[0];
const last = data?.user?.lastName?.[0];
if (first && last) return (first + last).toUpperCase();
return null;
});
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
userMenuOpen = false;
}
};
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick, true);
};
}
</script>
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
@@ -103,20 +124,66 @@ onMount(() => {
</button>
{/each}
</div>
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
/>
{m.nav_logout()}
</button>
</form>
<!-- User menu -->
<div
class="relative"
{@attach clickOutside}
onkeydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }}
role="none"
>
{#if userInitials}
<button
type="button"
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
>
{userInitials}
</button>
{:else}
<button
type="button"
aria-label={m.nav_profile()}
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
/>
</button>
{/if}
{#if userMenuOpen}
<div
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-brand-sand bg-white shadow-md"
>
<a
href="/profile"
onclick={() => (userMenuOpen = false)}
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
>
{m.nav_profile()}
</a>
<div class="border-t border-brand-sand">
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
>
{m.nav_logout()}
</button>
</form>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>

View File

@@ -10,6 +10,7 @@ afterEach(cleanup);
// ─── Test data ────────────────────────────────────────────────────────────────
const baseData = {
user: undefined,
canWrite: true,
documents: [],
initialValues: { senderName: '', receiverName: '' },
@@ -31,8 +32,8 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [],
transcription: null,
filePath: null,
transcription: undefined,
filePath: undefined,
createdAt: '1923-04-12T00:00:00Z',
updatedAt: '1923-04-12T00:00:00Z',
...overrides
@@ -84,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

@@ -8,6 +8,8 @@ afterEach(cleanup);
// ─── Test data ────────────────────────────────────────────────────────────────
const baseData = {
user: undefined,
canWrite: true,
persons: [],
initialSenderId: '',
initialSenderName: '',
@@ -18,14 +20,15 @@ const baseData = {
describe('New document page sender prefill', () => {
it('shows an empty sender input when no senderId is in the URL', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('#senderId-search');
expect(input?.value).toBe('');
});
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
render(Page, {
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
form: null
});
const input = document.querySelector<HTMLInputElement>('#senderId-search');
expect(input?.value).toBe('Hans Müller');
@@ -33,7 +36,8 @@ describe('New document page sender prefill', () => {
it('sets the hidden senderId input to the prefilled ID', async () => {
render(Page, {
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
form: null
});
const hidden = document.querySelector<HTMLInputElement>(
'input[type="hidden"][name="senderId"]'
@@ -46,7 +50,7 @@ describe('New document page sender prefill', () => {
describe('New document page receiver prefill', () => {
it('shows no receiver chips when initialReceivers is empty', async () => {
render(Page, { data: baseData });
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
});
@@ -55,7 +59,7 @@ describe('New document page receiver prefill', () => {
...baseData,
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
};
render(Page, { data });
render(Page, { data, form: null });
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
});
@@ -64,7 +68,7 @@ describe('New document page receiver prefill', () => {
...baseData,
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
};
render(Page, { data });
render(Page, { data, form: null });
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
expect(hidden?.value).toBe('p2');
});

View File

@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { createRawSnippet } from 'svelte';
afterEach(cleanup);
const emptySnippet = createRawSnippet(() => ({ render: () => '<span></span>' }));
import Layout from './+layout.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
// Minimal data required by the layout
const makeData = (overrides = {}) => ({
user: {
id: '1',
username: 'max',
firstName: 'Max',
lastName: 'Müller',
groups: [],
enabled: true,
createdAt: ''
},
canWrite: true,
...overrides
});
// ─── User avatar ──────────────────────────────────────────────────────────────
describe('Layout user avatar button', () => {
it('shows user initials when first and last name are set', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await expect.element(page.getByRole('button', { name: /MM/ })).toBeInTheDocument();
});
it('shows fallback icon button when names are not set', async () => {
render(Layout, {
data: makeData({
user: { id: '1', username: 'x', groups: [], enabled: true, createdAt: '' }
}),
children: emptySnippet
});
// Button should still exist (with aria-label for accessibility)
await expect.element(page.getByRole('button', { name: /Profil/i })).toBeInTheDocument();
});
});
// ─── Dropdown ─────────────────────────────────────────────────────────────────
describe('Layout user dropdown', () => {
it('dropdown is hidden initially', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await tick();
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
});
it('opens dropdown on button click', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await page.getByRole('button', { name: /MM/ }).click();
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
});
it('profile link points to /profile', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await page.getByRole('button', { name: /MM/ }).click();
await expect
.element(page.getByRole('link', { name: /Profil/i }))
.toHaveAttribute('href', '/profile');
});
it('logout button is in the dropdown', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await page.getByRole('button', { name: /MM/ }).click();
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
});
it('closes dropdown when Escape is pressed', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
const btn = page.getByRole('button', { name: /MM/ });
await btn.click();
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await tick();
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
});
});

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

@@ -31,8 +31,10 @@ export const actions = {
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const notes = formData.get('notes')?.toString().trim() || undefined;
const birthYear = formData.get('birthYear')?.toString().trim() || undefined;
const deathYear = formData.get('deathYear')?.toString().trim() || undefined;
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
if (!firstName || !lastName) {
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });

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', () => {

View File

@@ -0,0 +1,54 @@
import { 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 ({ locals }) => {
return { user: locals.user };
};
export const actions: Actions = {
updateProfile: async ({ request, fetch }) => {
const formData = await request.formData();
const body = {
firstName: formData.get('firstName')?.toString().trim() || undefined,
lastName: formData.get('lastName')?.toString().trim() || undefined,
birthDate: (formData.get('birthDate')?.toString() || undefined) as string | undefined,
email: formData.get('email')?.toString().trim() || undefined,
contact: formData.get('contact')?.toString().trim() || undefined
};
const api = createApiClient(fetch);
const result = await api.PUT('/api/users/me', { body });
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { updateError: getErrorMessage(code) });
}
return { updateSuccess: true };
},
changePassword: async ({ request, fetch }) => {
const formData = await request.formData();
const currentPassword = formData.get('currentPassword')?.toString() ?? '';
const newPassword = formData.get('newPassword')?.toString() ?? '';
const confirmPassword = formData.get('confirmPassword')?.toString() ?? '';
if (newPassword !== confirmPassword) {
return fail(400, { passwordError: 'PASSWORDS_DO_NOT_MATCH' });
}
const api = createApiClient(fetch);
const result = await api.POST('/api/users/me/password', {
body: { currentPassword, newPassword }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { passwordError: getErrorMessage(code) });
}
return { passwordSuccess: true };
}
};

View File

@@ -0,0 +1,239 @@
<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.user?.birthDate)));
let birthDateIso = $state(untrack(() => data.user?.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-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Back link -->
<a
href="/"
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.profile_heading()}</h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Personal info 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>
{#if form?.updateSuccess}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.profile_saved()}
</div>
{/if}
{#if form?.updateError}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{form.updateError}
</div>
{/if}
<form method="POST" action="?/updateProfile" use:enhance>
<div class="space-y-4">
<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.user?.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.user?.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>
<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.user?.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.user?.contact ?? ''}</textarea
>
</label>
</div>
<button
type="submit"
class="mt-5 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>
</form>
</div>
<!-- Password change 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_password()}
</h2>
{#if form?.passwordSuccess}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.profile_password_changed()}
</div>
{/if}
{#if form?.passwordError}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{#if form.passwordError === 'PASSWORDS_DO_NOT_MATCH'}
{m.profile_password_mismatch()}
{:else}
{form.passwordError}
{/if}
</div>
{/if}
<form method="POST" action="?/changePassword" use:enhance>
<div class="space-y-4">
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_current_password()}
</span>
<input
type="password"
name="currentPassword"
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.profile_label_new_password()}
</span>
<input
type="password"
name="newPassword"
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.profile_label_new_password_confirm()}
</span>
<input
type="password"
name="confirmPassword"
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>
</div>
<button
type="submit"
class="mt-5 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>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export const load: PageServerLoad = async ({ params, fetch }) => {
const api = createApiClient(fetch);
const result = await api.GET('/api/users/{id}', { params: { path: { id: params.id } } });
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { profileUser: result.data! };
};

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
const fullName = $derived.by(() => {
const first = data.profileUser.firstName;
const last = data.profileUser.lastName;
return first || last ? [first, last].filter(Boolean).join(' ') : data.profileUser.username;
});
const initials = $derived.by(() => {
const first = data.profileUser.firstName;
const last = data.profileUser.lastName;
if (first && last) return `${first[0]}${last[0]}`.toUpperCase();
if (first) return first[0].toUpperCase();
if (last) return last[0].toUpperCase();
return null;
});
</script>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<!-- Back link -->
<a
href="/"
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>
Zurück
</a>
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.user_profile_heading()}</h1>
<div class="max-w-md">
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<!-- Avatar -->
<div class="mb-5 flex justify-center">
{#if initials}
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-brand-navy text-white"
>
<span class="font-serif text-xl font-bold">{initials}</span>
</div>
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-brand-navy text-white"
>
<svg
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
</div>
{/if}
</div>
<!-- Name and username -->
<div class="mb-5 text-center">
<h2 class="font-serif text-xl font-bold text-brand-navy">{fullName}</h2>
{#if data.profileUser.firstName || data.profileUser.lastName}
<p class="mt-0.5 font-sans text-sm text-gray-400">@{data.profileUser.username}</p>
{/if}
</div>
<!-- Field rows -->
<div class="flex flex-col gap-4">
{#if data.profileUser.email}
<div class="flex flex-col gap-0.5">
<span class="font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>E-Mail</span
>
<span class="font-serif text-sm text-brand-navy">{data.profileUser.email}</span>
</div>
{/if}
{#if data.profileUser.contact}
<div class="flex flex-col gap-0.5">
<span class="font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>Kontakt</span
>
<span class="font-serif text-sm text-brand-navy">{data.profileUser.contact}</span>
</div>
{/if}
</div>
</div>
</div>
</div>