Files
cameleer-server/ui/src/pages/Admin/UsersTab.tsx

600 lines
21 KiB
TypeScript
Raw Normal View History

import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
Input,
MonoText,
SectionHeader,
Tag,
InlineEdit,
RadioGroup,
RadioItem,
InfoCallout,
MultiSelect,
ConfirmDialog,
AlertDialog,
SplitPane,
EntityList,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
useUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
useAssignRoleToUser,
useRemoveRoleFromUser,
useAddUserToGroup,
useRemoveUserFromGroup,
useSetPassword,
useGroups,
useRoles,
} from '../../api/queries/admin/rbac';
import type { UserDetail } from '../../api/queries/admin/rbac';
import { useAuthStore } from '../../auth/auth-store';
import styles from './UserManagement.module.css';
export default function UsersTab() {
const { toast } = useToast();
const { data: users, isLoading } = useUsers();
const { data: allGroups } = useGroups();
const { data: allRoles } = useRoles();
const currentUsername = useAuthStore((s) => s.username);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserDetail | null>(null);
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null);
// Create form state
const [newUsername, setNewUsername] = useState('');
const [newDisplay, setNewDisplay] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local');
// Password reset state
const [resettingPassword, setResettingPassword] = useState(false);
const [newPw, setNewPw] = useState('');
// Mutations
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const assignRole = useAssignRoleToUser();
const removeRole = useRemoveRoleFromUser();
const addToGroup = useAddUserToGroup();
const removeFromGroup = useRemoveUserFromGroup();
const setPassword = useSetPassword();
const userList = users ?? [];
const filtered = useMemo(() => {
if (!search) return userList;
const q = search.toLowerCase();
return userList.filter(
(u) =>
u.displayName.toLowerCase().includes(q) ||
(u.email ?? '').toLowerCase().includes(q) ||
u.userId.toLowerCase().includes(q),
);
}, [userList, search]);
const selected = userList.find((u) => u.userId === selectedId) ?? null;
const isSelf =
currentUsername != null &&
selected != null &&
selected.displayName === currentUsername;
const duplicateUsername =
newUsername.trim() !== '' &&
userList.some(
(u) => u.displayName.toLowerCase() === newUsername.trim().toLowerCase(),
);
// Derived data for detail pane
const directGroupIds = new Set(selected?.directGroups.map((g) => g.id) ?? []);
const directRoleIds = new Set(selected?.directRoles.map((r) => r.id) ?? []);
const inheritedRoles =
selected?.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 }));
function handleCreate() {
if (!newUsername.trim()) return;
if (newProvider === 'local' && !newPassword.trim()) return;
createUser.mutate(
{
username: newUsername.trim(),
displayName: newDisplay.trim() || undefined,
email: newEmail.trim() || undefined,
password: newProvider === 'local' ? newPassword : undefined,
},
{
onSuccess: () => {
toast({
title: 'User created',
description: newDisplay.trim() || newUsername.trim(),
variant: 'success',
});
setCreating(false);
setNewUsername('');
setNewDisplay('');
setNewEmail('');
setNewPassword('');
setNewProvider('local');
},
onError: () => {
toast({ title: 'Failed to create user', variant: 'error' });
},
},
);
}
function handleDelete() {
if (!deleteTarget) return;
deleteUser.mutate(deleteTarget.userId, {
onSuccess: () => {
toast({
title: 'User deleted',
description: deleteTarget.displayName,
variant: 'warning',
});
if (selectedId === deleteTarget.userId) setSelectedId(null);
setDeleteTarget(null);
},
onError: () => {
toast({ title: 'Failed to delete user', variant: 'error' });
setDeleteTarget(null);
},
});
}
function handleResetPassword() {
if (!selected || !newPw.trim()) return;
setPassword.mutate(
{ userId: selected.userId, password: newPw },
{
onSuccess: () => {
toast({
title: 'Password updated',
description: selected.displayName,
variant: 'success',
});
setResettingPassword(false);
setNewPw('');
},
onError: () => {
toast({ title: 'Failed to update password', variant: 'error' });
},
},
);
}
function getUserGroupPath(user: UserDetail): string {
if (user.directGroups.length === 0) return 'no groups';
return user.directGroups.map((g) => g.name).join(', ');
}
if (isLoading) return <Spinner size="md" />;
return (
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<RadioGroup
name="provider"
value={newProvider}
onChange={(v) => setNewProvider(v as 'local' | 'oidc')}
orientation="horizontal"
>
<RadioItem value="local" label="Local" />
<RadioItem value="oidc" label="OIDC" />
</RadioGroup>
<div className={styles.createFormRow}>
<Input
placeholder="Username *"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
<Input
placeholder="Display name"
value={newDisplay}
onChange={(e) => setNewDisplay(e.target.value)}
/>
</div>
{duplicateUsername && (
<span style={{ color: 'var(--error)', fontSize: 11 }}>
Username already exists
</span>
)}
<Input
placeholder="Email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
{newProvider === 'local' && (
<Input
placeholder="Password *"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
)}
{newProvider === 'oidc' && (
<InfoCallout variant="amber">
OIDC users authenticate via the configured identity provider.
Pre-register to assign roles/groups before their first login.
</InfoCallout>
)}
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => setCreating(false)}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createUser.isPending}
disabled={
!newUsername.trim() ||
(newProvider === 'local' && !newPassword.trim()) ||
duplicateUsername
}
>
Create
</Button>
</div>
</div>
)}
<EntityList
items={filtered}
renderItem={(user) => (
<>
<Avatar name={user.displayName} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{user.displayName}
{user.provider !== 'local' && (
<Badge
label={user.provider}
color="running"
variant="outlined"
className={styles.providerBadge}
/>
)}
</div>
<div className={styles.entityMeta}>
{user.email || user.userId} &middot;{' '}
{getUserGroupPath(user)}
</div>
<div className={styles.entityTags}>
{user.directRoles.map((r) => (
<Badge key={r.id} label={r.name} color="warning" />
))}
{user.directGroups.map((g) => (
<Badge key={g.id} label={g.name} color="success" />
))}
</div>
</div>
</>
)}
getItemId={(user) => user.userId}
selectedId={selectedId ?? undefined}
onSelect={(id) => {
setSelectedId(id);
setResettingPassword(false);
}}
searchPlaceholder="Search users..."
onSearch={setSearch}
addLabel="+ Add user"
onAdd={() => setCreating(true)}
emptyMessage="No users match your search"
/>
</>
}
detail={
selected ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.displayName} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
<InlineEdit
value={selected.displayName}
onSave={(v) =>
updateUser.mutate(
{ userId: selected.userId, displayName: v },
{
onSuccess: () =>
toast({
title: 'Display name updated',
variant: 'success',
}),
onError: () =>
toast({
title: 'Failed to update name',
variant: 'error',
}),
},
)
}
/>
</div>
<div className={styles.detailEmail}>
{selected.email || selected.userId}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
disabled={isSelf}
>
Delete
</Button>
</div>
<SectionHeader>Status</SectionHeader>
<div className={styles.sectionTags}>
<Tag label="Active" color="success" />
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.userId}</MonoText>
<span className={styles.metaLabel}>Created</span>
<span className={styles.metaValue}>
{new Date(selected.createdAt).toLocaleDateString()}
</span>
<span className={styles.metaLabel}>Provider</span>
<span className={styles.metaValue}>{selected.provider}</span>
</div>
<SectionHeader>Security</SectionHeader>
<div className={styles.securitySection}>
{selected.provider === 'local' ? (
<>
<div className={styles.securityRow}>
<span className={styles.metaLabel}>Password</span>
<span className={styles.passwordDots}>
</span>
{!resettingPassword && (
<Button
size="sm"
variant="ghost"
onClick={() => {
setResettingPassword(true);
setNewPw('');
}}
>
Reset password
</Button>
)}
</div>
{resettingPassword && (
<div className={styles.resetForm}>
<Input
placeholder="New password"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
className={styles.resetInput}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setResettingPassword(false)}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleResetPassword}
loading={setPassword.isPending}
disabled={!newPw.trim()}
>
Set
</Button>
</div>
)}
</>
) : (
<>
<div className={styles.securityRow}>
<span className={styles.metaLabel}>Authentication</span>
<span className={styles.metaValue}>
OIDC ({selected.provider})
</span>
</div>
<InfoCallout variant="amber">
Password managed by the identity provider.
</InfoCallout>
</>
)}
</div>
<SectionHeader>Group membership (direct only)</SectionHeader>
<div className={styles.sectionTags}>
{selected.directGroups.map((g) => (
<Tag
key={g.id}
label={g.name}
color="success"
onRemove={() => {
removeFromGroup.mutate(
{ userId: selected.userId, groupId: g.id },
{
onSuccess: () =>
toast({ title: 'Group removed', variant: 'success' }),
onError: () =>
toast({
title: 'Failed to remove group',
variant: 'error',
}),
},
);
}}
/>
))}
{selected.directGroups.length === 0 && (
<span className={styles.inheritedNote}>(no groups)</span>
)}
<MultiSelect
options={availableGroups}
value={[]}
onChange={(ids) => {
for (const groupId of ids) {
addToGroup.mutate(
{ userId: selected.userId, groupId },
{
onSuccess: () =>
toast({ title: 'Added to group', variant: 'success' }),
onError: () =>
toast({
title: 'Failed to add group',
variant: 'error',
}),
},
);
}
}}
placeholder="+ Add"
/>
</div>
<SectionHeader>
Effective roles (direct + inherited)
</SectionHeader>
<div className={styles.sectionTags}>
{selected.directRoles.map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
removeRole.mutate(
{ userId: selected.userId, roleId: r.id },
{
onSuccess: () =>
toast({
title: 'Role removed',
description: r.name,
variant: 'success',
}),
onError: () =>
toast({
title: 'Failed to remove role',
variant: 'error',
}),
},
);
}}
/>
))}
{inheritedRoles.map((r) => (
<Badge
key={r.id}
label={`${r.name} ↑ group`}
color="warning"
variant="dashed"
className={styles.inherited}
/>
))}
{selected.directRoles.length === 0 &&
inheritedRoles.length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={(roleIds) => {
for (const roleId of roleIds) {
assignRole.mutate(
{ userId: selected.userId, roleId },
{
onSuccess: () =>
toast({
title: 'Role assigned',
variant: 'success',
}),
onError: () =>
toast({
title: 'Failed to assign role',
variant: 'error',
}),
},
);
}
}}
placeholder="+ Add"
/>
</div>
{inheritedRoles.length > 0 && (
<span className={styles.inheritedNote}>
Roles with are inherited through group membership
</span>
)}
</>
) : null
}
emptyMessage="Select a user to view details"
/>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete user "${deleteTarget?.displayName}"? This cannot be undone.`}
confirmText={deleteTarget?.displayName ?? ''}
loading={deleteUser.isPending}
/>
<AlertDialog
open={removeGroupTarget !== null}
onClose={() => setRemoveGroupTarget(null)}
onConfirm={() => {
if (removeGroupTarget && selected) {
removeFromGroup.mutate(
{ userId: selected.userId, groupId: removeGroupTarget },
{
onSuccess: () =>
toast({ title: 'Group removed', variant: 'success' }),
onError: () =>
toast({
title: 'Failed to remove group',
variant: 'error',
}),
},
);
}
setRemoveGroupTarget(null);
}}
title="Remove group membership"
description="Removing this group may also revoke inherited roles. Continue?"
confirmLabel="Remove"
variant="warning"
/>
</>
);
}