Files
unibus_admin/web/src/pages/UsersPage.tsx
T
egutierrez c7631074cb feat: make the Users tab operational, drop the degraded empty state
With user management now wired through the control-plane API, the Users
tab is always functional against a live gateway. Remove the "Gestión de
users no disponible" alert and the writable gating (button disabled,
revoke hidden) that were driven by the old users_backend === "none"
case. The backend badge now reads the wiring in use ("control-plane" or
"sqlite"). Add user (handle + 64-hex sign-pub + role) and revoke (with
explicit confirmation) consume the gateway REST unchanged. Includes the
rebuilt SPA bundle embedded by the binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:10:35 +02:00

176 lines
6.2 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { IconPlus, IconRefresh, IconUserOff } from "@tabler/icons-react";
import { api, ApiError } from "../api";
import type { UserView } from "../types";
import { fmtTime, trunc } from "../util";
function notifyErr(e: unknown) {
notifications.show({ color: "red", title: "Error", message: e instanceof ApiError ? e.message : String(e) });
}
export function UsersPage({ usersBackend }: { usersBackend: string }) {
const [users, setUsers] = useState<UserView[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [addOpen, addCtl] = useDisclosure(false);
const load = useCallback(() => {
setLoading(true);
api
.listUsers()
.then((u) => { setUsers(u); setErr(null); })
.catch((e: ApiError) => setErr(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const revoke = async (u: UserView) => {
if (!window.confirm(`¿Revocar a "${u.handle}"? Pierde acceso al bus en AMBOS planos de inmediato (control y datos).`)) return;
try {
await api.revokeUser(u.sign_pub);
notifications.show({ color: "teal", title: "Revocado", message: u.handle });
load();
} catch (e) {
notifyErr(e);
}
};
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<Title order={3}>Users</Title>
{users && <Badge color="brand" variant="light">{users.length}</Badge>}
<Tooltip label="Vía de gestión de la allowlist del bus">
<Badge variant="outline" color="teal" style={{ textTransform: "none" }}>
backend: {usersBackend}
</Badge>
</Tooltip>
</Group>
<Group gap="xs">
<Tooltip label="Refrescar">
<ActionIcon variant="light" color="brand" onClick={load} loading={loading}>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={addCtl.open}>
Añadir user
</Button>
</Group>
</Group>
{err && <Text c="red">{err}</Text>}
{!users && !err && <Loader color="brand" />}
{users && (
<Card withBorder bg="dark.7" p={0} radius="md">
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Handle</Table.Th>
<Table.Th>Rol</Table.Th>
<Table.Th>Estado</Table.Th>
<Table.Th>sign_pub</Table.Th>
<Table.Th>Creado</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((u) => (
<Table.Tr key={u.sign_pub}>
<Table.Td><Text fw={600}>{u.handle}</Text></Table.Td>
<Table.Td><Badge variant="dot" color={u.role === "admin" ? "brand" : "gray"}>{u.role}</Badge></Table.Td>
<Table.Td>
<Badge variant="light" color={u.status === "active" ? "teal" : "red"}>{u.status}</Badge>
</Table.Td>
<Table.Td>
<Tooltip label={u.sign_pub}>
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>{trunc(u.sign_pub, 12, 8)}</Text>
</Tooltip>
</Table.Td>
<Table.Td><Text size="xs" c="dimmed">{fmtTime(u.created_at)}</Text></Table.Td>
<Table.Td>
{u.status === "active" && (
<Tooltip label="Revocar acceso">
<ActionIcon variant="subtle" color="red" onClick={() => revoke(u)}>
<IconUserOff size={16} />
</ActionIcon>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<AddUserModal opened={addOpen} onClose={addCtl.close} onAdded={load} />
</Stack>
);
}
function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose: () => void; onAdded: () => void }) {
const [handle, setHandle] = useState("");
const [signPub, setSignPub] = useState("");
const [role, setRole] = useState<string>("member");
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
try {
await api.addUser({ handle: handle.trim(), sign_pub: signPub.trim(), role });
notifications.show({ color: "teal", title: "User añadido", message: handle });
setHandle(""); setSignPub(""); setRole("member");
onClose();
onAdded();
} catch (e) {
notifyErr(e);
} finally {
setBusy(false);
}
};
const ready = handle.trim().length > 0 && signPub.trim().length === 64;
return (
<Modal opened={opened} onClose={onClose} title="Añadir user al bus" centered>
<Stack gap="sm">
<TextInput label="Handle" placeholder="ana" value={handle} onChange={(e) => setHandle(e.currentTarget.value)} data-autofocus />
<TextInput
label="sign_pub (hex, 64)"
description="Clave pública Ed25519 del usuario (la misma que autentica control + datos)"
placeholder="48bc0dc8…"
value={signPub}
onChange={(e) => setSignPub(e.currentTarget.value)}
error={signPub.length > 0 && signPub.trim().length !== 64 ? "64 chars hex" : undefined}
/>
<Select label="Rol" data={[{ value: "member", label: "member" }, { value: "admin", label: "admin" }]} value={role} onChange={(v) => setRole(v || "member")} allowDeselect={false} />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancelar</Button>
<Button onClick={submit} loading={busy} disabled={!ready}>Añadir</Button>
</Group>
</Stack>
</Modal>
);
}