feat: user profile page and nav avatar dropdown (#35) #45
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ChangePasswordDTO {
|
||||
private String currentPassword;
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
7
frontend/src/app.d.ts
vendored
7
frontend/src/app.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
86
frontend/src/routes/layout.svelte.spec.ts
Normal file
86
frontend/src/routes/layout.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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.' });
|
||||
|
||||
54
frontend/src/routes/profile/+page.server.ts
Normal file
54
frontend/src/routes/profile/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
239
frontend/src/routes/profile/+page.svelte
Normal file
239
frontend/src/routes/profile/+page.svelte
Normal 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>
|
||||
16
frontend/src/routes/users/[id]/+page.server.ts
Normal file
16
frontend/src/routes/users/[id]/+page.server.ts
Normal 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! };
|
||||
};
|
||||
103
frontend/src/routes/users/[id]/+page.svelte
Normal file
103
frontend/src/routes/users/[id]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user