From 65d08b0825337ac09510e54c33b366b156aaf3ae Mon Sep 17 00:00:00 2001 From: AppMod Upgrade Date: Thu, 30 Jul 2026 15:40:13 +0200 Subject: [PATCH 1/2] Mask email addresses in admin user list Admin GET /users now returns masked emails (e.g. r***8@gmail.com). A new GET /users/{id} endpoint returns the full email for admins. The admin dashboard shows the masked address with a "reveal" button that fetches the detail endpoint on demand, and the Edit flow also fetches /users/{id} so the edit form always gets the real address. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UserController.java | 12 ++++- .../usermanagementapi/dto/UserResponse.java | 10 ++-- src/main/resources/templates/admin.html | 51 +++++++++++-------- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/empress/usermanagementapi/controller/UserController.java b/src/main/java/com/empress/usermanagementapi/controller/UserController.java index 8d954ad..82bbf36 100644 --- a/src/main/java/com/empress/usermanagementapi/controller/UserController.java +++ b/src/main/java/com/empress/usermanagementapi/controller/UserController.java @@ -44,19 +44,27 @@ public UserController(UserService userService) { // ——— Admin-only endpoints ——— - // return all users sorted by id (ascending) + // return all users sorted by id (ascending), emails masked @GetMapping @PreAuthorize("hasRole('ADMIN')") public List getAllUsers() { log.debug("Retrieving all users"); List users = userService.findAll(Sort.by(Sort.Direction.ASC, "id")) .stream() - .map(UserResponse::fromEntity) + .map(UserResponse::fromEntityMasked) .collect(Collectors.toList()); log.debug("Retrieved {} users", users.size()); return users; } + @GetMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity getUserById(@PathVariable Long id) { + Optional opt = userService.findById(id); + if (opt.isEmpty()) return ResponseEntity.notFound().build(); + return ResponseEntity.ok(UserResponse.fromEntity(opt.get())); + } + /** * Creates a new user with validation. * diff --git a/src/main/java/com/empress/usermanagementapi/dto/UserResponse.java b/src/main/java/com/empress/usermanagementapi/dto/UserResponse.java index 0c4acd8..cee59ce 100644 --- a/src/main/java/com/empress/usermanagementapi/dto/UserResponse.java +++ b/src/main/java/com/empress/usermanagementapi/dto/UserResponse.java @@ -2,6 +2,7 @@ import com.empress.usermanagementapi.entity.Role; import com.empress.usermanagementapi.entity.User; +import com.empress.usermanagementapi.util.LoggingUtil; /** * Data Transfer Object for User responses in REST API. @@ -19,9 +20,6 @@ public class UserResponse { public UserResponse() { } - /** - * Create a UserResponse from a User entity. - */ public static UserResponse fromEntity(User user) { UserResponse response = new UserResponse(); response.setId(user.getId()); @@ -32,6 +30,12 @@ public static UserResponse fromEntity(User user) { return response; } + public static UserResponse fromEntityMasked(User user) { + UserResponse response = fromEntity(user); + response.setEmail(LoggingUtil.maskEmail(user.getEmail())); + return response; + } + // Getters and setters public Long getId() { diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index a9c31ae..c1a9e83 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -376,7 +376,10 @@
Health Details
tr.innerHTML = ` ${u.id} ${u.username} - ${u.email} + + ${u.email} + + ${u.role} @@ -392,14 +395,18 @@
Health Details
} function attachHandlers(cachedUsers) { - // Edit → copy row values into the bottom form and highlight row + // Edit → fetch full user detail (unmasked email) then populate form document.querySelectorAll('.edit-in-form').forEach(btn => { - btn.onclick = () => { + btn.onclick = async () => { const id = +btn.dataset.id; - const u = cachedUsers.find(x => x.id === id); - if (!u) return; + let u; + try { + u = await authFetch(`/users/${id}`, { method: 'GET' }); + } catch (err) { + alert('Could not load user: ' + err.message); + return; + } - // copy values to form (ONLY editable place) document.getElementById('newUsername').value = u.username; document.getElementById('newEmail').value = u.email; document.getElementById('newRole').value = u.role; @@ -410,12 +417,25 @@
Health Details
setFormMode('update'); highlightRow(id); - // bring form into view document.getElementById('createForm') .scrollIntoView({behavior:'smooth', block:'center'}); }; }); + // Reveal → fetch full user detail and replace masked email in table + document.querySelectorAll('.reveal-email').forEach(btn => { + btn.onclick = async () => { + const id = +btn.dataset.id; + try { + const u = await authFetch(`/users/${id}`, { method: 'GET' }); + document.getElementById(`email-display-${id}`).textContent = u.email; + btn.remove(); + } catch (err) { + alert('Could not reveal email: ' + err.message); + } + }; + }); + // Delete document.querySelectorAll('.delete-btn').forEach(btn => { btn.onclick = async () => { @@ -519,30 +539,19 @@
Health Details
try { const users = await authFetch('/users', { method: 'GET' }); - const lowerU = username.toLowerCase(), lowerE = email.toLowerCase(); + const lowerU = username.toLowerCase(); - // exclude record being edited from duplicate checks + // username duplicate check (emails are masked in list, so email check is server-side) const usernameTaken = users.some(u => (editingUserId ? u.id !== editingUserId : true) && (u.username||'').toLowerCase() === lowerU ); - const emailTaken = users.some(u => - (editingUserId ? u.id !== editingUserId : true) && - (u.email||'').toLowerCase() === lowerE - ); - let blocked = false; if (usernameTaken){ usernameError.textContent = 'Username already exists.'; usernameError.classList.remove('d-none'); - blocked = true; - } - if (emailTaken){ - emailError.textContent = 'Email already in use.'; - emailError.classList.remove('d-none'); - blocked = true; + return; } - if (blocked) return; if (editingUserId){ // update existing user const payload = { username, email, role }; From 3bb6ab033f05356eb3374ad8d064943a6d6199d8 Mon Sep 17 00:00:00 2001 From: AppMod Upgrade Date: Thu, 30 Jul 2026 15:47:00 +0200 Subject: [PATCH 2/2] Address Codex review: stale edit guard and case-insensitive email check Guard against stale out-of-order fetch responses in the admin edit handler by tracking pendingEditId and discarding responses that arrive after a newer edit click. Fix case-insensitive email duplicate detection: add existsByEmailIgnoreCase to UserRepository, use it in UserService, and tighten the update guard to equalsIgnoreCase so casing variants are caught consistently at the server layer. Co-Authored-By: Claude Sonnet 4.6 --- .../empress/usermanagementapi/controller/UserController.java | 2 +- .../empress/usermanagementapi/repository/UserRepository.java | 1 + .../com/empress/usermanagementapi/service/UserService.java | 2 +- src/main/resources/templates/admin.html | 5 ++++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/empress/usermanagementapi/controller/UserController.java b/src/main/java/com/empress/usermanagementapi/controller/UserController.java index 82bbf36..5ac849a 100644 --- a/src/main/java/com/empress/usermanagementapi/controller/UserController.java +++ b/src/main/java/com/empress/usermanagementapi/controller/UserController.java @@ -158,7 +158,7 @@ public ResponseEntity updateUser(@PathVariable Long id, } // Check if email is being changed to a different email - if (!request.getEmail().equals(existing.getEmail())) { + if (!request.getEmail().equalsIgnoreCase(existing.getEmail())) { // Check if the new email is already taken by another user if (userService.emailExists(request.getEmail())) { log.warn("User update failed - email already exists: {}", diff --git a/src/main/java/com/empress/usermanagementapi/repository/UserRepository.java b/src/main/java/com/empress/usermanagementapi/repository/UserRepository.java index fdd5253..785e496 100644 --- a/src/main/java/com/empress/usermanagementapi/repository/UserRepository.java +++ b/src/main/java/com/empress/usermanagementapi/repository/UserRepository.java @@ -8,6 +8,7 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); Optional findByUsernameAndEmail(String username, String email); User findByUsername(String username); + boolean existsByEmailIgnoreCase(String email); boolean existsByEmail(String email); boolean existsByUsername(String username); } diff --git a/src/main/java/com/empress/usermanagementapi/service/UserService.java b/src/main/java/com/empress/usermanagementapi/service/UserService.java index f1c4e78..fc415eb 100644 --- a/src/main/java/com/empress/usermanagementapi/service/UserService.java +++ b/src/main/java/com/empress/usermanagementapi/service/UserService.java @@ -29,7 +29,7 @@ public UserService(UserRepository userRepo, PasswordEncoder passwordEncoder) { * Check if an email is already in use. */ public boolean emailExists(String email) { - return userRepo.existsByEmail(email); + return userRepo.existsByEmailIgnoreCase(email); } /** diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index c1a9e83..8280d53 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -297,6 +297,7 @@
Health Details
const API = ''; let editingUserId = null; // when set, the Create button acts as Update let editingUserName = null; // for heading text + let pendingEditId = null; // guards against stale out-of-order fetch responses async function authFetch(path, opts = {}) { opts.credentials = 'include'; @@ -399,13 +400,15 @@
Health Details
document.querySelectorAll('.edit-in-form').forEach(btn => { btn.onclick = async () => { const id = +btn.dataset.id; + pendingEditId = id; let u; try { u = await authFetch(`/users/${id}`, { method: 'GET' }); } catch (err) { - alert('Could not load user: ' + err.message); + if (pendingEditId === id) alert('Could not load user: ' + err.message); return; } + if (pendingEditId !== id) return; // superseded by a later edit click document.getElementById('newUsername').value = u.username; document.getElementById('newEmail').value = u.email;