feat(spa): create user via invite link, permanent delete, pending invites
Users tab gains the wallet-model account flow:
- "Crear usuario" button -> modal (handle + role + expiry) -> POST /api/invites
-> shows the copyable single-use join link (<client-base>/join?token=…). Warns
when the gateway has no client base URL configured (falls back to the panel's
own origin).
- Per-row "Eliminar" -> STRONG confirmation modal that requires typing the handle
and spells out the permanence and the difference from revoke -> DELETE
/api/users/{pub}.
- Pending invites card: handle, role, partial token, expiry, copy-link.
Includes the rebuilt embedded SPA bundle (web/dist) so the Go binary ships the
new UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
-158
File diff suppressed because one or more lines are too long
Vendored
+191
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
<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-CGRScjCy.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Dg19WJJu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ndvieWwa.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function AdminShell({ me }: { me: MeInfo }) {
|
||||
<AppShell.Main>
|
||||
{tab === "cluster" && <ClusterPage />}
|
||||
{tab === "rooms" && <RoomsPage />}
|
||||
{tab === "users" && <UsersPage usersBackend={me.users_backend} />}
|
||||
{tab === "users" && <UsersPage usersBackend={me.users_backend} joinBaseURL={me.join_base_url} />}
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
// and real, and never signs or speaks NATS itself.
|
||||
import type {
|
||||
AddUserReq,
|
||||
CreateInviteReq,
|
||||
CreateRoomReq,
|
||||
InviteReq,
|
||||
InviteView,
|
||||
MeInfo,
|
||||
MemberView,
|
||||
NodeHealth,
|
||||
@@ -76,4 +78,15 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sign_pub: signPub }),
|
||||
}),
|
||||
deleteUser: (signPub: string) =>
|
||||
req<{ status: string }>(`/api/users/${encodeURIComponent(signPub)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
listInvites: () => req<InviteView[]>("/api/invites"),
|
||||
createInvite: (r: CreateInviteReq) =>
|
||||
req<InviteView>("/api/invites", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(r),
|
||||
}),
|
||||
};
|
||||
|
||||
+243
-15
@@ -1,12 +1,15 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CopyButton,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -17,26 +20,49 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconPlus, IconRefresh, IconUserOff } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconTicket,
|
||||
IconTrash,
|
||||
IconUserOff,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { UserView } from "../types";
|
||||
import type { InviteView, 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 }) {
|
||||
// resolveJoinURL prefers the gateway-built link; when the gateway has no client
|
||||
// base URL configured it falls back to the panel's own origin so the link is at
|
||||
// least clickable, and the caller surfaces a warning to configure it properly.
|
||||
function resolveJoinURL(inv: { join_url: string; token: string }): string {
|
||||
if (inv.join_url) return inv.join_url;
|
||||
return `${window.location.origin}/join?token=${inv.token}`;
|
||||
}
|
||||
|
||||
export function UsersPage({ usersBackend, joinBaseURL }: { usersBackend: string; joinBaseURL: string }) {
|
||||
const [users, setUsers] = useState<UserView[] | null>(null);
|
||||
const [invites, setInvites] = useState<InviteView[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addOpen, addCtl] = useDisclosure(false);
|
||||
const [createOpen, createCtl] = useDisclosure(false);
|
||||
const [toDelete, setToDelete] = useState<UserView | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.listUsers()
|
||||
.then((u) => { setUsers(u); setErr(null); })
|
||||
Promise.all([api.listUsers(), api.listInvites().catch(() => [] as InviteView[])])
|
||||
.then(([u, inv]) => {
|
||||
setUsers(u);
|
||||
setInvites(inv);
|
||||
setErr(null);
|
||||
})
|
||||
.catch((e: ApiError) => setErr(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -44,7 +70,7 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
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;
|
||||
if (!window.confirm(`¿Revocar a "${u.handle}"? Pierde acceso al bus en AMBOS planos de inmediato (control y datos). La identidad permanece en la lista como revocada (auditable).`)) return;
|
||||
try {
|
||||
await api.revokeUser(u.sign_pub);
|
||||
notifications.show({ color: "teal", title: "Revocado", message: u.handle });
|
||||
@@ -72,8 +98,11 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={addCtl.open}>
|
||||
Añadir user
|
||||
<Button variant="default" leftSection={<IconPlus size={16} />} onClick={addCtl.open}>
|
||||
Añadir user (clave conocida)
|
||||
</Button>
|
||||
<Button leftSection={<IconTicket size={16} />} onClick={createCtl.open}>
|
||||
Crear usuario
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -81,6 +110,48 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
{err && <Text c="red">{err}</Text>}
|
||||
{!users && !err && <Loader color="brand" />}
|
||||
|
||||
{invites.length > 0 && (
|
||||
<Card withBorder bg="dark.7" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
<IconTicket size={18} />
|
||||
<Text fw={600}>Invitaciones pendientes</Text>
|
||||
<Badge color="brand" variant="light">{invites.length}</Badge>
|
||||
</Group>
|
||||
<Table verticalSpacing="xs" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Handle</Table.Th>
|
||||
<Table.Th>Rol</Table.Th>
|
||||
<Table.Th>Token</Table.Th>
|
||||
<Table.Th>Caduca</Table.Th>
|
||||
<Table.Th>Enlace</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{invites.map((inv) => (
|
||||
<Table.Tr key={inv.token}>
|
||||
<Table.Td><Text fw={600}>{inv.handle}</Text></Table.Td>
|
||||
<Table.Td><Badge variant="dot" color={inv.role === "admin" ? "brand" : "gray"}>{inv.role}</Badge></Table.Td>
|
||||
<Table.Td><Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>{trunc(inv.token, 10, 6)}</Text></Table.Td>
|
||||
<Table.Td><Text size="xs" c="dimmed">{fmtTime(inv.expires_at)}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<CopyButton value={resolveJoinURL(inv)}>
|
||||
{({ copied, copy }) => (
|
||||
<Button size="xs" variant="light" color={copied ? "teal" : "brand"} leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />} onClick={copy}>
|
||||
{copied ? "Copiado" : "Copiar enlace"}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{users && (
|
||||
<Card withBorder bg="dark.7" p={0} radius="md">
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
|
||||
@@ -109,13 +180,20 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
</Table.Td>
|
||||
<Table.Td><Text size="xs" c="dimmed">{fmtTime(u.created_at)}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
{u.status === "active" && (
|
||||
<Tooltip label="Revocar acceso">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => revoke(u)}>
|
||||
<IconUserOff size={16} />
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
{u.status === "active" && (
|
||||
<Tooltip label="Revocar acceso (deja rastro auditable)">
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => revoke(u)}>
|
||||
<IconUserOff size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Eliminar (borrado permanente)">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => setToDelete(u)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -125,10 +203,14 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
|
||||
)}
|
||||
|
||||
<AddUserModal opened={addOpen} onClose={addCtl.close} onAdded={load} />
|
||||
<CreateInviteModal opened={createOpen} onClose={createCtl.close} onCreated={load} joinBaseURL={joinBaseURL} />
|
||||
<DeleteUserModal user={toDelete} onClose={() => setToDelete(null)} onDeleted={load} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// AddUserModal registers a user from a sign_pub the admin already holds (the
|
||||
// pre-invite flow, kept for advanced use). The wallet-model path is CreateInvite.
|
||||
function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose: () => void; onAdded: () => void }) {
|
||||
const [handle, setHandle] = useState("");
|
||||
const [signPub, setSignPub] = useState("");
|
||||
@@ -153,8 +235,12 @@ function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose:
|
||||
const ready = handle.trim().length > 0 && signPub.trim().length === 64;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Añadir user al bus" centered>
|
||||
<Modal opened={opened} onClose={onClose} title="Añadir user (clave ya conocida)" centered>
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Para cuando ya tienes la clave pública del usuario. Para el alta normal sin manejar
|
||||
claves, usa «Crear usuario» (enlace de invitación).
|
||||
</Text>
|
||||
<TextInput label="Handle" placeholder="ana" value={handle} onChange={(e) => setHandle(e.currentTarget.value)} data-autofocus />
|
||||
<TextInput
|
||||
label="sign_pub (hex, 64)"
|
||||
@@ -173,3 +259,145 @@ function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose:
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// CreateInviteModal is the wallet-model account creation: the admin fixes a handle
|
||||
// and role and gets back a single-use join link. The admin never sees a private
|
||||
// key — the user generates its own keypair when it opens the link.
|
||||
function CreateInviteModal({
|
||||
opened,
|
||||
onClose,
|
||||
onCreated,
|
||||
joinBaseURL,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
joinBaseURL: string;
|
||||
}) {
|
||||
const [handle, setHandle] = useState("");
|
||||
const [role, setRole] = useState<string>("member");
|
||||
const [days, setDays] = useState<number | string>(7);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [created, setCreated] = useState<InviteView | null>(null);
|
||||
|
||||
const reset = () => { setHandle(""); setRole("member"); setDays(7); setCreated(null); };
|
||||
const close = () => { reset(); onClose(); };
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const ttl = typeof days === "number" ? days * 86400 : 0;
|
||||
const inv = await api.createInvite({ handle: handle.trim(), role, ttl_secs: ttl });
|
||||
setCreated(inv);
|
||||
onCreated();
|
||||
} catch (e) {
|
||||
notifyErr(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ready = handle.trim().length > 0;
|
||||
const joinURL = created ? resolveJoinURL(created) : "";
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title="Crear usuario (enlace de invitación)" centered>
|
||||
{!created ? (
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Genera un enlace de un solo uso. El usuario lo abre en su dispositivo, crea ahí su
|
||||
clave (la privada nunca sale de su equipo) y se registra. Tú no manejas ninguna clave.
|
||||
</Text>
|
||||
<TextInput label="Handle" placeholder="ana" value={handle} onChange={(e) => setHandle(e.currentTarget.value)} data-autofocus />
|
||||
<Select label="Rol" data={[{ value: "member", label: "member" }, { value: "admin", label: "admin" }]} value={role} onChange={(v) => setRole(v || "member")} allowDeselect={false} />
|
||||
<NumberInput label="Caducidad (días)" min={1} max={90} value={days} onChange={setDays} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={close}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={busy} disabled={!ready} leftSection={<IconTicket size={16} />}>Crear enlace</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Alert color="teal" icon={<IconCheck size={16} />} title={`Invitación para "${created.handle}" (${created.role})`}>
|
||||
Comparte este enlace con el usuario. Es de un solo uso y caduca el {fmtTime(created.expires_at)}.
|
||||
</Alert>
|
||||
{!joinBaseURL && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />} title="Base URL del cliente sin configurar">
|
||||
El enlace usa el origen de este panel como respaldo. Configura el cliente real con
|
||||
<Text span fw={600}> --join-base-url </Text> o la variable
|
||||
<Text span fw={600}> UNIBUS_JOIN_BASE_URL </Text> en el gateway.
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput readOnly value={joinURL} styles={{ input: { fontFamily: "monospace", fontSize: 12 } }} />
|
||||
<Group justify="space-between">
|
||||
<CopyButton value={joinURL}>
|
||||
{({ copied, copy }) => (
|
||||
<Button color={copied ? "teal" : "brand"} leftSection={copied ? <IconCheck size={16} /> : <IconCopy size={16} />} onClick={copy}>
|
||||
{copied ? "Copiado" : "Copiar enlace"}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Button variant="default" onClick={close}>Cerrar</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// DeleteUserModal is the STRONG confirmation for a permanent hard-delete. Unlike
|
||||
// revoke (a one-click window.confirm), purging requires typing the handle, so it
|
||||
// cannot be triggered by a stray click. The wording makes the irreversibility and
|
||||
// the difference from revoke explicit.
|
||||
function DeleteUserModal({ user, onClose, onDeleted }: { user: UserView | null; onClose: () => void; onDeleted: () => void }) {
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => { setConfirm(""); }, [user]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteUser(user.sign_pub);
|
||||
notifications.show({ color: "red", title: "Usuario eliminado", message: `${user.handle} purgado del allowlist` });
|
||||
onClose();
|
||||
onDeleted();
|
||||
} catch (e) {
|
||||
notifyErr(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ready = confirm.trim() === user.handle;
|
||||
|
||||
return (
|
||||
<Modal opened={!!user} onClose={onClose} title="Borrado permanente" centered>
|
||||
<Stack gap="sm">
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Esto NO es revocar">
|
||||
<Text size="sm">
|
||||
Vas a <Text span fw={700}>BORRAR PERMANENTEMENTE</Text> a «{user.handle}» del allowlist
|
||||
del bus. Se elimina por completo (sin rastro auditable, a diferencia de revocar). El
|
||||
usuario deja de poder autenticarse en ambos planos; sus membresías de rooms quedan
|
||||
inertes. Esta acción es irreversible.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label={`Escribe «${user.handle}» para confirmar`}
|
||||
placeholder={user.handle}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancelar</Button>
|
||||
<Button color="red" leftSection={<IconTrash size={16} />} onClick={submit} loading={busy} disabled={!ready}>
|
||||
Eliminar permanentemente
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,23 @@ export interface MeInfo {
|
||||
sign_pub: string;
|
||||
users_backend: string; // "sqlite" | "kv" | "none"
|
||||
mock: boolean;
|
||||
join_base_url: string; // base URL of the end-user client (for invite links); "" when unset
|
||||
}
|
||||
|
||||
export interface InviteView {
|
||||
token: string;
|
||||
handle: string;
|
||||
role: string;
|
||||
expires_at: string;
|
||||
used: boolean;
|
||||
created_at: string;
|
||||
join_url: string; // pre-built join link; "" when the gateway has no client base URL
|
||||
}
|
||||
|
||||
export interface CreateInviteReq {
|
||||
handle: string;
|
||||
role: string;
|
||||
ttl_secs: number;
|
||||
}
|
||||
|
||||
export interface CreateRoomReq {
|
||||
|
||||
Reference in New Issue
Block a user