diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java index 4ac4fa47..608debf0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -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 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 getUser(@PathVariable UUID id) { + AppUser user = userService.getById(id); + user.setPassword(null); return ResponseEntity.ok(user); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/ChangePasswordDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/ChangePasswordDTO.java new file mode 100644 index 00000000..51a171e2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/ChangePasswordDTO.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +@Data +public class ChangePasswordDTO { + private String currentPassword; + private String newPassword; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateProfileDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateProfileDTO.java new file mode 100644 index 00000000..ff8fdc13 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateProfileDTO.java @@ -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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java index a34332ce..f82768c6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java @@ -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); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index f9f251d5..7e4a1ac6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -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 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java index 254b3c8f..5a9ea965 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java @@ -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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/AppUserRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/AppUserRepository.java index 1822efbb..290f15a1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/AppUserRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/AppUserRepository.java @@ -11,4 +11,5 @@ import java.util.UUID; @Repository public interface AppUserRepository extends JpaRepository { Optional findByUsername(String username); + Optional findByEmail(String email); } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index 8eb92ed9..c50deea7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -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)); diff --git a/backend/src/main/resources/db/migration/V7__add_profile_fields.sql b/backend/src/main/resources/db/migration/V7__add_profile_fields.sql new file mode 100644 index 00000000..25b29b7d --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__add_profile_fields.sql @@ -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); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java index fe3ad905..7a58dd45 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java @@ -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 diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 52a3f977..b26158df 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 75fa54f9..86f97be0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 58be11ed..1e6ba43f 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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" } diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 7ac40dc5..0663671f 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -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 { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 6c1692db..78c5dae5 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -63,9 +63,8 @@ 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) { const token = event.cookies.get('auth_token'); if (!token) { diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index e3a16bc5..beb18d90 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -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': diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index decd2f1c..9d693743 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -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; 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; 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; - }; - }; - }; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0734b8f5..3dd6aea5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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); + }; +}
@@ -103,20 +124,66 @@ onMount(() => { {/each}
-
- -
+ + +
{ if (e.key === 'Escape') userMenuOpen = false; }} + role="none" + > + {#if userInitials} + + {:else} + + {/if} + + {#if userMenuOpen} + + {/if} +
diff --git a/frontend/src/routes/conversations/page.svelte.spec.ts b/frontend/src/routes/conversations/page.svelte.spec.ts index c7cb41fc..2e935a3b 100644 --- a/frontend/src/routes/conversations/page.svelte.spec.ts +++ b/frontend/src/routes/conversations/page.svelte.spec.ts @@ -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 = {}) => ({ 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 diff --git a/frontend/src/routes/documents/new/page.svelte.spec.ts b/frontend/src/routes/documents/new/page.svelte.spec.ts index 7968fe30..0a269279 100644 --- a/frontend/src/routes/documents/new/page.svelte.spec.ts +++ b/frontend/src/routes/documents/new/page.svelte.spec.ts @@ -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('#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('#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( '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('input[name="receiverIds"]'); expect(hidden?.value).toBe('p2'); }); diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts new file mode 100644 index 00000000..add63655 --- /dev/null +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -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: () => '' })); +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(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/+page.server.ts b/frontend/src/routes/persons/[id]/+page.server.ts index 4a9bbcc9..0bb0ca2a 100644 --- a/frontend/src/routes/persons/[id]/+page.server.ts +++ b/frontend/src/routes/persons/[id]/+page.server.ts @@ -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.' }); diff --git a/frontend/src/routes/profile/+page.server.ts b/frontend/src/routes/profile/+page.server.ts new file mode 100644 index 00000000..7a14518a --- /dev/null +++ b/frontend/src/routes/profile/+page.server.ts @@ -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 }; + } +}; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte new file mode 100644 index 00000000..6cb15754 --- /dev/null +++ b/frontend/src/routes/profile/+page.svelte @@ -0,0 +1,239 @@ + + +
+ + + + + + {m.btn_back_to_overview()} + + +

{m.profile_heading()}

+ +
+ +
+

+ {m.profile_section_personal()} +

+ + {#if form?.updateSuccess} +
+ {m.profile_saved()} +
+ {/if} + {#if form?.updateError} +
+ {form.updateError} +
+ {/if} + +
+
+ + + + + + + + + +
+ + +
+
+ + +
+

+ {m.profile_section_password()} +

+ + {#if form?.passwordSuccess} +
+ {m.profile_password_changed()} +
+ {/if} + {#if form?.passwordError} +
+ {#if form.passwordError === 'PASSWORDS_DO_NOT_MATCH'} + {m.profile_password_mismatch()} + {:else} + {form.passwordError} + {/if} +
+ {/if} + +
+
+ + + + + +
+ + +
+
+
+
diff --git a/frontend/src/routes/users/[id]/+page.server.ts b/frontend/src/routes/users/[id]/+page.server.ts new file mode 100644 index 00000000..10c3291c --- /dev/null +++ b/frontend/src/routes/users/[id]/+page.server.ts @@ -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! }; +}; diff --git a/frontend/src/routes/users/[id]/+page.svelte b/frontend/src/routes/users/[id]/+page.svelte new file mode 100644 index 00000000..c33a459d --- /dev/null +++ b/frontend/src/routes/users/[id]/+page.svelte @@ -0,0 +1,103 @@ + + +
+ + + + + + Zurück + + +

{m.user_profile_heading()}

+ +
+
+ +
+ {#if initials} +
+ {initials} +
+ {:else} +
+ + + +
+ {/if} +
+ + +
+

{fullName}

+ {#if data.profileUser.firstName || data.profileUser.lastName} +

@{data.profileUser.username}

+ {/if} +
+ + +
+ {#if data.profileUser.email} +
+ E-Mail + {data.profileUser.email} +
+ {/if} + + {#if data.profileUser.contact} +
+ Kontakt + {data.profileUser.contact} +
+ {/if} +
+
+
+