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:
Vendored
+163
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+13
-1
@@ -1 +1,13 @@
|
|||||||
<!doctype html><title>unibus_admin</title><p>build pending</p>
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>unibus · admin</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-D7Qf15Sh.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-ndvieWwa.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>unibus · admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "unibus-admin-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/core": "^9.3.0",
|
||||||
|
"@mantine/hooks": "^9.3.0",
|
||||||
|
"@mantine/notifications": "^9.3.0",
|
||||||
|
"@tabler/icons-react": "^3.36.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"typescript": "~5.6.3",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1567
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-preset-mantine": {},
|
||||||
|
"postcss-simple-vars": {
|
||||||
|
variables: {
|
||||||
|
"mantine-breakpoint-xs": "36em",
|
||||||
|
"mantine-breakpoint-sm": "48em",
|
||||||
|
"mantine-breakpoint-md": "62em",
|
||||||
|
"mantine-breakpoint-lg": "75em",
|
||||||
|
"mantine-breakpoint-xl": "88em",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// The build output (web/dist) is embedded into the Go binary, so the default
|
||||||
|
// outDir `dist` is exactly what embed.go expects. In dev, /api and /healthz are
|
||||||
|
// proxied to the gateway (run it with --mock for sample data) so `pnpm dev`
|
||||||
|
// drives the real handlers.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: { outDir: "dist", emptyOutDir: true },
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5182,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:8480",
|
||||||
|
"/healthz": "http://127.0.0.1:8480",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user