refactor: restructure RBAC page to container + tab components, add CSS module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,178 +1,38 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Tabs, DataTable, Badge, Avatar, Button, Input, Modal, FormField,
|
||||
Select, AlertDialog, StatCard, Spinner,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import {
|
||||
useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats,
|
||||
useCreateUser, useUpdateUser, useDeleteUser,
|
||||
useAssignRoleToUser, useRemoveRoleFromUser,
|
||||
useAddUserToGroup, useRemoveUserFromGroup,
|
||||
useCreateGroup, useUpdateGroup, useDeleteGroup,
|
||||
useCreateRole, useUpdateRole, useDeleteRole,
|
||||
useAssignRoleToGroup, useRemoveRoleFromGroup,
|
||||
} from '../../api/queries/admin/rbac';
|
||||
import { useState } from 'react';
|
||||
import { StatCard, Tabs } from '@cameleer/design-system';
|
||||
import { useRbacStats } from '../../api/queries/admin/rbac';
|
||||
import styles from './UserManagement.module.css';
|
||||
|
||||
// Lazy imports for tab components (will be created in tasks 17-19)
|
||||
// For now, use placeholder components so the page compiles
|
||||
const UsersTab = () => <div>Users tab — coming soon</div>;
|
||||
const GroupsTab = () => <div>Groups tab — coming soon</div>;
|
||||
const RolesTab = () => <div>Roles tab — coming soon</div>;
|
||||
|
||||
export default function RbacPage() {
|
||||
const [tab, setTab] = useState('users');
|
||||
const { data: stats } = useRbacStats();
|
||||
const [tab, setTab] = useState('users');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>RBAC Management</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<h2 style={{ margin: '0 0 16px' }}>User Management</h2>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
||||
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
||||
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: 'Users', value: 'users', count: stats?.userCount },
|
||||
{ label: 'Groups', value: 'groups', count: stats?.groupCount },
|
||||
{ label: 'Roles', value: 'roles', count: stats?.roleCount },
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: 'Groups', value: 'groups' },
|
||||
{ label: 'Roles', value: 'roles' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'groups' && <GroupsTab />}
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersTab() {
|
||||
const { data: users, isLoading } = useUsers();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({ username: '', displayName: '', email: '', password: '' });
|
||||
const createUser = useCreateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'userId', header: 'Username', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||
{ key: 'displayName', header: 'Display Name' },
|
||||
{ key: 'email', header: 'Email' },
|
||||
{ key: 'provider', header: 'Provider', render: (v) => <Badge label={String(v)} /> },
|
||||
{
|
||||
key: 'effectiveRoles', header: 'Roles',
|
||||
render: (v) => (
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
const rows = (users || []).map((u: any) => ({ ...u, id: u.userId }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create User</Button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={rows} pageSize={20} />
|
||||
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create User">
|
||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||
<FormField label="Username" required><Input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></FormField>
|
||||
<FormField label="Display Name"><Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} /></FormField>
|
||||
<FormField label="Email"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></FormField>
|
||||
<FormField label="Password"><Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></FormField>
|
||||
<Button variant="primary" onClick={() => { createUser.mutate(form); setCreateOpen(false); setForm({ username: '', displayName: '', email: '', password: '' }); }}>Create</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={() => { if (deleteId) deleteUser.mutate(deleteId); setDeleteId(null); }}
|
||||
title="Delete User"
|
||||
description={`Are you sure you want to delete user "${deleteId}"?`}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsTab() {
|
||||
const { data: groups, isLoading } = useGroups();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '' });
|
||||
const createGroup = useCreateGroup();
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||
{ key: 'members', header: 'Members', render: (v) => String((v as any[])?.length ?? 0) },
|
||||
{
|
||||
key: 'effectiveRoles', header: 'Roles',
|
||||
render: (v) => (
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Group</Button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={groups || []} pageSize={20} />
|
||||
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Group">
|
||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
|
||||
<Button variant="primary" onClick={() => { createGroup.mutate(form); setCreateOpen(false); setForm({ name: '' }); }}>Create</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesTab() {
|
||||
const { data: roles, isLoading } = useRoles();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', description: '', scope: '' });
|
||||
const createRole = useCreateRole();
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||
{ key: 'description', header: 'Description' },
|
||||
{ key: 'scope', header: 'Scope', render: (v) => v ? <Badge label={String(v)} /> : null },
|
||||
{ key: 'system', header: 'System', render: (v) => v ? <Badge label="System" color="warning" /> : null },
|
||||
{ key: 'effectivePrincipals', header: 'Users', render: (v) => String((v as any[])?.length ?? 0) },
|
||||
];
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Role</Button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={roles || []} pageSize={20} />
|
||||
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Role">
|
||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
|
||||
<FormField label="Description"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></FormField>
|
||||
<FormField label="Scope"><Input value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })} /></FormField>
|
||||
<Button variant="primary" onClick={() => { createRole.mutate(form); setCreateOpen(false); setForm({ name: '', description: '', scope: '' }); }}>Create</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'groups' && <GroupsTab />}
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
48
ui/src/pages/Admin/UserManagement.module.css
Normal file
48
ui/src/pages/Admin/UserManagement.module.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.statStrip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; }
|
||||
.splitPane { display: grid; grid-template-columns: 52fr 48fr; height: calc(100vh - 280px); }
|
||||
.listPane { overflow-y: auto; border-right: 1px solid var(--border-subtle); padding-right: 16px; }
|
||||
.detailPane { overflow-y: auto; padding-left: 16px; }
|
||||
.listHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.entityList { display: flex; flex-direction: column; gap: 2px; }
|
||||
.entityItem {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
|
||||
cursor: pointer; border-radius: 6px; transition: background 0.1s;
|
||||
}
|
||||
.entityItem:hover { background: var(--bg-hover); }
|
||||
.entityItemSelected { background: var(--bg-raised); }
|
||||
.entityInfo { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.entityName { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||||
.entityMeta { font-size: 11px; color: var(--text-muted); }
|
||||
.entityTags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px; }
|
||||
.createForm {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); padding: 12px; margin-bottom: 12px;
|
||||
}
|
||||
.createFormActions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
|
||||
.detailHeader {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.metaGrid {
|
||||
display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px;
|
||||
font-size: 13px; margin-bottom: 16px;
|
||||
}
|
||||
.metaLabel {
|
||||
font-weight: 700; font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: 0.6px; color: var(--text-muted);
|
||||
}
|
||||
.sectionTags { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.inheritedNote { font-size: 11px; font-style: italic; color: var(--text-muted); margin-top: 4px; }
|
||||
.securitySection {
|
||||
padding: 12px; border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); margin-bottom: 16px;
|
||||
}
|
||||
.resetForm { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.emptyDetail {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; color: var(--text-muted); font-size: 13px;
|
||||
}
|
||||
.sectionTitle {
|
||||
font-size: 13px; font-weight: 700; color: var(--text-primary);
|
||||
margin-bottom: 8px; margin-top: 16px;
|
||||
}
|
||||
Reference in New Issue
Block a user