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:
hsiegeln
2026-03-17 18:34:40 +01:00
parent 65001e0ed0
commit 4821ddebba

View File

@@ -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"
/>
</>
);
}