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>
1205 lines
31 KiB
Markdown
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"
|
|
```
|