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);