feat: Mantine SPA (Cluster/Rooms/Users) + verified end-to-end
SPA (React 19 + Vite 6 + Mantine v9, dark/indigo, @fn_library-style): - AdminShell: AppShell nav (Cluster/Rooms/Users), operator endpoint badge - ClusterPage: per-node up/down + posture badges (enforce/acl/tls/cluster/store), 10s auto-refresh - RoomsPage: room table (E2E/cleartext, persist, signed, epoch, role), create modal, members drawer with kick(+rekey) and invite modal - UsersPage: allowlist table (handle/role/status/sign_pub), add modal, revoke with confirmation, degraded state when no store backend - api.ts: single repository layer hitting /api; gateway decides mock vs live Verified end-to-end against a local membershipd in BOTH postures: - auth-off: create room, list rooms, signed members GET, add/revoke user - enforce + TLS + nkey (production posture): TLS-pinned healthz, nkey NATS connect, signed control-plane requests verified by the server, 403 surfaced for a non-member room pnpm build green (tsc + vite); go build/vet green; dist embedded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
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, IconInfoCircle } 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 writable = usersBackend !== "none";
|
||||
|
||||
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>}
|
||||
<Badge variant="outline" color={writable ? "teal" : "gray"} style={{ textTransform: "none" }}>
|
||||
store: {usersBackend}
|
||||
</Badge>
|
||||
</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} disabled={!writable}>
|
||||
Añadir user
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!writable && (
|
||||
<Alert icon={<IconInfoCircle size={18} />} color="yellow" variant="light" title="Gestión de users no disponible">
|
||||
El plano de control no expone endpoint de users; viven solo en el store. Arranca el gateway con <code>--db</code>
|
||||
(single-node) o con acceso KV admin del cluster para listar/dar de alta/revocar. Coordinar con la vía KV que
|
||||
añade <code>quick/0011-deploy-gaps</code>.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{err && writable && <Text c="red">{err}</Text>}
|
||||
{!users && !err && writable && <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>
|
||||
{writable && 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user