# 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"
```
