import { useCallback, useEffect, useState } from "react"; import { ActionIcon, Alert, Badge, Button, Card, CopyButton, Group, Loader, Modal, NumberInput, Select, Stack, Table, Text, TextInput, Title, Tooltip, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertTriangle, IconCheck, IconCopy, IconPlus, IconRefresh, IconTicket, IconTrash, IconUserOff, } from "@tabler/icons-react"; import { api, ApiError } from "../api"; 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) }); } // 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(null); const [invites, setInvites] = useState([]); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); const [addOpen, addCtl] = useDisclosure(false); const [createOpen, createCtl] = useDisclosure(false); const [toDelete, setToDelete] = useState(null); const load = useCallback(() => { setLoading(true); 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)); }, []); 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). 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 }); load(); } catch (e) { notifyErr(e); } }; return ( Users {users && {users.length}} backend: {usersBackend} {err && {err}} {!users && !err && } {invites.length > 0 && ( Invitaciones pendientes {invites.length} Handle Rol Token Caduca Enlace {invites.map((inv) => ( {inv.handle} {inv.role} {trunc(inv.token, 10, 6)} {fmtTime(inv.expires_at)} {({ copied, copy }) => ( )} ))}
)} {users && ( Handle Rol Estado sign_pub Creado {users.map((u) => ( {u.handle} {u.role} {u.status} {trunc(u.sign_pub, 12, 8)} {fmtTime(u.created_at)} {u.status === "active" && ( revoke(u)}> )} setToDelete(u)}> ))}
)} setToDelete(null)} onDeleted={load} />
); } // 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(""); const [role, setRole] = useState("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 ( Para cuando ya tienes la clave pública del usuario. Para el alta normal sin manejar claves, usa «Crear usuario» (enlace de invitación). setHandle(e.currentTarget.value)} data-autofocus /> setSignPub(e.currentTarget.value)} error={signPub.length > 0 && signPub.trim().length !== 64 ? "64 chars hex" : undefined} /> setRole(v || "member")} allowDeselect={false} /> ) : ( } 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)}. {!joinBaseURL && ( } title="Base URL del cliente sin configurar"> El enlace usa el origen de este panel como respaldo. Configura el cliente real con --join-base-url o la variable UNIBUS_JOIN_BASE_URL en el gateway. )} {({ copied, copy }) => ( )} )} ); } // 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 ( } title="Esto NO es revocar"> Vas a BORRAR PERMANENTEMENTE 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. setConfirm(e.currentTarget.value)} data-autofocus /> ); }