- 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>
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
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 {
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
return name.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
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);
|
|
// Try to find a parent -> child path
|
|
for (const g of user.directGroups) {
|
|
const detail = groupMap.get(g.id);
|
|
if (detail?.parentGroupId) {
|
|
const parent = groupMap.get(detail.parentGroupId);
|
|
if (parent) return `${parent.name} > ${g.name}`;
|
|
}
|
|
}
|
|
return names.join(', ');
|
|
}
|
|
|
|
export function UsersTab() {
|
|
const users = useUsers();
|
|
const groups = useGroups();
|
|
const { data: allRoles } = useRoles();
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
const [filter, setFilter] = useState('');
|
|
|
|
const groupMap = useMemo(() => {
|
|
const map = new Map<string, GroupDetail>();
|
|
for (const g of groups.data ?? []) {
|
|
map.set(g.id, g);
|
|
}
|
|
return map;
|
|
}, [groups.data]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const list = users.data ?? [];
|
|
if (!filter) return list;
|
|
const lower = filter.toLowerCase();
|
|
return list.filter(
|
|
(u) =>
|
|
u.displayName.toLowerCase().includes(lower) ||
|
|
u.email.toLowerCase().includes(lower) ||
|
|
u.userId.toLowerCase().includes(lower)
|
|
);
|
|
}, [users.data, filter]);
|
|
|
|
const selectedUser = useMemo(
|
|
() => (users.data ?? []).find((u) => u.userId === selected) ?? null,
|
|
[users.data, selected]
|
|
);
|
|
|
|
if (users.isLoading) {
|
|
return <div className={styles.loading}>Loading...</div>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className={styles.panelHeader}>
|
|
<div>
|
|
<div className={styles.panelTitle}>Users</div>
|
|
<div className={styles.panelSubtitle}>
|
|
Manage identities, group membership and direct roles
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={styles.split}>
|
|
<div className={styles.listPane}>
|
|
<div className={styles.searchBar}>
|
|
<input
|
|
className={styles.searchInput}
|
|
placeholder="Search users..."
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className={styles.entityList}>
|
|
{filtered.map((user) => {
|
|
const isSelected = user.userId === selected;
|
|
return (
|
|
<div
|
|
key={user.userId}
|
|
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
|
onClick={() => setSelected(user.userId)}
|
|
>
|
|
<div className={`${styles.avatar} ${styles.avatarUser}`}>
|
|
{getInitials(user.displayName)}
|
|
</div>
|
|
<div className={styles.entityInfo}>
|
|
<div className={styles.entityName}>
|
|
{user.displayName}
|
|
{user.provider !== 'local' && (
|
|
<span className={styles.oidcBadge}>{user.provider}</span>
|
|
)}
|
|
</div>
|
|
<div className={styles.entityMeta}>
|
|
{user.email} · {buildGroupPath(user, groupMap)}
|
|
</div>
|
|
<div className={styles.tagList}>
|
|
{user.directRoles.map((r) => (
|
|
<span key={r.id} className={`${styles.tag} ${styles.tagRole}`}>
|
|
{r.name}
|
|
</span>
|
|
))}
|
|
{user.effectiveRoles
|
|
.filter((er) => !user.directRoles.some((dr) => dr.id === er.id))
|
|
.map((r) => (
|
|
<span
|
|
key={r.id}
|
|
className={`${styles.tag} ${styles.tagRole} ${styles.tagInherited}`}
|
|
>
|
|
{r.name}
|
|
</span>
|
|
))}
|
|
{user.directGroups.map((g) => (
|
|
<span key={g.id} className={`${styles.tag} ${styles.tagGroup}`}>
|
|
{g.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`${styles.statusDot} ${styles.statusActive}`}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className={styles.detailPane}>
|
|
{!selectedUser ? (
|
|
<div className={styles.detailEmpty}>
|
|
<span>Select a user to view details</span>
|
|
</div>
|
|
) : (
|
|
<UserDetailView
|
|
user={selectedUser}
|
|
groupMap={groupMap}
|
|
allGroups={groups.data || []}
|
|
allRoles={allRoles || []}
|
|
onDeselect={() => setSelected(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 }[] = [];
|
|
for (const g of user.directGroups) {
|
|
const detail = groupMap.get(g.id);
|
|
if (detail?.parentGroupId) {
|
|
const parent = groupMap.get(detail.parentGroupId);
|
|
if (parent && !tree.some((t) => t.name === parent.name)) {
|
|
tree.push({ name: parent.name, depth: 0, annotation: '' });
|
|
}
|
|
tree.push({ name: g.name, depth: 1, annotation: 'child group' });
|
|
} else {
|
|
tree.push({ name: g.name, depth: 0, annotation: '' });
|
|
}
|
|
}
|
|
return tree;
|
|
}, [user, groupMap]);
|
|
|
|
const inheritedRoles = user.effectiveRoles.filter(
|
|
(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.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.fieldRow}>
|
|
<span className={styles.fieldLabel}>Status</span>
|
|
<span className={styles.fieldVal} style={{ color: 'var(--green)', fontSize: 12 }}>
|
|
Active
|
|
</span>
|
|
</div>
|
|
<div className={styles.fieldRow}>
|
|
<span className={styles.fieldLabel}>ID</span>
|
|
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{user.userId}</span>
|
|
</div>
|
|
<div className={styles.fieldRow}>
|
|
<span className={styles.fieldLabel}>Created</span>
|
|
<span className={styles.fieldVal}>{new Date(user.createdAt).toLocaleString()}</span>
|
|
</div>
|
|
|
|
<hr className={styles.divider} />
|
|
|
|
<div className={styles.detailSection}>
|
|
<div className={styles.detailSectionTitle}>
|
|
Group membership <span>direct only</span>
|
|
</div>
|
|
{user.directGroups.length === 0 ? (
|
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No group membership</span>
|
|
) : (
|
|
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}>
|
|
<div className={styles.detailSectionTitle}>
|
|
Effective roles <span>direct + inherited</span>
|
|
</div>
|
|
{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) => (
|
|
<span key={r.id} className={`${styles.chip} ${styles.chipRole} ${styles.chipInherited}`}>
|
|
{r.name}
|
|
<span className={styles.chipSource}>
|
|
{r.source ? `\u2191 ${r.source}` : ''}
|
|
</span>
|
|
</span>
|
|
))}
|
|
{inheritedRoles.length > 0 && (
|
|
<div className={styles.inheritNote}>
|
|
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 && (
|
|
<div className={styles.detailSection}>
|
|
<div className={styles.detailSectionTitle}>Group tree</div>
|
|
{groupTree.map((node, i) => (
|
|
<div key={i} className={styles.treeRow}>
|
|
{node.depth > 0 && (
|
|
<div className={styles.treeIndent}>
|
|
<div className={styles.treeCorner} />
|
|
</div>
|
|
)}
|
|
{node.name}
|
|
{node.annotation && (
|
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', marginLeft: 4 }}>
|
|
{node.annotation}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDeleteDialog
|
|
isOpen={showDeleteDialog}
|
|
onClose={() => setShowDeleteDialog(false)}
|
|
onConfirm={() => {
|
|
deleteUserMut.mutate(user.userId, {
|
|
onSuccess: () => {
|
|
setShowDeleteDialog(false);
|
|
onDeselect();
|
|
},
|
|
});
|
|
}}
|
|
resourceName={user.displayName || user.userId}
|
|
resourceType="user"
|
|
/>
|
|
</>
|
|
);
|
|
}
|