Compare commits
19 Commits
ecd76bda97
...
f01487ccb4
| Author | SHA1 | Date | |
|---|---|---|---|
| f01487ccb4 | |||
|
|
033cfcf5fc | ||
|
|
6d650cdf34 | ||
|
|
6f5b5b8655 | ||
|
|
653ef958ed | ||
|
|
48b17f83a3 | ||
|
|
9d08e74913 | ||
|
|
f42e6279e6 | ||
|
|
d025919f8d | ||
|
|
db6143f9da | ||
|
|
4821ddebba | ||
|
|
65001e0ed0 | ||
|
|
1881aca0e4 | ||
|
|
4842507ff3 | ||
|
|
708aae720c | ||
|
|
ebe97bd386 | ||
|
|
01295c84d8 | ||
|
|
eb0cc8c141 | ||
|
|
b06b3f52a8 |
@@ -11,6 +11,8 @@ import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.agent.AgentState;
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
import com.cameleer3.server.core.rbac.SystemRole;
|
||||
import com.cameleer3.server.core.security.Ed25519SigningService;
|
||||
import com.cameleer3.server.core.security.InvalidTokenException;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
@@ -50,17 +52,20 @@ public class AgentRegistrationController {
|
||||
private final BootstrapTokenValidator bootstrapTokenValidator;
|
||||
private final JwtService jwtService;
|
||||
private final Ed25519SigningService ed25519SigningService;
|
||||
private final RbacService rbacService;
|
||||
|
||||
public AgentRegistrationController(AgentRegistryService registryService,
|
||||
AgentRegistryConfig config,
|
||||
BootstrapTokenValidator bootstrapTokenValidator,
|
||||
JwtService jwtService,
|
||||
Ed25519SigningService ed25519SigningService) {
|
||||
Ed25519SigningService ed25519SigningService,
|
||||
RbacService rbacService) {
|
||||
this.registryService = registryService;
|
||||
this.config = config;
|
||||
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
||||
this.jwtService = jwtService;
|
||||
this.ed25519SigningService = ed25519SigningService;
|
||||
this.rbacService = rbacService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@@ -97,6 +102,9 @@ public class AgentRegistrationController {
|
||||
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
|
||||
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
|
||||
|
||||
// Assign AGENT role via RBAC
|
||||
rbacService.assignRoleToUser(request.agentId(), SystemRole.AGENT_ID);
|
||||
|
||||
// Issue JWT tokens with AGENT role
|
||||
List<String> roles = List.of("AGENT");
|
||||
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditResult;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
import com.cameleer3.server.core.rbac.GroupDetail;
|
||||
import com.cameleer3.server.core.rbac.GroupRepository;
|
||||
import com.cameleer3.server.core.rbac.GroupSummary;
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Admin endpoints for group management.
|
||||
* Protected by {@code ROLE_ADMIN}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/groups")
|
||||
@Tag(name = "Group Admin", description = "Group management (ADMIN only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class GroupAdminController {
|
||||
|
||||
private final GroupRepository groupRepository;
|
||||
private final RbacService rbacService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public GroupAdminController(GroupRepository groupRepository, RbacService rbacService,
|
||||
AuditService auditService) {
|
||||
this.groupRepository = groupRepository;
|
||||
this.rbacService = rbacService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all groups with hierarchy and effective roles")
|
||||
@ApiResponse(responseCode = "200", description = "Group list returned")
|
||||
public ResponseEntity<List<GroupDetail>> listGroups() {
|
||||
List<GroupSummary> summaries = groupRepository.findAll();
|
||||
List<GroupDetail> details = new ArrayList<>();
|
||||
for (GroupSummary summary : summaries) {
|
||||
groupRepository.findById(summary.id()).ifPresent(details::add);
|
||||
}
|
||||
return ResponseEntity.ok(details);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get group by ID with effective roles")
|
||||
@ApiResponse(responseCode = "200", description = "Group found")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
public ResponseEntity<GroupDetail> getGroup(@PathVariable UUID id) {
|
||||
return groupRepository.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new group")
|
||||
@ApiResponse(responseCode = "200", description = "Group created")
|
||||
public ResponseEntity<Map<String, UUID>> createGroup(@RequestBody CreateGroupRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UUID id = groupRepository.create(request.name(), request.parentGroupId());
|
||||
auditService.log("create_group", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(Map.of("id", id));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update group name or parent")
|
||||
@ApiResponse(responseCode = "200", description = "Group updated")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
@ApiResponse(responseCode = "409", description = "Cycle detected in group hierarchy")
|
||||
public ResponseEntity<Void> updateGroup(@PathVariable UUID id,
|
||||
@RequestBody UpdateGroupRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
Optional<GroupDetail> existing = groupRepository.findById(id);
|
||||
if (existing.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Cycle detection: walk ancestor chain of proposed parent and check if it includes 'id'
|
||||
if (request.parentGroupId() != null) {
|
||||
List<GroupSummary> ancestors = groupRepository.findAncestorChain(request.parentGroupId());
|
||||
for (GroupSummary ancestor : ancestors) {
|
||||
if (ancestor.id().equals(id)) {
|
||||
return ResponseEntity.status(409).build();
|
||||
}
|
||||
}
|
||||
// Also check that the proposed parent itself is not the group being updated
|
||||
if (request.parentGroupId().equals(id)) {
|
||||
return ResponseEntity.status(409).build();
|
||||
}
|
||||
}
|
||||
|
||||
groupRepository.update(id, request.name(), request.parentGroupId());
|
||||
auditService.log("update_group", AuditCategory.RBAC, id.toString(),
|
||||
null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Delete group")
|
||||
@ApiResponse(responseCode = "204", description = "Group deleted")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (groupRepository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
groupRepository.delete(id);
|
||||
auditService.log("delete_group", AuditCategory.RBAC, id.toString(),
|
||||
null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/roles/{roleId}")
|
||||
@Operation(summary = "Assign a role to a group")
|
||||
@ApiResponse(responseCode = "200", description = "Role assigned to group")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
public ResponseEntity<Void> assignRoleToGroup(@PathVariable UUID id,
|
||||
@PathVariable UUID roleId,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (groupRepository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
groupRepository.addRole(id, roleId);
|
||||
auditService.log("assign_role_to_group", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/roles/{roleId}")
|
||||
@Operation(summary = "Remove a role from a group")
|
||||
@ApiResponse(responseCode = "204", description = "Role removed from group")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
public ResponseEntity<Void> removeRoleFromGroup(@PathVariable UUID id,
|
||||
@PathVariable UUID roleId,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (groupRepository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
groupRepository.removeRole(id, roleId);
|
||||
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
public record CreateGroupRequest(String name, UUID parentGroupId) {}
|
||||
public record UpdateGroupRequest(String name, UUID parentGroupId) {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
import com.cameleer3.server.core.rbac.RbacStats;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Admin endpoint for RBAC statistics.
|
||||
* Protected by {@code ROLE_ADMIN}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/rbac")
|
||||
@Tag(name = "RBAC Stats", description = "RBAC statistics (ADMIN only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class RbacStatsController {
|
||||
|
||||
private final RbacService rbacService;
|
||||
|
||||
public RbacStatsController(RbacService rbacService) {
|
||||
this.rbacService = rbacService;
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
@Operation(summary = "Get RBAC statistics for the dashboard")
|
||||
@ApiResponse(responseCode = "200", description = "RBAC stats returned")
|
||||
public ResponseEntity<RbacStats> getStats() {
|
||||
return ResponseEntity.ok(rbacService.getStats());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditResult;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
import com.cameleer3.server.core.rbac.RoleDetail;
|
||||
import com.cameleer3.server.core.rbac.RoleRepository;
|
||||
import com.cameleer3.server.core.rbac.SystemRole;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Admin endpoints for role management.
|
||||
* Protected by {@code ROLE_ADMIN}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/roles")
|
||||
@Tag(name = "Role Admin", description = "Role management (ADMIN only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class RoleAdminController {
|
||||
|
||||
private final RoleRepository roleRepository;
|
||||
private final RbacService rbacService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public RoleAdminController(RoleRepository roleRepository, RbacService rbacService,
|
||||
AuditService auditService) {
|
||||
this.roleRepository = roleRepository;
|
||||
this.rbacService = rbacService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all roles (system and custom)")
|
||||
@ApiResponse(responseCode = "200", description = "Role list returned")
|
||||
public ResponseEntity<List<RoleDetail>> listRoles() {
|
||||
return ResponseEntity.ok(roleRepository.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get role by ID with effective principals")
|
||||
@ApiResponse(responseCode = "200", description = "Role found")
|
||||
@ApiResponse(responseCode = "404", description = "Role not found")
|
||||
public ResponseEntity<RoleDetail> getRole(@PathVariable UUID id) {
|
||||
return roleRepository.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a custom role")
|
||||
@ApiResponse(responseCode = "200", description = "Role created")
|
||||
public ResponseEntity<Map<String, UUID>> createRole(@RequestBody CreateRoleRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
String desc = request.description() != null ? request.description() : "";
|
||||
String sc = request.scope() != null ? request.scope() : "custom";
|
||||
UUID id = roleRepository.create(request.name(), desc, sc);
|
||||
auditService.log("create_role", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(Map.of("id", id));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update a custom role")
|
||||
@ApiResponse(responseCode = "200", description = "Role updated")
|
||||
@ApiResponse(responseCode = "403", description = "Cannot modify system role")
|
||||
@ApiResponse(responseCode = "404", description = "Role not found")
|
||||
public ResponseEntity<Void> updateRole(@PathVariable UUID id,
|
||||
@RequestBody UpdateRoleRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (SystemRole.isSystem(id)) {
|
||||
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("reason", "system_role_protected"), AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
if (roleRepository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
roleRepository.update(id, request.name(), request.description(), request.scope());
|
||||
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
|
||||
null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Delete a custom role")
|
||||
@ApiResponse(responseCode = "204", description = "Role deleted")
|
||||
@ApiResponse(responseCode = "403", description = "Cannot delete system role")
|
||||
@ApiResponse(responseCode = "404", description = "Role not found")
|
||||
public ResponseEntity<Void> deleteRole(@PathVariable UUID id,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (SystemRole.isSystem(id)) {
|
||||
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("reason", "system_role_protected"), AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
if (roleRepository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
roleRepository.delete(id);
|
||||
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
|
||||
null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
public record CreateRoleRequest(String name, String description, String scope) {}
|
||||
public record UpdateRoleRequest(String name, String description, String scope) {}
|
||||
}
|
||||
@@ -3,28 +3,36 @@ package com.cameleer3.server.app.controller;
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditResult;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
import com.cameleer3.server.core.rbac.SystemRole;
|
||||
import com.cameleer3.server.core.rbac.UserDetail;
|
||||
import com.cameleer3.server.core.security.UserInfo;
|
||||
import com.cameleer3.server.core.security.UserRepository;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Admin endpoints for user management.
|
||||
* Protected by {@code ROLE_ADMIN} via SecurityConfig URL patterns.
|
||||
* Protected by {@code ROLE_ADMIN}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/users")
|
||||
@@ -32,47 +40,127 @@ import java.util.Map;
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class UserAdminController {
|
||||
|
||||
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
private final RbacService rbacService;
|
||||
private final UserRepository userRepository;
|
||||
private final AuditService auditService;
|
||||
|
||||
public UserAdminController(UserRepository userRepository, AuditService auditService) {
|
||||
public UserAdminController(RbacService rbacService, UserRepository userRepository,
|
||||
AuditService auditService) {
|
||||
this.rbacService = rbacService;
|
||||
this.userRepository = userRepository;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all users")
|
||||
@Operation(summary = "List all users with RBAC detail")
|
||||
@ApiResponse(responseCode = "200", description = "User list returned")
|
||||
public ResponseEntity<List<UserInfo>> listUsers() {
|
||||
return ResponseEntity.ok(userRepository.findAll());
|
||||
public ResponseEntity<List<UserDetail>> listUsers() {
|
||||
return ResponseEntity.ok(rbacService.listUsers());
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
@Operation(summary = "Get user by ID")
|
||||
@Operation(summary = "Get user by ID with RBAC detail")
|
||||
@ApiResponse(responseCode = "200", description = "User found")
|
||||
@ApiResponse(responseCode = "404", description = "User not found")
|
||||
public ResponseEntity<UserInfo> getUser(@PathVariable String userId) {
|
||||
return userRepository.findById(userId)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PutMapping("/{userId}/roles")
|
||||
@Operation(summary = "Update user roles")
|
||||
@ApiResponse(responseCode = "200", description = "Roles updated")
|
||||
@ApiResponse(responseCode = "404", description = "User not found")
|
||||
public ResponseEntity<Void> updateRoles(@PathVariable String userId,
|
||||
@RequestBody RolesRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (userRepository.findById(userId).isEmpty()) {
|
||||
public ResponseEntity<UserDetail> getUser(@PathVariable String userId) {
|
||||
UserDetail detail = rbacService.getUser(userId);
|
||||
if (detail == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
userRepository.updateRoles(userId, request.roles());
|
||||
auditService.log("update_roles", AuditCategory.USER_MGMT, userId,
|
||||
Map.of("roles", request.roles()), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(detail);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a local user")
|
||||
@ApiResponse(responseCode = "200", description = "User created")
|
||||
public ResponseEntity<UserDetail> createUser(@RequestBody CreateUserRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = "user:" + request.username();
|
||||
UserInfo user = new UserInfo(userId, "local",
|
||||
request.email() != null ? request.email() : "",
|
||||
request.displayName() != null ? request.displayName() : request.username(),
|
||||
Instant.now());
|
||||
userRepository.upsert(user);
|
||||
if (request.password() != null && !request.password().isBlank()) {
|
||||
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
||||
}
|
||||
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
|
||||
auditService.log("create_user", AuditCategory.USER_MGMT, userId,
|
||||
Map.of("username", request.username()), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(rbacService.getUser(userId));
|
||||
}
|
||||
|
||||
@PutMapping("/{userId}")
|
||||
@Operation(summary = "Update user display name or email")
|
||||
@ApiResponse(responseCode = "200", description = "User updated")
|
||||
@ApiResponse(responseCode = "404", description = "User not found")
|
||||
public ResponseEntity<Void> updateUser(@PathVariable String userId,
|
||||
@RequestBody UpdateUserRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
var existing = userRepository.findById(userId);
|
||||
if (existing.isEmpty()) return ResponseEntity.notFound().build();
|
||||
var user = existing.get();
|
||||
var updated = new UserInfo(user.userId(), user.provider(),
|
||||
request.email() != null ? request.email() : user.email(),
|
||||
request.displayName() != null ? request.displayName() : user.displayName(),
|
||||
user.createdAt());
|
||||
userRepository.upsert(updated);
|
||||
auditService.log("update_user", AuditCategory.USER_MGMT, userId,
|
||||
null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/roles/{roleId}")
|
||||
@Operation(summary = "Assign a role to a user")
|
||||
@ApiResponse(responseCode = "200", description = "Role assigned")
|
||||
@ApiResponse(responseCode = "404", description = "User or role not found")
|
||||
public ResponseEntity<Void> assignRoleToUser(@PathVariable String userId,
|
||||
@PathVariable UUID roleId,
|
||||
HttpServletRequest httpRequest) {
|
||||
rbacService.assignRoleToUser(userId, roleId);
|
||||
auditService.log("assign_role_to_user", AuditCategory.USER_MGMT, userId,
|
||||
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{userId}/roles/{roleId}")
|
||||
@Operation(summary = "Remove a role from a user")
|
||||
@ApiResponse(responseCode = "204", description = "Role removed")
|
||||
public ResponseEntity<Void> removeRoleFromUser(@PathVariable String userId,
|
||||
@PathVariable UUID roleId,
|
||||
HttpServletRequest httpRequest) {
|
||||
rbacService.removeRoleFromUser(userId, roleId);
|
||||
auditService.log("remove_role_from_user", AuditCategory.USER_MGMT, userId,
|
||||
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/groups/{groupId}")
|
||||
@Operation(summary = "Add a user to a group")
|
||||
@ApiResponse(responseCode = "200", description = "User added to group")
|
||||
public ResponseEntity<Void> addUserToGroup(@PathVariable String userId,
|
||||
@PathVariable UUID groupId,
|
||||
HttpServletRequest httpRequest) {
|
||||
rbacService.addUserToGroup(userId, groupId);
|
||||
auditService.log("add_user_to_group", AuditCategory.USER_MGMT, userId,
|
||||
Map.of("groupId", groupId), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{userId}/groups/{groupId}")
|
||||
@Operation(summary = "Remove a user from a group")
|
||||
@ApiResponse(responseCode = "204", description = "User removed from group")
|
||||
public ResponseEntity<Void> removeUserFromGroup(@PathVariable String userId,
|
||||
@PathVariable UUID groupId,
|
||||
HttpServletRequest httpRequest) {
|
||||
rbacService.removeUserFromGroup(userId, groupId);
|
||||
auditService.log("remove_user_from_group", AuditCategory.USER_MGMT, userId,
|
||||
Map.of("groupId", groupId), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{userId}")
|
||||
@Operation(summary = "Delete user")
|
||||
@ApiResponse(responseCode = "204", description = "User deleted")
|
||||
@@ -84,5 +172,6 @@ public class UserAdminController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
public record RolesRequest(List<String> roles) {}
|
||||
public record CreateUserRequest(String username, String displayName, String email, String password) {}
|
||||
public record UpdateUserRequest(String displayName, String email) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.cameleer3.server.app.rbac;
|
||||
|
||||
import com.cameleer3.server.core.rbac.*;
|
||||
import com.cameleer3.server.core.security.UserInfo;
|
||||
import com.cameleer3.server.core.security.UserRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class RbacServiceImpl implements RbacService {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final UserRepository userRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
|
||||
public RbacServiceImpl(JdbcTemplate jdbc, UserRepository userRepository,
|
||||
GroupRepository groupRepository, RoleRepository roleRepository) {
|
||||
this.jdbc = jdbc;
|
||||
this.userRepository = userRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserDetail> listUsers() {
|
||||
return userRepository.findAll().stream()
|
||||
.map(this::buildUserDetail)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetail getUser(String userId) {
|
||||
UserInfo user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) return null;
|
||||
return buildUserDetail(user);
|
||||
}
|
||||
|
||||
private UserDetail buildUserDetail(UserInfo user) {
|
||||
List<RoleSummary> directRoles = getDirectRolesForUser(user.userId());
|
||||
List<GroupSummary> directGroups = getDirectGroupsForUser(user.userId());
|
||||
List<RoleSummary> effectiveRoles = getEffectiveRolesForUser(user.userId());
|
||||
List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(user.userId());
|
||||
return new UserDetail(user.userId(), user.provider(), user.email(),
|
||||
user.displayName(), user.createdAt(),
|
||||
directRoles, directGroups, effectiveRoles, effectiveGroups);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assignRoleToUser(String userId, UUID roleId) {
|
||||
jdbc.update("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||
userId, roleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeRoleFromUser(String userId, UUID roleId) {
|
||||
jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userId, roleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addUserToGroup(String userId, UUID groupId) {
|
||||
jdbc.update("INSERT INTO user_groups (user_id, group_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||
userId, groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUserFromGroup(String userId, UUID groupId) {
|
||||
jdbc.update("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?", userId, groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RoleSummary> getEffectiveRolesForUser(String userId) {
|
||||
List<RoleSummary> direct = getDirectRolesForUser(userId);
|
||||
|
||||
List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(userId);
|
||||
Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
|
||||
for (RoleSummary r : direct) {
|
||||
roleMap.put(r.id(), r);
|
||||
}
|
||||
for (GroupSummary group : effectiveGroups) {
|
||||
List<RoleSummary> groupRoles = jdbc.query("""
|
||||
SELECT r.id, r.name, r.system FROM group_roles gr
|
||||
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
|
||||
""", (rs, rowNum) -> new RoleSummary(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getBoolean("system"),
|
||||
group.name()
|
||||
), group.id());
|
||||
for (RoleSummary r : groupRoles) {
|
||||
roleMap.putIfAbsent(r.id(), r);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(roleMap.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GroupSummary> getEffectiveGroupsForUser(String userId) {
|
||||
List<GroupSummary> directGroups = getDirectGroupsForUser(userId);
|
||||
Set<UUID> visited = new LinkedHashSet<>();
|
||||
List<GroupSummary> all = new ArrayList<>();
|
||||
for (GroupSummary g : directGroups) {
|
||||
collectAncestors(g.id(), visited, all);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
private void collectAncestors(UUID groupId, Set<UUID> visited, List<GroupSummary> result) {
|
||||
if (!visited.add(groupId)) return;
|
||||
var rows = jdbc.query("SELECT id, name, parent_group_id FROM groups WHERE id = ?",
|
||||
(rs, rowNum) -> new Object[]{
|
||||
new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
|
||||
rs.getObject("parent_group_id", UUID.class)
|
||||
}, groupId);
|
||||
if (rows.isEmpty()) return;
|
||||
result.add((GroupSummary) rows.get(0)[0]);
|
||||
UUID parentId = (UUID) rows.get(0)[1];
|
||||
if (parentId != null) {
|
||||
collectAncestors(parentId, visited, result);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RoleSummary> getEffectiveRolesForGroup(UUID groupId) {
|
||||
List<RoleSummary> direct = jdbc.query("""
|
||||
SELECT r.id, r.name, r.system FROM group_roles gr
|
||||
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
|
||||
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
|
||||
rs.getString("name"), rs.getBoolean("system"), "direct"), groupId);
|
||||
|
||||
Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
|
||||
for (RoleSummary r : direct) roleMap.put(r.id(), r);
|
||||
|
||||
List<GroupSummary> ancestors = groupRepository.findAncestorChain(groupId);
|
||||
for (GroupSummary ancestor : ancestors) {
|
||||
if (ancestor.id().equals(groupId)) continue;
|
||||
List<RoleSummary> parentRoles = jdbc.query("""
|
||||
SELECT r.id, r.name, r.system FROM group_roles gr
|
||||
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
|
||||
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
|
||||
rs.getString("name"), rs.getBoolean("system"),
|
||||
ancestor.name()), ancestor.id());
|
||||
for (RoleSummary r : parentRoles) roleMap.putIfAbsent(r.id(), r);
|
||||
}
|
||||
return new ArrayList<>(roleMap.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserSummary> getEffectivePrincipalsForRole(UUID roleId) {
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
List<UserSummary> result = new ArrayList<>();
|
||||
|
||||
List<UserSummary> direct = jdbc.query("""
|
||||
SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
|
||||
JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
|
||||
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
|
||||
rs.getString("display_name"), rs.getString("provider")), roleId);
|
||||
for (UserSummary u : direct) {
|
||||
if (seen.add(u.userId())) result.add(u);
|
||||
}
|
||||
|
||||
List<UUID> groupsWithRole = jdbc.query(
|
||||
"SELECT group_id FROM group_roles WHERE role_id = ?",
|
||||
(rs, rowNum) -> rs.getObject("group_id", UUID.class), roleId);
|
||||
|
||||
Set<UUID> allGroups = new LinkedHashSet<>(groupsWithRole);
|
||||
for (UUID gid : groupsWithRole) {
|
||||
collectDescendants(gid, allGroups);
|
||||
}
|
||||
for (UUID gid : allGroups) {
|
||||
List<UserSummary> members = jdbc.query("""
|
||||
SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
|
||||
JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
|
||||
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
|
||||
rs.getString("display_name"), rs.getString("provider")), gid);
|
||||
for (UserSummary u : members) {
|
||||
if (seen.add(u.userId())) result.add(u);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void collectDescendants(UUID groupId, Set<UUID> result) {
|
||||
List<UUID> children = jdbc.query(
|
||||
"SELECT id FROM groups WHERE parent_group_id = ?",
|
||||
(rs, rowNum) -> rs.getObject("id", UUID.class), groupId);
|
||||
for (UUID child : children) {
|
||||
if (result.add(child)) {
|
||||
collectDescendants(child, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getSystemRoleNames(String userId) {
|
||||
return getEffectiveRolesForUser(userId).stream()
|
||||
.filter(RoleSummary::system)
|
||||
.map(RoleSummary::name)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RbacStats getStats() {
|
||||
int userCount = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
|
||||
int activeUserCount = jdbc.queryForObject(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM user_roles", Integer.class);
|
||||
int groupCount = jdbc.queryForObject("SELECT COUNT(*) FROM groups", Integer.class);
|
||||
int roleCount = jdbc.queryForObject("SELECT COUNT(*) FROM roles", Integer.class);
|
||||
int maxDepth = computeMaxGroupDepth();
|
||||
return new RbacStats(userCount, activeUserCount, groupCount, maxDepth, roleCount);
|
||||
}
|
||||
|
||||
private int computeMaxGroupDepth() {
|
||||
List<UUID> roots = jdbc.query(
|
||||
"SELECT id FROM groups WHERE parent_group_id IS NULL",
|
||||
(rs, rowNum) -> rs.getObject("id", UUID.class));
|
||||
int max = 0;
|
||||
for (UUID root : roots) {
|
||||
max = Math.max(max, measureDepth(root, 1));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private int measureDepth(UUID groupId, int currentDepth) {
|
||||
List<UUID> children = jdbc.query(
|
||||
"SELECT id FROM groups WHERE parent_group_id = ?",
|
||||
(rs, rowNum) -> rs.getObject("id", UUID.class), groupId);
|
||||
if (children.isEmpty()) return currentDepth;
|
||||
int max = currentDepth;
|
||||
for (UUID child : children) {
|
||||
max = Math.max(max, measureDepth(child, currentDepth + 1));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private List<RoleSummary> getDirectRolesForUser(String userId) {
|
||||
return jdbc.query("""
|
||||
SELECT r.id, r.name, r.system FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?
|
||||
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
|
||||
rs.getString("name"), rs.getBoolean("system"), "direct"), userId);
|
||||
}
|
||||
|
||||
private List<GroupSummary> getDirectGroupsForUser(String userId) {
|
||||
return jdbc.query("""
|
||||
SELECT g.id, g.name FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = ?
|
||||
""", (rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class),
|
||||
rs.getString("name")), userId);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import com.cameleer3.server.app.dto.OidcPublicConfigResponse;
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditResult;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
import com.cameleer3.server.core.rbac.SystemRole;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import com.cameleer3.server.core.security.OidcConfig;
|
||||
import com.cameleer3.server.core.security.OidcConfigRepository;
|
||||
@@ -33,11 +35,12 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* OIDC authentication endpoints for the UI.
|
||||
* <p>
|
||||
* Always registered — returns 404 when OIDC is not configured or disabled.
|
||||
* Always registered -- returns 404 when OIDC is not configured or disabled.
|
||||
* Configuration is read from the database (managed via admin UI).
|
||||
*/
|
||||
@RestController
|
||||
@@ -52,17 +55,20 @@ public class OidcAuthController {
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
private final AuditService auditService;
|
||||
private final RbacService rbacService;
|
||||
|
||||
public OidcAuthController(OidcTokenExchanger tokenExchanger,
|
||||
OidcConfigRepository configRepository,
|
||||
JwtService jwtService,
|
||||
UserRepository userRepository,
|
||||
AuditService auditService) {
|
||||
AuditService auditService,
|
||||
RbacService rbacService) {
|
||||
this.tokenExchanger = tokenExchanger;
|
||||
this.configRepository = configRepository;
|
||||
this.jwtService = jwtService;
|
||||
this.userRepository = userRepository;
|
||||
this.auditService = auditService;
|
||||
this.rbacService = rbacService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,11 +136,16 @@ public class OidcAuthController {
|
||||
"Account not provisioned. Contact your administrator.");
|
||||
}
|
||||
|
||||
// Resolve roles: DB override > OIDC claim > default
|
||||
List<String> roles = resolveRoles(existingUser, oidcUser.roles(), config.get());
|
||||
|
||||
// Upsert user (without roles -- roles are in user_roles table)
|
||||
userRepository.upsert(new UserInfo(
|
||||
userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now()));
|
||||
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));
|
||||
|
||||
// Assign roles if new user
|
||||
if (existingUser.isEmpty()) {
|
||||
assignRolesForNewUser(userId, oidcUser.roles(), config.get());
|
||||
}
|
||||
|
||||
List<String> roles = rbacService.getSystemRoleNames(userId);
|
||||
|
||||
String accessToken = jwtService.createAccessToken(userId, "user", roles);
|
||||
String refreshToken = jwtService.createRefreshToken(userId, "user", roles);
|
||||
@@ -153,14 +164,14 @@ public class OidcAuthController {
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> resolveRoles(Optional<UserInfo> existing, List<String> oidcRoles, OidcConfig config) {
|
||||
if (existing.isPresent() && !existing.get().roles().isEmpty()) {
|
||||
return existing.get().roles();
|
||||
private void assignRolesForNewUser(String userId, List<String> oidcRoles, OidcConfig config) {
|
||||
List<String> roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles();
|
||||
for (String roleName : roleNames) {
|
||||
UUID roleId = SystemRole.BY_NAME.get(roleName.toUpperCase());
|
||||
if (roleId != null) {
|
||||
rbacService.assignRoleToUser(userId, roleId);
|
||||
}
|
||||
}
|
||||
if (!oidcRoles.isEmpty()) {
|
||||
return oidcRoles;
|
||||
}
|
||||
return config.defaultRoles();
|
||||
}
|
||||
|
||||
public record CallbackRequest(String code, String redirectUri) {}
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.cameleer3.server.app.dto.ErrorResponse;
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditResult;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
import com.cameleer3.server.core.rbac.SystemRole;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
||||
@@ -19,6 +21,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -28,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Authentication endpoints for the UI (local credentials).
|
||||
@@ -42,18 +46,22 @@ import java.util.Map;
|
||||
public class UiAuthController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
|
||||
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final SecurityProperties properties;
|
||||
private final UserRepository userRepository;
|
||||
private final AuditService auditService;
|
||||
private final RbacService rbacService;
|
||||
|
||||
public UiAuthController(JwtService jwtService, SecurityProperties properties,
|
||||
UserRepository userRepository, AuditService auditService) {
|
||||
UserRepository userRepository, AuditService auditService,
|
||||
RbacService rbacService) {
|
||||
this.jwtService = jwtService;
|
||||
this.properties = properties;
|
||||
this.userRepository = userRepository;
|
||||
this.auditService = auditService;
|
||||
this.rbacService = rbacService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
@@ -65,32 +73,41 @@ public class UiAuthController {
|
||||
HttpServletRequest httpRequest) {
|
||||
String configuredUser = properties.getUiUser();
|
||||
String configuredPassword = properties.getUiPassword();
|
||||
|
||||
if (configuredUser == null || configuredUser.isBlank()
|
||||
|| configuredPassword == null || configuredPassword.isBlank()) {
|
||||
log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured");
|
||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
||||
Map.of("reason", "UI authentication not configured"), AuditResult.FAILURE, httpRequest);
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured");
|
||||
}
|
||||
|
||||
if (!configuredUser.equals(request.username())
|
||||
|| !configuredPassword.equals(request.password())) {
|
||||
log.debug("UI login failed for user: {}", request.username());
|
||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
||||
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
|
||||
}
|
||||
|
||||
String subject = "user:" + request.username();
|
||||
List<String> roles = List.of("ADMIN");
|
||||
|
||||
// Upsert local user into store
|
||||
try {
|
||||
userRepository.upsert(new UserInfo(
|
||||
subject, "local", "", request.username(), roles, Instant.now()));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to upsert local user to store (login continues): {}", e.getMessage());
|
||||
// Try env-var admin first
|
||||
boolean envMatch = configuredUser != null && !configuredUser.isBlank()
|
||||
&& configuredPassword != null && !configuredPassword.isBlank()
|
||||
&& configuredUser.equals(request.username())
|
||||
&& configuredPassword.equals(request.password());
|
||||
|
||||
if (!envMatch) {
|
||||
// Try per-user password
|
||||
Optional<String> hash = userRepository.getPasswordHash(subject);
|
||||
if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) {
|
||||
log.debug("UI login failed for user: {}", request.username());
|
||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
||||
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
|
||||
}
|
||||
}
|
||||
|
||||
if (envMatch) {
|
||||
// Env-var admin: upsert and ensure ADMIN role + Admins group
|
||||
try {
|
||||
userRepository.upsert(new UserInfo(
|
||||
subject, "local", "", request.username(), Instant.now()));
|
||||
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID);
|
||||
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to upsert local admin to store (login continues): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
// Per-user logins: user already exists in DB (created by admin)
|
||||
|
||||
List<String> roles = rbacService.getSystemRoleNames(subject);
|
||||
if (roles.isEmpty()) {
|
||||
roles = List.of("VIEWER");
|
||||
}
|
||||
|
||||
String accessToken = jwtService.createAccessToken(subject, "user", roles);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.cameleer3.server.app.storage;
|
||||
|
||||
import com.cameleer3.server.core.rbac.*;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Repository
|
||||
public class PostgresGroupRepository implements GroupRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public PostgresGroupRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GroupSummary> findAll() {
|
||||
return jdbc.query("SELECT id, name FROM groups ORDER BY name",
|
||||
(rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<GroupDetail> findById(UUID id) {
|
||||
var rows = jdbc.query(
|
||||
"SELECT id, name, parent_group_id, created_at FROM groups WHERE id = ?",
|
||||
(rs, rowNum) -> new GroupDetail(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getObject("parent_group_id", UUID.class),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
List.of(), List.of(), List.of(), List.of()
|
||||
), id);
|
||||
if (rows.isEmpty()) return Optional.empty();
|
||||
var g = rows.get(0);
|
||||
|
||||
List<RoleSummary> directRoles = jdbc.query("""
|
||||
SELECT r.id, r.name, r.system FROM group_roles gr
|
||||
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
|
||||
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
|
||||
rs.getString("name"), rs.getBoolean("system"), "direct"), id);
|
||||
|
||||
List<UserSummary> members = jdbc.query("""
|
||||
SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
|
||||
JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
|
||||
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
|
||||
rs.getString("display_name"), rs.getString("provider")), id);
|
||||
|
||||
List<GroupSummary> children = findChildGroups(id);
|
||||
|
||||
return Optional.of(new GroupDetail(g.id(), g.name(), g.parentGroupId(),
|
||||
g.createdAt(), directRoles, List.of(), members, children));
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID create(String name, UUID parentGroupId) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("INSERT INTO groups (id, name, parent_group_id) VALUES (?, ?, ?)",
|
||||
id, name, parentGroupId);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(UUID id, String name, UUID parentGroupId) {
|
||||
jdbc.update("UPDATE groups SET name = COALESCE(?, name), parent_group_id = ? WHERE id = ?",
|
||||
name, parentGroupId, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(UUID id) {
|
||||
jdbc.update("DELETE FROM groups WHERE id = ?", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addRole(UUID groupId, UUID roleId) {
|
||||
jdbc.update("INSERT INTO group_roles (group_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||
groupId, roleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeRole(UUID groupId, UUID roleId) {
|
||||
jdbc.update("DELETE FROM group_roles WHERE group_id = ? AND role_id = ?", groupId, roleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GroupSummary> findChildGroups(UUID parentId) {
|
||||
return jdbc.query("SELECT id, name FROM groups WHERE parent_group_id = ? ORDER BY name",
|
||||
(rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
|
||||
parentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GroupSummary> findAncestorChain(UUID groupId) {
|
||||
List<GroupSummary> chain = new ArrayList<>();
|
||||
UUID current = groupId;
|
||||
Set<UUID> visited = new HashSet<>();
|
||||
while (current != null && visited.add(current)) {
|
||||
UUID id = current;
|
||||
var rows = jdbc.query(
|
||||
"SELECT id, name, parent_group_id FROM groups WHERE id = ?",
|
||||
(rs, rowNum) -> new Object[]{
|
||||
new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
|
||||
rs.getObject("parent_group_id", UUID.class)
|
||||
}, id);
|
||||
if (rows.isEmpty()) break;
|
||||
chain.add((GroupSummary) rows.get(0)[0]);
|
||||
current = (UUID) rows.get(0)[1];
|
||||
}
|
||||
Collections.reverse(chain);
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.cameleer3.server.app.storage;
|
||||
|
||||
import com.cameleer3.server.core.rbac.*;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Repository
|
||||
public class PostgresRoleRepository implements RoleRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public PostgresRoleRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RoleDetail> findAll() {
|
||||
return jdbc.query("""
|
||||
SELECT id, name, description, scope, system, created_at FROM roles ORDER BY system DESC, name
|
||||
""", (rs, rowNum) -> new RoleDetail(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("description"),
|
||||
rs.getString("scope"),
|
||||
rs.getBoolean("system"),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
List.of(), List.of(), List.of()
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RoleDetail> findById(UUID id) {
|
||||
var rows = jdbc.query("""
|
||||
SELECT id, name, description, scope, system, created_at FROM roles WHERE id = ?
|
||||
""", (rs, rowNum) -> new RoleDetail(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("description"),
|
||||
rs.getString("scope"),
|
||||
rs.getBoolean("system"),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
List.of(), List.of(), List.of()
|
||||
), id);
|
||||
if (rows.isEmpty()) return Optional.empty();
|
||||
var r = rows.get(0);
|
||||
|
||||
List<GroupSummary> assignedGroups = jdbc.query("""
|
||||
SELECT g.id, g.name FROM group_roles gr
|
||||
JOIN groups g ON g.id = gr.group_id WHERE gr.role_id = ?
|
||||
""", (rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class),
|
||||
rs.getString("name")), id);
|
||||
|
||||
List<UserSummary> directUsers = jdbc.query("""
|
||||
SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
|
||||
JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
|
||||
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
|
||||
rs.getString("display_name"), rs.getString("provider")), id);
|
||||
|
||||
return Optional.of(new RoleDetail(r.id(), r.name(), r.description(),
|
||||
r.scope(), r.system(), r.createdAt(), assignedGroups, directUsers, List.of()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID create(String name, String description, String scope) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("INSERT INTO roles (id, name, description, scope, system) VALUES (?, ?, ?, ?, false)",
|
||||
id, name, description, scope);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(UUID id, String name, String description, String scope) {
|
||||
jdbc.update("""
|
||||
UPDATE roles SET name = COALESCE(?, name), description = COALESCE(?, description),
|
||||
scope = COALESCE(?, scope) WHERE id = ? AND system = false
|
||||
""", name, description, scope, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(UUID id) {
|
||||
jdbc.update("DELETE FROM roles WHERE id = ? AND system = false", id);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import com.cameleer3.server.core.security.UserRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Array;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -22,35 +20,28 @@ public class PostgresUserRepository implements UserRepository {
|
||||
@Override
|
||||
public Optional<UserInfo> findById(String userId) {
|
||||
var results = jdbc.query(
|
||||
"SELECT * FROM users WHERE user_id = ?",
|
||||
"SELECT user_id, provider, email, display_name, created_at FROM users WHERE user_id = ?",
|
||||
(rs, rowNum) -> mapUser(rs), userId);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserInfo> findAll() {
|
||||
return jdbc.query("SELECT * FROM users ORDER BY user_id",
|
||||
return jdbc.query("SELECT user_id, provider, email, display_name, created_at FROM users ORDER BY user_id",
|
||||
(rs, rowNum) -> mapUser(rs));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void upsert(UserInfo user) {
|
||||
jdbc.update("""
|
||||
INSERT INTO users (user_id, provider, email, display_name, roles, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, now(), now())
|
||||
INSERT INTO users (user_id, provider, email, display_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, now(), now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
provider = EXCLUDED.provider, email = EXCLUDED.email,
|
||||
display_name = EXCLUDED.display_name, roles = EXCLUDED.roles,
|
||||
display_name = EXCLUDED.display_name,
|
||||
updated_at = now()
|
||||
""",
|
||||
user.userId(), user.provider(), user.email(), user.displayName(),
|
||||
user.roles().toArray(new String[0]));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateRoles(String userId, List<String> roles) {
|
||||
jdbc.update("UPDATE users SET roles = ?, updated_at = now() WHERE user_id = ?",
|
||||
roles.toArray(new String[0]), userId);
|
||||
user.userId(), user.provider(), user.email(), user.displayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -58,14 +49,27 @@ public class PostgresUserRepository implements UserRepository {
|
||||
jdbc.update("DELETE FROM users WHERE user_id = ?", userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPassword(String userId, String passwordHash) {
|
||||
jdbc.update("UPDATE users SET password_hash = ? WHERE user_id = ?", passwordHash, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getPasswordHash(String userId) {
|
||||
List<String> results = jdbc.query(
|
||||
"SELECT password_hash FROM users WHERE user_id = ?",
|
||||
(rs, rowNum) -> rs.getString("password_hash"),
|
||||
userId);
|
||||
if (results.isEmpty() || results.get(0) == null) return Optional.empty();
|
||||
return Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
Array rolesArray = rs.getArray("roles");
|
||||
String[] roles = rolesArray != null ? (String[]) rolesArray.getArray() : new String[0];
|
||||
java.sql.Timestamp ts = rs.getTimestamp("created_at");
|
||||
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;
|
||||
return new UserInfo(
|
||||
rs.getString("user_id"), rs.getString("provider"),
|
||||
rs.getString("email"), rs.getString("display_name"),
|
||||
List.of(roles), createdAt);
|
||||
createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail JSONB,
|
||||
result TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
|
||||
CREATE INDEX idx_audit_log_username ON audit_log (username);
|
||||
CREATE INDEX idx_audit_log_category ON audit_log (category);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log (action);
|
||||
CREATE INDEX idx_audit_log_target ON audit_log (target);
|
||||
@@ -1,2 +0,0 @@
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit;
|
||||
@@ -0,0 +1,289 @@
|
||||
-- V1__init.sql - Consolidated schema for Cameleer3
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit;
|
||||
|
||||
-- =============================================================
|
||||
-- RBAC
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'custom',
|
||||
system BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO roles (id, name, description, scope, system) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true),
|
||||
('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true),
|
||||
('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true),
|
||||
('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true);
|
||||
|
||||
CREATE TABLE groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE group_roles (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (group_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE user_groups (
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
|
||||
CREATE INDEX idx_user_groups_user_id ON user_groups(user_id);
|
||||
CREATE INDEX idx_group_roles_group_id ON group_roles(group_id);
|
||||
CREATE INDEX idx_groups_parent ON groups(parent_group_id);
|
||||
|
||||
-- =============================================================
|
||||
-- Execution data (TimescaleDB hypertables)
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE executions (
|
||||
execution_id TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
group_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
correlation_id TEXT,
|
||||
exchange_id TEXT,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
duration_ms BIGINT,
|
||||
error_message TEXT,
|
||||
error_stacktrace TEXT,
|
||||
diagram_content_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (execution_id, start_time)
|
||||
);
|
||||
|
||||
SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
|
||||
|
||||
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
|
||||
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
|
||||
CREATE INDEX idx_executions_group_time ON executions (group_name, start_time DESC);
|
||||
CREATE INDEX idx_executions_correlation ON executions (correlation_id);
|
||||
|
||||
CREATE TABLE processor_executions (
|
||||
id BIGSERIAL,
|
||||
execution_id TEXT NOT NULL,
|
||||
processor_id TEXT NOT NULL,
|
||||
processor_type TEXT NOT NULL,
|
||||
diagram_node_id TEXT,
|
||||
group_name TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
depth INT NOT NULL,
|
||||
parent_processor_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
duration_ms BIGINT,
|
||||
error_message TEXT,
|
||||
error_stacktrace TEXT,
|
||||
input_body TEXT,
|
||||
output_body TEXT,
|
||||
input_headers JSONB,
|
||||
output_headers JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (execution_id, processor_id, start_time)
|
||||
);
|
||||
|
||||
SELECT create_hypertable('processor_executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
|
||||
|
||||
CREATE INDEX idx_proc_exec_execution ON processor_executions (execution_id);
|
||||
CREATE INDEX idx_proc_exec_type_time ON processor_executions (processor_type, start_time DESC);
|
||||
|
||||
-- =============================================================
|
||||
-- Agent metrics
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE agent_metrics (
|
||||
agent_id TEXT NOT NULL,
|
||||
metric_name TEXT NOT NULL,
|
||||
metric_value DOUBLE PRECISION NOT NULL,
|
||||
tags JSONB,
|
||||
collected_at TIMESTAMPTZ NOT NULL,
|
||||
server_received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
SELECT create_hypertable('agent_metrics', 'collected_at', chunk_time_interval => INTERVAL '1 day');
|
||||
|
||||
CREATE INDEX idx_metrics_agent_name ON agent_metrics (agent_id, metric_name, collected_at DESC);
|
||||
|
||||
-- =============================================================
|
||||
-- Route diagrams
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE route_diagrams (
|
||||
content_hash TEXT PRIMARY KEY,
|
||||
route_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
definition TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
|
||||
|
||||
-- =============================================================
|
||||
-- OIDC configuration
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE oidc_config (
|
||||
config_id TEXT PRIMARY KEY DEFAULT 'default',
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
issuer_uri TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
roles_claim TEXT,
|
||||
default_roles TEXT[] NOT NULL DEFAULT '{}',
|
||||
auto_signup BOOLEAN DEFAULT false,
|
||||
display_name_claim TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- Continuous aggregates
|
||||
-- =============================================================
|
||||
|
||||
CREATE MATERIALIZED VIEW stats_1m_all
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM executions
|
||||
WHERE status IS NOT NULL
|
||||
GROUP BY bucket
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_all',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
CREATE MATERIALIZED VIEW stats_1m_app
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM executions
|
||||
WHERE status IS NOT NULL
|
||||
GROUP BY bucket, group_name
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_app',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
CREATE MATERIALIZED VIEW stats_1m_route
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
route_id,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM executions
|
||||
WHERE status IS NOT NULL
|
||||
GROUP BY bucket, group_name, route_id
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_route',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
CREATE MATERIALIZED VIEW stats_1m_processor
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
route_id,
|
||||
processor_type,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM processor_executions
|
||||
GROUP BY bucket, group_name, route_id, processor_type
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_processor',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
-- =============================================================
|
||||
-- Admin
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE admin_thresholds (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT NOT NULL,
|
||||
CONSTRAINT single_row CHECK (id = 1)
|
||||
);
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail JSONB,
|
||||
result TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
|
||||
CREATE INDEX idx_audit_log_username ON audit_log (username);
|
||||
CREATE INDEX idx_audit_log_category ON audit_log (category);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log (action);
|
||||
CREATE INDEX idx_audit_log_target ON audit_log (target);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Built-in Admins group
|
||||
INSERT INTO groups (id, name) VALUES
|
||||
('00000000-0000-0000-0000-000000000010', 'Admins');
|
||||
|
||||
-- Assign ADMIN role to Admins group
|
||||
INSERT INTO group_roles (group_id, role_id) VALUES
|
||||
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
|
||||
@@ -1,25 +0,0 @@
|
||||
CREATE TABLE executions (
|
||||
execution_id TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
group_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
correlation_id TEXT,
|
||||
exchange_id TEXT,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
duration_ms BIGINT,
|
||||
error_message TEXT,
|
||||
error_stacktrace TEXT,
|
||||
diagram_content_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (execution_id, start_time)
|
||||
);
|
||||
|
||||
SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
|
||||
|
||||
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
|
||||
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
|
||||
CREATE INDEX idx_executions_group_time ON executions (group_name, start_time DESC);
|
||||
CREATE INDEX idx_executions_correlation ON executions (correlation_id);
|
||||
@@ -1,28 +0,0 @@
|
||||
CREATE TABLE processor_executions (
|
||||
id BIGSERIAL,
|
||||
execution_id TEXT NOT NULL,
|
||||
processor_id TEXT NOT NULL,
|
||||
processor_type TEXT NOT NULL,
|
||||
diagram_node_id TEXT,
|
||||
group_name TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
depth INT NOT NULL,
|
||||
parent_processor_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
duration_ms BIGINT,
|
||||
error_message TEXT,
|
||||
error_stacktrace TEXT,
|
||||
input_body TEXT,
|
||||
output_body TEXT,
|
||||
input_headers JSONB,
|
||||
output_headers JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (execution_id, processor_id, start_time)
|
||||
);
|
||||
|
||||
SELECT create_hypertable('processor_executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
|
||||
|
||||
CREATE INDEX idx_proc_exec_execution ON processor_executions (execution_id);
|
||||
CREATE INDEX idx_proc_exec_type_time ON processor_executions (processor_type, start_time DESC);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN password_hash TEXT;
|
||||
@@ -1,12 +0,0 @@
|
||||
CREATE TABLE agent_metrics (
|
||||
agent_id TEXT NOT NULL,
|
||||
metric_name TEXT NOT NULL,
|
||||
metric_value DOUBLE PRECISION NOT NULL,
|
||||
tags JSONB,
|
||||
collected_at TIMESTAMPTZ NOT NULL,
|
||||
server_received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
SELECT create_hypertable('agent_metrics', 'collected_at', chunk_time_interval => INTERVAL '1 day');
|
||||
|
||||
CREATE INDEX idx_metrics_agent_name ON agent_metrics (agent_id, metric_name, collected_at DESC);
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE route_diagrams (
|
||||
content_hash TEXT PRIMARY KEY,
|
||||
route_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
definition TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
roles TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
CREATE TABLE oidc_config (
|
||||
config_id TEXT PRIMARY KEY DEFAULT 'default',
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
issuer_uri TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
roles_claim TEXT,
|
||||
default_roles TEXT[] NOT NULL DEFAULT '{}',
|
||||
auto_signup BOOLEAN DEFAULT false,
|
||||
display_name_claim TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1,87 +0,0 @@
|
||||
-- Global stats
|
||||
CREATE MATERIALIZED VIEW stats_1m_all
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM executions
|
||||
WHERE status IS NOT NULL
|
||||
GROUP BY bucket
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_all',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
-- Per-application stats
|
||||
CREATE MATERIALIZED VIEW stats_1m_app
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM executions
|
||||
WHERE status IS NOT NULL
|
||||
GROUP BY bucket, group_name
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_app',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
-- Per-route stats
|
||||
CREATE MATERIALIZED VIEW stats_1m_route
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
route_id,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM executions
|
||||
WHERE status IS NOT NULL
|
||||
GROUP BY bucket, group_name, route_id
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_route',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
-- Per-processor stats (uses denormalized group_name/route_id on processor_executions)
|
||||
CREATE MATERIALIZED VIEW stats_1m_processor
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
route_id,
|
||||
processor_type,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM processor_executions
|
||||
GROUP BY bucket, group_name, route_id, processor_type
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_processor',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE admin_thresholds (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT NOT NULL,
|
||||
CONSTRAINT single_row CHECK (id = 1)
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.cameleer3.server.core.admin;
|
||||
|
||||
public enum AuditCategory {
|
||||
INFRA, AUTH, USER_MGMT, CONFIG
|
||||
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record GroupDetail(UUID id, String name, UUID parentGroupId, Instant createdAt,
|
||||
List<RoleSummary> directRoles, List<RoleSummary> effectiveRoles,
|
||||
List<UserSummary> members, List<GroupSummary> childGroups) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface GroupRepository {
|
||||
List<GroupSummary> findAll();
|
||||
Optional<GroupDetail> findById(UUID id);
|
||||
UUID create(String name, UUID parentGroupId);
|
||||
void update(UUID id, String name, UUID parentGroupId);
|
||||
void delete(UUID id);
|
||||
void addRole(UUID groupId, UUID roleId);
|
||||
void removeRole(UUID groupId, UUID roleId);
|
||||
List<GroupSummary> findChildGroups(UUID parentId);
|
||||
List<GroupSummary> findAncestorChain(UUID groupId);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record GroupSummary(UUID id, String name) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface RbacService {
|
||||
List<UserDetail> listUsers();
|
||||
UserDetail getUser(String userId);
|
||||
void assignRoleToUser(String userId, UUID roleId);
|
||||
void removeRoleFromUser(String userId, UUID roleId);
|
||||
void addUserToGroup(String userId, UUID groupId);
|
||||
void removeUserFromGroup(String userId, UUID groupId);
|
||||
List<RoleSummary> getEffectiveRolesForUser(String userId);
|
||||
List<GroupSummary> getEffectiveGroupsForUser(String userId);
|
||||
List<RoleSummary> getEffectiveRolesForGroup(UUID groupId);
|
||||
List<UserSummary> getEffectivePrincipalsForRole(UUID roleId);
|
||||
List<String> getSystemRoleNames(String userId);
|
||||
RbacStats getStats();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
public record RbacStats(int userCount, int activeUserCount, int groupCount, int maxGroupDepth, int roleCount) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RoleDetail(UUID id, String name, String description, String scope, boolean system,
|
||||
Instant createdAt, List<GroupSummary> assignedGroups, List<UserSummary> directUsers,
|
||||
List<UserSummary> effectivePrincipals) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface RoleRepository {
|
||||
List<RoleDetail> findAll();
|
||||
Optional<RoleDetail> findById(UUID id);
|
||||
UUID create(String name, String description, String scope);
|
||||
void update(UUID id, String name, String description, String scope);
|
||||
void delete(UUID id);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record RoleSummary(UUID id, String name, boolean system, String source) {}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class SystemRole {
|
||||
private SystemRole() {}
|
||||
|
||||
public static final UUID AGENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
public static final UUID VIEWER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
public static final UUID OPERATOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||
public static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000004");
|
||||
|
||||
public static final UUID ADMINS_GROUP_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||
|
||||
public static final Set<UUID> IDS = Set.of(AGENT_ID, VIEWER_ID, OPERATOR_ID, ADMIN_ID);
|
||||
|
||||
public static final Map<String, UUID> BY_NAME = Map.of(
|
||||
"AGENT", AGENT_ID, "VIEWER", VIEWER_ID, "OPERATOR", OPERATOR_ID, "ADMIN", ADMIN_ID);
|
||||
|
||||
public static boolean isSystem(UUID id) { return IDS.contains(id); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record UserDetail(String userId, String provider, String email, String displayName,
|
||||
Instant createdAt, List<RoleSummary> directRoles, List<GroupSummary> directGroups,
|
||||
List<RoleSummary> effectiveRoles, List<GroupSummary> effectiveGroups) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
public record UserSummary(String userId, String displayName, String provider) {}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.cameleer3.server.core.security;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents a persisted user in the system.
|
||||
@@ -10,7 +9,6 @@ import java.util.List;
|
||||
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
|
||||
* @param email user email (may be empty)
|
||||
* @param displayName display name (may be empty)
|
||||
* @param roles assigned roles (e.g. {@code ["ADMIN"]}, {@code ["VIEWER"]})
|
||||
* @param createdAt first creation timestamp
|
||||
*/
|
||||
public record UserInfo(
|
||||
@@ -18,6 +16,5 @@ public record UserInfo(
|
||||
String provider,
|
||||
String email,
|
||||
String displayName,
|
||||
List<String> roles,
|
||||
Instant createdAt
|
||||
) {}
|
||||
|
||||
@@ -14,7 +14,9 @@ public interface UserRepository {
|
||||
|
||||
void upsert(UserInfo user);
|
||||
|
||||
void updateRoles(String userId, List<String> roles);
|
||||
|
||||
void delete(String userId);
|
||||
|
||||
void setPassword(String userId, String passwordHash);
|
||||
|
||||
Optional<String> getPasswordHash(String userId);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,5 +18,7 @@ export async function adminFetch<T>(path: string, options?: RequestInit): Promis
|
||||
}
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
304
ui/src/api/queries/admin/rbac.ts
Normal file
304
ui/src/api/queries/admin/rbac.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminFetch } from './admin-api';
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export interface RoleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
system: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface GroupSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface UserDetail {
|
||||
userId: string;
|
||||
provider: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
createdAt: string;
|
||||
directRoles: RoleSummary[];
|
||||
directGroups: GroupSummary[];
|
||||
effectiveRoles: RoleSummary[];
|
||||
effectiveGroups: GroupSummary[];
|
||||
}
|
||||
|
||||
export interface GroupDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
parentGroupId: string | null;
|
||||
createdAt: string;
|
||||
directRoles: RoleSummary[];
|
||||
effectiveRoles: RoleSummary[];
|
||||
members: UserSummary[];
|
||||
childGroups: GroupSummary[];
|
||||
}
|
||||
|
||||
export interface RoleDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: string;
|
||||
system: boolean;
|
||||
createdAt: string;
|
||||
assignedGroups: GroupSummary[];
|
||||
directUsers: UserSummary[];
|
||||
effectivePrincipals: UserSummary[];
|
||||
}
|
||||
|
||||
export interface RbacStats {
|
||||
userCount: number;
|
||||
activeUserCount: number;
|
||||
groupCount: number;
|
||||
maxGroupDepth: number;
|
||||
roleCount: number;
|
||||
}
|
||||
|
||||
// ─── Query hooks ───
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'rbac', 'users'],
|
||||
queryFn: () => adminFetch<UserDetail[]>('/users'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUser(userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'rbac', 'users', userId],
|
||||
queryFn: () => adminFetch<UserDetail>(`/users/${encodeURIComponent(userId!)}`),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroups() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'rbac', 'groups'],
|
||||
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroup(groupId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'rbac', 'groups', groupId],
|
||||
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoles() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'rbac', 'roles'],
|
||||
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRole(roleId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'rbac', 'roles', roleId],
|
||||
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
|
||||
enabled: !!roleId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRbacStats() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'rbac', 'stats'],
|
||||
queryFn: () => adminFetch<RbacStats>('/rbac/stats'),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Mutation hooks ───
|
||||
|
||||
export function useAssignRoleToUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
||||
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveRoleFromUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
|
||||
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddUserToGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
||||
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveUserFromGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
|
||||
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; parentGroupId?: string }) =>
|
||||
adminFetch<{ id: string }>('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) =>
|
||||
adminFetch(`/groups/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
adminFetch(`/groups/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignRoleToGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
||||
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveRoleFromGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
|
||||
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRole() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; description?: string; scope?: string }) =>
|
||||
adminFetch<{ id: string }>('/roles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRole() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string; name?: string; description?: string; scope?: string }) =>
|
||||
adminFetch(`/roles/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRole() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
adminFetch(`/roles/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; displayName?: string; email?: string; password?: string }) =>
|
||||
adminFetch<UserDetail>('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, ...data }: { userId: string; displayName?: string; email?: string }) =>
|
||||
adminFetch(`/users/${encodeURIComponent(userId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
758
ui/src/api/schema.d.ts
vendored
758
ui/src/api/schema.d.ts
vendored
@@ -4,23 +4,6 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/admin/users/{userId}/roles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** Update user roles */
|
||||
put: operations["updateRoles"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/thresholds": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -39,6 +22,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/roles/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get role by ID with effective principals */
|
||||
get: operations["getRole"];
|
||||
/** Update a custom role */
|
||||
put: operations["updateRole"];
|
||||
post?: never;
|
||||
/** Delete a custom role */
|
||||
delete: operations["deleteRole"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/oidc": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -58,6 +60,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/groups/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get group by ID with effective roles */
|
||||
get: operations["getGroup"];
|
||||
/** Update group name or parent */
|
||||
put: operations["updateGroup"];
|
||||
post?: never;
|
||||
/** Delete group */
|
||||
delete: operations["deleteGroup"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/search/executions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -327,6 +348,60 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/{userId}/roles/{roleId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Assign a role to a user */
|
||||
post: operations["assignRoleToUser"];
|
||||
/** Remove a role from a user */
|
||||
delete: operations["removeRoleFromUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/{userId}/groups/{groupId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Add a user to a group */
|
||||
post: operations["addUserToGroup"];
|
||||
/** Remove a user from a group */
|
||||
delete: operations["removeUserFromGroup"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/roles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List all roles (system and custom) */
|
||||
get: operations["listRoles"];
|
||||
put?: never;
|
||||
/** Create a custom role */
|
||||
post: operations["createRole"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/oidc/test": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -344,6 +419,42 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/groups": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List all groups with hierarchy and effective roles */
|
||||
get: operations["listGroups"];
|
||||
put?: never;
|
||||
/** Create a new group */
|
||||
post: operations["createGroup"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/groups/{id}/roles/{roleId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Assign a role to a group */
|
||||
post: operations["assignRoleToGroup"];
|
||||
/** Remove a role from a group */
|
||||
delete: operations["removeRoleFromGroup"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/database/queries/{pid}/kill": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -533,7 +644,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List all users */
|
||||
/** List all users with RBAC detail */
|
||||
get: operations["listUsers"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -550,7 +661,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get user by ID */
|
||||
/** Get user by ID with RBAC detail */
|
||||
get: operations["getUser"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -561,6 +672,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/rbac/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get RBAC statistics for the dashboard */
|
||||
get: operations["getStats"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/opensearch/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -735,9 +863,6 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
RolesRequest: {
|
||||
roles?: string[];
|
||||
};
|
||||
/** @description Database monitoring thresholds */
|
||||
DatabaseThresholdsRequest: {
|
||||
/**
|
||||
@@ -833,6 +958,11 @@ export interface components {
|
||||
database?: components["schemas"]["DatabaseThresholds"];
|
||||
opensearch?: components["schemas"]["OpenSearchThresholds"];
|
||||
};
|
||||
UpdateRoleRequest: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
scope?: string;
|
||||
};
|
||||
/** @description OIDC configuration update request */
|
||||
OidcAdminConfigRequest: {
|
||||
enabled?: boolean;
|
||||
@@ -860,6 +990,11 @@ export interface components {
|
||||
autoSignup?: boolean;
|
||||
displayNameClaim?: string;
|
||||
};
|
||||
UpdateGroupRequest: {
|
||||
name?: string;
|
||||
/** Format: uuid */
|
||||
parentGroupId?: string;
|
||||
};
|
||||
SearchRequest: {
|
||||
status?: string;
|
||||
/** Format: date-time */
|
||||
@@ -978,11 +1113,21 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
targetCount?: number;
|
||||
};
|
||||
CreateRoleRequest: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
scope?: string;
|
||||
};
|
||||
/** @description OIDC provider connectivity test result */
|
||||
OidcTestResult: {
|
||||
status: string;
|
||||
authorizationEndpoint: string;
|
||||
};
|
||||
CreateGroupRequest: {
|
||||
name?: string;
|
||||
/** Format: uuid */
|
||||
parentGroupId?: string;
|
||||
};
|
||||
ExecutionStats: {
|
||||
/** Format: int64 */
|
||||
totalCount: number;
|
||||
@@ -1107,14 +1252,59 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
timeout?: number;
|
||||
};
|
||||
UserInfo: {
|
||||
userId: string;
|
||||
provider: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
GroupSummary: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
RoleSummary: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
system?: boolean;
|
||||
source?: string;
|
||||
};
|
||||
UserDetail: {
|
||||
userId?: string;
|
||||
provider?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
createdAt?: string;
|
||||
directRoles?: components["schemas"]["RoleSummary"][];
|
||||
directGroups?: components["schemas"]["GroupSummary"][];
|
||||
effectiveRoles?: components["schemas"]["RoleSummary"][];
|
||||
effectiveGroups?: components["schemas"]["GroupSummary"][];
|
||||
};
|
||||
RoleDetail: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
scope?: string;
|
||||
system?: boolean;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
assignedGroups?: components["schemas"]["GroupSummary"][];
|
||||
directUsers?: components["schemas"]["UserSummary"][];
|
||||
effectivePrincipals?: components["schemas"]["UserSummary"][];
|
||||
};
|
||||
UserSummary: {
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
provider?: string;
|
||||
};
|
||||
RbacStats: {
|
||||
/** Format: int32 */
|
||||
userCount?: number;
|
||||
/** Format: int32 */
|
||||
activeUserCount?: number;
|
||||
/** Format: int32 */
|
||||
groupCount?: number;
|
||||
/** Format: int32 */
|
||||
maxGroupDepth?: number;
|
||||
/** Format: int32 */
|
||||
roleCount?: number;
|
||||
};
|
||||
/** @description OpenSearch cluster status */
|
||||
OpenSearchStatusResponse: {
|
||||
@@ -1264,6 +1454,19 @@ export interface components {
|
||||
*/
|
||||
totalPages?: number;
|
||||
};
|
||||
GroupDetail: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
/** Format: uuid */
|
||||
parentGroupId?: string;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
directRoles?: components["schemas"]["RoleSummary"][];
|
||||
effectiveRoles?: components["schemas"]["RoleSummary"][];
|
||||
members?: components["schemas"]["UserSummary"][];
|
||||
childGroups?: components["schemas"]["GroupSummary"][];
|
||||
};
|
||||
/** @description Table size and row count information */
|
||||
TableSizeResponse: {
|
||||
/** @description Table name */
|
||||
@@ -1379,7 +1582,7 @@ export interface components {
|
||||
username?: string;
|
||||
action?: string;
|
||||
/** @enum {string} */
|
||||
category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG";
|
||||
category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC";
|
||||
target?: string;
|
||||
detail?: {
|
||||
[key: string]: Record<string, never>;
|
||||
@@ -1398,37 +1601,6 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
updateRoles: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RolesRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Roles updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description User not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getThresholds: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1473,6 +1645,109 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getRole: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role found */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["RoleDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Role not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["RoleDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateRole: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateRoleRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Role updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Cannot modify system role */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Role not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteRole: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role deleted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Cannot delete system role */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Role not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1544,6 +1819,102 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Group found */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["GroupDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Group not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["GroupDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateGroupRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Group updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Group not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Cycle detected in group hierarchy */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Group deleted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Group not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
searchGet: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -2045,6 +2416,143 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
assignRoleToUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role assigned */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description User or role not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
removeRoleFromUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role removed */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
addUserToGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
groupId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description User added to group */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
removeUserFromGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
groupId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description User removed from group */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
listRoles: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role list returned */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["RoleDetail"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createRole: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateRoleRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Role created */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
testConnection: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2074,6 +2582,108 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
listGroups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Group list returned */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["GroupDetail"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateGroupRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Group created */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
assignRoleToGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
roleId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role assigned to group */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Group not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
removeRoleFromGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
roleId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role removed from group */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Group not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
killQuery: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2395,7 +3005,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["UserInfo"][];
|
||||
"*/*": components["schemas"]["UserDetail"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2417,7 +3027,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["UserInfo"];
|
||||
"*/*": components["schemas"]["UserDetail"];
|
||||
};
|
||||
};
|
||||
/** @description User not found */
|
||||
@@ -2426,7 +3036,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["UserInfo"];
|
||||
"*/*": components["schemas"]["UserDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2451,6 +3061,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description RBAC stats returned */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["RbacStats"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -6,7 +6,6 @@ export type ExecutionDetail = components['schemas']['ExecutionDetail'];
|
||||
export type ExecutionStats = components['schemas']['ExecutionStats'];
|
||||
export type StatsTimeseries = components['schemas']['StatsTimeseries'];
|
||||
export type TimeseriesBucket = components['schemas']['TimeseriesBucket'];
|
||||
export type UserInfo = components['schemas']['UserInfo'];
|
||||
export type ProcessorNode = components['schemas']['ProcessorNode'];
|
||||
export type AgentInstance = components['schemas']['AgentInstanceResponse'];
|
||||
export type OidcAdminConfigResponse = components['schemas']['OidcAdminConfigResponse'];
|
||||
|
||||
@@ -124,6 +124,7 @@ const ADMIN_LINKS = [
|
||||
{ to: '/admin/opensearch', label: 'OpenSearch' },
|
||||
{ to: '/admin/audit', label: 'Audit Log' },
|
||||
{ to: '/admin/oidc', label: 'OIDC' },
|
||||
{ to: '/admin/rbac', label: 'User Management' },
|
||||
];
|
||||
|
||||
function AdminSubMenu({ collapsed: sidebarCollapsed }: { collapsed: boolean }) {
|
||||
|
||||
@@ -1,59 +1,24 @@
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
/* ─── Filter Bar ─── */
|
||||
.filterBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.totalCount {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.accessDenied {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Filters ─── */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 120px;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterGroup:nth-child(3),
|
||||
.filterGroup:nth-child(5) {
|
||||
.filterGroupGrow {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
@@ -68,11 +33,12 @@
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 10px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.filterInput:focus {
|
||||
@@ -87,19 +53,19 @@
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 10px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ─── Table ─── */
|
||||
.tableWrapper {
|
||||
/* ─── Table Area ─── */
|
||||
.tableArea {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.table {
|
||||
@@ -109,23 +75,37 @@
|
||||
}
|
||||
|
||||
.table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
padding: 10px 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
.thTimestamp {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.thResult {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 8px 14px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ─── Event Rows ─── */
|
||||
.eventRow {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
@@ -139,16 +119,32 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mono {
|
||||
.cellTimestamp {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cellUser {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cellTarget {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Badges ─── */
|
||||
.categoryBadge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -161,26 +157,62 @@
|
||||
.resultBadge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.resultSuccess {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.resultFailure {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
/* ─── Detail Row ─── */
|
||||
/* ─── Expanded Detail Row ─── */
|
||||
.detailRow td {
|
||||
padding: 0 12px 12px;
|
||||
padding: 0 14px 14px;
|
||||
background: var(--bg-hover);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detailContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detailMeta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detailField {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detailJson {
|
||||
@@ -203,16 +235,19 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
padding: 6px 14px;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
@@ -228,33 +263,30 @@
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Empty State ─── */
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filters {
|
||||
.filterBar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
.filterGroupGrow {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.cellTarget {
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
|
||||
import layout from '../../styles/AdminLayout.module.css';
|
||||
import styles from './AuditLogPage.module.css';
|
||||
|
||||
function defaultFrom(): string {
|
||||
@@ -18,9 +19,9 @@ export function AuditLogPage() {
|
||||
|
||||
if (!roles.includes('ADMIN')) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.accessDenied}>
|
||||
Access Denied — this page requires the ADMIN role.
|
||||
<div className={layout.page}>
|
||||
<div className={layout.accessDenied}>
|
||||
Access Denied -- this page requires the ADMIN role.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,15 +57,21 @@ function AuditLogContent() {
|
||||
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.pageTitle}>Audit Log</h1>
|
||||
{data && (
|
||||
<span className={styles.totalCount}>{data.totalCount.toLocaleString()} events</span>
|
||||
)}
|
||||
<div className={layout.page}>
|
||||
{/* Header */}
|
||||
<div className={layout.panelHeader}>
|
||||
<div>
|
||||
<div className={layout.panelTitle}>Audit Log</div>
|
||||
<div className={layout.panelSubtitle}>
|
||||
{data
|
||||
? `${data.totalCount.toLocaleString()} events`
|
||||
: 'Loading...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filters}>
|
||||
{/* Filter bar */}
|
||||
<div className={styles.filterBar}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>From</label>
|
||||
<input
|
||||
@@ -107,107 +114,152 @@ function AuditLogContent() {
|
||||
<option value="CONFIG">CONFIG</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.filterGroup}>
|
||||
<div className={`${styles.filterGroup} ${styles.filterGroupGrow}`}>
|
||||
<label className={styles.filterLabel}>Search</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.filterInput}
|
||||
placeholder="Search..."
|
||||
placeholder="Search actions, targets..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audit.isLoading ? (
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<div className={styles.emptyState}>No audit events found for the selected filters.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>User</th>
|
||||
<th>Category</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((event) => (
|
||||
<>
|
||||
<tr
|
||||
key={event.id}
|
||||
className={`${styles.eventRow} ${expandedRow === event.id ? styles.eventRowExpanded : ''}`}
|
||||
onClick={() =>
|
||||
setExpandedRow((prev) => (prev === event.id ? null : event.id))
|
||||
}
|
||||
>
|
||||
<td className={styles.mono}>
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</td>
|
||||
<td>{event.username}</td>
|
||||
<td>
|
||||
<span className={styles.categoryBadge}>{event.category}</span>
|
||||
</td>
|
||||
<td>{event.action}</td>
|
||||
<td className={styles.mono}>{event.target}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`${styles.resultBadge} ${
|
||||
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
|
||||
}`}
|
||||
>
|
||||
{event.result}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedRow === event.id && (
|
||||
<tr key={`${event.id}-detail`} className={styles.detailRow}>
|
||||
<td colSpan={6}>
|
||||
<pre className={styles.detailJson}>
|
||||
{JSON.stringify(event.detail, null, 2)}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Table area */}
|
||||
<div className={styles.tableArea}>
|
||||
{audit.isLoading ? (
|
||||
<div className={layout.loading}>Loading...</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
No audit events found for the selected filters.
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.thTimestamp}>Timestamp</th>
|
||||
<th>User</th>
|
||||
<th>Category</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th className={styles.thResult}>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((event) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
isExpanded={expandedRow === event.id}
|
||||
onToggle={() =>
|
||||
setExpandedRow((prev) => (prev === event.id ? null : event.id))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className={styles.pageInfo}>
|
||||
Showing {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
{/* Pagination */}
|
||||
{data && data.totalCount > 0 && (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className={styles.pageInfo}>
|
||||
{showingFrom}--{showingTo} of {data.totalCount.toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
event: {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
username: string;
|
||||
category: string;
|
||||
action: string;
|
||||
target: string;
|
||||
result: string;
|
||||
detail: Record<string, unknown>;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
};
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`${styles.eventRow} ${isExpanded ? styles.eventRowExpanded : ''}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className={styles.cellTimestamp}>{formatTimestamp(event.timestamp)}</td>
|
||||
<td className={styles.cellUser}>{event.username}</td>
|
||||
<td>
|
||||
<span className={styles.categoryBadge}>{event.category}</span>
|
||||
</td>
|
||||
<td>{event.action}</td>
|
||||
<td className={styles.cellTarget}>{event.target}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`${styles.resultBadge} ${
|
||||
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
|
||||
}`}
|
||||
>
|
||||
{event.result}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className={styles.detailRow}>
|
||||
<td colSpan={6}>
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.detailMeta}>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>IP Address</span>
|
||||
<span className={styles.detailValue}>{event.ipAddress}</span>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>User Agent</span>
|
||||
<span className={styles.detailValue}>{event.userAgent}</span>
|
||||
</div>
|
||||
</div>
|
||||
{event.detail && Object.keys(event.detail).length > 0 && (
|
||||
<pre className={styles.detailJson}>
|
||||
{JSON.stringify(event.detail, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
|
||||
@@ -1,73 +1,10 @@
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.headerInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ─── Meta ─── */
|
||||
.metaItem {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.globalRefresh {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.globalRefresh:hover {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.accessDenied {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Progress Bar ─── */
|
||||
.progressContainer {
|
||||
margin-bottom: 16px;
|
||||
@@ -309,9 +246,4 @@
|
||||
.thresholdGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { StatusBadge } from '../../components/admin/StatusBadge';
|
||||
import { RefreshableCard } from '../../components/admin/RefreshableCard';
|
||||
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
|
||||
import {
|
||||
useDatabaseStatus,
|
||||
@@ -11,16 +10,33 @@ import {
|
||||
useKillQuery,
|
||||
} from '../../api/queries/admin/database';
|
||||
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
|
||||
import layout from '../../styles/AdminLayout.module.css';
|
||||
import styles from './DatabaseAdminPage.module.css';
|
||||
|
||||
type Section = 'pool' | 'tables' | 'queries' | 'maintenance' | 'thresholds';
|
||||
|
||||
interface SectionDef {
|
||||
id: Section;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const SECTIONS: SectionDef[] = [
|
||||
{ id: 'pool', label: 'Connection Pool', icon: 'CP' },
|
||||
{ id: 'tables', label: 'Table Sizes', icon: 'TS' },
|
||||
{ id: 'queries', label: 'Active Queries', icon: 'AQ' },
|
||||
{ id: 'maintenance', label: 'Maintenance', icon: 'MN' },
|
||||
{ id: 'thresholds', label: 'Thresholds', icon: 'TH' },
|
||||
];
|
||||
|
||||
export function DatabaseAdminPage() {
|
||||
const roles = useAuthStore((s) => s.roles);
|
||||
|
||||
if (!roles.includes('ADMIN')) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.accessDenied}>
|
||||
Access Denied — this page requires the ADMIN role.
|
||||
<div className={layout.page}>
|
||||
<div className={layout.accessDenied}>
|
||||
Access Denied -- this page requires the ADMIN role.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -30,6 +46,8 @@ export function DatabaseAdminPage() {
|
||||
}
|
||||
|
||||
function DatabaseAdminContent() {
|
||||
const [selectedSection, setSelectedSection] = useState<Section>('pool');
|
||||
|
||||
const status = useDatabaseStatus();
|
||||
const pool = useDatabasePool();
|
||||
const tables = useDatabaseTables();
|
||||
@@ -38,21 +56,39 @@ function DatabaseAdminContent() {
|
||||
|
||||
if (status.isLoading) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.pageTitle}>Database Administration</h1>
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
<div className={layout.page}>
|
||||
<div className={layout.loading}>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const db = status.data;
|
||||
|
||||
function getMiniStatus(section: Section): string {
|
||||
switch (section) {
|
||||
case 'pool': {
|
||||
const d = pool.data;
|
||||
if (!d) return '--';
|
||||
const pct = d.maxPoolSize > 0 ? Math.round((d.activeConnections / d.maxPoolSize) * 100) : 0;
|
||||
return `${pct}%`;
|
||||
}
|
||||
case 'tables':
|
||||
return tables.data ? `${tables.data.length}` : '--';
|
||||
case 'queries':
|
||||
return queries.data ? `${queries.data.length}` : '--';
|
||||
case 'maintenance':
|
||||
return 'Coming soon';
|
||||
case 'thresholds':
|
||||
return thresholds.data ? 'Configured' : '--';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerInfo}>
|
||||
<h1 className={styles.pageTitle}>Database Administration</h1>
|
||||
<div className={styles.headerMeta}>
|
||||
<div className={layout.page}>
|
||||
<div className={layout.panelHeader}>
|
||||
<div>
|
||||
<div className={layout.panelTitle}>Database</div>
|
||||
<div className={layout.panelSubtitle}>
|
||||
<StatusBadge
|
||||
status={db?.connected ? 'healthy' : 'critical'}
|
||||
label={db?.connected ? 'Connected' : 'Disconnected'}
|
||||
@@ -64,7 +100,7 @@ function DatabaseAdminContent() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.globalRefresh}
|
||||
className={layout.btnAction}
|
||||
onClick={() => {
|
||||
status.refetch();
|
||||
pool.refetch();
|
||||
@@ -76,18 +112,46 @@ function DatabaseAdminContent() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PoolSection
|
||||
pool={pool}
|
||||
warningPct={thresholds.data?.database?.connectionPoolWarning}
|
||||
criticalPct={thresholds.data?.database?.connectionPoolCritical}
|
||||
/>
|
||||
<TablesSection tables={tables} />
|
||||
<QueriesSection
|
||||
queries={queries}
|
||||
warningSeconds={thresholds.data?.database?.queryDurationWarning}
|
||||
/>
|
||||
<MaintenanceSection />
|
||||
<ThresholdsSection thresholds={thresholds.data} />
|
||||
<div className={layout.split}>
|
||||
<div className={layout.listPane}>
|
||||
<div className={layout.entityList}>
|
||||
{SECTIONS.map((sec) => (
|
||||
<div
|
||||
key={sec.id}
|
||||
className={`${layout.entityItem} ${selectedSection === sec.id ? layout.entityItemSelected : ''}`}
|
||||
onClick={() => setSelectedSection(sec.id)}
|
||||
>
|
||||
<div className={layout.sectionIcon}>{sec.icon}</div>
|
||||
<div className={layout.entityInfo}>
|
||||
<div className={layout.entityName}>{sec.label}</div>
|
||||
</div>
|
||||
<div className={layout.miniStatus}>{getMiniStatus(sec.id)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={layout.detailPane}>
|
||||
{selectedSection === 'pool' && (
|
||||
<PoolSection
|
||||
pool={pool}
|
||||
warningPct={thresholds.data?.database?.connectionPoolWarning}
|
||||
criticalPct={thresholds.data?.database?.connectionPoolCritical}
|
||||
/>
|
||||
)}
|
||||
{selectedSection === 'tables' && <TablesSection tables={tables} />}
|
||||
{selectedSection === 'queries' && (
|
||||
<QueriesSection
|
||||
queries={queries}
|
||||
warningSeconds={thresholds.data?.database?.queryDurationWarning}
|
||||
/>
|
||||
)}
|
||||
{selectedSection === 'maintenance' && <MaintenanceSection />}
|
||||
{selectedSection === 'thresholds' && (
|
||||
<ThresholdsSection thresholds={thresholds.data} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,12 +177,8 @@ function PoolSection({
|
||||
: '#22c55e';
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Connection Pool"
|
||||
onRefresh={() => pool.refetch()}
|
||||
isRefreshing={pool.isFetching}
|
||||
autoRefresh
|
||||
>
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Connection Pool</div>
|
||||
<div className={styles.progressContainer}>
|
||||
<div className={styles.progressLabel}>
|
||||
{data.activeConnections} / {data.maxPoolSize} connections
|
||||
@@ -149,7 +209,7 @@ function PoolSection({
|
||||
<span className={styles.metricLabel}>Max Wait</span>
|
||||
</div>
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,13 +217,10 @@ function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables
|
||||
const data = tables.data;
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Table Sizes"
|
||||
onRefresh={() => tables.refetch()}
|
||||
isRefreshing={tables.isFetching}
|
||||
>
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Table Sizes</div>
|
||||
{!data ? (
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
<div className={layout.loading}>Loading...</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
@@ -188,7 +245,7 @@ function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,12 +263,8 @@ function QueriesSection({
|
||||
const warningSec = warningSeconds ?? 30;
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Active Queries"
|
||||
onRefresh={() => queries.refetch()}
|
||||
isRefreshing={queries.isFetching}
|
||||
autoRefresh
|
||||
>
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Active Queries</div>
|
||||
{!data || data.length === 0 ? (
|
||||
<div className={styles.emptyState}>No active queries</div>
|
||||
) : (
|
||||
@@ -265,13 +318,14 @@ function QueriesSection({
|
||||
resourceName={String(killTarget ?? '')}
|
||||
resourceType="query (PID)"
|
||||
/>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MaintenanceSection() {
|
||||
return (
|
||||
<RefreshableCard title="Maintenance">
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Maintenance</div>
|
||||
<div className={styles.maintenanceGrid}>
|
||||
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
|
||||
VACUUM ANALYZE
|
||||
@@ -283,7 +337,7 @@ function MaintenanceSection() {
|
||||
Refresh Aggregates
|
||||
</button>
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,7 +369,8 @@ function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<RefreshableCard title="Thresholds" collapsible defaultCollapsed>
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Thresholds</div>
|
||||
<div className={styles.thresholdGrid}>
|
||||
<div className={styles.thresholdField}>
|
||||
<label className={styles.thresholdLabel}>Pool Warning %</label>
|
||||
@@ -369,7 +424,7 @@ function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,4 @@
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* ─── Toggle ─── */
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -84,6 +59,7 @@
|
||||
background: #0a0e17;
|
||||
}
|
||||
|
||||
/* ─── Form Fields ─── */
|
||||
.field {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -123,6 +99,7 @@
|
||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
||||
}
|
||||
|
||||
/* ─── Tags ─── */
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -182,31 +159,17 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* ─── Header Action Button Variants ─── */
|
||||
.btnPrimary {
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--amber);
|
||||
background: var(--amber);
|
||||
color: #0a0e17;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
border-color: var(--amber) !important;
|
||||
background: var(--amber) !important;
|
||||
color: #0a0e17 !important;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
background: var(--amber-hover);
|
||||
border-color: var(--amber-hover);
|
||||
.btnPrimary:hover:not(:disabled) {
|
||||
background: var(--amber-hover) !important;
|
||||
border-color: var(--amber-hover) !important;
|
||||
}
|
||||
|
||||
.btnPrimary:disabled {
|
||||
@@ -215,19 +178,12 @@
|
||||
}
|
||||
|
||||
.btnOutline {
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-color: var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btnOutline:hover {
|
||||
.btnOutline:hover:not(:disabled) {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -238,21 +194,13 @@
|
||||
}
|
||||
|
||||
.btnDanger {
|
||||
margin-left: auto;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: 1px solid var(--rose-dim);
|
||||
color: var(--rose);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border-color: var(--rose-dim) !important;
|
||||
color: var(--rose) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.btnDanger:hover {
|
||||
background: var(--rose-glow);
|
||||
.btnDanger:hover:not(:disabled) {
|
||||
background: var(--rose-glow) !important;
|
||||
}
|
||||
|
||||
.btnDanger:disabled {
|
||||
@@ -260,6 +208,7 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Confirm Bar ─── */
|
||||
.confirmBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -283,6 +232,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ─── Status Messages ─── */
|
||||
.successMsg {
|
||||
margin-top: 16px;
|
||||
padding: 10px 12px;
|
||||
@@ -303,6 +253,7 @@
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
/* ─── Skeleton Loading ─── */
|
||||
.skeleton {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
background: var(--bg-raised);
|
||||
@@ -322,13 +273,6 @@
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.accessDenied {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useDeleteOidcConfig,
|
||||
} from '../../api/queries/oidc-admin';
|
||||
import type { OidcAdminConfigRequest } from '../../api/types';
|
||||
import layout from '../../styles/AdminLayout.module.css';
|
||||
import styles from './OidcAdminPage.module.css';
|
||||
|
||||
interface FormData {
|
||||
@@ -36,9 +37,9 @@ export function OidcAdminPage() {
|
||||
|
||||
if (!roles.includes('ADMIN')) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.accessDenied}>
|
||||
Access Denied — this page requires the ADMIN role.
|
||||
<div className={layout.page}>
|
||||
<div className={layout.accessDenied}>
|
||||
Access Denied -- this page requires the ADMIN role.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -137,10 +138,14 @@ function OidcAdminForm() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.pageTitle}>OIDC Configuration</h1>
|
||||
<p className={styles.subtitle}>Configure external identity provider</p>
|
||||
<div className={styles.card}>
|
||||
<div className={layout.page}>
|
||||
<div className={layout.panelHeader}>
|
||||
<div>
|
||||
<div className={layout.panelTitle}>OIDC Configuration</div>
|
||||
<div className={layout.panelSubtitle}>Configure external identity provider</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={layout.detailOnly}>
|
||||
<div className={styles.skeletonWide} />
|
||||
<div className={styles.skeletonMedium} />
|
||||
<div className={styles.skeletonWide} />
|
||||
@@ -154,108 +159,151 @@ function OidcAdminForm() {
|
||||
const isConfigured = data?.configured ?? false;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.pageTitle}>OIDC Configuration</h1>
|
||||
<p className={styles.subtitle}>Configure external identity provider</p>
|
||||
|
||||
<div className={styles.card}>
|
||||
<div className={styles.toggleRow}>
|
||||
<div className={styles.toggleInfo}>
|
||||
<div className={styles.toggleLabel}>Enabled</div>
|
||||
<div className={styles.toggleDesc}>
|
||||
Allow users to sign in with the configured OIDC identity provider
|
||||
</div>
|
||||
</div>
|
||||
<div className={layout.page}>
|
||||
<div className={layout.panelHeader}>
|
||||
<div>
|
||||
<div className={layout.panelTitle}>OIDC Configuration</div>
|
||||
<div className={layout.panelSubtitle}>Configure external identity provider</div>
|
||||
</div>
|
||||
<div className={layout.headerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggle} ${form.enabled ? styles.toggleOn : ''}`}
|
||||
onClick={() => updateField('enabled', !form.enabled)}
|
||||
aria-label="Toggle OIDC enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.toggleRow}>
|
||||
<div className={styles.toggleInfo}>
|
||||
<div className={styles.toggleLabel}>Auto Sign-Up</div>
|
||||
<div className={styles.toggleDesc}>
|
||||
Automatically create accounts for new OIDC users. When disabled, an admin must
|
||||
pre-create the user before they can sign in.
|
||||
</div>
|
||||
</div>
|
||||
className={`${layout.btnAction} ${styles.btnPrimary}`}
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggle} ${form.autoSignup ? styles.toggleOn : ''}`}
|
||||
onClick={() => updateField('autoSignup', !form.autoSignup)}
|
||||
aria-label="Toggle auto sign-up"
|
||||
/>
|
||||
className={`${layout.btnAction} ${styles.btnOutline}`}
|
||||
onClick={handleTest}
|
||||
disabled={!isConfigured || testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${layout.btnAction} ${styles.btnDanger}`}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={!isConfigured || deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Issuer URI</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="url"
|
||||
value={form.issuerUri}
|
||||
onChange={(e) => updateField('issuerUri', e.target.value)}
|
||||
placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration"
|
||||
/>
|
||||
</div>
|
||||
<div className={layout.detailOnly}>
|
||||
<div className={layout.detailSection}>
|
||||
<div className={layout.detailSectionTitle}>Behavior</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Client ID</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.clientId}
|
||||
onChange={(e) => updateField('clientId', e.target.value)}
|
||||
placeholder="cameleer3"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toggleRow}>
|
||||
<div className={styles.toggleInfo}>
|
||||
<div className={styles.toggleLabel}>Enabled</div>
|
||||
<div className={styles.toggleDesc}>
|
||||
Allow users to sign in with the configured OIDC identity provider
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggle} ${form.enabled ? styles.toggleOn : ''}`}
|
||||
onClick={() => updateField('enabled', !form.enabled)}
|
||||
aria-label="Toggle OIDC enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Client Secret</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) => {
|
||||
updateField('clientSecret', e.target.value);
|
||||
setSecretTouched(true);
|
||||
}}
|
||||
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Roles Claim</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.rolesClaim}
|
||||
onChange={(e) => updateField('rolesClaim', e.target.value)}
|
||||
placeholder="realm_access.roles"
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Dot-separated path to roles array in the ID token
|
||||
<div className={styles.toggleRow}>
|
||||
<div className={styles.toggleInfo}>
|
||||
<div className={styles.toggleLabel}>Auto Sign-Up</div>
|
||||
<div className={styles.toggleDesc}>
|
||||
Automatically create accounts for new OIDC users. When disabled, an admin must
|
||||
pre-create the user before they can sign in.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggle} ${form.autoSignup ? styles.toggleOn : ''}`}
|
||||
onClick={() => updateField('autoSignup', !form.autoSignup)}
|
||||
aria-label="Toggle auto sign-up"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Display Name Claim</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.displayNameClaim}
|
||||
onChange={(e) => updateField('displayNameClaim', e.target.value)}
|
||||
placeholder="name"
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name)
|
||||
<div className={layout.detailSection}>
|
||||
<div className={layout.detailSectionTitle}>Provider Settings</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Issuer URI</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="url"
|
||||
value={form.issuerUri}
|
||||
onChange={(e) => updateField('issuerUri', e.target.value)}
|
||||
placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Client ID</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.clientId}
|
||||
onChange={(e) => updateField('clientId', e.target.value)}
|
||||
placeholder="cameleer3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Client Secret</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) => {
|
||||
updateField('clientSecret', e.target.value);
|
||||
setSecretTouched(true);
|
||||
}}
|
||||
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Default Roles</label>
|
||||
<div className={layout.detailSection}>
|
||||
<div className={layout.detailSectionTitle}>Claim Mapping</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Roles Claim</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.rolesClaim}
|
||||
onChange={(e) => updateField('rolesClaim', e.target.value)}
|
||||
placeholder="realm_access.roles"
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Dot-separated path to roles array in the ID token
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Display Name Claim</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.displayNameClaim}
|
||||
onChange={(e) => updateField('displayNameClaim', e.target.value)}
|
||||
placeholder="name"
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={layout.detailSection}>
|
||||
<div className={layout.detailSectionTitle}>Default Roles</div>
|
||||
|
||||
<div className={styles.tags}>
|
||||
{form.defaultRoles.map((role) => (
|
||||
<span key={role} className={styles.tag}>
|
||||
@@ -291,33 +339,6 @@ function OidcAdminForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnPrimary}
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnOutline}
|
||||
onClick={handleTest}
|
||||
disabled={!isConfigured || testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDanger}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={!isConfigured || deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className={styles.confirmBar}>
|
||||
<span>Delete OIDC configuration? This cannot be undone.</span>
|
||||
|
||||
@@ -1,73 +1,3 @@
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.headerInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.globalRefresh {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.globalRefresh:hover {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.accessDenied {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Progress Bar ─── */
|
||||
.progressContainer {
|
||||
margin-bottom: 16px;
|
||||
@@ -405,6 +335,12 @@
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.metricsGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -414,11 +350,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { StatusBadge, type Status } from '../../components/admin/StatusBadge';
|
||||
import { RefreshableCard } from '../../components/admin/RefreshableCard';
|
||||
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
|
||||
import {
|
||||
useOpenSearchStatus,
|
||||
@@ -12,8 +11,11 @@ import {
|
||||
type IndicesParams,
|
||||
} from '../../api/queries/admin/opensearch';
|
||||
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
|
||||
import layout from '../../styles/AdminLayout.module.css';
|
||||
import styles from './OpenSearchAdminPage.module.css';
|
||||
|
||||
type Section = 'pipeline' | 'indices' | 'performance' | 'operations' | 'thresholds';
|
||||
|
||||
function clusterHealthToStatus(health: string | undefined): Status {
|
||||
switch (health?.toLowerCase()) {
|
||||
case 'green': return 'healthy';
|
||||
@@ -23,14 +25,22 @@ function clusterHealthToStatus(health: string | undefined): Status {
|
||||
}
|
||||
}
|
||||
|
||||
const SECTIONS: { key: Section; label: string; icon: string }[] = [
|
||||
{ key: 'pipeline', label: 'Indexing Pipeline', icon: '>' },
|
||||
{ key: 'indices', label: 'Indices', icon: '#' },
|
||||
{ key: 'performance', label: 'Performance', icon: '~' },
|
||||
{ key: 'operations', label: 'Operations', icon: '*' },
|
||||
{ key: 'thresholds', label: 'Thresholds', icon: '=' },
|
||||
];
|
||||
|
||||
export function OpenSearchAdminPage() {
|
||||
const roles = useAuthStore((s) => s.roles);
|
||||
|
||||
if (!roles.includes('ADMIN')) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.accessDenied}>
|
||||
Access Denied — this page requires the ADMIN role.
|
||||
<div className={layout.page}>
|
||||
<div className={layout.accessDenied}>
|
||||
Access Denied -- this page requires the ADMIN role.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -40,6 +50,8 @@ export function OpenSearchAdminPage() {
|
||||
}
|
||||
|
||||
function OpenSearchAdminContent() {
|
||||
const [selectedSection, setSelectedSection] = useState<Section>('pipeline');
|
||||
|
||||
const status = useOpenSearchStatus();
|
||||
const pipeline = usePipelineStats();
|
||||
const performance = usePerformanceStats();
|
||||
@@ -47,35 +59,48 @@ function OpenSearchAdminContent() {
|
||||
|
||||
if (status.isLoading) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.pageTitle}>OpenSearch Administration</h1>
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
<div className={layout.page}>
|
||||
<div className={layout.loading}>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const os = status.data;
|
||||
|
||||
function getMiniStatus(key: Section): string {
|
||||
switch (key) {
|
||||
case 'pipeline':
|
||||
return pipeline.data ? `Queue: ${pipeline.data.queueDepth}` : '--';
|
||||
case 'indices':
|
||||
return '--';
|
||||
case 'performance':
|
||||
return performance.data
|
||||
? `${(performance.data.queryCacheHitRate * 100).toFixed(0)}% hit`
|
||||
: '--';
|
||||
case 'operations':
|
||||
return 'Coming soon';
|
||||
case 'thresholds':
|
||||
return 'Configured';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerInfo}>
|
||||
<h1 className={styles.pageTitle}>OpenSearch Administration</h1>
|
||||
<div className={styles.headerMeta}>
|
||||
<div className={layout.page}>
|
||||
<div className={layout.panelHeader}>
|
||||
<div>
|
||||
<div className={layout.panelTitle}>OpenSearch</div>
|
||||
<div className={layout.panelSubtitle}>
|
||||
<StatusBadge
|
||||
status={clusterHealthToStatus(os?.clusterHealth)}
|
||||
label={os?.clusterHealth ?? 'Unknown'}
|
||||
/>
|
||||
{os?.version && <span className={styles.metaItem}>v{os.version}</span>}
|
||||
{os?.nodeCount !== undefined && (
|
||||
<span className={styles.metaItem}>{os.nodeCount} node(s)</span>
|
||||
)}
|
||||
{os?.host && <span className={styles.metaItem}>{os.host}</span>}
|
||||
{os?.version && <span>v{os.version}</span>}
|
||||
{os?.nodeCount !== undefined && <span>{os.nodeCount} node(s)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.globalRefresh}
|
||||
className={layout.btnAction}
|
||||
onClick={() => {
|
||||
status.refetch();
|
||||
pipeline.refetch();
|
||||
@@ -86,11 +111,39 @@ function OpenSearchAdminContent() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PipelineSection pipeline={pipeline} thresholds={thresholds.data} />
|
||||
<IndicesSection />
|
||||
<PerformanceSection performance={performance} thresholds={thresholds.data} />
|
||||
<OperationsSection />
|
||||
<OsThresholdsSection thresholds={thresholds.data} />
|
||||
<div className={layout.split}>
|
||||
<div className={layout.listPane}>
|
||||
<div className={layout.entityList}>
|
||||
{SECTIONS.map((s) => (
|
||||
<div
|
||||
key={s.key}
|
||||
className={`${layout.entityItem} ${selectedSection === s.key ? layout.entityItemSelected : ''}`}
|
||||
onClick={() => setSelectedSection(s.key)}
|
||||
>
|
||||
<div className={layout.sectionIcon}>{s.icon}</div>
|
||||
<div className={layout.entityInfo}>
|
||||
<div className={layout.entityName}>{s.label}</div>
|
||||
</div>
|
||||
<div className={layout.miniStatus}>{getMiniStatus(s.key)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={layout.detailPane}>
|
||||
{selectedSection === 'pipeline' && (
|
||||
<PipelineSection pipeline={pipeline} thresholds={thresholds.data} />
|
||||
)}
|
||||
{selectedSection === 'indices' && <IndicesSection />}
|
||||
{selectedSection === 'performance' && (
|
||||
<PerformanceSection performance={performance} thresholds={thresholds.data} />
|
||||
)}
|
||||
{selectedSection === 'operations' && <OperationsSection />}
|
||||
{selectedSection === 'thresholds' && (
|
||||
<OsThresholdsSection thresholds={thresholds.data} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,12 +167,8 @@ function PipelineSection({
|
||||
: '#22c55e';
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Indexing Pipeline"
|
||||
onRefresh={() => pipeline.refetch()}
|
||||
isRefreshing={pipeline.isFetching}
|
||||
autoRefresh
|
||||
>
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Indexing Pipeline</div>
|
||||
<div className={styles.progressContainer}>
|
||||
<div className={styles.progressLabel}>
|
||||
Queue: {data.queueDepth} / {data.maxQueueSize}
|
||||
@@ -146,7 +195,7 @@ function PipelineSection({
|
||||
<span className={styles.metricLabel}>Indexing Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,11 +218,8 @@ function IndicesSection() {
|
||||
const totalPages = data?.totalPages ?? 0;
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Indices"
|
||||
onRefresh={() => indices.refetch()}
|
||||
isRefreshing={indices.isFetching}
|
||||
>
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Indices</div>
|
||||
<div className={styles.filterRow}>
|
||||
<input
|
||||
className={styles.filterInput}
|
||||
@@ -185,7 +231,7 @@ function IndicesSection() {
|
||||
</div>
|
||||
|
||||
{!data ? (
|
||||
<div className={styles.loading}>Loading...</div>
|
||||
<div className={layout.loading}>Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.tableWrapper}>
|
||||
@@ -270,7 +316,7 @@ function IndicesSection() {
|
||||
resourceName={deleteTarget ?? ''}
|
||||
resourceType="index"
|
||||
/>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -293,12 +339,8 @@ function PerformanceSection({
|
||||
: '#22c55e';
|
||||
|
||||
return (
|
||||
<RefreshableCard
|
||||
title="Performance"
|
||||
onRefresh={() => performance.refetch()}
|
||||
isRefreshing={performance.isFetching}
|
||||
autoRefresh
|
||||
>
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Performance</div>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={styles.metric}>
|
||||
<span className={styles.metricValue}>{(data.queryCacheHitRate * 100).toFixed(1)}%</span>
|
||||
@@ -329,13 +371,14 @@ function PerformanceSection({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationsSection() {
|
||||
return (
|
||||
<RefreshableCard title="Operations">
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Operations</div>
|
||||
<div className={styles.operationsGrid}>
|
||||
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
|
||||
Force Merge
|
||||
@@ -347,7 +390,7 @@ function OperationsSection() {
|
||||
Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,7 +421,8 @@ function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<RefreshableCard title="Thresholds" collapsible defaultCollapsed>
|
||||
<>
|
||||
<div className={layout.detailSectionTitle}>Thresholds</div>
|
||||
<div className={styles.thresholdGrid}>
|
||||
<div className={styles.thresholdField}>
|
||||
<label className={styles.thresholdLabel}>Queue Warning</label>
|
||||
@@ -432,7 +476,7 @@ function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</RefreshableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
151
ui/src/pages/admin/rbac/DashboardTab.tsx
Normal file
151
ui/src/pages/admin/rbac/DashboardTab.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useRbacStats, useGroups } from '../../../api/queries/admin/rbac';
|
||||
import type { GroupDetail } from '../../../api/queries/admin/rbac';
|
||||
import styles from './RbacPage.module.css';
|
||||
|
||||
export function DashboardTab() {
|
||||
const stats = useRbacStats();
|
||||
const groups = useGroups();
|
||||
|
||||
const groupList: GroupDetail[] = groups.data ?? [];
|
||||
|
||||
// Build inheritance diagram data: top-level groups sorted alphabetically,
|
||||
// children sorted alphabetically and indented below their parent.
|
||||
const { topLevelGroups, childMap } = useMemo(() => {
|
||||
const sorted = [...groupList].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const top = sorted.filter((g) => !g.parentGroupId);
|
||||
const cMap = new Map<string, GroupDetail[]>();
|
||||
for (const g of sorted) {
|
||||
if (g.parentGroupId) {
|
||||
const children = cMap.get(g.parentGroupId) ?? [];
|
||||
children.push(g);
|
||||
cMap.set(g.parentGroupId, children);
|
||||
}
|
||||
}
|
||||
return { topLevelGroups: top, childMap: cMap };
|
||||
}, [groupList]);
|
||||
|
||||
// Derive roles from groups in tree order (top-level then children), collecting
|
||||
// each group's directRoles, deduplicating by id and preserving first-seen order.
|
||||
const roleList = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const result: { id: string; name: string }[] = [];
|
||||
for (const g of topLevelGroups) {
|
||||
for (const r of g.directRoles) {
|
||||
if (!seen.has(r.id)) {
|
||||
seen.add(r.id);
|
||||
result.push(r);
|
||||
}
|
||||
}
|
||||
for (const child of childMap.get(g.id) ?? []) {
|
||||
for (const r of child.directRoles) {
|
||||
if (!seen.has(r.id)) {
|
||||
seen.add(r.id);
|
||||
result.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [topLevelGroups, childMap]);
|
||||
|
||||
// Collect unique users from all groups, sorted alphabetically by displayName.
|
||||
const allUsers = useMemo(() => {
|
||||
const userMap = new Map<string, string>();
|
||||
for (const g of groupList) {
|
||||
for (const m of g.members) {
|
||||
userMap.set(m.userId, m.displayName);
|
||||
}
|
||||
}
|
||||
return new Map(
|
||||
[...userMap.entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
);
|
||||
}, [groupList]);
|
||||
|
||||
if (stats.isLoading) {
|
||||
return <div className={styles.loading}>Loading...</div>;
|
||||
}
|
||||
|
||||
const s = stats.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<div className={styles.panelTitle}>RBAC Overview</div>
|
||||
<div className={styles.panelSubtitle}>Inheritance model and system summary</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statLabel}>Users</div>
|
||||
<div className={styles.statValue}>{s?.userCount ?? 0}</div>
|
||||
<div className={styles.statSub}>{s?.activeUserCount ?? 0} active</div>
|
||||
</div>
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statLabel}>Groups</div>
|
||||
<div className={styles.statValue}>{s?.groupCount ?? 0}</div>
|
||||
<div className={styles.statSub}>Nested up to {s?.maxGroupDepth ?? 0} levels</div>
|
||||
</div>
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statLabel}>Roles</div>
|
||||
<div className={styles.statValue}>{s?.roleCount ?? 0}</div>
|
||||
<div className={styles.statSub}>Direct + inherited</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.inhDiagram}>
|
||||
<div className={styles.inhTitle}>Inheritance model</div>
|
||||
<div className={styles.inhRow}>
|
||||
<div className={styles.inhCol}>
|
||||
<div className={styles.inhColTitle}>Groups</div>
|
||||
{topLevelGroups.map((g) => (
|
||||
<div key={g.id}>
|
||||
<div className={`${styles.inhItem} ${styles.inhItemGroup}`}>{g.name}</div>
|
||||
{(childMap.get(g.id) ?? []).map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className={`${styles.inhItem} ${styles.inhItemGroup} ${styles.inhItemChild}`}
|
||||
>
|
||||
{child.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.inhArrow}>→</div>
|
||||
<div className={styles.inhCol}>
|
||||
<div className={styles.inhColTitle}>Roles on groups</div>
|
||||
{roleList.map((r) => (
|
||||
<div key={r.id} className={`${styles.inhItem} ${styles.inhItemRole}`}>
|
||||
{r.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.inhArrow}>→</div>
|
||||
<div className={styles.inhCol}>
|
||||
<div className={styles.inhColTitle}>Users inherit</div>
|
||||
{Array.from(allUsers.entries())
|
||||
.slice(0, 5)
|
||||
.map(([id, name]) => (
|
||||
<div key={id} className={`${styles.inhItem} ${styles.inhItemUser}`}>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
{allUsers.size > 5 && (
|
||||
<div className={styles.inhItem} style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||
+ {allUsers.size - 5} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.inheritNote} style={{ marginTop: 12 }}>
|
||||
Users inherit all roles from every group they belong to — and transitively from parent
|
||||
groups. Roles can also be assigned directly to users, overriding or extending inherited
|
||||
permissions.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
428
ui/src/pages/admin/rbac/GroupsTab.tsx
Normal file
428
ui/src/pages/admin/rbac/GroupsTab.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
useGroups,
|
||||
useGroup,
|
||||
useCreateGroup,
|
||||
useDeleteGroup,
|
||||
useUpdateGroup,
|
||||
useAssignRoleToGroup,
|
||||
useRemoveRoleFromGroup,
|
||||
useRoles,
|
||||
} from '../../../api/queries/admin/rbac';
|
||||
import type { GroupDetail } from '../../../api/queries/admin/rbac';
|
||||
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
||||
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
|
||||
import { hashColor } from './avatar-colors';
|
||||
import styles from './RbacPage.module.css';
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function getGroupMeta(group: GroupDetail, groupMap: Map<string, GroupDetail>): string {
|
||||
const parts: string[] = [];
|
||||
if (group.parentGroupId) {
|
||||
const parent = groupMap.get(group.parentGroupId);
|
||||
parts.push(`Child of ${parent?.name ?? 'unknown'}`);
|
||||
} else {
|
||||
parts.push('Top-level');
|
||||
}
|
||||
if (group.childGroups.length > 0) {
|
||||
parts.push(`${group.childGroups.length} child group${group.childGroups.length !== 1 ? 's' : ''}`);
|
||||
}
|
||||
parts.push(`${group.members.length} member${group.members.length !== 1 ? 's' : ''}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function getDescendantIds(groupId: string, allGroups: GroupDetail[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
function walk(id: string) {
|
||||
const g = allGroups.find(x => x.id === id);
|
||||
if (!g) return;
|
||||
for (const child of g.childGroups) {
|
||||
if (!ids.has(child.id)) {
|
||||
ids.add(child.id);
|
||||
walk(child.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(groupId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function GroupsTab() {
|
||||
const groups = useGroups();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newParentId, setNewParentId] = useState('');
|
||||
const [createError, setCreateError] = useState('');
|
||||
const createGroup = useCreateGroup();
|
||||
const { data: allRoles } = useRoles();
|
||||
|
||||
const groupDetail = useGroup(selectedId);
|
||||
|
||||
const groupMap = useMemo(() => {
|
||||
const map = new Map<string, GroupDetail>();
|
||||
for (const g of groups.data ?? []) {
|
||||
map.set(g.id, g);
|
||||
}
|
||||
return map;
|
||||
}, [groups.data]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = groups.data ?? [];
|
||||
if (!filter) return list;
|
||||
const lower = filter.toLowerCase();
|
||||
return list.filter((g) => g.name.toLowerCase().includes(lower));
|
||||
}, [groups.data, filter]);
|
||||
|
||||
if (groups.isLoading) {
|
||||
return <div className={styles.loading}>Loading...</div>;
|
||||
}
|
||||
|
||||
const detail = groupDetail.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<div className={styles.panelTitle}>Groups</div>
|
||||
<div className={styles.panelSubtitle}>
|
||||
Organise users in nested hierarchies; roles propagate to all members
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add group</button>
|
||||
</div>
|
||||
<div className={styles.split}>
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.searchBar}>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
placeholder="Search groups..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{showCreateForm && (
|
||||
<div className={styles.createForm}>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Name</label>
|
||||
<input className={styles.createFormInput} value={newName}
|
||||
onChange={e => { setNewName(e.target.value); setCreateError(''); }}
|
||||
placeholder="Group name" autoFocus />
|
||||
</div>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Parent</label>
|
||||
<select className={styles.createFormSelect} value={newParentId}
|
||||
onChange={e => setNewParentId(e.target.value)}>
|
||||
<option value="">(Top-level)</option>
|
||||
{(groups.data || []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{createError && <div className={styles.createFormError}>{createError}</div>}
|
||||
<div className={styles.createFormActions}>
|
||||
<button type="button" className={styles.createFormBtn}
|
||||
onClick={() => { setShowCreateForm(false); setNewName(''); setNewParentId(''); setCreateError(''); }}>Cancel</button>
|
||||
<button type="button" className={styles.createFormBtnPrimary}
|
||||
disabled={!newName.trim() || createGroup.isPending}
|
||||
onClick={() => {
|
||||
createGroup.mutate({ name: newName.trim(), parentGroupId: newParentId || undefined }, {
|
||||
onSuccess: () => { setShowCreateForm(false); setNewName(''); setNewParentId(''); setCreateError(''); },
|
||||
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create group'),
|
||||
});
|
||||
}}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.entityList}>
|
||||
{filtered.map((group) => {
|
||||
const isSelected = group.id === selectedId;
|
||||
const color = hashColor(group.name);
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||
onClick={() => setSelectedId(group.id)}
|
||||
>
|
||||
<div className={styles.avatar} style={{ background: color.bg, color: color.fg, borderRadius: 8 }}>
|
||||
{getInitials(group.name)}
|
||||
</div>
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>{group.name}</div>
|
||||
<div className={styles.entityMeta}>{getGroupMeta(group, groupMap)}</div>
|
||||
<div className={styles.tagList}>
|
||||
{group.directRoles.map((r) => (
|
||||
<span key={r.id} className={`${styles.tag} ${styles.tagRole}`}>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
{group.effectiveRoles
|
||||
.filter((er) => !group.directRoles.some((dr) => dr.id === er.id))
|
||||
.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className={`${styles.tag} ${styles.tagRole} ${styles.tagInherited}`}
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailPane}>
|
||||
{!detail ? (
|
||||
<div className={styles.detailEmpty}>
|
||||
<span>Select a group to view details</span>
|
||||
</div>
|
||||
) : (
|
||||
<GroupDetailView
|
||||
group={detail}
|
||||
groupMap={groupMap}
|
||||
allGroups={groups.data || []}
|
||||
allRoles={allRoles || []}
|
||||
onDeselect={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ADMINS_GROUP_ID = '00000000-0000-0000-0000-000000000010';
|
||||
|
||||
function GroupDetailView({
|
||||
group,
|
||||
groupMap,
|
||||
allGroups,
|
||||
allRoles,
|
||||
onDeselect,
|
||||
}: {
|
||||
group: GroupDetail;
|
||||
groupMap: Map<string, GroupDetail>;
|
||||
allGroups: GroupDetail[];
|
||||
allRoles: Array<{ id: string; name: string }>;
|
||||
onDeselect: () => void;
|
||||
}) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState(group.name);
|
||||
const [editingParent, setEditingParent] = useState(false);
|
||||
const [parentValue, setParentValue] = useState(group.parentGroupId || '');
|
||||
const deleteGroup = useDeleteGroup();
|
||||
const updateGroup = useUpdateGroup();
|
||||
const assignRole = useAssignRoleToGroup();
|
||||
const removeRole = useRemoveRoleFromGroup();
|
||||
|
||||
const isBuiltIn = group.id === ADMINS_GROUP_ID;
|
||||
|
||||
// Reset editing state when group changes
|
||||
const [prevGroupId, setPrevGroupId] = useState(group.id);
|
||||
if (prevGroupId !== group.id) {
|
||||
setPrevGroupId(group.id);
|
||||
setEditingName(false);
|
||||
setNameValue(group.name);
|
||||
setEditingParent(false);
|
||||
setParentValue(group.parentGroupId || '');
|
||||
}
|
||||
|
||||
const hierarchyLabel = group.parentGroupId
|
||||
? `Child of ${groupMap.get(group.parentGroupId)?.name ?? 'unknown'}`
|
||||
: 'Top-level group';
|
||||
|
||||
const inheritedRoles = group.effectiveRoles.filter(
|
||||
(er) => !group.directRoles.some((dr) => dr.id === er.id)
|
||||
);
|
||||
|
||||
const availableRoles = (allRoles || [])
|
||||
.filter(r => !group.directRoles.some(dr => dr.id === r.id))
|
||||
.map(r => ({ id: r.id, label: r.name }));
|
||||
|
||||
const descendantIds = getDescendantIds(group.id, allGroups);
|
||||
const parentOptions = allGroups.filter(g => g.id !== group.id && !descendantIds.has(g.id));
|
||||
|
||||
// Build hierarchy tree
|
||||
const tree = useMemo(() => {
|
||||
const rows: { name: string; depth: number }[] = [];
|
||||
// Walk up to find root
|
||||
const ancestors: GroupDetail[] = [];
|
||||
let current: GroupDetail | undefined = group;
|
||||
while (current?.parentGroupId) {
|
||||
const parent = groupMap.get(current.parentGroupId);
|
||||
if (parent) ancestors.unshift(parent);
|
||||
current = parent;
|
||||
}
|
||||
for (let i = 0; i < ancestors.length; i++) {
|
||||
rows.push({ name: ancestors[i].name, depth: i });
|
||||
}
|
||||
rows.push({ name: group.name, depth: ancestors.length });
|
||||
for (const child of group.childGroups) {
|
||||
rows.push({ name: child.name, depth: ancestors.length + 1 });
|
||||
}
|
||||
return rows;
|
||||
}, [group, groupMap]);
|
||||
|
||||
const color = hashColor(group.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.detailHeader}>
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg, borderRadius: 10 }}>
|
||||
{getInitials(group.name)}
|
||||
</div>
|
||||
{editingName ? (
|
||||
<input
|
||||
className={styles.editNameInput}
|
||||
value={nameValue}
|
||||
onChange={e => setNameValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (nameValue.trim() && nameValue !== group.name) {
|
||||
updateGroup.mutate({ id: group.id, name: nameValue.trim(), parentGroupId: group.parentGroupId });
|
||||
}
|
||||
setEditingName(false);
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(group.name); setEditingName(false); } }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.detailName}
|
||||
onClick={() => !isBuiltIn && setEditingName(true)}
|
||||
style={{ cursor: isBuiltIn ? 'default' : 'pointer' }}
|
||||
title={isBuiltIn ? undefined : 'Click to edit'}>
|
||||
{group.name}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.detailEmail}>{hierarchyLabel}</div>
|
||||
</div>
|
||||
<button type="button" className={styles.btnDelete}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isBuiltIn || deleteGroup.isPending}
|
||||
title={isBuiltIn ? 'Built-in group cannot be deleted' : 'Delete group'}>Delete</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>ID</span>
|
||||
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{group.id}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Parent</span>
|
||||
{editingParent ? (
|
||||
<div className={styles.parentEditRow}>
|
||||
<select className={styles.parentSelect} value={parentValue}
|
||||
onChange={e => setParentValue(e.target.value)}>
|
||||
<option value="">(Top-level)</option>
|
||||
{parentOptions.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
<button type="button" className={styles.parentEditBtn}
|
||||
onClick={() => {
|
||||
updateGroup.mutate(
|
||||
{ id: group.id, name: group.name, parentGroupId: parentValue || null },
|
||||
{ onSuccess: () => setEditingParent(false) }
|
||||
);
|
||||
}}
|
||||
disabled={updateGroup.isPending}>Save</button>
|
||||
<button type="button" className={styles.parentEditBtn}
|
||||
onClick={() => { setParentValue(group.parentGroupId || ''); setEditingParent(false); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className={styles.fieldVal}>
|
||||
{hierarchyLabel}
|
||||
{!isBuiltIn && (
|
||||
<button type="button" className={styles.fieldEditBtn}
|
||||
onClick={() => setEditingParent(true)}>Edit</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>
|
||||
Members <span>direct</span>
|
||||
</div>
|
||||
{group.members.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No direct members</span>
|
||||
) : (
|
||||
group.members.map((m) => (
|
||||
<span key={m.userId} className={styles.chip}>
|
||||
{m.displayName}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
{group.childGroups.length > 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6 }}>
|
||||
+ all members of {group.childGroups.map((c) => c.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{group.childGroups.length > 0 && (
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>Child groups</div>
|
||||
{group.childGroups.map((c) => (
|
||||
<span key={c.id} className={`${styles.chip} ${styles.chipGroup}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>
|
||||
Assigned roles <span>on this group</span>
|
||||
</div>
|
||||
{group.directRoles.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No roles assigned</span>
|
||||
) : (
|
||||
group.directRoles.map((r) => (
|
||||
<span key={r.id} className={`${styles.chip} ${styles.chipRole}`}>
|
||||
{r.name}
|
||||
<button type="button" className={styles.chipRemove}
|
||||
onClick={() => removeRole.mutate({ groupId: group.id, roleId: r.id })}
|
||||
disabled={removeRole.isPending} title="Remove role">x</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
<MultiSelectDropdown items={availableRoles}
|
||||
onApply={async (ids) => { await Promise.allSettled(ids.map(rid => assignRole.mutateAsync({ groupId: group.id, roleId: rid }))); }}
|
||||
placeholder="Search roles..." />
|
||||
{inheritedRoles.length > 0 && (
|
||||
<div className={styles.inheritNote}>
|
||||
{group.childGroups.length > 0
|
||||
? `Child groups ${group.childGroups.map((c) => c.name).join(' and ')} inherit these roles, and may additionally carry their own.`
|
||||
: 'Roles are inherited from parent groups in the hierarchy.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>Group hierarchy</div>
|
||||
{tree.map((node, i) => (
|
||||
<div key={i} className={styles.treeRow}>
|
||||
{node.depth > 0 && (
|
||||
<div className={styles.treeIndent}>
|
||||
<div className={styles.treeCorner} />
|
||||
</div>
|
||||
)}
|
||||
{node.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteDialog isOpen={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}
|
||||
onConfirm={() => { deleteGroup.mutate(group.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }}
|
||||
resourceName={group.name} resourceType="group" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
894
ui/src/pages/admin/rbac/RbacPage.module.css
Normal file
894
ui/src/pages/admin/rbac/RbacPage.module.css
Normal file
@@ -0,0 +1,894 @@
|
||||
/* ─── Page Layout ─── */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accessDenied {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Tabs ─── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
font-size: 13px;
|
||||
padding: 10px 18px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
background: none;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
font-family: var(--font-body);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--green);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Split Layout ─── */
|
||||
.split {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.listPane {
|
||||
width: 52%;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detailPane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ─── Panel Header ─── */
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panelSubtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btnAdd {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.btnAdd:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ─── Search Bar ─── */
|
||||
.searchBar {
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: var(--font-body);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--amber-dim);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── Entity List ─── */
|
||||
.entityList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entityItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.entityItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.entityItemSelected {
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
/* ─── Avatars ─── */
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatarUser {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.avatarGroup {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--green);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.avatarRole {
|
||||
background: rgba(240, 180, 41, 0.15);
|
||||
color: var(--amber);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ─── Entity Info ─── */
|
||||
.entityInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entityName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entityMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ─── Tags ─── */
|
||||
.tagList {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tagRole {
|
||||
background: var(--amber-glow);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.tagGroup {
|
||||
background: var(--green-glow);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.tagInherited {
|
||||
opacity: 0.65;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ─── Status Dot ─── */
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusActive {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.statusInactive {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── OIDC Badge ─── */
|
||||
.oidcBadge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--cyan-glow);
|
||||
color: var(--cyan);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* ─── Lock Icon (system role) ─── */
|
||||
.lockIcon {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ─── Detail Pane ─── */
|
||||
.detailEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detailAvatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detailName {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detailEmail {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.detailSection {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detailSectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.detailSectionTitle span {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ─── Chips ─── */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-raised);
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.chipRole {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.chipGroup {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
background: var(--green-glow);
|
||||
}
|
||||
|
||||
.chipUser {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.chipInherited {
|
||||
border-style: dashed;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.chipSource {
|
||||
font-size: 9px;
|
||||
opacity: 0.6;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ─── Inherit Note ─── */
|
||||
.inheritNote {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid var(--green);
|
||||
}
|
||||
|
||||
/* ─── Field Rows ─── */
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fieldVal {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fieldMono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ─── Tree ─── */
|
||||
.treeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.treeIndent {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.treeCorner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
/* ─── Overview / Dashboard ─── */
|
||||
.overviewGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.statSub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ─── Inheritance Diagram ─── */
|
||||
.inhDiagram {
|
||||
margin: 16px 20px 0;
|
||||
}
|
||||
|
||||
.inhTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.inhRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.inhCol {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inhColTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inhArrow {
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 22px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inhItem {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-raised);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inhItemGroup {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
background: var(--green-glow);
|
||||
}
|
||||
|
||||
.inhItemRole {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.inhItemUser {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.inhItemChild {
|
||||
margin-left: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ─── Loading / Error ─── */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Multi-Select Dropdown ─── */
|
||||
.multiSelectWrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.addChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px dashed var(--amber);
|
||||
color: var(--amber);
|
||||
background: rgba(240, 180, 41, 0.08);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.addChip:hover {
|
||||
background: rgba(240, 180, 41, 0.18);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
min-width: 220px;
|
||||
max-height: 300px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dropdownSearch {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dropdownSearchInput {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dropdownSearchInput:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.dropdownList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dropdownItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.dropdownItemCheckbox {
|
||||
accent-color: var(--amber);
|
||||
}
|
||||
|
||||
.dropdownFooter {
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dropdownApply {
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--amber);
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdownApply:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropdownEmpty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── Remove button on chips ─── */
|
||||
.chipRemove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
font-size: 10px;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
border-radius: 50%;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.chipRemove:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chipRemove:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* ─── Delete button ─── */
|
||||
.btnDelete {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--rose);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--rose);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.btnDelete:hover {
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
}
|
||||
|
||||
.btnDelete:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Inline Create Form ─── */
|
||||
.createForm {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.createFormRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.createFormLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.createFormInput {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.createFormInput:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.createFormSelect {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.createFormActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.createFormBtn {
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.createFormBtnPrimary {
|
||||
composes: createFormBtn;
|
||||
background: var(--amber);
|
||||
border-color: var(--amber);
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.createFormBtnPrimary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.createFormError {
|
||||
font-size: 11px;
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
/* ─── Detail header with actions ─── */
|
||||
.detailHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.detailHeaderInfo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── Parent group dropdown ─── */
|
||||
.parentSelect {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* ─── Parent Edit Mode ─── */
|
||||
.parentEditRow {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.parentEditBtn {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.parentEditBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.fieldEditBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--amber);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fieldEditBtn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ─── Editable Name Input ─── */
|
||||
.editNameInput {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
66
ui/src/pages/admin/rbac/RbacPage.tsx
Normal file
66
ui/src/pages/admin/rbac/RbacPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useAuthStore } from '../../../auth/auth-store';
|
||||
import { DashboardTab } from './DashboardTab';
|
||||
import { UsersTab } from './UsersTab';
|
||||
import { GroupsTab } from './GroupsTab';
|
||||
import { RolesTab } from './RolesTab';
|
||||
import styles from './RbacPage.module.css';
|
||||
|
||||
const TABS = ['dashboard', 'users', 'groups', 'roles'] as const;
|
||||
type TabKey = (typeof TABS)[number];
|
||||
|
||||
const TAB_LABELS: Record<TabKey, string> = {
|
||||
dashboard: 'Dashboard',
|
||||
users: 'Users',
|
||||
groups: 'Groups',
|
||||
roles: 'Roles',
|
||||
};
|
||||
|
||||
export function RbacPage() {
|
||||
const roles = useAuthStore((s) => s.roles);
|
||||
|
||||
if (!roles.includes('ADMIN')) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.accessDenied}>
|
||||
Access Denied — this page requires the ADMIN role.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <RbacContent />;
|
||||
}
|
||||
|
||||
function RbacContent() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rawTab = searchParams.get('tab');
|
||||
const activeTab: TabKey = TABS.includes(rawTab as TabKey) ? (rawTab as TabKey) : 'dashboard';
|
||||
|
||||
function setTab(tab: TabKey) {
|
||||
setSearchParams({ tab }, { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.tabs}>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||||
onClick={() => setTab(tab)}
|
||||
>
|
||||
{TAB_LABELS[tab]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.tabContent}>
|
||||
{activeTab === 'dashboard' && <DashboardTab />}
|
||||
{activeTab === 'users' && <UsersTab />}
|
||||
{activeTab === 'groups' && <GroupsTab />}
|
||||
{activeTab === 'roles' && <RolesTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
ui/src/pages/admin/rbac/RolesTab.tsx
Normal file
295
ui/src/pages/admin/rbac/RolesTab.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRoles, useRole, useCreateRole, useDeleteRole, useUpdateRole } from '../../../api/queries/admin/rbac';
|
||||
import type { RoleDetail } from '../../../api/queries/admin/rbac';
|
||||
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
||||
import { hashColor } from './avatar-colors';
|
||||
import styles from './RbacPage.module.css';
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function getRoleMeta(role: RoleDetail): string {
|
||||
const parts: string[] = [];
|
||||
if (role.description) parts.push(role.description);
|
||||
const total = role.assignedGroups.length + role.directUsers.length;
|
||||
parts.push(`${total} assignment${total !== 1 ? 's' : ''}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function RolesTab() {
|
||||
const roles = useRoles();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newScope, setNewScope] = useState('custom');
|
||||
const [createError, setCreateError] = useState('');
|
||||
const createRole = useCreateRole();
|
||||
|
||||
const roleDetail = useRole(selectedId);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = roles.data ?? [];
|
||||
if (!filter) return list;
|
||||
const lower = filter.toLowerCase();
|
||||
return list.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(lower) ||
|
||||
r.description.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [roles.data, filter]);
|
||||
|
||||
if (roles.isLoading) {
|
||||
return <div className={styles.loading}>Loading...</div>;
|
||||
}
|
||||
|
||||
const detail = roleDetail.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<div className={styles.panelTitle}>Roles</div>
|
||||
<div className={styles.panelSubtitle}>
|
||||
Define permission scopes; assign to users or groups
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add role</button>
|
||||
</div>
|
||||
<div className={styles.split}>
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.searchBar}>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
placeholder="Search roles..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{showCreateForm && (
|
||||
<div className={styles.createForm}>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Name</label>
|
||||
<input className={styles.createFormInput} value={newName}
|
||||
onChange={e => { setNewName(e.target.value); setCreateError(''); }}
|
||||
placeholder="Role name" autoFocus />
|
||||
</div>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Desc</label>
|
||||
<input className={styles.createFormInput} value={newDesc}
|
||||
onChange={e => setNewDesc(e.target.value)} placeholder="Optional description" />
|
||||
</div>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Scope</label>
|
||||
<input className={styles.createFormInput} value={newScope}
|
||||
onChange={e => setNewScope(e.target.value)} placeholder="custom" />
|
||||
</div>
|
||||
{createError && <div className={styles.createFormError}>{createError}</div>}
|
||||
<div className={styles.createFormActions}>
|
||||
<button type="button" className={styles.createFormBtn}
|
||||
onClick={() => { setShowCreateForm(false); setNewName(''); setNewDesc(''); setNewScope('custom'); setCreateError(''); }}>Cancel</button>
|
||||
<button type="button" className={styles.createFormBtnPrimary}
|
||||
disabled={!newName.trim() || createRole.isPending}
|
||||
onClick={() => {
|
||||
createRole.mutate({ name: newName.trim(), description: newDesc, scope: newScope || undefined }, {
|
||||
onSuccess: () => { setShowCreateForm(false); setNewName(''); setNewDesc(''); setNewScope('custom'); setCreateError(''); },
|
||||
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create role'),
|
||||
});
|
||||
}}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.entityList}>
|
||||
{filtered.map((role) => {
|
||||
const isSelected = role.id === selectedId;
|
||||
const color = hashColor(role.name);
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||
onClick={() => setSelectedId(role.id)}
|
||||
>
|
||||
<div className={styles.avatar} style={{ background: color.bg, color: color.fg, borderRadius: 6 }}>
|
||||
{getInitials(role.name)}
|
||||
</div>
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>
|
||||
{role.name}
|
||||
{role.system && <span className={styles.lockIcon}>🔒</span>}
|
||||
</div>
|
||||
<div className={styles.entityMeta}>{getRoleMeta(role)}</div>
|
||||
<div className={styles.tagList}>
|
||||
{role.assignedGroups.map((g) => (
|
||||
<span key={g.id} className={`${styles.tag} ${styles.tagGroup}`}>
|
||||
{g.name}
|
||||
</span>
|
||||
))}
|
||||
{role.directUsers.map((u) => (
|
||||
<span key={u.userId} className={`${styles.tag} ${styles.tagGroup}`}>
|
||||
{u.displayName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailPane}>
|
||||
{!detail ? (
|
||||
<div className={styles.detailEmpty}>
|
||||
<span>Select a role to view details</span>
|
||||
</div>
|
||||
) : (
|
||||
<RoleDetailView role={detail} onDeselect={() => setSelectedId(null)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleDetailView({ role, onDeselect }: { role: RoleDetail; onDeselect: () => void }) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState(role.name);
|
||||
const deleteRole = useDeleteRole();
|
||||
const updateRole = useUpdateRole();
|
||||
|
||||
const isBuiltIn = role.system;
|
||||
|
||||
// Reset editing state when role changes
|
||||
const [prevRoleId, setPrevRoleId] = useState(role.id);
|
||||
if (prevRoleId !== role.id) {
|
||||
setPrevRoleId(role.id);
|
||||
setEditingName(false);
|
||||
setNameValue(role.name);
|
||||
}
|
||||
|
||||
const color = hashColor(role.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.detailHeader}>
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg, borderRadius: 8 }}>
|
||||
{getInitials(role.name)}
|
||||
</div>
|
||||
{editingName ? (
|
||||
<input
|
||||
className={styles.editNameInput}
|
||||
value={nameValue}
|
||||
onChange={e => setNameValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (nameValue.trim() && nameValue !== role.name) {
|
||||
updateRole.mutate({ id: role.id, name: nameValue.trim() });
|
||||
}
|
||||
setEditingName(false);
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(role.name); setEditingName(false); } }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.detailName}
|
||||
onClick={() => !isBuiltIn && setEditingName(true)}
|
||||
style={{ cursor: isBuiltIn ? 'default' : 'pointer' }}
|
||||
title={isBuiltIn ? undefined : 'Click to edit'}>
|
||||
{role.name}
|
||||
{role.system && <span className={styles.lockIcon}>🔒</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!role.system && (
|
||||
<button type="button" className={styles.btnDelete}
|
||||
onClick={() => setShowDeleteDialog(true)} disabled={deleteRole.isPending}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailEmail}>{role.description || 'No description'}</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>ID</span>
|
||||
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{role.id}</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Scope</span>
|
||||
<span className={styles.fieldVal}>{role.scope || 'system-wide'}</span>
|
||||
</div>
|
||||
{role.system && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Type</span>
|
||||
<span className={styles.fieldVal} style={{ color: 'var(--text-muted)' }}>
|
||||
System role (read-only)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>Assigned to groups</div>
|
||||
{role.assignedGroups.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Not assigned to any groups</span>
|
||||
) : (
|
||||
role.assignedGroups.map((g) => (
|
||||
<span key={g.id} className={`${styles.chip} ${styles.chipGroup}`}>
|
||||
{g.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>Assigned to users (direct)</div>
|
||||
{role.directUsers.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No direct user assignments</span>
|
||||
) : (
|
||||
role.directUsers.map((u) => (
|
||||
<span key={u.userId} className={`${styles.chip} ${styles.chipUser}`}>
|
||||
{u.displayName}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>
|
||||
Effective principals <span>via inheritance</span>
|
||||
</div>
|
||||
{role.effectivePrincipals.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No effective principals</span>
|
||||
) : (
|
||||
<>
|
||||
{role.effectivePrincipals.map((u) => {
|
||||
const isDirect = role.directUsers.some((du) => du.userId === u.userId);
|
||||
return (
|
||||
<span
|
||||
key={u.userId}
|
||||
className={`${styles.chip} ${!isDirect ? styles.chipInherited : ''}`}
|
||||
>
|
||||
{u.displayName}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{role.effectivePrincipals.some(
|
||||
(u) => !role.directUsers.some((du) => du.userId === u.userId)
|
||||
) && (
|
||||
<div className={styles.inheritNote}>
|
||||
Some principals inherit this role through group membership rather than direct
|
||||
assignment.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!role.system && (
|
||||
<ConfirmDeleteDialog isOpen={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}
|
||||
onConfirm={() => { deleteRole.mutate(role.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }}
|
||||
resourceName={role.name} resourceType="role" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
455
ui/src/pages/admin/rbac/UsersTab.tsx
Normal file
455
ui/src/pages/admin/rbac/UsersTab.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useUsers, useGroups, useRoles, useDeleteUser, useCreateUser, useUpdateUser, useAddUserToGroup, useRemoveUserFromGroup, useAssignRoleToUser, useRemoveRoleFromUser } from '../../../api/queries/admin/rbac';
|
||||
import type { UserDetail, GroupDetail, RoleDetail } from '../../../api/queries/admin/rbac';
|
||||
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
||||
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
|
||||
import { useAuthStore } from '../../../auth/auth-store';
|
||||
import { hashColor } from './avatar-colors';
|
||||
import styles from './RbacPage.module.css';
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function buildGroupPath(user: UserDetail, groupMap: Map<string, GroupDetail>): string {
|
||||
if (user.directGroups.length === 0) return '(no groups)';
|
||||
const names = user.directGroups.map((g) => g.name);
|
||||
// Try to find a parent -> child path
|
||||
for (const g of user.directGroups) {
|
||||
const detail = groupMap.get(g.id);
|
||||
if (detail?.parentGroupId) {
|
||||
const parent = groupMap.get(detail.parentGroupId);
|
||||
if (parent) return `${parent.name} > ${g.name}`;
|
||||
}
|
||||
}
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
export function UsersTab() {
|
||||
const users = useUsers();
|
||||
const groups = useGroups();
|
||||
const { data: allRoles } = useRoles();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newDisplayName, setNewDisplayName] = useState('');
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [createError, setCreateError] = useState('');
|
||||
const createUser = useCreateUser();
|
||||
|
||||
const groupMap = useMemo(() => {
|
||||
const map = new Map<string, GroupDetail>();
|
||||
for (const g of groups.data ?? []) {
|
||||
map.set(g.id, g);
|
||||
}
|
||||
return map;
|
||||
}, [groups.data]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = users.data ?? [];
|
||||
if (!filter) return list;
|
||||
const lower = filter.toLowerCase();
|
||||
return list.filter(
|
||||
(u) =>
|
||||
u.displayName.toLowerCase().includes(lower) ||
|
||||
u.email.toLowerCase().includes(lower) ||
|
||||
u.userId.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [users.data, filter]);
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => (users.data ?? []).find((u) => u.userId === selected) ?? null,
|
||||
[users.data, selected]
|
||||
);
|
||||
|
||||
if (users.isLoading) {
|
||||
return <div className={styles.loading}>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<div className={styles.panelTitle}>Users</div>
|
||||
<div className={styles.panelSubtitle}>
|
||||
Manage identities, group membership and direct roles
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add user</button>
|
||||
</div>
|
||||
<div className={styles.split}>
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.searchBar}>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
placeholder="Search users..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{showCreateForm && (
|
||||
<div className={styles.createForm}>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Username</label>
|
||||
<input className={styles.createFormInput} value={newUsername}
|
||||
onChange={e => { setNewUsername(e.target.value); setCreateError(''); }}
|
||||
placeholder="Username (required)" autoFocus />
|
||||
</div>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Display</label>
|
||||
<input className={styles.createFormInput} value={newDisplayName}
|
||||
onChange={e => setNewDisplayName(e.target.value)}
|
||||
placeholder="Display name (optional)" />
|
||||
</div>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Email</label>
|
||||
<input className={styles.createFormInput} value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
placeholder="Email (optional)" />
|
||||
</div>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Password</label>
|
||||
<input className={styles.createFormInput} type="password" value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="Password (required for local login)" />
|
||||
</div>
|
||||
{createError && <div className={styles.createFormError}>{createError}</div>}
|
||||
<div className={styles.createFormActions}>
|
||||
<button type="button" className={styles.createFormBtn}
|
||||
onClick={() => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setNewPassword(''); setCreateError(''); }}>Cancel</button>
|
||||
<button type="button" className={styles.createFormBtnPrimary}
|
||||
disabled={!newUsername.trim() || createUser.isPending}
|
||||
onClick={() => {
|
||||
createUser.mutate({
|
||||
username: newUsername.trim(),
|
||||
displayName: newDisplayName.trim() || undefined,
|
||||
email: newEmail.trim() || undefined,
|
||||
password: newPassword || undefined,
|
||||
}, {
|
||||
onSuccess: () => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setNewPassword(''); setCreateError(''); },
|
||||
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create user'),
|
||||
});
|
||||
}}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.entityList}>
|
||||
{filtered.map((user) => {
|
||||
const isSelected = user.userId === selected;
|
||||
const color = hashColor(user.displayName || user.userId);
|
||||
return (
|
||||
<div
|
||||
key={user.userId}
|
||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||
onClick={() => setSelected(user.userId)}
|
||||
>
|
||||
<div className={styles.avatar} style={{ background: color.bg, color: color.fg }}>
|
||||
{getInitials(user.displayName || user.userId)}
|
||||
</div>
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>
|
||||
{user.displayName}
|
||||
{user.provider !== 'local' && (
|
||||
<span className={styles.oidcBadge}>{user.provider}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{user.email} · {buildGroupPath(user, groupMap)}
|
||||
</div>
|
||||
<div className={styles.tagList}>
|
||||
{user.directRoles.map((r) => (
|
||||
<span key={r.id} className={`${styles.tag} ${styles.tagRole}`}>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
{user.effectiveRoles
|
||||
.filter((er) => !user.directRoles.some((dr) => dr.id === er.id))
|
||||
.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className={`${styles.tag} ${styles.tagRole} ${styles.tagInherited}`}
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
{user.directGroups.map((g) => (
|
||||
<span key={g.id} className={`${styles.tag} ${styles.tagGroup}`}>
|
||||
{g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.statusDot} ${styles.statusActive}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailPane}>
|
||||
{!selectedUser ? (
|
||||
<div className={styles.detailEmpty}>
|
||||
<span>Select a user to view details</span>
|
||||
</div>
|
||||
) : (
|
||||
<UserDetailView
|
||||
user={selectedUser}
|
||||
groupMap={groupMap}
|
||||
allGroups={groups.data || []}
|
||||
allRoles={allRoles || []}
|
||||
onDeselect={() => setSelected(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetailView({
|
||||
user,
|
||||
groupMap,
|
||||
allGroups,
|
||||
allRoles,
|
||||
onDeselect,
|
||||
}: {
|
||||
user: UserDetail;
|
||||
groupMap: Map<string, GroupDetail>;
|
||||
allGroups: GroupDetail[];
|
||||
allRoles: RoleDetail[];
|
||||
onDeselect: () => void;
|
||||
}) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState(user.displayName);
|
||||
const deleteUserMut = useDeleteUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const addToGroup = useAddUserToGroup();
|
||||
const removeFromGroup = useRemoveUserFromGroup();
|
||||
const assignRole = useAssignRoleToUser();
|
||||
const removeRole = useRemoveRoleFromUser();
|
||||
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null;
|
||||
const isSelf = currentUserId === user.userId;
|
||||
|
||||
// Reset editing state when user changes
|
||||
const [prevUserId, setPrevUserId] = useState(user.userId);
|
||||
if (prevUserId !== user.userId) {
|
||||
setPrevUserId(user.userId);
|
||||
setEditingName(false);
|
||||
setNameValue(user.displayName);
|
||||
}
|
||||
|
||||
// Build group tree for this user
|
||||
const groupTree = useMemo(() => {
|
||||
const tree: { name: string; depth: number; annotation: string }[] = [];
|
||||
for (const g of user.directGroups) {
|
||||
const detail = groupMap.get(g.id);
|
||||
if (detail?.parentGroupId) {
|
||||
const parent = groupMap.get(detail.parentGroupId);
|
||||
if (parent && !tree.some((t) => t.name === parent.name)) {
|
||||
tree.push({ name: parent.name, depth: 0, annotation: '' });
|
||||
}
|
||||
tree.push({ name: g.name, depth: 1, annotation: 'child group' });
|
||||
} else {
|
||||
tree.push({ name: g.name, depth: 0, annotation: '' });
|
||||
}
|
||||
}
|
||||
return tree;
|
||||
}, [user, groupMap]);
|
||||
|
||||
const inheritedRoles = user.effectiveRoles.filter(
|
||||
(er) => !user.directRoles.some((dr) => dr.id === er.id)
|
||||
);
|
||||
|
||||
const availableGroups = allGroups
|
||||
.filter((g) => !user.directGroups.some((dg) => dg.id === g.id))
|
||||
.map((g) => ({ id: g.id, label: g.name }));
|
||||
|
||||
const availableRoles = allRoles
|
||||
.filter((r) => !user.directRoles.some((dr) => dr.id === r.id))
|
||||
.map((r) => ({ id: r.id, label: r.name }));
|
||||
|
||||
const color = hashColor(user.displayName || user.userId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.detailHeader}>
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg }}>
|
||||
{getInitials(user.displayName || user.userId)}
|
||||
</div>
|
||||
{editingName ? (
|
||||
<input
|
||||
className={styles.editNameInput}
|
||||
value={nameValue}
|
||||
onChange={e => setNameValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (nameValue.trim() && nameValue !== user.displayName) {
|
||||
updateUser.mutate({ userId: user.userId, displayName: nameValue.trim() });
|
||||
}
|
||||
setEditingName(false);
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(user.displayName); setEditingName(false); } }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.detailName}
|
||||
onClick={() => setEditingName(true)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Click to edit">
|
||||
{user.displayName}
|
||||
{user.provider !== 'local' && (
|
||||
<span className={styles.oidcBadge}>{user.provider}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.detailEmail}>{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDelete}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isSelf || deleteUserMut.isPending}
|
||||
title={isSelf ? 'Cannot delete your own account' : 'Delete user'}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Status</span>
|
||||
<span className={styles.fieldVal} style={{ color: 'var(--green)', fontSize: 12 }}>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>ID</span>
|
||||
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{user.userId}</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Created</span>
|
||||
<span className={styles.fieldVal}>{new Date(user.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>
|
||||
Group membership <span>direct only</span>
|
||||
</div>
|
||||
{user.directGroups.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No group membership</span>
|
||||
) : (
|
||||
user.directGroups.map((g) => (
|
||||
<span key={g.id} className={`${styles.chip} ${styles.chipGroup}`}>
|
||||
{g.name}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipRemove}
|
||||
onClick={() => removeFromGroup.mutate({ userId: user.userId, groupId: g.id })}
|
||||
disabled={removeFromGroup.isPending}
|
||||
title="Remove from group"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
<MultiSelectDropdown
|
||||
items={availableGroups}
|
||||
onApply={async (ids) => {
|
||||
await Promise.allSettled(
|
||||
ids.map((gid) => addToGroup.mutateAsync({ userId: user.userId, groupId: gid }))
|
||||
);
|
||||
}}
|
||||
placeholder="Search groups..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>
|
||||
Effective roles <span>direct + inherited</span>
|
||||
</div>
|
||||
{user.directRoles.map((r) => (
|
||||
<span key={r.id} className={`${styles.chip} ${styles.chipRole}`}>
|
||||
{r.name}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipRemove}
|
||||
onClick={() => removeRole.mutate({ userId: user.userId, roleId: r.id })}
|
||||
disabled={removeRole.isPending}
|
||||
title="Remove role"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{inheritedRoles.map((r) => (
|
||||
<span key={r.id} className={`${styles.chip} ${styles.chipRole} ${styles.chipInherited}`}>
|
||||
{r.name}
|
||||
<span className={styles.chipSource}>
|
||||
{r.source ? `\u2191 ${r.source}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{inheritedRoles.length > 0 && (
|
||||
<div className={styles.inheritNote}>
|
||||
Dashed roles are inherited transitively through group membership.
|
||||
</div>
|
||||
)}
|
||||
<MultiSelectDropdown
|
||||
items={availableRoles}
|
||||
onApply={async (ids) => {
|
||||
await Promise.allSettled(
|
||||
ids.map((rid) => assignRole.mutateAsync({ userId: user.userId, roleId: rid }))
|
||||
);
|
||||
}}
|
||||
placeholder="Search roles..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groupTree.length > 0 && (
|
||||
<div className={styles.detailSection}>
|
||||
<div className={styles.detailSectionTitle}>Group tree</div>
|
||||
{groupTree.map((node, i) => (
|
||||
<div key={i} className={styles.treeRow}>
|
||||
{node.depth > 0 && (
|
||||
<div className={styles.treeIndent}>
|
||||
<div className={styles.treeCorner} />
|
||||
</div>
|
||||
)}
|
||||
{node.name}
|
||||
{node.annotation && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', marginLeft: 4 }}>
|
||||
{node.annotation}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
onConfirm={() => {
|
||||
deleteUserMut.mutate(user.userId, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteDialog(false);
|
||||
onDeselect();
|
||||
},
|
||||
});
|
||||
}}
|
||||
resourceName={user.displayName || user.userId}
|
||||
resourceType="user"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
ui/src/pages/admin/rbac/avatar-colors.ts
Normal file
18
ui/src/pages/admin/rbac/avatar-colors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
const AVATAR_COLORS = [
|
||||
{ bg: 'rgba(59, 130, 246, 0.15)', fg: '#3B82F6' }, // blue
|
||||
{ bg: 'rgba(16, 185, 129, 0.15)', fg: '#10B981' }, // green
|
||||
{ bg: 'rgba(240, 180, 41, 0.15)', fg: '#F0B429' }, // amber
|
||||
{ bg: 'rgba(168, 85, 247, 0.15)', fg: '#A855F7' }, // purple
|
||||
{ bg: 'rgba(244, 63, 94, 0.15)', fg: '#F43F5E' }, // rose
|
||||
{ bg: 'rgba(34, 211, 238, 0.15)', fg: '#22D3EE' }, // cyan
|
||||
{ bg: 'rgba(251, 146, 60, 0.15)', fg: '#FB923C' }, // orange
|
||||
{ bg: 'rgba(132, 204, 22, 0.15)', fg: '#84CC16' }, // lime
|
||||
];
|
||||
|
||||
export function hashColor(str: string): { bg: string; fg: string } {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||
}
|
||||
120
ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx
Normal file
120
ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import styles from '../RbacPage.module.css';
|
||||
|
||||
interface MultiSelectItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectDropdownProps {
|
||||
items: MultiSelectItem[];
|
||||
onApply: (selectedIds: string[]) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function MultiSelectDropdown({ items, onApply, placeholder = 'Search...', label = '+ Add' }: MultiSelectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
}
|
||||
}
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = items.filter(item =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
onApply(Array.from(selected));
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.multiSelectWrapper} ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addChip}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.dropdown}>
|
||||
<div className={styles.dropdownSearch}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.dropdownSearchInput}
|
||||
placeholder={placeholder}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.dropdownList}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className={styles.dropdownEmpty}>No items found</div>
|
||||
) : (
|
||||
filtered.map(item => (
|
||||
<label key={item.id} className={styles.dropdownItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.dropdownItemCheckbox}
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => toggle(item.id)}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.dropdownFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dropdownApply}
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply{selected.size > 0 ? ` (${selected.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const SwaggerPage = lazy(() => import('./pages/swagger/SwaggerPage').then(m => (
|
||||
const DatabaseAdminPage = lazy(() => import('./pages/admin/DatabaseAdminPage').then(m => ({ default: m.DatabaseAdminPage })));
|
||||
const OpenSearchAdminPage = lazy(() => import('./pages/admin/OpenSearchAdminPage').then(m => ({ default: m.OpenSearchAdminPage })));
|
||||
const AuditLogPage = lazy(() => import('./pages/admin/AuditLogPage').then(m => ({ default: m.AuditLogPage })));
|
||||
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -38,6 +39,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'admin/opensearch', element: <Suspense fallback={null}><OpenSearchAdminPage /></Suspense> },
|
||||
{ path: 'admin/audit', element: <Suspense fallback={null}><AuditLogPage /></Suspense> },
|
||||
{ path: 'admin/oidc', element: <OidcAdminPage /> },
|
||||
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> },
|
||||
{ path: 'swagger', element: <Suspense fallback={null}><SwaggerPage /></Suspense> },
|
||||
],
|
||||
},
|
||||
|
||||
299
ui/src/styles/AdminLayout.module.css
Normal file
299
ui/src/styles/AdminLayout.module.css
Normal file
@@ -0,0 +1,299 @@
|
||||
/* ─── Shared Admin Layout ─── */
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accessDenied {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Panel Header ─── */
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panelSubtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btnAction {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.btnAction:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ─── Split Layout ─── */
|
||||
.split {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.listPane {
|
||||
width: 280px;
|
||||
min-width: 220px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detailPane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ─── Search Bar ─── */
|
||||
.searchBar {
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: var(--font-body);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--amber-dim);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── Entity List (section nav / item list) ─── */
|
||||
.entityList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entityItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.entityItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.entityItemSelected {
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.entityInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entityName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entityMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ─── Detail Pane ─── */
|
||||
.detailEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detailSection {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detailSectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.detailSectionTitle span {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* ─── Field Rows ─── */
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fieldVal {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fieldMono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ─── Section Icon ─── */
|
||||
.sectionIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* ─── Status Indicators ─── */
|
||||
.miniStatus {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Pagination ─── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pageBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── Header Actions Row ─── */
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ─── Detail-only layout (no split, e.g. OIDC) ─── */
|
||||
.detailOnly {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
Reference in New Issue
Block a user