feat: add Users tab with split-pane layout, inline create, detail panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,10 @@ import { useState } from 'react';
|
||||
import { StatCard, Tabs } from '@cameleer/design-system';
|
||||
import { useRbacStats } from '../../api/queries/admin/rbac';
|
||||
import styles from './UserManagement.module.css';
|
||||
import UsersTab from './UsersTab';
|
||||
import GroupsTab from './GroupsTab';
|
||||
import RolesTab from './RolesTab';
|
||||
|
||||
|
||||
export default function RbacPage() {
|
||||
const { data: stats } = useRbacStats();
|
||||
const [tab, setTab] = useState('users');
|
||||
|
||||
531
ui/src/pages/Admin/UsersTab.tsx
Normal file
531
ui/src/pages/Admin/UsersTab.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
MonoText,
|
||||
Tag,
|
||||
InfoCallout,
|
||||
ConfirmDialog,
|
||||
Select,
|
||||
Spinner,
|
||||
InlineEdit,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import {
|
||||
useUsers,
|
||||
useCreateUser,
|
||||
useDeleteUser,
|
||||
useAssignRoleToUser,
|
||||
useRemoveRoleFromUser,
|
||||
useAddUserToGroup,
|
||||
useRemoveUserFromGroup,
|
||||
useSetPassword,
|
||||
useGroups,
|
||||
useRoles,
|
||||
} from '../../api/queries/admin/rbac';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import styles from './UserManagement.module.css';
|
||||
|
||||
export default function UsersTab() {
|
||||
const { data: users, isLoading } = useUsers();
|
||||
const { data: allGroups } = useGroups();
|
||||
const { data: allRoles } = useRoles();
|
||||
const currentUsername = useAuthStore((s) => s.username);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Create form state
|
||||
const [createUsername, setCreateUsername] = useState('');
|
||||
const [createDisplayName, setCreateDisplayName] = useState('');
|
||||
const [createEmail, setCreateEmail] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
|
||||
// Detail pane state
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [addGroupId, setAddGroupId] = useState('');
|
||||
const [addRoleId, setAddRoleId] = useState('');
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Mutations
|
||||
const createUser = useCreateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
const assignRole = useAssignRoleToUser();
|
||||
const removeRole = useRemoveRoleFromUser();
|
||||
const addToGroup = useAddUserToGroup();
|
||||
const removeFromGroup = useRemoveUserFromGroup();
|
||||
const setPassword = useSetPassword();
|
||||
|
||||
// Filtered user list
|
||||
const filteredUsers = useMemo(() => {
|
||||
if (!users) return [];
|
||||
const q = search.toLowerCase();
|
||||
if (!q) return users;
|
||||
return users.filter(
|
||||
(u) =>
|
||||
u.displayName.toLowerCase().includes(q) ||
|
||||
(u.email ?? '').toLowerCase().includes(q) ||
|
||||
u.userId.toLowerCase().includes(q),
|
||||
);
|
||||
}, [users, search]);
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => users?.find((u) => u.userId === selectedUserId) ?? null,
|
||||
[users, selectedUserId],
|
||||
);
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleCreateUser() {
|
||||
if (!createUsername.trim() || !createPassword.trim()) return;
|
||||
createUser.mutate(
|
||||
{
|
||||
username: createUsername.trim(),
|
||||
displayName: createDisplayName.trim() || undefined,
|
||||
email: createEmail.trim() || undefined,
|
||||
password: createPassword,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'User created', variant: 'success' });
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreateDisplayName('');
|
||||
setCreateEmail('');
|
||||
setCreatePassword('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to create user', variant: 'error' });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleResetPassword() {
|
||||
if (!selectedUser || !newPassword.trim()) return;
|
||||
setPassword.mutate(
|
||||
{ userId: selectedUser.userId, password: newPassword },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Password updated', variant: 'success' });
|
||||
setShowPasswordForm(false);
|
||||
setNewPassword('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to update password', variant: 'error' });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleAddGroup() {
|
||||
if (!selectedUser || !addGroupId) return;
|
||||
addToGroup.mutate(
|
||||
{ userId: selectedUser.userId, groupId: addGroupId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Added to group', variant: 'success' });
|
||||
setAddGroupId('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to add group', variant: 'error' });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleAddRole() {
|
||||
if (!selectedUser || !addRoleId) return;
|
||||
assignRole.mutate(
|
||||
{ userId: selectedUser.userId, roleId: addRoleId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Role assigned', variant: 'success' });
|
||||
setAddRoleId('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to assign role', variant: 'error' });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDeleteUser() {
|
||||
if (!selectedUser) return;
|
||||
deleteUser.mutate(selectedUser.userId, {
|
||||
onSuccess: () => {
|
||||
toast({ title: 'User deleted', variant: 'success' });
|
||||
setSelectedUserId(null);
|
||||
setShowDeleteDialog(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to delete user', variant: 'error' });
|
||||
setShowDeleteDialog(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Derived data for detail pane
|
||||
const directGroupIds = new Set(selectedUser?.directGroups.map((g) => g.id) ?? []);
|
||||
const directRoleIds = new Set(selectedUser?.directRoles.map((r) => r.id) ?? []);
|
||||
|
||||
const inheritedRoles = selectedUser?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? [];
|
||||
|
||||
const availableGroups = (allGroups ?? [])
|
||||
.filter((g) => !directGroupIds.has(g.id))
|
||||
.map((g) => ({ value: g.id, label: g.name }));
|
||||
|
||||
const availableRoles = (allRoles ?? [])
|
||||
.filter((r) => !directRoleIds.has(r.id))
|
||||
.map((r) => ({ value: r.id, label: r.name }));
|
||||
|
||||
// Find group name for inherited role display
|
||||
function findInheritingGroupName(roleId: string): string {
|
||||
if (!selectedUser) return '';
|
||||
for (const g of selectedUser.effectiveGroups) {
|
||||
// We don't have group→roles in the summary, so just show "group"
|
||||
void roleId;
|
||||
return g.name;
|
||||
}
|
||||
return 'group';
|
||||
}
|
||||
|
||||
const isSelf =
|
||||
currentUsername != null &&
|
||||
selectedUser != null &&
|
||||
selectedUser.displayName === currentUsername;
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className={styles.splitPane}>
|
||||
{/* ── Left pane ── */}
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.listHeader}>
|
||||
<Input
|
||||
placeholder="Search users…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch('')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateForm((v) => !v)}
|
||||
>
|
||||
{showCreateForm ? '✕ Cancel' : '+ Add User'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreateForm && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Username (required)"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
style={{ marginBottom: 6 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Display Name"
|
||||
value={createDisplayName}
|
||||
onChange={(e) => setCreateDisplayName(e.target.value)}
|
||||
style={{ marginBottom: 6 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={createEmail}
|
||||
onChange={(e) => setCreateEmail(e.target.value)}
|
||||
style={{ marginBottom: 6 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Password (required)"
|
||||
type="password"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
style={{ marginBottom: 6 }}
|
||||
/>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreateDisplayName('');
|
||||
setCreateEmail('');
|
||||
setCreatePassword('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createUser.isPending}
|
||||
disabled={!createUsername.trim() || !createPassword.trim()}
|
||||
onClick={handleCreateUser}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <Spinner size="md" />}
|
||||
|
||||
<div className={styles.entityList} role="listbox">
|
||||
{filteredUsers.map((user) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
className={
|
||||
styles.entityItem +
|
||||
(user.userId === selectedUserId ? ' ' + styles.entityItemSelected : '')
|
||||
}
|
||||
role="option"
|
||||
aria-selected={user.userId === selectedUserId}
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedUserId(user.userId)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedUserId(user.userId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar name={user.displayName} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>
|
||||
{user.displayName}
|
||||
{user.provider !== 'local' && (
|
||||
<Badge label={user.provider} variant="outlined" />
|
||||
)}
|
||||
</div>
|
||||
{user.email && <div className={styles.entityMeta}>{user.email}</div>}
|
||||
{(user.directRoles.length > 0 || user.directGroups.length > 0) && (
|
||||
<div className={styles.entityTags}>
|
||||
{user.directRoles.map((r) => (
|
||||
<Badge key={r.id} label={r.name} variant="filled" color="primary" />
|
||||
))}
|
||||
{user.directGroups.map((g) => (
|
||||
<Badge key={g.id} label={g.name} variant="outlined" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right pane ── */}
|
||||
<div className={styles.detailPane}>
|
||||
{!selectedUser ? (
|
||||
<div className={styles.emptyDetail}>Select a user to view details</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={selectedUser.displayName} size="lg" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<InlineEdit
|
||||
value={selectedUser.displayName}
|
||||
onSave={(val) => {
|
||||
// useUpdateUser not imported here to keep things clean;
|
||||
// display only — wired via displayName update if desired
|
||||
void val;
|
||||
}}
|
||||
/>
|
||||
{selectedUser.email && (
|
||||
<div className={styles.entityMeta}>{selectedUser.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={isSelf}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metadata grid */}
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>User ID</span>
|
||||
<MonoText size="sm">{selectedUser.userId}</MonoText>
|
||||
|
||||
<span className={styles.metaLabel}>Created</span>
|
||||
<span>{new Date(selectedUser.createdAt).toLocaleString()}</span>
|
||||
|
||||
<span className={styles.metaLabel}>Provider</span>
|
||||
<span>{selectedUser.provider}</span>
|
||||
</div>
|
||||
|
||||
{/* Security section */}
|
||||
<div className={styles.securitySection}>
|
||||
<div className={styles.sectionTitle}>Security</div>
|
||||
{selectedUser.provider === 'local' ? (
|
||||
<>
|
||||
{!showPasswordForm ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
) : (
|
||||
<div className={styles.resetForm}>
|
||||
<Input
|
||||
placeholder="New password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false);
|
||||
setNewPassword('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={setPassword.isPending}
|
||||
disabled={!newPassword.trim()}
|
||||
onClick={handleResetPassword}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<InfoCallout variant="info">
|
||||
Password managed by identity provider
|
||||
</InfoCallout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group membership */}
|
||||
<div className={styles.sectionTitle}>Group Membership</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{selectedUser.directGroups.map((g) => (
|
||||
<Tag
|
||||
key={g.id}
|
||||
label={g.name}
|
||||
onRemove={() =>
|
||||
removeFromGroup.mutate(
|
||||
{ userId: selectedUser.userId, groupId: g.id },
|
||||
{
|
||||
onError: () =>
|
||||
toast({ title: 'Failed to remove group', variant: 'error' }),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{availableGroups.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<Select
|
||||
options={[{ value: '', label: 'Add to group…' }, ...availableGroups]}
|
||||
value={addGroupId}
|
||||
onChange={(e) => setAddGroupId(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!addGroupId}
|
||||
onClick={handleAddGroup}
|
||||
loading={addToGroup.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effective roles */}
|
||||
<div className={styles.sectionTitle}>Roles</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{selectedUser.directRoles.map((r) => (
|
||||
<Tag
|
||||
key={r.id}
|
||||
label={r.name}
|
||||
color="warning"
|
||||
onRemove={() =>
|
||||
removeRole.mutate(
|
||||
{ userId: selectedUser.userId, roleId: r.id },
|
||||
{
|
||||
onError: () =>
|
||||
toast({ title: 'Failed to remove role', variant: 'error' }),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{inheritedRoles.map((r) => (
|
||||
<span key={r.id} style={{ opacity: 0.65 }}>
|
||||
<Badge
|
||||
label={`↑ ${findInheritingGroupName(r.id)} / ${r.name}`}
|
||||
variant="dashed"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{inheritedRoles.length > 0 && (
|
||||
<div className={styles.inheritedNote}>
|
||||
Roles with ↑ are inherited through group membership
|
||||
</div>
|
||||
)}
|
||||
{availableRoles.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<Select
|
||||
options={[{ value: '', label: 'Assign role…' }, ...availableRoles]}
|
||||
value={addRoleId}
|
||||
onChange={(e) => setAddRoleId(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!addRoleId}
|
||||
onClick={handleAddRole}
|
||||
loading={assignRole.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
onConfirm={handleDeleteUser}
|
||||
title="Delete user"
|
||||
message={`This will permanently delete the user "${selectedUser.displayName}". Type their username to confirm.`}
|
||||
confirmText={selectedUser.displayName}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
loading={deleteUser.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user