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,98 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AppShell,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconServer2,
|
||||
IconMessages,
|
||||
IconUsers,
|
||||
IconShieldLock,
|
||||
} from "@tabler/icons-react";
|
||||
import type { MeInfo } from "./types";
|
||||
import { trunc } from "./util";
|
||||
import { ClusterPage } from "./pages/ClusterPage";
|
||||
import { RoomsPage } from "./pages/RoomsPage";
|
||||
import { UsersPage } from "./pages/UsersPage";
|
||||
|
||||
type Tab = "cluster" | "rooms" | "users";
|
||||
|
||||
const NAV: { key: Tab; label: string; icon: typeof IconServer2; desc: string }[] = [
|
||||
{ key: "cluster", label: "Cluster", icon: IconServer2, desc: "Salud y posture de los 3 nodos" },
|
||||
{ key: "rooms", label: "Rooms", icon: IconMessages, desc: "Salas, miembros, claves" },
|
||||
{ key: "users", label: "Users", icon: IconUsers, desc: "Allowlist del bus" },
|
||||
];
|
||||
|
||||
export function AdminShell({ me }: { me: MeInfo }) {
|
||||
const [tab, setTab] = useState<Tab>("cluster");
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 56 }}
|
||||
navbar={{ width: 240, breakpoint: "sm" }}
|
||||
padding="md"
|
||||
styles={{ main: { backgroundColor: "var(--mantine-color-dark-8)" } }}
|
||||
>
|
||||
<AppShell.Header bg="dark.9">
|
||||
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="brand">
|
||||
<IconShieldLock size={20} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4} lh={1}>
|
||||
unibus · admin
|
||||
</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
plano de control
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{me.mock && (
|
||||
<Badge color="yellow" variant="light">
|
||||
MOCK
|
||||
</Badge>
|
||||
)}
|
||||
<Tooltip label={`endpoint ${me.endpoint}`} multiline>
|
||||
<Badge color="brand" variant="light" style={{ textTransform: "none" }}>
|
||||
{trunc(me.endpoint, 12, 6)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar bg="dark.9" p="xs">
|
||||
<ScrollArea>
|
||||
{NAV.map((n) => (
|
||||
<NavLink
|
||||
key={n.key}
|
||||
active={tab === n.key}
|
||||
label={n.label}
|
||||
description={n.desc}
|
||||
leftSection={<n.icon size={18} />}
|
||||
onClick={() => setTab(n.key)}
|
||||
variant="filled"
|
||||
mb={4}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
{tab === "cluster" && <ClusterPage />}
|
||||
{tab === "rooms" && <RoomsPage />}
|
||||
{tab === "users" && <UsersPage usersBackend={me.users_backend} />}
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Center, Loader, Stack, Text } from "@mantine/core";
|
||||
import { api, ApiError } from "./api";
|
||||
import type { MeInfo } from "./types";
|
||||
import { AdminShell } from "./AdminShell";
|
||||
|
||||
export function App() {
|
||||
const [me, setMe] = useState<MeInfo | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.me()
|
||||
.then(setMe)
|
||||
.catch((e: ApiError) => setErr(e.message));
|
||||
}, []);
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<Center h="100vh" bg="dark.9">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text c="red">No se pudo contactar el gateway</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{err}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (!me) {
|
||||
return (
|
||||
<Center h="100vh" bg="dark.9">
|
||||
<Loader color="brand" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return <AdminShell me={me} />;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// The single repository layer the SPA talks through. Every call hits the Go
|
||||
// gateway under /api; the gateway decides whether to answer from the live bus or
|
||||
// from sample data (--mock). The browser therefore has ONE code path for mock
|
||||
// and real, and never signs or speaks NATS itself.
|
||||
import type {
|
||||
AddUserReq,
|
||||
CreateRoomReq,
|
||||
InviteReq,
|
||||
MeInfo,
|
||||
MemberView,
|
||||
NodeHealth,
|
||||
RoomView,
|
||||
UserView,
|
||||
} from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
body && typeof body === "object" && "error" in body
|
||||
? String((body as { error: unknown }).error)
|
||||
: `HTTP ${res.status}`;
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => req<MeInfo>("/api/me"),
|
||||
cluster: () => req<NodeHealth[]>("/api/cluster"),
|
||||
|
||||
listRooms: () => req<RoomView[]>("/api/rooms"),
|
||||
createRoom: (r: CreateRoomReq) =>
|
||||
req<RoomView>("/api/rooms", { method: "POST", body: JSON.stringify(r) }),
|
||||
listMembers: (roomID: string) =>
|
||||
req<MemberView[]>(`/api/rooms/${encodeURIComponent(roomID)}/members`),
|
||||
invite: (roomID: string, r: InviteReq) =>
|
||||
req<{ status: string }>(`/api/rooms/${encodeURIComponent(roomID)}/invite`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(r),
|
||||
}),
|
||||
kick: (roomID: string, endpoint: string) =>
|
||||
req<{ status: string }>(`/api/rooms/${encodeURIComponent(roomID)}/kick`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ endpoint }),
|
||||
}),
|
||||
|
||||
listUsers: () => req<UserView[]>("/api/users"),
|
||||
addUser: (r: AddUserReq) =>
|
||||
req<{ status: string }>("/api/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(r),
|
||||
}),
|
||||
revokeUser: (signPub: string) =>
|
||||
req<{ status: string }>("/api/users/revoke", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sign_pub: signPub }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import { theme } from "./theme";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<MantineProvider theme={theme} forceColorScheme="dark">
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Indicator,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconRefresh, IconServer2 } from "@tabler/icons-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { NodeHealth, Posture } from "../types";
|
||||
|
||||
function PostureBadges({ p, up }: { p: Posture; up: boolean }) {
|
||||
if (!up) return <Text c="dimmed" size="sm">—</Text>;
|
||||
const flag = (on: boolean, label: string) => (
|
||||
<Badge key={label} size="sm" variant={on ? "filled" : "outline"} color={on ? "teal" : "gray"}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{flag(p.enforce, "enforce")}
|
||||
{flag(p.acl, "acl")}
|
||||
{flag(p.tls, "tls")}
|
||||
{flag(p.cluster, "cluster")}
|
||||
<Badge size="sm" variant="light" color="brand" style={{ textTransform: "none" }}>
|
||||
store: {p.store || "?"}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClusterPage() {
|
||||
const [nodes, setNodes] = useState<NodeHealth[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.cluster()
|
||||
.then((n) => {
|
||||
setNodes(n);
|
||||
setErr(null);
|
||||
})
|
||||
.catch((e: ApiError) => setErr(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 10_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
const upCount = nodes?.filter((n) => n.up).length ?? 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Title order={3}>Cluster</Title>
|
||||
{nodes && (
|
||||
<Badge color={upCount === nodes.length ? "teal" : upCount === 0 ? "red" : "yellow"} variant="light">
|
||||
{upCount}/{nodes.length} up
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Tooltip label="Refrescar">
|
||||
<ActionIcon variant="light" color="brand" onClick={load} loading={loading}>
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{err && <Text c="red">{err}</Text>}
|
||||
{!nodes && !err && <Loader color="brand" />}
|
||||
|
||||
{nodes && (
|
||||
<Card withBorder bg="dark.7" p={0} radius="md">
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Nodo</Table.Th>
|
||||
<Table.Th>Estado</Table.Th>
|
||||
<Table.Th>Latencia</Table.Th>
|
||||
<Table.Th>Posture</Table.Th>
|
||||
<Table.Th>URL</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{nodes.map((n) => (
|
||||
<Table.Tr key={n.name}>
|
||||
<Table.Td>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Indicator color={n.up ? "teal" : "red"} size={9} processing={n.up}>
|
||||
<IconServer2 size={18} />
|
||||
</Indicator>
|
||||
<Text fw={600}>{n.name}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{n.up ? (
|
||||
<Badge color="teal" variant="light">up</Badge>
|
||||
) : (
|
||||
<Tooltip label={n.error || "sin respuesta"} multiline w={260}>
|
||||
<Badge color="red" variant="light">down</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{n.up ? `${n.latency_ms} ms` : "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<PostureBadges p={n.posture} up={n.up} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{n.url}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Posture leída de <code>GET /healthz</code> de cada nodo (enforce + ACL + TLS + cluster + backend de store).
|
||||
El meta-leader y el tamaño de quórum requieren el endpoint de monitoreo de NATS (gap conocido).
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconUsers,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconUserMinus,
|
||||
IconUserPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { MemberView, RoomView } from "../types";
|
||||
import { trunc } from "../util";
|
||||
|
||||
function notifyErr(e: unknown) {
|
||||
notifications.show({ color: "red", title: "Error", message: e instanceof ApiError ? e.message : String(e) });
|
||||
}
|
||||
|
||||
export function RoomsPage() {
|
||||
const [rooms, setRooms] = useState<RoomView[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, createCtl] = useDisclosure(false);
|
||||
const [active, setActive] = useState<RoomView | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.listRooms()
|
||||
.then((r) => { setRooms(r); setErr(null); })
|
||||
.catch((e: ApiError) => setErr(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Title order={3}>Rooms</Title>
|
||||
{rooms && <Badge color="brand" variant="light">{rooms.length}</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={createCtl.open}>
|
||||
Crear room
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{err && <Text c="red">{err}</Text>}
|
||||
{!rooms && !err && <Loader color="brand" />}
|
||||
{rooms && rooms.length === 0 && (
|
||||
<Text c="dimmed">El admin no posee ni pertenece a ninguna room todavía.</Text>
|
||||
)}
|
||||
|
||||
{rooms && rooms.length > 0 && (
|
||||
<Card withBorder bg="dark.7" p={0} radius="md">
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Subject</Table.Th>
|
||||
<Table.Th>Modo</Table.Th>
|
||||
<Table.Th>Persist</Table.Th>
|
||||
<Table.Th>Firmado</Table.Th>
|
||||
<Table.Th>Epoch</Table.Th>
|
||||
<Table.Th>Rol</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rooms.map((r) => (
|
||||
<Table.Tr key={r.room_id}>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{r.subject}</Text>
|
||||
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>{trunc(r.room_id, 14, 4)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.encrypt ? (
|
||||
<Badge color="teal" variant="light" leftSection={<IconLock size={12} />}>E2E</Badge>
|
||||
) : (
|
||||
<Badge color="orange" variant="light" leftSection={<IconLockOpen size={12} />}>cleartext</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td><Text size="sm" c="dimmed">{r.persist ? "sí" : "no"}</Text></Table.Td>
|
||||
<Table.Td><Text size="sm" c="dimmed">{r.sign_msgs ? "sí" : "no"}</Text></Table.Td>
|
||||
<Table.Td><Badge variant="default">{r.epoch}</Badge></Table.Td>
|
||||
<Table.Td><Badge variant="dot" color={r.role === "owner" ? "brand" : "gray"}>{r.role}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Ver miembros / claves">
|
||||
<ActionIcon variant="subtle" color="brand" onClick={() => setActive(r)}>
|
||||
<IconUsers size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<CreateRoomModal opened={createOpen} onClose={createCtl.close} onCreated={load} />
|
||||
<MembersDrawer room={active} onClose={() => setActive(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateRoomModal({ opened, onClose, onCreated }: { opened: boolean; onClose: () => void; onCreated: () => void }) {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [encrypt, setEncrypt] = useState(true);
|
||||
const [persist, setPersist] = useState(true);
|
||||
const [sign, setSign] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.createRoom({ subject: subject.trim(), encrypt, persist, sign_msgs: sign });
|
||||
notifications.show({ color: "teal", title: "Room creada", message: `${r.subject} (${trunc(r.room_id, 12, 4)})` });
|
||||
setSubject("");
|
||||
onClose();
|
||||
onCreated();
|
||||
} catch (e) {
|
||||
notifyErr(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Crear room" centered>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Subject"
|
||||
description="Identificador del canal en NATS (ej. team.general)"
|
||||
placeholder="team.general"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<Switch
|
||||
label="Cifrado de extremo a extremo (E2E)"
|
||||
description="Recomendado. En despliegue público los nodos rechazan rooms en claro."
|
||||
checked={encrypt}
|
||||
onChange={(e) => setEncrypt(e.currentTarget.checked)}
|
||||
/>
|
||||
<Switch label="Persistente (JetStream / historial)" checked={persist} onChange={(e) => setPersist(e.currentTarget.checked)} />
|
||||
<Switch label="Mensajes firmados" checked={sign} onChange={(e) => setSign(e.currentTarget.checked)} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={busy} disabled={subject.trim().length === 0}>Crear</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersDrawer({ room, onClose }: { room: RoomView | null; onClose: () => void }) {
|
||||
const [members, setMembers] = useState<MemberView[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [inviteOpen, inviteCtl] = useDisclosure(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!room) return;
|
||||
setMembers(null);
|
||||
setErr(null);
|
||||
api
|
||||
.listMembers(room.room_id)
|
||||
.then(setMembers)
|
||||
.catch((e: ApiError) => setErr(e.message));
|
||||
}, [room]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const kick = async (endpoint: string) => {
|
||||
if (!room) return;
|
||||
if (!window.confirm(`¿Expulsar a ${endpoint}? Esto rota la clave de la room (epoch nuevo) y el expulsado deja de poder descifrar.`)) return;
|
||||
try {
|
||||
await api.kick(room.room_id, endpoint);
|
||||
notifications.show({ color: "teal", title: "Rekey", message: `Miembro expulsado y clave rotada` });
|
||||
load();
|
||||
} catch (e) {
|
||||
notifyErr(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={room !== null}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size="lg"
|
||||
title={room ? <Text fw={700}>{room.subject}</Text> : ""}
|
||||
>
|
||||
{room && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">epoch {room.epoch} · {room.encrypt ? "E2E" : "cleartext"}</Text>
|
||||
<Button size="xs" leftSection={<IconUserPlus size={14} />} onClick={inviteCtl.open} disabled={room.role !== "owner"}>
|
||||
Invitar
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{err && <Text c="red" size="sm">{err}</Text>}
|
||||
{!members && !err && <Loader color="brand" size="sm" />}
|
||||
|
||||
{members && (
|
||||
<Table verticalSpacing="xs" horizontalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Endpoint</Table.Th>
|
||||
<Table.Th>Rol</Table.Th>
|
||||
<Table.Th>sign_pub</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{members.map((m) => (
|
||||
<Table.Tr key={m.endpoint}>
|
||||
<Table.Td><Text size="sm" style={{ fontFamily: "monospace" }}>{trunc(m.endpoint, 14, 6)}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="dot" color={m.role === "owner" ? "brand" : "gray"}>{m.role}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={m.sign_pub}>
|
||||
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>{trunc(m.sign_pub, 10, 6)}</Text>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{m.role !== "owner" && room.role === "owner" && (
|
||||
<Tooltip label="Expulsar + rekey">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => kick(m.endpoint)}>
|
||||
<IconUserMinus size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
<InviteModal room={room} opened={inviteOpen} onClose={inviteCtl.close} onInvited={load} />
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteModal({ room, opened, onClose, onInvited }: { room: RoomView; opened: boolean; onClose: () => void; onInvited: () => void }) {
|
||||
const [endpoint, setEndpoint] = useState("");
|
||||
const [signPub, setSignPub] = useState("");
|
||||
const [kexPub, setKexPub] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.invite(room.room_id, { endpoint: endpoint.trim(), sign_pub: signPub.trim(), kex_pub: kexPub.trim() });
|
||||
notifications.show({ color: "teal", title: "Invitado", message: "Clave de room sellada para el nuevo miembro" });
|
||||
setEndpoint(""); setSignPub(""); setKexPub("");
|
||||
onClose();
|
||||
onInvited();
|
||||
} catch (e) {
|
||||
notifyErr(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ready = signPub.trim().length === 64 && kexPub.trim().length === 64;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={`Invitar a ${room.subject}`} centered>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Para una room E2E la clave se sella contra la clave X25519 del invitado, por eso se piden ambas claves públicas (hex de 64 chars).
|
||||
</Text>
|
||||
<TextInput label="Endpoint (opcional)" description="Se deriva de sign_pub si se deja vacío" value={endpoint} onChange={(e) => setEndpoint(e.currentTarget.value)} />
|
||||
<TextInput label="sign_pub (hex, 64)" value={signPub} onChange={(e) => setSignPub(e.currentTarget.value)} error={signPub.length > 0 && signPub.trim().length !== 64 ? "64 chars hex" : undefined} />
|
||||
<TextInput label="kex_pub (hex, 64)" value={kexPub} onChange={(e) => setKexPub(e.currentTarget.value)} error={kexPub.length > 0 && kexPub.trim().length !== 64 ? "64 chars hex" : undefined} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={busy} disabled={!ready}>Invitar</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createTheme, type MantineColorsTuple } from "@mantine/core";
|
||||
|
||||
// Acento de marca de unibus — el mismo violeta-índigo que la web del bus, para
|
||||
// que el panel de administración y el cliente compartan identidad visual.
|
||||
const brand: MantineColorsTuple = [
|
||||
"#f1edff",
|
||||
"#dcd3ff",
|
||||
"#b5a3f5",
|
||||
"#8d70ed",
|
||||
"#6c47e6",
|
||||
"#5a2fe2",
|
||||
"#5023e0",
|
||||
"#4119c7",
|
||||
"#3915b3",
|
||||
"#2f0f9e",
|
||||
];
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: "brand",
|
||||
colors: { brand },
|
||||
fontFamily:
|
||||
"Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
defaultRadius: "md",
|
||||
headings: { fontWeight: "650" },
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Wire types mirroring the gateway's REST shapes (internal/admin/repo.go). The
|
||||
// browser only ever sees these — never raw keys or NATS frames.
|
||||
|
||||
export interface Posture {
|
||||
enforce: boolean;
|
||||
acl: boolean;
|
||||
tls: boolean;
|
||||
cluster: boolean;
|
||||
store: string;
|
||||
}
|
||||
|
||||
export interface NodeHealth {
|
||||
name: string;
|
||||
url: string;
|
||||
up: boolean;
|
||||
posture: Posture;
|
||||
latency_ms: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RoomView {
|
||||
room_id: string;
|
||||
subject: string;
|
||||
epoch: number;
|
||||
encrypt: boolean;
|
||||
persist: boolean;
|
||||
sign_msgs: boolean;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface MemberView {
|
||||
endpoint: string;
|
||||
role: string;
|
||||
sign_pub: string;
|
||||
kex_pub: string;
|
||||
}
|
||||
|
||||
export interface UserView {
|
||||
sign_pub: string;
|
||||
handle: string;
|
||||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
revoked_at?: string;
|
||||
}
|
||||
|
||||
export interface MeInfo {
|
||||
endpoint: string;
|
||||
sign_pub: string;
|
||||
users_backend: string; // "sqlite" | "kv" | "none"
|
||||
mock: boolean;
|
||||
}
|
||||
|
||||
export interface CreateRoomReq {
|
||||
subject: string;
|
||||
encrypt: boolean;
|
||||
persist: boolean;
|
||||
sign_msgs: boolean;
|
||||
}
|
||||
|
||||
export interface InviteReq {
|
||||
endpoint: string;
|
||||
sign_pub: string;
|
||||
kex_pub: string;
|
||||
}
|
||||
|
||||
export interface AddUserReq {
|
||||
sign_pub: string;
|
||||
handle: string;
|
||||
role: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Small presentation helpers shared across pages.
|
||||
|
||||
// trunc shortens a long hex key to head…tail for dense tables, keeping enough
|
||||
// to recognize it while staying copy-friendly via the full value in a tooltip.
|
||||
export function trunc(s: string, head = 10, tail = 6): string {
|
||||
if (!s) return "";
|
||||
if (s.length <= head + tail + 1) return s;
|
||||
return `${s.slice(0, head)}…${s.slice(-tail)}`;
|
||||
}
|
||||
|
||||
// fmtTime renders an RFC3339 / ISO timestamp as a compact local datetime, or
|
||||
// returns the raw string when it is not parseable.
|
||||
export function fmtTime(s: string): string {
|
||||
if (!s) return "—";
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return s;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
Reference in New Issue
Block a user