From 752d7ec0e70e2b81fa1356376934a892cacdb350 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:32:45 +0100 Subject: [PATCH] feat: add Users tab with split-pane layout, inline create, detail panel Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/Admin/RbacPage.tsx | 2 +- ui/src/pages/Admin/UsersTab.tsx | 531 ++++++++++++++++++++++++++++++++ 2 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/Admin/UsersTab.tsx diff --git a/ui/src/pages/Admin/RbacPage.tsx b/ui/src/pages/Admin/RbacPage.tsx index b344ff45..9d4d2481 100644 --- a/ui/src/pages/Admin/RbacPage.tsx +++ b/ui/src/pages/Admin/RbacPage.tsx @@ -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'); diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx new file mode 100644 index 00000000..24830d30 --- /dev/null +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -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(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 ( +
+ {/* ── Left pane ── */} +
+
+ setSearch(e.target.value)} + onClear={() => setSearch('')} + style={{ flex: 1 }} + /> + +
+ + {showCreateForm && ( +
+ setCreateUsername(e.target.value)} + style={{ marginBottom: 6 }} + /> + setCreateDisplayName(e.target.value)} + style={{ marginBottom: 6 }} + /> + setCreateEmail(e.target.value)} + style={{ marginBottom: 6 }} + /> + setCreatePassword(e.target.value)} + style={{ marginBottom: 6 }} + /> +
+ + +
+
+ )} + + {isLoading && } + +
+ {filteredUsers.map((user) => ( +
setSelectedUserId(user.userId)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedUserId(user.userId); + } + }} + > + +
+
+ {user.displayName} + {user.provider !== 'local' && ( + + )} +
+ {user.email &&
{user.email}
} + {(user.directRoles.length > 0 || user.directGroups.length > 0) && ( +
+ {user.directRoles.map((r) => ( + + ))} + {user.directGroups.map((g) => ( + + ))} +
+ )} +
+
+ ))} +
+
+ + {/* ── Right pane ── */} +
+ {!selectedUser ? ( +
Select a user to view details
+ ) : ( + <> + {/* Header */} +
+ +
+ { + // useUpdateUser not imported here to keep things clean; + // display only — wired via displayName update if desired + void val; + }} + /> + {selectedUser.email && ( +
{selectedUser.email}
+ )} +
+ +
+ + {/* Metadata grid */} +
+ User ID + {selectedUser.userId} + + Created + {new Date(selectedUser.createdAt).toLocaleString()} + + Provider + {selectedUser.provider} +
+ + {/* Security section */} +
+
Security
+ {selectedUser.provider === 'local' ? ( + <> + {!showPasswordForm ? ( + + ) : ( +
+ setNewPassword(e.target.value)} + style={{ flex: 1 }} + /> + + +
+ )} + + ) : ( + + Password managed by identity provider + + )} +
+ + {/* Group membership */} +
Group Membership
+
+ {selectedUser.directGroups.map((g) => ( + + removeFromGroup.mutate( + { userId: selectedUser.userId, groupId: g.id }, + { + onError: () => + toast({ title: 'Failed to remove group', variant: 'error' }), + }, + ) + } + /> + ))} +
+ {availableGroups.length > 0 && ( +
+ setAddRoleId(e.target.value)} + style={{ flex: 1 }} + /> + +
+ )} + + {/* Delete confirmation */} + 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} + /> + + )} +
+
+ ); +}