# RBAC CRUD Gaps Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add CRUD actions, assignment UI, and guards to the read-only RBAC management page; seed a built-in Admins group; fix date formatting and diagram ordering. **Architecture:** Backend seed migration + constant, then frontend changes: reusable MultiSelectDropdown component, CRUD forms/buttons on each tab, diagram sorting. All mutation hooks already exist in `rbac.ts`. **Tech Stack:** Java 17, Spring Boot, Flyway, React 19, TypeScript, TanStack Query, CSS Modules. **Spec:** `docs/superpowers/specs/2026-03-17-rbac-crud-gaps-design.md` --- ## File Map ### Backend | File | Change | |---|---| | Create: `cameleer-server-app/src/main/resources/db/migration/V2__admin_group_seed.sql` | Seed Admins group + ADMIN role assignment | | Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/SystemRole.java` | Add `ADMINS_GROUP_ID` | | Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java` | Add admin user to Admins group on login | ### Frontend | File | Change | |---|---| | Create: `ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx` | Reusable multi-select picker | | Modify: `ui/src/pages/admin/rbac/RbacPage.module.css` | Styles for dropdown, create forms, delete/remove buttons | | Modify: `ui/src/pages/admin/rbac/DashboardTab.tsx` | Sort diagram columns consistently | | Modify: `ui/src/pages/admin/rbac/UsersTab.tsx` | Delete button, group/role assignment, date format | | Modify: `ui/src/pages/admin/rbac/GroupsTab.tsx` | Add group form, delete, role assignment, parent dropdown | | Modify: `ui/src/pages/admin/rbac/RolesTab.tsx` | Add role form, delete (disabled for system) | --- ## Tasks ### Task 1: Backend — Admins Group Seed + SystemRole Constant + Auth **Files:** - Create: `cameleer-server-app/src/main/resources/db/migration/V2__admin_group_seed.sql` - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/SystemRole.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java` - [ ] **Step 1: Create V2 migration** ```sql -- 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'); ``` - [ ] **Step 2: Add ADMINS_GROUP_ID to SystemRole.java** Read `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/SystemRole.java` and add after the existing UUID constants: ```java public static final UUID ADMINS_GROUP_ID = UUID.fromString("00000000-0000-0000-0000-000000000010"); ``` - [ ] **Step 3: Update UiAuthController.login()** Read `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java`. In the `login()` method, after the line that assigns ADMIN role (`rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID)`), add: ```java rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID); ``` - [ ] **Step 4: Verify backend compiles** ```bash mvn clean compile ``` - [ ] **Step 5: Commit** ```bash git add cameleer-server-app/src/main/resources/db/migration/V2__admin_group_seed.sql cameleer-server-core/ cameleer-server-app/src/main/java/ git commit -m "feat: seed built-in Admins group and assign admin users on login" ``` --- ### Task 2: MultiSelectDropdown Component + CSS **Files:** - Create: `ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx` - Modify: `ui/src/pages/admin/rbac/RbacPage.module.css` - [ ] **Step 1: Add CSS styles for multi-select dropdown, create forms, and action buttons** Read `ui/src/pages/admin/rbac/RbacPage.module.css`. Append these new styles: ```css /* ─── Multi-Select Dropdown ─── */ .multiSelectWrapper { position: relative; display: inline-block; } .addChip { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 3px 8px; border-radius: 20px; border: 1px dashed var(--border); color: var(--text-muted); background: transparent; cursor: pointer; transition: background 0.1s, color 0.1s; } .addChip:hover { background: var(--bg-hover); color: var(--text-secondary); } .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; } ``` - [ ] **Step 2: Create MultiSelectDropdown component** Create `ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx`: ```tsx 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>(new Set()); const ref = useRef(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 (
{open && (
setSearch(e.target.value)} autoFocus />
{filtered.length === 0 ? (
No items found
) : ( filtered.map(item => ( )) )}
)}
); } ``` - [ ] **Step 3: Verify UI builds** ```bash cd ui && npm run build ``` - [ ] **Step 4: Commit** ```bash git add ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx ui/src/pages/admin/rbac/RbacPage.module.css git commit -m "feat: add MultiSelectDropdown component and CRUD styles" ``` --- ### Task 3: Dashboard Diagram Ordering **Files:** - Modify: `ui/src/pages/admin/rbac/DashboardTab.tsx` - [ ] **Step 1: Fix diagram column ordering** Read `ui/src/pages/admin/rbac/DashboardTab.tsx`. The inheritance diagram currently renders groups, roles, and users without explicit sorting. Change: 1. **Groups column**: Sort groups alphabetically. Render top-level groups first (no parentGroupId), with children indented below their parent. 2. **Roles column**: Iterate sorted groups top-to-bottom, collect each group's `directRoles`, deduplicate preserving first-seen order. Only show roles that are assigned to at least one group. 3. **Users column**: Sort users alphabetically by `displayName`. The existing code builds `groupList`, `roleList`, and `userList` from `useGroups()`, `useRoles()`, and `useUsers()`. Replace the list-building logic with sorted/ordered versions. For groups, build a tree structure: for each top-level group (parentGroupId is null), render it followed by its children (sorted). Use `useMemo` for the sorted lists. For roles, derive from the sorted groups' directRoles rather than from `useRoles()`. - [ ] **Step 2: Verify UI builds** ```bash cd ui && npm run build ``` - [ ] **Step 3: Commit** ```bash git add ui/src/pages/admin/rbac/DashboardTab.tsx git commit -m "fix: sort RBAC dashboard diagram columns consistently" ``` --- ### Task 4: UsersTab — Delete, Assignments, Date Format **Files:** - Modify: `ui/src/pages/admin/rbac/UsersTab.tsx` - [ ] **Step 1: Add imports and mutation hooks** Read `ui/src/pages/admin/rbac/UsersTab.tsx`. Add imports: ```tsx import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog'; import { MultiSelectDropdown } from './components/MultiSelectDropdown'; import { useAuthStore } from '../../../auth/auth-store'; import { useDeleteUser, useAddUserToGroup, useRemoveUserFromGroup, useAssignRoleToUser, useRemoveRoleFromUser, useRoles, } from '../../../api/queries/admin/rbac'; ``` - [ ] **Step 2: Add state and hooks in UsersTab component** Inside the `UsersTab` component function, after the existing state, add: ```tsx const { data: allRoles } = useRoles(); ``` - [ ] **Step 3: Add state, hooks, and props in UserDetail sub-component** The `UserDetail` sub-component needs new props and hooks. Update its signature to accept `onDeselect`, `allGroups`, and `allRoles`: ```tsx function UserDetailView({ user, groupMap, allGroups, allRoles, onDeselect }: { user: UserDetail; groupMap: Map; allGroups: GroupDetail[]; allRoles: RoleDetail[]; onDeselect: () => void; }) { ``` Update the parent's JSX call site to pass these props: ```tsx setSelected(null)} /> ``` Inside the sub-component, add state and hooks: ```tsx const [showDeleteDialog, setShowDeleteDialog] = useState(false); const deleteUserMut = useDeleteUser(); const addToGroup = useAddUserToGroup(); const removeFromGroup = useRemoveUserFromGroup(); const assignRole = useAssignRoleToUser(); const removeRole = useRemoveRoleFromUser(); ``` For the **self-delete guard**, parse the JWT subject from the access token to get the current user's actual userId: ```tsx const accessToken = useAuthStore((s) => s.accessToken); const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null; const isSelf = currentUserId === user.userId; ``` This is necessary because the auth store's `username` field holds the display name, not the JWT subject (`user:`). - [ ] **Step 4: Add delete button to detail header** In the detail pane header (where avatar + name are rendered), add a delete button: ```tsx const isSelf = currentUser && user.userId === `user:${currentUser}`;
{/* existing avatar, name, email */}
``` And the dialog at the bottom of the detail component: ```tsx setShowDeleteDialog(false)} onConfirm={() => { deleteUserMut.mutate(user.userId, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); // clear selection — parent must pass this callback }, }); }} resourceName={user.displayName || user.userId} resourceType="user" /> ``` The parent `UsersTab` must pass an `onDeselect` callback that sets `selectedUserId` to null. - [ ] **Step 5: Add group membership assignment UI** In the "Group membership" detail section, after the existing group chips, add the MultiSelectDropdown: ```tsx // Available groups = all groups NOT in user's directGroups const availableGroups = (allGroups || []) .filter(g => !user.directGroups.some(dg => dg.id === g.id)) .map(g => ({ id: g.id, label: g.name })); // In the section JSX: {user.directGroups.map(g => ( {g.name} ))} { await Promise.allSettled(ids.map(gid => addToGroup.mutateAsync({ userId: user.userId, groupId: gid }))); }} placeholder="Search groups..." label="+ Add" /> ``` - [ ] **Step 6: Add direct role assignment UI** In the "Effective roles" detail section, add assignment for direct roles and remove buttons on direct (non-inherited) chips: ```tsx // Available roles = all roles NOT in user's directRoles const availableRoles = (allRoles || []) .filter(r => !user.directRoles.some(dr => dr.id === r.id)) .map(r => ({ id: r.id, label: r.name })); // For each effective role chip: {user.effectiveRoles.map(r => ( {r.name} {r.source !== 'direct' && ↑ {r.source}} {r.source === 'direct' && ( )} ))} { await Promise.allSettled(ids.map(rid => assignRole.mutateAsync({ userId: user.userId, roleId: rid }))); }} placeholder="Search roles..." label="+ Add" /> ``` - [ ] **Step 7: Fix date format** In the "Created" field row, change from: ```tsx {formatDate(user.createdAt)} ``` to: ```tsx {new Date(user.createdAt).toLocaleString()} ``` Remove the `formatDate` helper if it's no longer used elsewhere. - [ ] **Step 8: Verify UI builds** ```bash cd ui && npm run build ``` - [ ] **Step 9: Commit** ```bash git add ui/src/pages/admin/rbac/UsersTab.tsx git commit -m "feat: add user delete, group/role assignment, and date format fix" ``` --- ### Task 5: GroupsTab — Create, Delete, Role Assignment, Parent Dropdown **Files:** - Modify: `ui/src/pages/admin/rbac/GroupsTab.tsx` - [ ] **Step 1: Add imports** ```tsx import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog'; import { MultiSelectDropdown } from './components/MultiSelectDropdown'; import { useCreateGroup, useDeleteGroup, useUpdateGroup, useAssignRoleToGroup, useRemoveRoleFromGroup, useRoles, } from '../../../api/queries/admin/rbac'; ``` - [ ] **Step 2: Add create group form** In the `GroupsTab` component, add state for the inline create form: ```tsx const [showCreateForm, setShowCreateForm] = useState(false); const [newName, setNewName] = useState(''); const [newParentId, setNewParentId] = useState(''); const [createError, setCreateError] = useState(''); const createGroup = useCreateGroup(); const { data: allRoles } = useRoles(); ``` In the panel header, change the subtitle area to include the add button: ```tsx ``` Below the search bar (inside the list pane, before the entity list), render the form conditionally: ```tsx {showCreateForm && (
{ setNewName(e.target.value); setCreateError(''); }} placeholder="Group name" autoFocus />
{createError &&
{createError}
}
)} ``` - [ ] **Step 3: Add delete button to group detail** The built-in Admins group (UUID `00000000-0000-0000-0000-000000000010`) cannot be deleted: ```tsx const ADMINS_GROUP_ID = '00000000-0000-0000-0000-000000000010'; const [showDeleteDialog, setShowDeleteDialog] = useState(false); const deleteGroup = useDeleteGroup(); const isBuiltIn = group.id === ADMINS_GROUP_ID; // In detail header:
{/* avatar, name, hierarchy label */}
// Dialog: setShowDeleteDialog(false)} onConfirm={() => { deleteGroup.mutate(group.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); }, }); }} resourceName={group.name} resourceType="group" /> ``` - [ ] **Step 4: Add role assignment to group detail** In the "Assigned roles" section: ```tsx const availableRoles = (allRoles || []) .filter(r => !group.directRoles.some(dr => dr.id === r.id)) .map(r => ({ id: r.id, label: r.name })); const assignRole = useAssignRoleToGroup(); const removeRole = useRemoveRoleFromGroup(); {group.directRoles.map(r => ( {r.name} ))} { await Promise.allSettled(ids.map(rid => assignRole.mutateAsync({ groupId: group.id, roleId: rid }))); }} placeholder="Search roles..." label="+ Add" /> ``` - [ ] **Step 5: Add parent group dropdown to group detail** In the detail header area, add a parent group selector. Must exclude the group itself and its transitive descendants to prevent cycles: ```tsx const updateGroup = useUpdateGroup(); function getDescendantIds(groupId: string, allGroups: GroupDetail[]): Set { const ids = new Set(); 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; } const descendantIds = getDescendantIds(group.id, allGroups || []); const parentOptions = (allGroups || []) .filter(g => g.id !== group.id && !descendantIds.has(g.id)); // Render in the detail fields area:
Parent
``` - [ ] **Step 6: Update GroupDetailView props and call site** Update `GroupDetailView` signature to accept new props: ```tsx function GroupDetailView({ group, groupMap, allGroups, allRoles, onDeselect }: { group: GroupDetail; groupMap: Map; allGroups: GroupDetail[]; allRoles: RoleDetail[]; onDeselect: () => void; }) { ``` Update the parent's JSX call site: ```tsx setSelected(null)} /> ``` - [ ] **Step 7: Verify UI builds** ```bash cd ui && npm run build ``` - [ ] **Step 8: Commit** ```bash git add ui/src/pages/admin/rbac/GroupsTab.tsx git commit -m "feat: add group create, delete, role assignment, and parent dropdown" ``` --- ### Task 6: RolesTab — Create + Delete **Files:** - Modify: `ui/src/pages/admin/rbac/RolesTab.tsx` - [ ] **Step 1: Add imports** ```tsx import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog'; import { useCreateRole, useDeleteRole } from '../../../api/queries/admin/rbac'; ``` - [ ] **Step 2: Add create role form** Same pattern as groups. In `RolesTab`, add state: ```tsx 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(); ``` Add button in panel header: ```tsx ``` Form below search bar: ```tsx {showCreateForm && (
{ setNewName(e.target.value); setCreateError(''); }} placeholder="Role name" autoFocus />
setNewDesc(e.target.value)} placeholder="Optional description" />
setNewScope(e.target.value)} placeholder="custom" />
{createError &&
{createError}
}
)} ``` - [ ] **Step 3: Add delete button to role detail + update props** Update `RoleDetailView` to accept `onDeselect` prop. Update the parent's JSX call site to pass `onDeselect={() => setSelected(null)}`. In the role detail sub-component: ```tsx const [showDeleteDialog, setShowDeleteDialog] = useState(false); const deleteRole = useDeleteRole(); // In detail header:
{/* avatar, name, lock icon */}
{!role.system && ( )}
// Dialog (only for non-system roles): {!role.system && ( setShowDeleteDialog(false)} onConfirm={() => { deleteRole.mutate(role.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); }, }); }} resourceName={role.name} resourceType="role" /> )} ``` - [ ] **Step 4: Verify UI builds** ```bash cd ui && npm run build ``` - [ ] **Step 5: Commit** ```bash git add ui/src/pages/admin/rbac/RolesTab.tsx git commit -m "feat: add role create and delete with system role protection" ``` --- ### Task 7: Final Build Verification - [ ] **Step 1: Full backend build** ```bash mvn clean compile ``` - [ ] **Step 2: Full frontend build** ```bash cd ui && npm run build ``` - [ ] **Step 3: Commit any cleanup if needed** ```bash git add -A git commit -m "chore: cleanup after RBAC CRUD gaps implementation" ```