Files
cameleer-server/docs/superpowers/plans/2026-03-17-rbac-crud-gaps.md
hsiegeln cb3ebfea7c
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
chore: rename cameleer3 to cameleer
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>
2026-04-15 15:28:42 +02:00

1205 lines
31 KiB
Markdown

# 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<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**
```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<string, GroupDetail>;
allGroups: GroupDetail[];
allRoles: RoleDetail[];
onDeselect: () => void;
}) {
```
Update the parent's JSX call site to pass these props:
```tsx
<UserDetailView
user={selectedUser}
groupMap={groupMap}
allGroups={groups || []}
allRoles={allRoles || []}
onDeselect={() => 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:<login>`).
- [ ] **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}`;
<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:
```tsx
<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:
```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 => (
<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:
```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 => (
<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:
```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
<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:
```tsx
{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:
```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:
<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:
```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 => (
<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:
```tsx
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:
```tsx
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:
```tsx
<GroupDetailView
group={detail}
groupMap={groupMap}
allGroups={groups || []}
allRoles={allRoles || []}
onDeselect={() => 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
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>
+ Add role
</button>
```
Form below search bar:
```tsx
{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:
```tsx
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**
```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"
```