2026-03-23 18:32:45 +01:00
|
|
|
import { useState, useMemo } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
Avatar,
|
|
|
|
|
Badge,
|
|
|
|
|
Button,
|
|
|
|
|
Input,
|
|
|
|
|
MonoText,
|
|
|
|
|
Tag,
|
|
|
|
|
InfoCallout,
|
|
|
|
|
ConfirmDialog,
|
|
|
|
|
Select,
|
|
|
|
|
Spinner,
|
|
|
|
|
InlineEdit,
|
|
|
|
|
useToast,
|
|
|
|
|
} from '@cameleer/design-system';
|
|
|
|
|
import {
|
|
|
|
|
useUsers,
|
|
|
|
|
useCreateUser,
|
|
|
|
|
useDeleteUser,
|
|
|
|
|
useAssignRoleToUser,
|
|
|
|
|
useRemoveRoleFromUser,
|
|
|
|
|
useAddUserToGroup,
|
|
|
|
|
useRemoveUserFromGroup,
|
|
|
|
|
useSetPassword,
|
|
|
|
|
useGroups,
|
|
|
|
|
useRoles,
|
|
|
|
|
} from '../../api/queries/admin/rbac';
|
|
|
|
|
import { useAuthStore } from '../../auth/auth-store';
|
|
|
|
|
import styles from './UserManagement.module.css';
|
|
|
|
|
|
|
|
|
|
export default function UsersTab() {
|
|
|
|
|
const { data: users, isLoading } = useUsers();
|
|
|
|
|
const { data: allGroups } = useGroups();
|
|
|
|
|
const { data: allRoles } = useRoles();
|
|
|
|
|
const currentUsername = useAuthStore((s) => s.username);
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
|
|
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Create form state
|
|
|
|
|
const [createUsername, setCreateUsername] = useState('');
|
|
|
|
|
const [createDisplayName, setCreateDisplayName] = useState('');
|
|
|
|
|
const [createEmail, setCreateEmail] = useState('');
|
|
|
|
|
const [createPassword, setCreatePassword] = useState('');
|
|
|
|
|
|
|
|
|
|
// Detail pane state
|
|
|
|
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
|
|
|
|
const [newPassword, setNewPassword] = useState('');
|
|
|
|
|
const [addGroupId, setAddGroupId] = useState('');
|
|
|
|
|
const [addRoleId, setAddRoleId] = useState('');
|
|
|
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Mutations
|
|
|
|
|
const createUser = useCreateUser();
|
|
|
|
|
const deleteUser = useDeleteUser();
|
|
|
|
|
const assignRole = useAssignRoleToUser();
|
|
|
|
|
const removeRole = useRemoveRoleFromUser();
|
|
|
|
|
const addToGroup = useAddUserToGroup();
|
|
|
|
|
const removeFromGroup = useRemoveUserFromGroup();
|
|
|
|
|
const setPassword = useSetPassword();
|
|
|
|
|
|
|
|
|
|
// Filtered user list
|
|
|
|
|
const filteredUsers = useMemo(() => {
|
|
|
|
|
if (!users) return [];
|
|
|
|
|
const q = search.toLowerCase();
|
|
|
|
|
if (!q) return users;
|
|
|
|
|
return users.filter(
|
|
|
|
|
(u) =>
|
|
|
|
|
u.displayName.toLowerCase().includes(q) ||
|
|
|
|
|
(u.email ?? '').toLowerCase().includes(q) ||
|
|
|
|
|
u.userId.toLowerCase().includes(q),
|
|
|
|
|
);
|
|
|
|
|
}, [users, search]);
|
|
|
|
|
|
|
|
|
|
const selectedUser = useMemo(
|
|
|
|
|
() => users?.find((u) => u.userId === selectedUserId) ?? null,
|
|
|
|
|
[users, selectedUserId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ── Handlers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function handleCreateUser() {
|
|
|
|
|
if (!createUsername.trim() || !createPassword.trim()) return;
|
|
|
|
|
createUser.mutate(
|
|
|
|
|
{
|
|
|
|
|
username: createUsername.trim(),
|
|
|
|
|
displayName: createDisplayName.trim() || undefined,
|
|
|
|
|
email: createEmail.trim() || undefined,
|
|
|
|
|
password: createPassword,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
toast({ title: 'User created', variant: 'success' });
|
|
|
|
|
setShowCreateForm(false);
|
|
|
|
|
setCreateUsername('');
|
|
|
|
|
setCreateDisplayName('');
|
|
|
|
|
setCreateEmail('');
|
|
|
|
|
setCreatePassword('');
|
|
|
|
|
},
|
|
|
|
|
onError: () => {
|
|
|
|
|
toast({ title: 'Failed to create user', variant: 'error' });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleResetPassword() {
|
|
|
|
|
if (!selectedUser || !newPassword.trim()) return;
|
|
|
|
|
setPassword.mutate(
|
|
|
|
|
{ userId: selectedUser.userId, password: newPassword },
|
|
|
|
|
{
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
toast({ title: 'Password updated', variant: 'success' });
|
|
|
|
|
setShowPasswordForm(false);
|
|
|
|
|
setNewPassword('');
|
|
|
|
|
},
|
|
|
|
|
onError: () => {
|
|
|
|
|
toast({ title: 'Failed to update password', variant: 'error' });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleAddGroup() {
|
|
|
|
|
if (!selectedUser || !addGroupId) return;
|
|
|
|
|
addToGroup.mutate(
|
|
|
|
|
{ userId: selectedUser.userId, groupId: addGroupId },
|
|
|
|
|
{
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
toast({ title: 'Added to group', variant: 'success' });
|
|
|
|
|
setAddGroupId('');
|
|
|
|
|
},
|
|
|
|
|
onError: () => {
|
|
|
|
|
toast({ title: 'Failed to add group', variant: 'error' });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleAddRole() {
|
|
|
|
|
if (!selectedUser || !addRoleId) return;
|
|
|
|
|
assignRole.mutate(
|
|
|
|
|
{ userId: selectedUser.userId, roleId: addRoleId },
|
|
|
|
|
{
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
toast({ title: 'Role assigned', variant: 'success' });
|
|
|
|
|
setAddRoleId('');
|
|
|
|
|
},
|
|
|
|
|
onError: () => {
|
|
|
|
|
toast({ title: 'Failed to assign role', variant: 'error' });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDeleteUser() {
|
|
|
|
|
if (!selectedUser) return;
|
|
|
|
|
deleteUser.mutate(selectedUser.userId, {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
toast({ title: 'User deleted', variant: 'success' });
|
|
|
|
|
setSelectedUserId(null);
|
|
|
|
|
setShowDeleteDialog(false);
|
|
|
|
|
},
|
|
|
|
|
onError: () => {
|
|
|
|
|
toast({ title: 'Failed to delete user', variant: 'error' });
|
|
|
|
|
setShowDeleteDialog(false);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Derived data for detail pane
|
|
|
|
|
const directGroupIds = new Set(selectedUser?.directGroups.map((g) => g.id) ?? []);
|
|
|
|
|
const directRoleIds = new Set(selectedUser?.directRoles.map((r) => r.id) ?? []);
|
|
|
|
|
|
|
|
|
|
const inheritedRoles = selectedUser?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? [];
|
|
|
|
|
|
|
|
|
|
const availableGroups = (allGroups ?? [])
|
|
|
|
|
.filter((g) => !directGroupIds.has(g.id))
|
|
|
|
|
.map((g) => ({ value: g.id, label: g.name }));
|
|
|
|
|
|
|
|
|
|
const availableRoles = (allRoles ?? [])
|
|
|
|
|
.filter((r) => !directRoleIds.has(r.id))
|
|
|
|
|
.map((r) => ({ value: r.id, label: r.name }));
|
|
|
|
|
|
|
|
|
|
// Find group name for inherited role display
|
|
|
|
|
function findInheritingGroupName(roleId: string): string {
|
|
|
|
|
if (!selectedUser) return '';
|
|
|
|
|
for (const g of selectedUser.effectiveGroups) {
|
|
|
|
|
// We don't have group→roles in the summary, so just show "group"
|
|
|
|
|
void roleId;
|
|
|
|
|
return g.name;
|
|
|
|
|
}
|
|
|
|
|
return 'group';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isSelf =
|
|
|
|
|
currentUsername != null &&
|
|
|
|
|
selectedUser != null &&
|
|
|
|
|
selectedUser.displayName === currentUsername;
|
|
|
|
|
|
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.splitPane}>
|
|
|
|
|
{/* ── Left pane ── */}
|
|
|
|
|
<div className={styles.listPane}>
|
|
|
|
|
<div className={styles.listHeader}>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Search users…"
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
onClear={() => setSearch('')}
|
|
|
|
|
style={{ flex: 1 }}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setShowCreateForm((v) => !v)}
|
|
|
|
|
>
|
|
|
|
|
{showCreateForm ? '✕ Cancel' : '+ Add User'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{showCreateForm && (
|
|
|
|
|
<div className={styles.createForm}>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Username (required)"
|
|
|
|
|
value={createUsername}
|
|
|
|
|
onChange={(e) => setCreateUsername(e.target.value)}
|
|
|
|
|
style={{ marginBottom: 6 }}
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Display Name"
|
|
|
|
|
value={createDisplayName}
|
|
|
|
|
onChange={(e) => setCreateDisplayName(e.target.value)}
|
|
|
|
|
style={{ marginBottom: 6 }}
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Email"
|
|
|
|
|
type="email"
|
|
|
|
|
value={createEmail}
|
|
|
|
|
onChange={(e) => setCreateEmail(e.target.value)}
|
|
|
|
|
style={{ marginBottom: 6 }}
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Password (required)"
|
|
|
|
|
type="password"
|
|
|
|
|
value={createPassword}
|
|
|
|
|
onChange={(e) => setCreatePassword(e.target.value)}
|
|
|
|
|
style={{ marginBottom: 6 }}
|
|
|
|
|
/>
|
|
|
|
|
<div className={styles.createFormActions}>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setShowCreateForm(false);
|
|
|
|
|
setCreateUsername('');
|
|
|
|
|
setCreateDisplayName('');
|
|
|
|
|
setCreateEmail('');
|
|
|
|
|
setCreatePassword('');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="sm"
|
|
|
|
|
loading={createUser.isPending}
|
|
|
|
|
disabled={!createUsername.trim() || !createPassword.trim()}
|
|
|
|
|
onClick={handleCreateUser}
|
|
|
|
|
>
|
|
|
|
|
Create
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isLoading && <Spinner size="md" />}
|
|
|
|
|
|
|
|
|
|
<div className={styles.entityList} role="listbox">
|
|
|
|
|
{filteredUsers.map((user) => (
|
|
|
|
|
<div
|
|
|
|
|
key={user.userId}
|
|
|
|
|
className={
|
|
|
|
|
styles.entityItem +
|
|
|
|
|
(user.userId === selectedUserId ? ' ' + styles.entityItemSelected : '')
|
|
|
|
|
}
|
|
|
|
|
role="option"
|
|
|
|
|
aria-selected={user.userId === selectedUserId}
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={() => setSelectedUserId(user.userId)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSelectedUserId(user.userId);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Avatar name={user.displayName} size="sm" />
|
|
|
|
|
<div className={styles.entityInfo}>
|
|
|
|
|
<div className={styles.entityName}>
|
|
|
|
|
{user.displayName}
|
|
|
|
|
{user.provider !== 'local' && (
|
|
|
|
|
<Badge label={user.provider} variant="outlined" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-24 08:21:11 +01:00
|
|
|
<div className={styles.entityMeta}>
|
|
|
|
|
{user.email || user.userId}
|
|
|
|
|
{user.directGroups.length > 0 && ` · ${user.directGroups.map((g) => g.name).join(', ')}`}
|
|
|
|
|
{user.directGroups.length === 0 && ' · no groups'}
|
|
|
|
|
</div>
|
2026-03-23 18:32:45 +01:00
|
|
|
{(user.directRoles.length > 0 || user.directGroups.length > 0) && (
|
|
|
|
|
<div className={styles.entityTags}>
|
|
|
|
|
{user.directRoles.map((r) => (
|
|
|
|
|
<Badge key={r.id} label={r.name} variant="filled" color="primary" />
|
|
|
|
|
))}
|
|
|
|
|
{user.directGroups.map((g) => (
|
|
|
|
|
<Badge key={g.id} label={g.name} variant="outlined" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ── Right pane ── */}
|
|
|
|
|
<div className={styles.detailPane}>
|
|
|
|
|
{!selectedUser ? (
|
|
|
|
|
<div className={styles.emptyDetail}>Select a user to view details</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className={styles.detailHeader}>
|
|
|
|
|
<Avatar name={selectedUser.displayName} size="lg" />
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
<InlineEdit
|
|
|
|
|
value={selectedUser.displayName}
|
|
|
|
|
onSave={(val) => {
|
|
|
|
|
// useUpdateUser not imported here to keep things clean;
|
|
|
|
|
// display only — wired via displayName update if desired
|
|
|
|
|
void val;
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
{selectedUser.email && (
|
|
|
|
|
<div className={styles.entityMeta}>{selectedUser.email}</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="danger"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={isSelf}
|
|
|
|
|
onClick={() => setShowDeleteDialog(true)}
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Metadata grid */}
|
|
|
|
|
<div className={styles.metaGrid}>
|
|
|
|
|
<span className={styles.metaLabel}>User ID</span>
|
|
|
|
|
<MonoText size="sm">{selectedUser.userId}</MonoText>
|
|
|
|
|
|
|
|
|
|
<span className={styles.metaLabel}>Created</span>
|
|
|
|
|
<span>{new Date(selectedUser.createdAt).toLocaleString()}</span>
|
|
|
|
|
|
|
|
|
|
<span className={styles.metaLabel}>Provider</span>
|
|
|
|
|
<span>{selectedUser.provider}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Security section */}
|
|
|
|
|
<div className={styles.securitySection}>
|
|
|
|
|
<div className={styles.sectionTitle}>Security</div>
|
|
|
|
|
{selectedUser.provider === 'local' ? (
|
|
|
|
|
<>
|
|
|
|
|
{!showPasswordForm ? (
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setShowPasswordForm(true)}
|
|
|
|
|
>
|
|
|
|
|
Reset password
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<div className={styles.resetForm}>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="New password"
|
|
|
|
|
type="password"
|
|
|
|
|
value={newPassword}
|
|
|
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
|
|
|
style={{ flex: 1 }}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setShowPasswordForm(false);
|
|
|
|
|
setNewPassword('');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="sm"
|
|
|
|
|
loading={setPassword.isPending}
|
|
|
|
|
disabled={!newPassword.trim()}
|
|
|
|
|
onClick={handleResetPassword}
|
|
|
|
|
>
|
|
|
|
|
Set
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<InfoCallout variant="info">
|
|
|
|
|
Password managed by identity provider
|
|
|
|
|
</InfoCallout>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Group membership */}
|
|
|
|
|
<div className={styles.sectionTitle}>Group Membership</div>
|
|
|
|
|
<div className={styles.sectionTags}>
|
|
|
|
|
{selectedUser.directGroups.map((g) => (
|
|
|
|
|
<Tag
|
|
|
|
|
key={g.id}
|
|
|
|
|
label={g.name}
|
|
|
|
|
onRemove={() =>
|
|
|
|
|
removeFromGroup.mutate(
|
|
|
|
|
{ userId: selectedUser.userId, groupId: g.id },
|
|
|
|
|
{
|
|
|
|
|
onError: () =>
|
|
|
|
|
toast({ title: 'Failed to remove group', variant: 'error' }),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{availableGroups.length > 0 && (
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
|
|
|
<Select
|
|
|
|
|
options={[{ value: '', label: 'Add to group…' }, ...availableGroups]}
|
|
|
|
|
value={addGroupId}
|
|
|
|
|
onChange={(e) => setAddGroupId(e.target.value)}
|
|
|
|
|
style={{ flex: 1 }}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={!addGroupId}
|
|
|
|
|
onClick={handleAddGroup}
|
|
|
|
|
loading={addToGroup.isPending}
|
|
|
|
|
>
|
|
|
|
|
Add
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Effective roles */}
|
|
|
|
|
<div className={styles.sectionTitle}>Roles</div>
|
|
|
|
|
<div className={styles.sectionTags}>
|
|
|
|
|
{selectedUser.directRoles.map((r) => (
|
|
|
|
|
<Tag
|
|
|
|
|
key={r.id}
|
|
|
|
|
label={r.name}
|
|
|
|
|
color="warning"
|
|
|
|
|
onRemove={() =>
|
|
|
|
|
removeRole.mutate(
|
|
|
|
|
{ userId: selectedUser.userId, roleId: r.id },
|
|
|
|
|
{
|
|
|
|
|
onError: () =>
|
|
|
|
|
toast({ title: 'Failed to remove role', variant: 'error' }),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
{inheritedRoles.map((r) => (
|
|
|
|
|
<span key={r.id} style={{ opacity: 0.65 }}>
|
|
|
|
|
<Badge
|
|
|
|
|
label={`↑ ${findInheritingGroupName(r.id)} / ${r.name}`}
|
|
|
|
|
variant="dashed"
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{inheritedRoles.length > 0 && (
|
|
|
|
|
<div className={styles.inheritedNote}>
|
|
|
|
|
Roles with ↑ are inherited through group membership
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{availableRoles.length > 0 && (
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
|
|
|
<Select
|
|
|
|
|
options={[{ value: '', label: 'Assign role…' }, ...availableRoles]}
|
|
|
|
|
value={addRoleId}
|
|
|
|
|
onChange={(e) => setAddRoleId(e.target.value)}
|
|
|
|
|
style={{ flex: 1 }}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={!addRoleId}
|
|
|
|
|
onClick={handleAddRole}
|
|
|
|
|
loading={assignRole.isPending}
|
|
|
|
|
>
|
|
|
|
|
Add
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Delete confirmation */}
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
open={showDeleteDialog}
|
|
|
|
|
onClose={() => setShowDeleteDialog(false)}
|
|
|
|
|
onConfirm={handleDeleteUser}
|
|
|
|
|
title="Delete user"
|
|
|
|
|
message={`This will permanently delete the user "${selectedUser.displayName}". Type their username to confirm.`}
|
|
|
|
|
confirmText={selectedUser.displayName}
|
|
|
|
|
confirmLabel="Delete"
|
|
|
|
|
variant="danger"
|
|
|
|
|
loading={deleteUser.isPending}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|