diff --git a/ui/src/api/queries/admin/rbac.ts b/ui/src/api/queries/admin/rbac.ts index 707c2b6d..773db41a 100644 --- a/ui/src/api/queries/admin/rbac.ts +++ b/ui/src/api/queries/admin/rbac.ts @@ -264,6 +264,34 @@ export function useDeleteRole() { }); } +export function useCreateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { username: string; displayName?: string; email?: string }) => + adminFetch('/users', { + method: 'POST', + body: JSON.stringify(data), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useUpdateUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, ...data }: { userId: string; displayName?: string; email?: string }) => + adminFetch(`/users/${encodeURIComponent(userId)}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + export function useDeleteUser() { const qc = useQueryClient(); return useMutation({ diff --git a/ui/src/pages/admin/rbac/GroupsTab.tsx b/ui/src/pages/admin/rbac/GroupsTab.tsx index 8eb76041..33814442 100644 --- a/ui/src/pages/admin/rbac/GroupsTab.tsx +++ b/ui/src/pages/admin/rbac/GroupsTab.tsx @@ -12,6 +12,7 @@ import { import type { GroupDetail } from '../../../api/queries/admin/rbac'; import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog'; import { MultiSelectDropdown } from './components/MultiSelectDropdown'; +import { hashColor } from './avatar-colors'; import styles from './RbacPage.module.css'; function getInitials(name: string): string { @@ -140,13 +141,14 @@ export function GroupsTab() {
{filtered.map((group) => { const isSelected = group.id === selectedId; + const color = hashColor(group.name); return (
setSelectedId(group.id)} > -
+
{getInitials(group.name)}
@@ -211,6 +213,8 @@ function GroupDetailView({ onDeselect: () => void; }) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(group.name); const deleteGroup = useDeleteGroup(); const updateGroup = useUpdateGroup(); const assignRole = useAssignRoleToGroup(); @@ -218,6 +222,14 @@ function GroupDetailView({ const isBuiltIn = group.id === ADMINS_GROUP_ID; + // Reset editing state when group changes + const [prevGroupId, setPrevGroupId] = useState(group.id); + if (prevGroupId !== group.id) { + setPrevGroupId(group.id); + setEditingName(false); + setNameValue(group.name); + } + const hierarchyLabel = group.parentGroupId ? `Child of ${groupMap.get(group.parentGroupId)?.name ?? 'unknown'}` : 'Top-level group'; @@ -254,14 +266,37 @@ function GroupDetailView({ return rows; }, [group, groupMap]); + const color = hashColor(group.name); + return ( <>
-
+
{getInitials(group.name)}
-
{group.name}
+ {editingName ? ( + setNameValue(e.target.value)} + onBlur={() => { + if (nameValue.trim() && nameValue !== group.name) { + updateGroup.mutate({ id: group.id, name: nameValue.trim() }); + } + setEditingName(false); + }} + onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(group.name); setEditingName(false); } }} + autoFocus + /> + ) : ( +
!isBuiltIn && setEditingName(true)} + style={{ cursor: isBuiltIn ? 'default' : 'pointer' }} + title={isBuiltIn ? undefined : 'Click to edit'}> + {group.name} +
+ )}
{hierarchyLabel}
+
@@ -82,17 +90,57 @@ export function UsersTab() { onChange={(e) => setFilter(e.target.value)} />
+ {showCreateForm && ( +
+
+ + { setNewUsername(e.target.value); setCreateError(''); }} + placeholder="Username (required)" autoFocus /> +
+
+ + setNewDisplayName(e.target.value)} + placeholder="Display name (optional)" /> +
+
+ + setNewEmail(e.target.value)} + placeholder="Email (optional)" /> +
+ {createError &&
{createError}
} +
+ + +
+
+ )}
{filtered.map((user) => { const isSelected = user.userId === selected; + const color = hashColor(user.displayName || user.userId); return (
setSelected(user.userId)} > -
- {getInitials(user.displayName)} +
+ {getInitials(user.displayName || user.userId)}
@@ -169,7 +217,10 @@ function UserDetailView({ onDeselect: () => void; }) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(user.displayName); const deleteUserMut = useDeleteUser(); + const updateUser = useUpdateUser(); const addToGroup = useAddUserToGroup(); const removeFromGroup = useRemoveUserFromGroup(); const assignRole = useAssignRoleToUser(); @@ -179,6 +230,14 @@ function UserDetailView({ const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null; const isSelf = currentUserId === user.userId; + // Reset editing state when user changes + const [prevUserId, setPrevUserId] = useState(user.userId); + if (prevUserId !== user.userId) { + setPrevUserId(user.userId); + setEditingName(false); + setNameValue(user.displayName); + } + // Build group tree for this user const groupTree = useMemo(() => { const tree: { name: string; depth: number; annotation: string }[] = []; @@ -209,19 +268,40 @@ function UserDetailView({ .filter((r) => !user.directRoles.some((dr) => dr.id === r.id)) .map((r) => ({ id: r.id, label: r.name })); + const color = hashColor(user.displayName || user.userId); + return ( <>
-
- {getInitials(user.displayName)} -
-
- {user.displayName} - {user.provider !== 'local' && ( - {user.provider} - )} +
+ {getInitials(user.displayName || user.userId)}
+ {editingName ? ( + setNameValue(e.target.value)} + onBlur={() => { + if (nameValue.trim() && nameValue !== user.displayName) { + updateUser.mutate({ userId: user.userId, displayName: nameValue.trim() }); + } + setEditingName(false); + }} + onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(user.displayName); setEditingName(false); } }} + autoFocus + /> + ) : ( +
setEditingName(true)} + style={{ cursor: 'pointer' }} + title="Click to edit"> + {user.displayName} + {user.provider !== 'local' && ( + {user.provider} + )} +
+ )}
{user.email}