From 1dd03f129f3016e33474ae9a767d73b770ee90e2 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 10 May 2026 18:49:12 -0400 Subject: [PATCH] feat(admin): users-management page with admin/agent role toggles Adds an admin users-management endpoint backing the `Escalated/Admin/Users/Index` Inertia page shipped with the shared `@escalated-dev/escalated` frontend package. Admins can grant or revoke the `is_admin` / `is_agent` flags from the panel instead of editing the database directly. - New `AdminUserController` exposes the agent-profile table (paged, searchable on name + email). `PATCH /escalated/api/admin/users/{userId}/role` flips one role at a time via `UpdateRoleRequest{ role, value }`. - `UserService.updateRole` enforces the cross-role rules: promoting to admin also flips agent on; demoting an admin via the agent toggle revokes both flags in one step. - Self-demote on admin is rejected server-side (HTTP 422), so an admin cannot lock themselves out of the panel they are using. - `V3__add_user_role_flags.sql` adds `is_admin` and `is_agent` columns to `escalated_agent_profiles`. Hosts wiring authorisation differently (Spring Security GrantedAuthorities, a custom user table, etc.) can override the controller in their own configuration. Mirrors the Laravel reference port escalated-laravel#94. --- CHANGELOG.md | 3 + .../admin/AdminUserController.java | 149 ++++++++++++++++ .../dev/escalated/models/AgentProfile.java | 22 +++ .../repositories/AgentProfileRepository.java | 7 + .../dev/escalated/services/UserService.java | 87 +++++++++ .../db/migration/V3__add_user_role_flags.sql | 12 ++ .../controllers/AdminUserControllerTest.java | 166 ++++++++++++++++++ 7 files changed, 446 insertions(+) create mode 100644 src/main/java/dev/escalated/controllers/admin/AdminUserController.java create mode 100644 src/main/java/dev/escalated/services/UserService.java create mode 100644 src/main/resources/db/migration/V3__add_user_role_flags.sql create mode 100644 src/test/java/dev/escalated/controllers/AdminUserControllerTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 740e1c8..852fe2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Admin users-management endpoint (`GET /escalated/api/admin/users`, `PATCH /escalated/api/admin/users/{userId}/role`) backing the `Escalated/Admin/Users/Index` page in the shared frontend. Lets an admin grant or revoke the `is_admin` / `is_agent` flags from the panel, with self-demote protection so an admin cannot lock themselves out. Mirrors the Laravel reference (`escalated-laravel#94`). + ### Changed - Translations are now consumed from the central `dev.escalated:escalated-locale` Maven artifact via a chained `ReloadableResourceBundleMessageSource`. Host apps can layer sparse overrides under `classpath:i18n/overrides/messages_{locale}.properties`. diff --git a/src/main/java/dev/escalated/controllers/admin/AdminUserController.java b/src/main/java/dev/escalated/controllers/admin/AdminUserController.java new file mode 100644 index 0000000..586858f --- /dev/null +++ b/src/main/java/dev/escalated/controllers/admin/AdminUserController.java @@ -0,0 +1,149 @@ +package dev.escalated.controllers.admin; + +import dev.escalated.models.AgentProfile; +import dev.escalated.repositories.AgentProfileRepository; +import dev.escalated.services.UserService; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Admin users-management endpoint. Lists users (host agent profiles) with + * their admin / agent flags and lets an admin grant or revoke either + * role. Backs the {@code Escalated/Admin/Users/Index} Inertia page shipped + * with the shared {@code @escalated-dev/escalated} frontend package. + */ +@RestController +@RequestMapping("/escalated/api/admin/users") +public class AdminUserController { + + private final UserService userService; + private final AgentProfileRepository agentRepository; + + public AdminUserController(UserService userService, AgentProfileRepository agentRepository) { + this.userService = userService; + this.agentRepository = agentRepository; + } + + @GetMapping + public ResponseEntity> index( + @RequestParam(required = false, defaultValue = "") String search, + @PageableDefault(size = 20) Pageable pageable, + Authentication authentication) { + + Page page = userService.search(search, pageable); + + List> data = page.getContent().stream() + .map(AdminUserController::toJson) + .toList(); + + Map meta = new LinkedHashMap<>(); + meta.put("current_page", page.getNumber() + 1); + meta.put("per_page", page.getSize()); + meta.put("total", page.getTotalElements()); + meta.put("last_page", Math.max(1, page.getTotalPages())); + + Map users = new LinkedHashMap<>(); + users.put("data", data); + users.put("meta", meta); + + Map props = new LinkedHashMap<>(); + props.put("users", users); + props.put("filters", Map.of("search", search == null ? "" : search)); + props.put("currentUserId", currentUserId(authentication)); + + return ResponseEntity.ok(props); + } + + @PatchMapping("/{userId}/role") + public ResponseEntity> updateRole(@PathVariable Long userId, + @RequestBody UpdateRoleRequest body, + Authentication authentication) { + if (body == null || body.getRole() == null) { + Map err = new HashMap<>(); + err.put("error", "Invalid role."); + return ResponseEntity.badRequest().body(err); + } + + try { + AgentProfile updated = userService.updateRole( + userId, + body.getRole(), + body.isValue(), + currentUserId(authentication)); + + Map response = new LinkedHashMap<>(); + response.put("user", toJson(updated)); + response.put("message", "User updated."); + return ResponseEntity.ok(response); + } catch (UserService.SelfDemoteException ex) { + Map err = new LinkedHashMap<>(); + err.put("error", ex.getMessage()); + return ResponseEntity.unprocessableEntity().body(err); + } catch (IllegalArgumentException ex) { + Map err = new LinkedHashMap<>(); + err.put("error", ex.getMessage()); + return ResponseEntity.badRequest().body(err); + } + } + + private static Map toJson(AgentProfile user) { + Map json = new LinkedHashMap<>(); + json.put("id", user.getId()); + json.put("name", user.getName()); + json.put("email", user.getEmail()); + json.put("is_admin", user.isAdmin()); + json.put("is_agent", user.isAgent()); + return json; + } + + /** + * Resolve the calling user's profile id from the security context. The + * filter chain (see {@code ApiTokenAuthenticationFilter}) sets the + * principal to the agent email; we look up the profile by email to get + * the numeric id we compare against the target. + */ + private Long currentUserId(Authentication authentication) { + if (authentication == null || authentication.getName() == null) { + return null; + } + return agentRepository.findByEmail(authentication.getName()) + .map(AgentProfile::getId) + .orElse(null); + } + + /** Request body for {@code PATCH /admin/users/{userId}/role}. */ + public static class UpdateRoleRequest { + private String role; + private boolean value; + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public boolean isValue() { + return value; + } + + public void setValue(boolean value) { + this.value = value; + } + } +} diff --git a/src/main/java/dev/escalated/models/AgentProfile.java b/src/main/java/dev/escalated/models/AgentProfile.java index 781bf1b..a0cba73 100644 --- a/src/main/java/dev/escalated/models/AgentProfile.java +++ b/src/main/java/dev/escalated/models/AgentProfile.java @@ -38,6 +38,12 @@ public class AgentProfile extends BaseEntity { @Column(name = "is_available", nullable = false) private boolean available = true; + @Column(name = "is_admin", nullable = false) + private boolean admin = false; + + @Column(name = "is_agent", nullable = false) + private boolean agent = true; + @Column private String avatar; @@ -115,6 +121,22 @@ public void setAvailable(boolean available) { this.available = available; } + public boolean isAdmin() { + return admin; + } + + public void setAdmin(boolean admin) { + this.admin = admin; + } + + public boolean isAgent() { + return agent; + } + + public void setAgent(boolean agent) { + this.agent = agent; + } + public String getAvatar() { return avatar; } diff --git a/src/main/java/dev/escalated/repositories/AgentProfileRepository.java b/src/main/java/dev/escalated/repositories/AgentProfileRepository.java index cfb1392..57aa216 100644 --- a/src/main/java/dev/escalated/repositories/AgentProfileRepository.java +++ b/src/main/java/dev/escalated/repositories/AgentProfileRepository.java @@ -3,6 +3,8 @@ import dev.escalated.models.AgentProfile; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -23,4 +25,9 @@ public interface AgentProfileRepository extends JpaRepository findAvailableAgentsWithSkill(@Param("skillId") Long skillId); + + @Query("SELECT ap FROM AgentProfile ap " + + "WHERE (:search IS NULL OR LOWER(ap.email) LIKE :search OR LOWER(ap.name) LIKE :search) " + + "ORDER BY ap.admin DESC, ap.agent DESC, ap.id ASC") + Page searchOrderedByRole(@Param("search") String search, Pageable pageable); } diff --git a/src/main/java/dev/escalated/services/UserService.java b/src/main/java/dev/escalated/services/UserService.java new file mode 100644 index 0000000..a6d974b --- /dev/null +++ b/src/main/java/dev/escalated/services/UserService.java @@ -0,0 +1,87 @@ +package dev.escalated.services; + +import dev.escalated.models.AgentProfile; +import dev.escalated.repositories.AgentProfileRepository; +import jakarta.persistence.EntityNotFoundException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Surface enough of the host user table for an admin to grant or revoke + * agent / admin access from the panel. The default Spring port pins this + * to the {@code is_admin} / {@code is_agent} columns on + * {@link AgentProfile} — hosts wiring authorisation differently (Spring + * Security {@code GrantedAuthority}, a custom user table, etc.) should + * override {@link dev.escalated.controllers.admin.AdminUserController} + * in their own configuration. + */ +@Service +public class UserService { + + public static final String ERROR_CANNOT_SELF_DEMOTE = "You cannot remove your own admin role."; + + private final AgentProfileRepository agentRepository; + + public UserService(AgentProfileRepository agentRepository) { + this.agentRepository = agentRepository; + } + + @Transactional(readOnly = true) + public Page search(String search, Pageable pageable) { + String term = (search == null || search.isBlank()) + ? null + : "%" + search.trim().toLowerCase() + "%"; + return agentRepository.searchOrderedByRole(term, pageable); + } + + /** + * Flip a single role on a target user. Returns the updated profile, or + * throws {@link SelfDemoteException} if the caller tries to remove + * their own admin role (which would lock them out of the panel they + * are using). + */ + @Transactional + public AgentProfile updateRole(Long targetId, String role, boolean value, Long currentUserId) { + if (!"admin".equals(role) && !"agent".equals(role)) { + throw new IllegalArgumentException("Invalid role: " + role); + } + + AgentProfile target = agentRepository.findById(targetId) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + targetId)); + + // Don't let an admin demote themselves and lock themselves out of + // the admin panel they're trying to use. + if ("admin".equals(role) && !value && currentUserId != null + && currentUserId.equals(target.getId())) { + throw new SelfDemoteException(ERROR_CANNOT_SELF_DEMOTE); + } + + if ("admin".equals(role)) { + target.setAdmin(value); + // Admins are agents; flipping admin off does not also revoke + // agent (an ex-admin can still answer tickets unless explicitly + // demoted). + if (value) { + target.setAgent(true); + } + } else { + target.setAgent(value); + if (!value && target.isAdmin()) { + // Revoking agent from an admin would leave the admin gate + // on but the agent gate off — confusing. Demote fully. + target.setAdmin(false); + } + } + + return agentRepository.save(target); + } + + /** Thrown when an admin tries to remove their own admin role. */ + public static class SelfDemoteException extends RuntimeException { + public SelfDemoteException(String message) { + super(message); + } + } +} diff --git a/src/main/resources/db/migration/V3__add_user_role_flags.sql b/src/main/resources/db/migration/V3__add_user_role_flags.sql new file mode 100644 index 0000000..36757b7 --- /dev/null +++ b/src/main/resources/db/migration/V3__add_user_role_flags.sql @@ -0,0 +1,12 @@ +-- Surface admin / agent role flags directly on the agent profile, so the +-- admin users-management page can grant or revoke either role without +-- assuming the host application has its own boolean columns. Hosts using +-- a different role implementation (Spring Security GrantedAuthorities, +-- a custom user table, etc.) can override AdminUserController in their +-- own configuration. + +ALTER TABLE escalated_agent_profiles + ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE escalated_agent_profiles + ADD COLUMN is_agent BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/src/test/java/dev/escalated/controllers/AdminUserControllerTest.java b/src/test/java/dev/escalated/controllers/AdminUserControllerTest.java new file mode 100644 index 0000000..6dc0941 --- /dev/null +++ b/src/test/java/dev/escalated/controllers/AdminUserControllerTest.java @@ -0,0 +1,166 @@ +package dev.escalated.controllers; + +import dev.escalated.controllers.admin.AdminUserController; +import dev.escalated.models.AgentProfile; +import dev.escalated.repositories.AgentProfileRepository; +import dev.escalated.security.ApiTokenAuthenticationFilter; +import dev.escalated.services.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * MockMvc coverage for the admin users-management endpoint. Mirrors the + * seven scenarios in the Laravel reference port (escalated-laravel#94). + */ +@WebMvcTest(AdminUserController.class) +@AutoConfigureMockMvc(addFilters = false) +@Import(UserService.class) +class AdminUserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ApiTokenAuthenticationFilter apiTokenFilter; + + @MockitoBean + private AgentProfileRepository agentRepository; + + @Test + void index_listsUsersWithAdminAndAgentFlags() throws Exception { + AgentProfile admin = user(1L, "Alice", "admin@example.com", true, true); + AgentProfile customer = user(2L, "Customer", "customer@example.com", false, false); + AgentProfile agent = user(3L, "Agent", "agent@example.com", false, true); + + Page page = new PageImpl<>(List.of(admin, agent, customer), PageRequest.of(0, 20), 3); + when(agentRepository.searchOrderedByRole(eq(null), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/escalated/api/admin/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.users.data[0].email").value("admin@example.com")) + .andExpect(jsonPath("$.users.data[0].is_admin").value(true)) + .andExpect(jsonPath("$.users.data[1].email").value("agent@example.com")) + .andExpect(jsonPath("$.users.data[2].email").value("customer@example.com")) + .andExpect(jsonPath("$.users.meta.total").value(3)) + .andExpect(jsonPath("$.filters.search").value("")); + } + + @Test + void index_filtersBySearchTerm() throws Exception { + AgentProfile match = user(10L, "Jane Acme", "jane@acme.test", false, true); + + Page page = new PageImpl<>(List.of(match), PageRequest.of(0, 20), 1); + when(agentRepository.searchOrderedByRole(eq("%acme%"), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/escalated/api/admin/users").param("search", "acme")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.users.data[0].email").value("jane@acme.test")) + .andExpect(jsonPath("$.users.data[1]").doesNotExist()) + .andExpect(jsonPath("$.filters.search").value("acme")); + } + + @Test + void updateRole_promotesUserToAdmin_alsoFlipsAgentOn() throws Exception { + AgentProfile target = user(5L, "Target", "target@example.com", false, false); + when(agentRepository.findById(5L)).thenReturn(Optional.of(target)); + when(agentRepository.save(any(AgentProfile.class))).thenAnswer(inv -> inv.getArgument(0)); + + mockMvc.perform(patch("/escalated/api/admin/users/5/role") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"admin\",\"value\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user.is_admin").value(true)) + .andExpect(jsonPath("$.user.is_agent").value(true)); + } + + @Test + void updateRole_promotesUserToAgentOnly_leavesAdminFalse() throws Exception { + AgentProfile target = user(5L, "Target", "target@example.com", false, false); + when(agentRepository.findById(5L)).thenReturn(Optional.of(target)); + when(agentRepository.save(any(AgentProfile.class))).thenAnswer(inv -> inv.getArgument(0)); + + mockMvc.perform(patch("/escalated/api/admin/users/5/role") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"agent\",\"value\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user.is_agent").value(true)) + .andExpect(jsonPath("$.user.is_admin").value(false)); + } + + @Test + @WithMockUser(username = "admin@example.com") + void updateRole_rejectsAdminSelfDemotion() throws Exception { + AgentProfile self = user(7L, "Self Admin", "admin@example.com", true, true); + when(agentRepository.findByEmail("admin@example.com")).thenReturn(Optional.of(self)); + when(agentRepository.findById(7L)).thenReturn(Optional.of(self)); + + mockMvc.perform(patch("/escalated/api/admin/users/7/role") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"admin\",\"value\":false}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.error").value(UserService.ERROR_CANNOT_SELF_DEMOTE)); + + verify(agentRepository, never()).save(any(AgentProfile.class)); + } + + @Test + void updateRole_demotingAdminViaAgentToggle_revokesBoth() throws Exception { + AgentProfile target = user(8L, "Was Admin", "was@example.com", true, true); + when(agentRepository.findById(8L)).thenReturn(Optional.of(target)); + when(agentRepository.save(any(AgentProfile.class))).thenAnswer(inv -> inv.getArgument(0)); + + mockMvc.perform(patch("/escalated/api/admin/users/8/role") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"agent\",\"value\":false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user.is_admin").value(false)) + .andExpect(jsonPath("$.user.is_agent").value(false)); + } + + @Test + void updateRole_rejectsUnknownRole() throws Exception { + mockMvc.perform(patch("/escalated/api/admin/users/1/role") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"superuser\",\"value\":true}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").exists()); + + verify(agentRepository, never()).save(any(AgentProfile.class)); + verify(agentRepository, never()).findById(anyLong()); + } + + private static AgentProfile user(Long id, String name, String email, boolean admin, boolean agent) { + AgentProfile u = new AgentProfile(); + u.setId(id); + u.setName(name); + u.setEmail(email); + u.setAdmin(admin); + u.setAgent(agent); + return u; + } +}