feat(backend): add user profile fields and profile/password endpoints

Add firstName, lastName, birthDate, contact to AppUser via V7 migration.
Add PUT /api/users/me and POST /api/users/me/password endpoints.
Add GET /api/users/{id} for public profile lookup.
Add EMAIL_ALREADY_IN_USE and WRONG_CURRENT_PASSWORD error codes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-20 23:02:55 +01:00
parent da0d5495d0
commit 2f803b2740
10 changed files with 221 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,9 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -66,6 +68,45 @@ public class UserService {
userRepository.delete(user);
}
public AppUser getById(UUID id) {
return userRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
}
@Transactional
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
AppUser user = getById(userId);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
if (!existing.getId().equals(userId)) {
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE,
"E-Mail wird bereits von einem anderen Konto verwendet");
}
});
user.setEmail(dto.getEmail().trim());
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
user.setEmail(null);
}
user.setFirstName(dto.getFirstName());
user.setLastName(dto.getLastName());
user.setBirthDate(dto.getBirthDate());
user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim());
return userRepository.save(user);
}
@Transactional
public void changePassword(UUID userId, ChangePasswordDTO dto) {
AppUser user = getById(userId);
if (!passwordEncoder.matches(dto.getCurrentPassword(), user.getPassword())) {
throw DomainException.badRequest(ErrorCode.WRONG_CURRENT_PASSWORD,
"Das aktuelle Passwort ist falsch");
}
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
userRepository.save(user);
}
public AppUser findByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username));

View File

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

View File

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