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