Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
149 changes: 149 additions & 0 deletions src/main/java/dev/escalated/controllers/admin/AdminUserController.java
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>> index(
@RequestParam(required = false, defaultValue = "") String search,
@PageableDefault(size = 20) Pageable pageable,
Authentication authentication) {

Page<AgentProfile> page = userService.search(search, pageable);

List<Map<String, Object>> data = page.getContent().stream()
.map(AdminUserController::toJson)
.toList();

Map<String, Object> 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<String, Object> users = new LinkedHashMap<>();
users.put("data", data);
users.put("meta", meta);

Map<String, Object> 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<Map<String, Object>> updateRole(@PathVariable Long userId,
@RequestBody UpdateRoleRequest body,
Authentication authentication) {
if (body == null || body.getRole() == null) {
Map<String, Object> 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<String, Object> response = new LinkedHashMap<>();
response.put("user", toJson(updated));
response.put("message", "User updated.");
return ResponseEntity.ok(response);
} catch (UserService.SelfDemoteException ex) {
Map<String, Object> err = new LinkedHashMap<>();
err.put("error", ex.getMessage());
return ResponseEntity.unprocessableEntity().body(err);
} catch (IllegalArgumentException ex) {
Map<String, Object> err = new LinkedHashMap<>();
err.put("error", ex.getMessage());
return ResponseEntity.badRequest().body(err);
}
}

private static Map<String, Object> toJson(AgentProfile user) {
Map<String, Object> 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;
}
}
}
22 changes: 22 additions & 0 deletions src/main/java/dev/escalated/models/AgentProfile.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,4 +25,9 @@ public interface AgentProfileRepository extends JpaRepository<AgentProfile, Long

@Query("SELECT ap FROM AgentProfile ap JOIN ap.skills s WHERE s.id = :skillId AND ap.active = true AND ap.available = true")
List<AgentProfile> 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<AgentProfile> searchOrderedByRole(@Param("search") String search, Pageable pageable);
}
87 changes: 87 additions & 0 deletions src/main/java/dev/escalated/services/UserService.java
Original file line number Diff line number Diff line change
@@ -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<AgentProfile> 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);
}
}
}
12 changes: 12 additions & 0 deletions src/main/resources/db/migration/V3__add_user_role_flags.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading