Rename Java packages from com.cameleer3 to com.cameleer, module directories from cameleer3-* to cameleer-*, and all references throughout workflows, Dockerfiles, docs, migrations, and pom.xml. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
31 KiB
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
-- 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:
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:
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
- Step 4: Verify backend compiles
mvn clean compile
- Step 5: Commit
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:
/* ─── 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:
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>
);
}
- Step 3: Verify UI builds
cd ui && npm run build
- Step 4: Commit
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:
- Groups column: Sort groups alphabetically. Render top-level groups first (no parentGroupId), with children indented below their parent.
- 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. - 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
cd ui && npm run build
- Step 3: Commit
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:
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:
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:
function UserDetailView({ user, groupMap, allGroups, allRoles, onDeselect }: {
user: UserDetail;
groupMap: Map<string, GroupDetail>;
allGroups: GroupDetail[];
allRoles: RoleDetail[];
onDeselect: () => void;
}) {
Update the parent's JSX call site to pass these props:
<UserDetailView
user={selectedUser}
groupMap={groupMap}
allGroups={groups || []}
allRoles={allRoles || []}
onDeselect={() => setSelected(null)}
/>
Inside the sub-component, add state and hooks:
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:
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:<login>).
- Step 4: Add delete button to detail header
In the detail pane header (where avatar + name are rendered), add a delete button:
const isSelf = currentUser && user.userId === `user:${currentUser}`;
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
{/* existing avatar, name, email */}
</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>
And the dialog at the bottom of the detail component:
<ConfirmDeleteDialog
isOpen={showDeleteDialog}
onClose={() => 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:
// 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 => (
<span key={g.id} className={styles.chip}>
{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..."
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:
// 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 => (
<span key={r.id} className={`${styles.chip} ${r.source !== 'direct' ? styles.chipInherited : ''}`}>
{r.name}
{r.source !== 'direct' && <span className={styles.chipSource}>↑ {r.source}</span>}
{r.source === 'direct' && (
<button
type="button"
className={styles.chipRemove}
onClick={() => removeRole.mutate({ userId: user.userId, 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({ userId: user.userId, roleId: rid })));
}}
placeholder="Search roles..."
label="+ Add"
/>
- Step 7: Fix date format
In the "Created" field row, change from:
{formatDate(user.createdAt)}
to:
{new Date(user.createdAt).toLocaleString()}
Remove the formatDate helper if it's no longer used elsewhere.
- Step 8: Verify UI builds
cd ui && npm run build
- Step 9: Commit
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
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:
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:
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>
+ Add group
</button>
Below the search bar (inside the list pane, before the entity list), render the form conditionally:
{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 || []).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>
)}
- Step 3: Add delete button to group detail
The built-in Admins group (UUID 00000000-0000-0000-0000-000000000010) cannot be deleted:
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:
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
{/* avatar, name, hierarchy label */}
</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>
// Dialog:
<ConfirmDeleteDialog
isOpen={showDeleteDialog}
onClose={() => 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:
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 => (
<span key={r.id} className={styles.chip}>
{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..."
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:
const updateGroup = useUpdateGroup();
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;
}
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:
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Parent</span>
<select
className={styles.parentSelect}
value={group.parentGroupId || ''}
onChange={e => {
updateGroup.mutate({
id: group.id,
parentGroupId: e.target.value || null,
});
}}
>
<option value="">(Top-level)</option>
{parentOptions.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
- Step 6: Update GroupDetailView props and call site
Update GroupDetailView signature to accept new props:
function GroupDetailView({ group, groupMap, allGroups, allRoles, onDeselect }: {
group: GroupDetail;
groupMap: Map<string, GroupDetail>;
allGroups: GroupDetail[];
allRoles: RoleDetail[];
onDeselect: () => void;
}) {
Update the parent's JSX call site:
<GroupDetailView
group={detail}
groupMap={groupMap}
allGroups={groups || []}
allRoles={allRoles || []}
onDeselect={() => setSelected(null)}
/>
- Step 7: Verify UI builds
cd ui && npm run build
- Step 8: Commit
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
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:
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:
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>
+ Add role
</button>
Form below search bar:
{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}>Description</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 || undefined, 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>
)}
- 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:
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteRole = useDeleteRole();
// In detail header:
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
{/* avatar, name, lock icon */}
</div>
{!role.system && (
<button
type="button"
className={styles.btnDelete}
onClick={() => setShowDeleteDialog(true)}
disabled={deleteRole.isPending}
>
Delete
</button>
)}
</div>
// Dialog (only for non-system roles):
{!role.system && (
<ConfirmDeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={() => {
deleteRole.mutate(role.id, {
onSuccess: () => { setShowDeleteDialog(false); onDeselect(); },
});
}}
resourceName={role.name}
resourceType="role"
/>
)}
- Step 4: Verify UI builds
cd ui && npm run build
- Step 5: Commit
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
mvn clean compile
- Step 2: Full frontend build
cd ui && npm run build
- Step 3: Commit any cleanup if needed
git add -A
git commit -m "chore: cleanup after RBAC CRUD gaps implementation"