feat(auth): remove username field, migrate identity to email

- AppUser entity: replace username with email (NOT NULL, UNIQUE,
  colon-pattern validated)
- AppUserRepository: remove findByUsername, rename search JPQL to
  searchByEmailOrName (searches email + firstName + lastName)
- CreateUserRequest: remove username, require email with colon guard
- UserService: rename findByUsername→findByEmail, createUserOrUpdate
  upserts by email, blank-email guard throws instead of setting null
- UserController + all other controllers: findByEmail(auth.getName())
- DataInitializer: email-based config and lookup, E2E users have email
- V44 migration: pre-check + email NOT NULL + drop username column
- All tests updated: .username() builders removed, mocks updated,
  NotificationRepositoryTest fixtures include email fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 20:49:15 +02:00
committed by marcel
parent c4444a07d1
commit 5e01db1c74
28 changed files with 247 additions and 265 deletions

View File

@@ -31,8 +31,8 @@ import java.util.Set;
@DependsOn("flyway")
public class DataInitializer {
@Value("${app.admin.username:admin}")
private String adminUsername;
@Value("${app.admin.email:admin@familyarchive.local}")
private String adminEmail;
@Value("${app.admin.password:admin123}")
private String adminPassword;
@@ -43,26 +43,23 @@ public class DataInitializer {
@Bean
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
return args -> {
if (userRepository.findByUsername(adminUsername).isEmpty()) {
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminUsername);
if (userRepository.findByEmail(adminEmail).isEmpty()) {
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminEmail);
// 1. Admin Gruppe erstellen
UserGroup adminGroup = UserGroup.builder()
.name("Administrators")
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
.build();
groupRepository.save(adminGroup);
// 2. Admin User erstellen
AppUser admin = AppUser.builder()
.username(adminUsername)
.password(passwordEncoder.encode(adminPassword)) // Passwort verschlüsseln!
.email("admin@familyarchive.local")
.email(adminEmail)
.password(passwordEncoder.encode(adminPassword))
.groups(Set.of(adminGroup))
.build();
userRepository.save(admin);
log.info("Default Admin erstellt: User='{}'", adminUsername);
log.info("Default Admin erstellt: Email='{}'", adminEmail);
}
};
}
@@ -84,16 +81,13 @@ public class DataInitializer {
TagRepository tagRepo,
PasswordEncoder passwordEncoder) {
return args -> {
// Always reset the admin password to the configured value so a failed password-reset
// test from a previous run can never leave the account locked out.
userRepository.findByUsername(adminUsername).ifPresent(admin -> {
userRepository.findByEmail(adminEmail).ifPresent(admin -> {
admin.setPassword(passwordEncoder.encode(adminPassword));
userRepository.save(admin);
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
});
// Always ensure the read-only test user exists, even when seed data was already loaded.
if (userRepository.findByUsername("reader").isEmpty()) {
if (userRepository.findByEmail("reader@familyarchive.local").isEmpty()) {
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
groupRepository.save(UserGroup.builder()
@@ -101,7 +95,7 @@ public class DataInitializer {
.permissions(Set.of("READ_ALL"))
.build()));
userRepository.save(AppUser.builder()
.username("reader")
.email("reader@familyarchive.local")
.password(passwordEncoder.encode("reader123"))
.groups(Set.of(leserGroup))
.build());
@@ -131,7 +125,6 @@ public class DataInitializer {
Tag tagUrlaub = tagRepo.save(Tag.builder().name("Urlaub").build());
// ── Documents ────────────────────────────────────────────────────
// 1. Fully transcribed letter — used by search + detail E2E tests
docRepo.save(Document.builder()
.title("Geburtsurkunde Hans Müller")
.originalFilename("geburtsurkunde_hans.pdf")
@@ -144,7 +137,6 @@ public class DataInitializer {
.transcription("Hiermit wird beurkundet, dass Hans Müller am 12. April 1923 in Berlin geboren wurde.")
.build());
// 2. Letter with multiple receivers and tags — tests multi-receiver display
docRepo.save(Document.builder()
.title("Brief aus dem Krieg")
.originalFilename("brief_krieg_1944.pdf")
@@ -157,7 +149,6 @@ public class DataInitializer {
.transcription("Liebe Anna, ich schreibe dir aus der Front. Es geht mir den Umständen entsprechend gut.")
.build());
// 3. Postcard — no transcription, tests PLACEHOLDER status
docRepo.save(Document.builder()
.title("Urlaubspostkarte Ostsee")
.originalFilename("postkarte_1965.jpg")
@@ -169,7 +160,6 @@ public class DataInitializer {
.tags(Set.of(tagUrlaub))
.build());
// 4. Document with no sender — tests null-sender display ("Unbekannt")
docRepo.save(Document.builder()
.title("Unbekanntes Dokument")
.originalFilename("unbekannt.pdf")
@@ -179,7 +169,6 @@ public class DataInitializer {
.receivers(Set.of(maria))
.build());
// 5. Document with minimal metadata — tests sparse display
docRepo.save(Document.builder()
.title("Scan ohne Titel")
.originalFilename("scan_ohne_titel.pdf")

View File

@@ -72,7 +72,7 @@ public class AnnotationController {
private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
AppUser user = userService.findByUsername(authentication.getName());
AppUser user = userService.findByEmail(authentication.getName());
return user != null ? user.getId() : null;
} catch (Exception e) {
log.warn("Could not resolve user for annotation: {}", e.getMessage());

View File

@@ -144,7 +144,7 @@ public class CommentController {
private AppUser resolveUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
return userService.findByUsername(authentication.getName());
return userService.findByEmail(authentication.getName());
} catch (Exception e) {
log.warn("Could not resolve user for comment: {}", e.getMessage());
return null;

View File

@@ -100,6 +100,6 @@ public class NotificationController {
// ─── private helpers ──────────────────────────────────────────────────────
private AppUser resolveUser(Authentication authentication) {
return userService.findByUsername(authentication.getName());
return userService.findByEmail(authentication.getName());
}
}

View File

@@ -161,7 +161,7 @@ public class OcrController {
private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
AppUser user = userService.findByUsername(authentication.getName());
AppUser user = userService.findByEmail(authentication.getName());
return user != null ? user.getId() : null;
} catch (Exception e) {
log.warn("Failed to resolve user ID for authentication: {}", authentication.getName(), e);

View File

@@ -101,7 +101,7 @@ public class TranscriptionBlockController {
if (authentication == null || !authentication.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
AppUser user = userService.findByUsername(authentication.getName());
AppUser user = userService.findByEmail(authentication.getName());
if (user == null) {
throw DomainException.unauthorized("User not found");
}

View File

@@ -38,7 +38,7 @@ public class UserController {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
AppUser user = userService.findByUsername(authentication.getName());
AppUser user = userService.findByEmail(authentication.getName());
user.setPassword(null);
return ResponseEntity.ok(user);
}
@@ -46,7 +46,7 @@ public class UserController {
@PutMapping("users/me")
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
@RequestBody UpdateProfileDTO dto) {
AppUser current = userService.findByUsername(authentication.getName());
AppUser current = userService.findByEmail(authentication.getName());
AppUser updated = userService.updateProfile(current.getId(), dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
@@ -56,7 +56,7 @@ public class UserController {
@ResponseStatus(HttpStatus.NO_CONTENT)
public void changePassword(Authentication authentication,
@RequestBody ChangePasswordDTO dto) {
AppUser current = userService.findByUsername(authentication.getName());
AppUser current = userService.findByEmail(authentication.getName());
userService.changePassword(current.getId(), dto);
}

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.time.LocalDate;
@@ -9,7 +10,8 @@ import java.util.UUID;
@Data
public class CreateUserRequest {
private String username;
@NotBlank
@Pattern(regexp = "^[^:]+$", message = "Email must not contain a colon")
private String email;
private String initialPassword;
private List<UUID> groupIds;

View File

@@ -1,6 +1,8 @@
package org.raddatz.familienarchiv.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
@@ -17,7 +19,7 @@ import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "users") // Tabellenname in Postgres
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@@ -30,26 +32,25 @@ public class AppUser {
private UUID id;
@Column(unique = true, nullable = false)
@NotBlank
@Pattern(regexp = "^[^:]+$", message = "Email must not contain a colon")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String username;
private String email;
@Column(nullable = false)
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; // Wird verschlüsselt gespeichert (BCrypt)
private String password;
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
private boolean enabled = true;
@Column(nullable = false)
@Builder.Default
@@ -61,7 +62,6 @@ public class AppUser {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean notifyOnMention = false;
// Ein User kann in mehreren Gruppen sein
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
@Builder.Default
@@ -77,26 +77,21 @@ public class AppUser {
return false;
}
return this.groups.stream().anyMatch(group -> group.getPermissions().contains(permission));
}
public AppUser updateFromRequest(CreateUserRequest request, PasswordEncoder passwordEncoder, Set<UserGroup> groups) {
if (request.getUsername() != null && !request.getUsername().isBlank()) {
this.username = request.getUsername();
}
public AppUser updateFromRequest(CreateUserRequest request, PasswordEncoder passwordEncoder, Set<UserGroup> groups) {
if (request.getEmail() != null && !request.getEmail().isBlank()) {
this.email = request.getEmail();
}
if (request.getEmail() != null && !request.getEmail().isBlank()) {
this.email = request.getEmail();
}
if (request.getInitialPassword() != null && !request.getInitialPassword().isBlank()) {
this.password = passwordEncoder.encode(request.getInitialPassword());
}
if (request.getInitialPassword() != null && !request.getInitialPassword().isBlank()) {
this.password = passwordEncoder.encode(request.getInitialPassword());
}
if (groups != null && !groups.isEmpty()) {
this.groups = groups;
}
if (groups != null && !groups.isEmpty()) {
this.groups = groups;
return this;
}
return this;
}
}

View File

@@ -13,11 +13,10 @@ import java.util.UUID;
@Repository
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsername(String username);
Optional<AppUser> findByEmail(String email);
@Query("SELECT u FROM AppUser u WHERE " +
"LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))")
List<AppUser> searchByNameOrUsername(@Param("q") String q, Pageable pageable);
}
"LOWER(u.email) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%'))")
List<AppUser> searchByEmailOrName(@Param("q") String q, Pageable pageable);
}

View File

@@ -175,7 +175,7 @@ public class CommentService {
String first = author.getFirstName();
String last = author.getLastName();
if ((first == null || first.isBlank()) && (last == null || last.isBlank())) {
return author.getUsername();
return author.getEmail();
}
return ((first != null ? first : "") + " " + (last != null ? last : "")).strip();
}

View File

@@ -100,7 +100,7 @@ public class DocumentVersionService {
return null;
}
try {
return userService.findByUsername(auth.getName());
return userService.findByEmail(auth.getName());
} catch (Exception e) {
log.warn("Could not resolve editor for version snapshot: {}", e.getMessage());
return null;
@@ -114,7 +114,7 @@ public class DocumentVersionService {
if (first != null && !first.isBlank() && last != null && !last.isBlank()) {
return first + " " + last;
}
return user.getUsername();
return user.getEmail();
}
private String serializeSnapshot(Document doc) {

View File

@@ -18,6 +18,6 @@ public class UserSearchService {
public List<AppUser> search(String query) {
if (query == null || query.isBlank()) return List.of();
return userRepository.searchByNameOrUsername(query.trim(), PageRequest.of(0, MAX_RESULTS));
return userRepository.searchByEmailOrName(query.trim(), PageRequest.of(0, MAX_RESULTS));
}
}

View File

@@ -36,23 +36,22 @@ public class UserService {
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getUsername());
log.info("Creating or updating user: {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
}
Optional<AppUser> existingUser = userRepository.findByUsername(request.getUsername());
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
AppUser user;
if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getUsername());
log.info("User exists, updating: {}", request.getEmail());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
} else {
log.info("Creating new user: {}", request.getUsername());
log.info("Creating new user: {}", request.getEmail());
user = AppUser.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
@@ -158,9 +157,9 @@ public class UserService {
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));
public AppUser findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for email: " + email));
}
public List<AppUser> getAllUsers() {

View File

@@ -0,0 +1,11 @@
-- Abort if any user has no email address set.
-- All users must have an email before this migration can run.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM users WHERE email IS NULL) THEN
RAISE EXCEPTION 'Migration aborted: some users have no email address. Set emails for all users before running this migration.';
END IF;
END $$;
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP COLUMN username;