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

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:

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