Compare commits

19 Commits

Author SHA1 Message Date
f01487ccb4 Merge pull request 'feature/rbac-management' (#86) from feature/rbac-management into main
All checks were successful
CI / build (push) Successful in 1m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 16s
CI / deploy (push) Successful in 1m14s
CI / deploy-feature (push) Has been skipped
Reviewed-on: cameleer/cameleer3-server#86
2026-03-17 19:51:13 +01:00
hsiegeln
033cfcf5fc refactor: rework audit log to full-width table with filter bar
All checks were successful
CI / build (push) Successful in 1m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 54s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 36s
CI / build (pull_request) Successful in 1m10s
CI / cleanup-branch (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Replace split-pane layout with a table-based design: horizontal filter
bar, full-width data table with sticky headers, expandable detail rows
showing IP/user-agent/JSON payload, and bottom pagination.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:39:55 +01:00
hsiegeln
6d650cdf34 feat: harmonize admin pages to split-pane layout with shared CSS
All checks were successful
CI / build (push) Successful in 1m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 35s
Extract shared admin layout styles into AdminLayout.module.css and
convert all admin pages to consistent patterns: Database/OpenSearch/
Audit Log use split-pane master/detail, OIDC uses full-width detail-only
with unified panelHeader treatment across all pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:30:38 +01:00
hsiegeln
6f5b5b8655 feat: add password support for local user creation and per-user login
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:08:19 +01:00
hsiegeln
653ef958ed fix: add edit mode for parent group dropdown, fix updateGroup to preserve parent
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:05:57 +01:00
hsiegeln
48b17f83a3 fix: handle empty 200 responses in adminFetch to fix stale UI after mutations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:04:41 +01:00
hsiegeln
9d08e74913 feat: SHA-based avatar colors, user create/edit, editable names, +Add visibility
All checks were successful
CI / build (push) Successful in 1m11s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 56s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 35s
- Add hashColor utility for unique avatar colors derived from entity names
- Add user creation form with username/displayName/email fields
- Add useCreateUser and useUpdateUser mutation hooks
- Make display names editable on all detail panes (click to edit)
- Protect built-in entities: Admins group and system roles not editable
- Make +Add chip more visible with amber border and background
- Send empty string instead of null for role description on create
- Add .editNameInput CSS for inline name editing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:52:07 +01:00
hsiegeln
f42e6279e6 fix: null safety in role/group creation, add user create/update endpoints
- RoleAdminController.createRole: default null description to "" and null scope to "custom"
- RoleAdminController.updateRole: pass null audit details to avoid NPE when name is null
- GroupAdminController.updateGroup: pass null audit details to avoid NPE when name is null
- UserAdminController: add POST / createUser endpoint with default VIEWER role assignment
- UserAdminController: add PUT /{userId} updateUser endpoint for displayName/email updates
2026-03-17 18:49:34 +01:00
hsiegeln
d025919f8d feat: add group create, delete, role assignment, and parent dropdown
All checks were successful
CI / build (push) Successful in 1m9s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 36s
- Add inline create form with name and parent group selection
- Add delete button with confirmation dialog (protected for built-in Admins group)
- Add role assignment with MultiSelectDropdown and remove buttons on chips
- Add parent group dropdown with cycle prevention (excludes self and descendants)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:35:04 +01:00
hsiegeln
db6143f9da feat: add role create and delete with system role protection
- Add create role form with name, description, and scope fields
- Add delete button on role detail view for non-system roles
- Use ConfirmDeleteDialog for safe deletion confirmation
- System roles protected from deletion (button hidden)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:34:46 +01:00
hsiegeln
4821ddebba feat: add user delete, group/role assignment, and date format fix
- Add delete button with self-delete guard (parses JWT sub claim)
- Add ConfirmDeleteDialog for safe user deletion
- Add MultiSelectDropdown for group membership assignment with remove buttons
- Add MultiSelectDropdown for direct role assignment with remove buttons
- Inherited roles show source but no remove button
- Change Created date format from date-only to full locale string
- Remove unused formatDate helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:34:40 +01:00
hsiegeln
65001e0ed0 feat: add MultiSelectDropdown component and CRUD styles 2026-03-17 18:32:16 +01:00
hsiegeln
1881aca0e4 fix: sort RBAC dashboard diagram columns consistently 2026-03-17 18:32:14 +01:00
hsiegeln
4842507ff3 feat: seed built-in Admins group and assign admin users on login
- Add V2 Flyway migration to create built-in Admins group (id: ...0010) with ADMIN role
- Add ADMINS_GROUP_ID constant to SystemRole
- Add user to Admins group on successful local login alongside role assignment
2026-03-17 18:30:16 +01:00
hsiegeln
708aae720c chore: regenerate OpenAPI spec and TypeScript types for RBAC endpoints
All checks were successful
CI / build (push) Successful in 1m11s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 51s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 36s
Remove dead UserInfo type export, patch PositionedNode.children.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:11:10 +01:00
hsiegeln
ebe97bd386 feat: add RBAC management UI with dashboard, users, groups, and roles tabs
All checks were successful
CI / build (push) Successful in 1m14s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 54s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 35s
Tab-based admin page at /admin/rbac with split-pane entity views,
inheritance visualization, OIDC badges, and role/group management.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:58:24 +01:00
hsiegeln
01295c84d8 feat: add Group, Role, and RBAC stats admin controllers
GroupAdminController with cycle detection, RoleAdminController
with system role protection, RbacStatsController for dashboard.
Rewrite UserAdminController to use RbacService.
2026-03-17 17:47:26 +01:00
hsiegeln
eb0cc8c141 feat: replace flat users.roles with relational RBAC model
New package com.cameleer3.server.core.rbac with SystemRole constants,
detail/summary records, GroupRepository, RoleRepository, RbacService.
Remove roles field from UserInfo. Implement PostgresGroupRepository,
PostgresRoleRepository, RbacServiceImpl with inheritance computation.
Update UiAuthController, OidcAuthController, AgentRegistrationController
to assign roles via user_roles table. JWT populated from effective system roles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:44:32 +01:00
hsiegeln
b06b3f52a8 refactor: consolidate V1-V10 Flyway migrations into single V1__init.sql
Add RBAC tables (roles, groups, group_roles, user_groups, user_roles)
with system role seeds and join indexes. Drop users.roles TEXT[] column.
2026-03-17 17:34:15 +01:00
62 changed files with 6573 additions and 1051 deletions

View File

@@ -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);

View File

@@ -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) {}
}

View File

@@ -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());
}
}

View File

@@ -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) {}
}

View File

@@ -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) {}
}

View File

@@ -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);
}
}

View File

@@ -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) {}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -1,2 +0,0 @@
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit;

View File

@@ -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);

View File

@@ -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');

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN password_hash TEXT;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()
);

View File

@@ -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()
);

View File

@@ -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');

View File

@@ -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)
);

View File

@@ -1,5 +1,5 @@
package com.cameleer3.server.core.admin;
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
}

View File

@@ -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) {}

View File

@@ -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);
}

View File

@@ -0,0 +1,5 @@
package com.cameleer3.server.core.rbac;
import java.util.UUID;
public record GroupSummary(UUID id, String name) {}

View File

@@ -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();
}

View File

@@ -0,0 +1,3 @@
package com.cameleer3.server.core.rbac;
public record RbacStats(int userCount, int activeUserCount, int groupCount, int maxGroupDepth, int roleCount) {}

View File

@@ -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) {}

View File

@@ -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);
}

View File

@@ -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) {}

View File

@@ -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); }
}

View File

@@ -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) {}

View File

@@ -0,0 +1,3 @@
package com.cameleer3.server.core.rbac;
public record UserSummary(String userId, String displayName, String provider) {}

View File

@@ -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
) {}

View File

@@ -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

View File

@@ -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);
}

View 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
View File

@@ -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;

View File

@@ -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'];

View File

@@ -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 }) {

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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>
</>
);
}

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>
</>
);
}

View 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}>&rarr;</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}>&rarr;</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>
);
}

View 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" />
</>
);
}

View 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;
}

View 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>
);
}

View 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}>&#128274;</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}>&#128274;</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" />
)}
</>
);
}

View 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"
/>
</>
);
}

View 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];
}

View 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>
);
}

View File

@@ -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> },
],
},

View 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;
}