feat: add Users tab with split-pane layout, inline create, detail panel
Some checks failed
CI / build (push) Failing after 39s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-23 18:32:45 +01:00
parent 9ab38dfc59
commit 752d7ec0e7
2 changed files with 532 additions and 1 deletions

View File

@@ -2,10 +2,10 @@ import { useState } from 'react';
import { StatCard, Tabs } from '@cameleer/design-system';
import { useRbacStats } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
import UsersTab from './UsersTab';
import GroupsTab from './GroupsTab';
import RolesTab from './RolesTab';
export default function RbacPage() {
const { data: stats } = useRbacStats();
const [tab, setTab] = useState('users');

View File

@@ -0,0 +1,531 @@
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>
{user.email && <div className={styles.entityMeta}>{user.email}</div>}
{(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>
);
}