From 4821ddebba74f7be4d159a1657e4acc8c6fd1803 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:34:40 +0100 Subject: [PATCH] feat: add user delete, group/role assignment, and date format fix - Add delete button with self-delete guard (parses JWT sub claim) - Add ConfirmDeleteDialog for safe user deletion - Add MultiSelectDropdown for group membership assignment with remove buttons - Add MultiSelectDropdown for direct role assignment with remove buttons - Inherited roles show source but no remove button - Change Created date format from date-only to full locale string - Remove unused formatDate helper Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/admin/rbac/UsersTab.tsx | 140 +++++++++++++++++++++------ 1 file changed, 113 insertions(+), 27 deletions(-) diff --git a/ui/src/pages/admin/rbac/UsersTab.tsx b/ui/src/pages/admin/rbac/UsersTab.tsx index 509e1d59..ec4dca54 100644 --- a/ui/src/pages/admin/rbac/UsersTab.tsx +++ b/ui/src/pages/admin/rbac/UsersTab.tsx @@ -1,7 +1,9 @@ import { useState, useMemo } from 'react'; -import { useUsers } from '../../../api/queries/admin/rbac'; -import type { UserDetail, GroupDetail } from '../../../api/queries/admin/rbac'; -import { useGroups } from '../../../api/queries/admin/rbac'; +import { useUsers, useGroups, useRoles, useDeleteUser, useAddUserToGroup, useRemoveUserFromGroup, useAssignRoleToUser, useRemoveRoleFromUser } from '../../../api/queries/admin/rbac'; +import type { UserDetail, GroupDetail, RoleDetail } from '../../../api/queries/admin/rbac'; +import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog'; +import { MultiSelectDropdown } from './components/MultiSelectDropdown'; +import { useAuthStore } from '../../../auth/auth-store'; import styles from './RbacPage.module.css'; function getInitials(name: string): string { @@ -10,18 +12,6 @@ function getInitials(name: string): string { return name.slice(0, 2).toUpperCase(); } -function formatDate(iso: string): string { - try { - return new Date(iso).toLocaleDateString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - } catch { - return iso; - } -} - function buildGroupPath(user: UserDetail, groupMap: Map): string { if (user.directGroups.length === 0) return '(no groups)'; const names = user.directGroups.map((g) => g.name); @@ -39,6 +29,7 @@ function buildGroupPath(user: UserDetail, groupMap: Map): s export function UsersTab() { const users = useUsers(); const groups = useGroups(); + const { data: allRoles } = useRoles(); const [selected, setSelected] = useState(null); const [filter, setFilter] = useState(''); @@ -150,7 +141,13 @@ export function UsersTab() { Select a user to view details ) : ( - + setSelected(null)} + /> )} @@ -158,13 +155,30 @@ export function UsersTab() { ); } -function UserDetail({ +function UserDetailView({ user, groupMap, + allGroups, + allRoles, + onDeselect, }: { user: UserDetail; groupMap: Map; + allGroups: GroupDetail[]; + allRoles: RoleDetail[]; + onDeselect: () => void; }) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const deleteUserMut = useDeleteUser(); + const addToGroup = useAddUserToGroup(); + const removeFromGroup = useRemoveUserFromGroup(); + const assignRole = useAssignRoleToUser(); + const removeRole = useRemoveRoleFromUser(); + + const accessToken = useAuthStore((s) => s.accessToken); + const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null; + const isSelf = currentUserId === user.userId; + // Build group tree for this user const groupTree = useMemo(() => { const tree: { name: string; depth: number; annotation: string }[] = []; @@ -187,18 +201,39 @@ function UserDetail({ (er) => !user.directRoles.some((dr) => dr.id === er.id) ); + const availableGroups = allGroups + .filter((g) => !user.directGroups.some((dg) => dg.id === g.id)) + .map((g) => ({ id: g.id, label: g.name })); + + const availableRoles = allRoles + .filter((r) => !user.directRoles.some((dr) => dr.id === r.id)) + .map((r) => ({ id: r.id, label: r.name })); + return ( <> -
- {getInitials(user.displayName)} +
+
+
+ {getInitials(user.displayName)} +
+
+ {user.displayName} + {user.provider !== 'local' && ( + {user.provider} + )} +
+
{user.email}
+
+
-
- {user.displayName} - {user.provider !== 'local' && ( - {user.provider} - )} -
-
{user.email}
Status @@ -212,7 +247,7 @@ function UserDetail({
Created - {formatDate(user.createdAt)} + {new Date(user.createdAt).toLocaleString()}

@@ -227,9 +262,27 @@ function UserDetail({ user.directGroups.map((g) => ( {g.name} + )) )} + { + await Promise.allSettled( + ids.map((gid) => addToGroup.mutateAsync({ userId: user.userId, groupId: gid })) + ); + }} + placeholder="Search groups..." + />
@@ -239,6 +292,15 @@ function UserDetail({ {user.directRoles.map((r) => ( {r.name} + ))} {inheritedRoles.map((r) => ( @@ -254,6 +316,15 @@ function UserDetail({ Dashed roles are inherited transitively through group membership.
)} + { + await Promise.allSettled( + ids.map((rid) => assignRole.mutateAsync({ userId: user.userId, roleId: rid })) + ); + }} + placeholder="Search roles..." + /> {groupTree.length > 0 && ( @@ -276,6 +347,21 @@ function UserDetail({ ))} )} + + setShowDeleteDialog(false)} + onConfirm={() => { + deleteUserMut.mutate(user.userId, { + onSuccess: () => { + setShowDeleteDialog(false); + onDeselect(); + }, + }); + }} + resourceName={user.displayName || user.userId} + resourceType="user" + /> ); }