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) <noreply@anthropic.com>
This commit is contained in:
@@ -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, GroupDetail>): 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<string, GroupDetail>): s
|
||||
export function UsersTab() {
|
||||
const users = useUsers();
|
||||
const groups = useGroups();
|
||||
const { data: allRoles } = useRoles();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
@@ -150,7 +141,13 @@ export function UsersTab() {
|
||||
<span>Select a user to view details</span>
|
||||
</div>
|
||||
) : (
|
||||
<UserDetail user={selectedUser} groupMap={groupMap} />
|
||||
<UserDetailView
|
||||
user={selectedUser}
|
||||
groupMap={groupMap}
|
||||
allGroups={groups.data || []}
|
||||
allRoles={allRoles || []}
|
||||
onDeselect={() => setSelected(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,13 +155,30 @@ export function UsersTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetail({
|
||||
function UserDetailView({
|
||||
user,
|
||||
groupMap,
|
||||
allGroups,
|
||||
allRoles,
|
||||
onDeselect,
|
||||
}: {
|
||||
user: UserDetail;
|
||||
groupMap: Map<string, GroupDetail>;
|
||||
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 (
|
||||
<>
|
||||
<div className={`${styles.detailAvatar} ${styles.avatarUser}`}>
|
||||
{getInitials(user.displayName)}
|
||||
<div className={styles.detailHeader}>
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={`${styles.detailAvatar} ${styles.avatarUser}`}>
|
||||
{getInitials(user.displayName)}
|
||||
</div>
|
||||
<div className={styles.detailName}>
|
||||
{user.displayName}
|
||||
{user.provider !== 'local' && (
|
||||
<span className={styles.oidcBadge}>{user.provider}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailEmail}>{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDelete}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isSelf || deleteUserMut.isPending}
|
||||
title={isSelf ? 'Cannot delete your own account' : 'Delete user'}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.detailName}>
|
||||
{user.displayName}
|
||||
{user.provider !== 'local' && (
|
||||
<span className={styles.oidcBadge}>{user.provider}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailEmail}>{user.email}</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Status</span>
|
||||
@@ -212,7 +247,7 @@ function UserDetail({
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Created</span>
|
||||
<span className={styles.fieldVal}>{formatDate(user.createdAt)}</span>
|
||||
<span className={styles.fieldVal}>{new Date(user.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
@@ -227,9 +262,27 @@ function UserDetail({
|
||||
user.directGroups.map((g) => (
|
||||
<span key={g.id} className={`${styles.chip} ${styles.chipGroup}`}>
|
||||
{g.name}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipRemove}
|
||||
onClick={() => removeFromGroup.mutate({ userId: user.userId, groupId: g.id })}
|
||||
disabled={removeFromGroup.isPending}
|
||||
title="Remove from group"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
<MultiSelectDropdown
|
||||
items={availableGroups}
|
||||
onApply={async (ids) => {
|
||||
await Promise.allSettled(
|
||||
ids.map((gid) => addToGroup.mutateAsync({ userId: user.userId, groupId: gid }))
|
||||
);
|
||||
}}
|
||||
placeholder="Search groups..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
@@ -239,6 +292,15 @@ function UserDetail({
|
||||
{user.directRoles.map((r) => (
|
||||
<span key={r.id} className={`${styles.chip} ${styles.chipRole}`}>
|
||||
{r.name}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipRemove}
|
||||
onClick={() => removeRole.mutate({ userId: user.userId, roleId: r.id })}
|
||||
disabled={removeRole.isPending}
|
||||
title="Remove role"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{inheritedRoles.map((r) => (
|
||||
@@ -254,6 +316,15 @@ function UserDetail({
|
||||
Dashed roles are inherited transitively through group membership.
|
||||
</div>
|
||||
)}
|
||||
<MultiSelectDropdown
|
||||
items={availableRoles}
|
||||
onApply={async (ids) => {
|
||||
await Promise.allSettled(
|
||||
ids.map((rid) => assignRole.mutateAsync({ userId: user.userId, roleId: rid }))
|
||||
);
|
||||
}}
|
||||
placeholder="Search roles..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groupTree.length > 0 && (
|
||||
@@ -276,6 +347,21 @@ function UserDetail({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
onConfirm={() => {
|
||||
deleteUserMut.mutate(user.userId, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteDialog(false);
|
||||
onDeselect();
|
||||
},
|
||||
});
|
||||
}}
|
||||
resourceName={user.displayName || user.userId}
|
||||
resourceType="user"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user