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:
@@ -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")
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -100,6 +100,6 @@ public class NotificationController {
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private AppUser resolveUser(Authentication authentication) {
|
||||
return userService.findByUsername(authentication.getName());
|
||||
return userService.findByEmail(authentication.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user