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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user