feat: initial scaffold of uniweb — unibus web frontend (SPA + gateway)

Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user
BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus
peer for the browser. Consumes unibus as a Go module via replace => ../unibus,
keeping its own replace fn-registry for the cybersecurity primitives.

go build/vet/test and pnpm build green in the new location.
This commit is contained in:
agent
2026-06-13 21:23:10 +02:00
commit e8e37d77fe
42 changed files with 5439 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Card,
Center,
Checkbox,
CopyButton,
Group,
Loader,
PasswordInput,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCheck,
IconCopy,
IconKey,
IconShieldLock,
} from "@tabler/icons-react";
import { api, ApiError } from "./api";
import { AuthCard, AuthHeader } from "./AuthShell";
import type { User } from "./types";
import { newMnemonic, mnemonicWords } from "./wallet/bip39";
import { deriveIdentity, type WalletIdentity } from "./wallet/derive";
import { saveAndOpen } from "./wallet/account";
type Step = "generating" | "show-seed" | "confirm-seed" | "password" | "joining";
// pickPositions chooses `count` distinct word positions (0-based) to ask the user
// to confirm. This is a UI choice, not key material, so Math.random is fine.
function pickPositions(total: number, count: number): number[] {
const all = Array.from({ length: total }, (_, i) => i);
for (let i = all.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[all[i], all[j]] = [all[j], all[i]];
}
return all.slice(0, count).sort((a, b) => a - b);
}
// Join is the onboarding page reached from an invite link (/join?token=XXX). It
// generates a brand-new BIP39 seed, derives the identity, shows the seed exactly
// once with a confirmation gate, takes a local password, registers the PUBLIC key
// with the bus using the token, and enters the chat. The seed is never persisted
// and never sent to the server.
export function Join({
token,
onJoined,
onRecover,
}: {
token: string;
onJoined: (u: User) => void;
onRecover: () => void;
}) {
const [step, setStep] = useState<Step>("generating");
const [mnemonic, setMnemonic] = useState("");
const [identity, setIdentity] = useState<WalletIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
// Generate the seed + identity once on mount. Deriving is fast and pure.
useEffect(() => {
if (!token) {
setError("Enlace de invitación inválido: falta el token.");
return;
}
try {
const m = newMnemonic();
setMnemonic(m);
setIdentity(deriveIdentity(m));
setStep("show-seed");
} catch {
setError("No se pudo generar la identidad en este navegador.");
}
}, [token]);
const words = useMemo(() => mnemonicWords(mnemonic), [mnemonic]);
if (error && step === "generating") {
return (
<AuthCard>
<Alert color="red" icon={<IconAlertTriangle size={18} />} title="Error">
{error}
</Alert>
<Button variant="light" mt="md" onClick={onRecover}>
Recuperar con mi seed
</Button>
</AuthCard>
);
}
if (step === "generating" || !identity) {
return (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
}
if (step === "show-seed") {
return (
<ShowSeed words={words} onContinue={() => setStep("confirm-seed")} />
);
}
if (step === "confirm-seed") {
return (
<ConfirmSeed
words={words}
onBack={() => setStep("show-seed")}
onConfirmed={() => setStep("password")}
/>
);
}
// step === "password" | "joining"
return (
<SetPassword
busy={step === "joining"}
error={error}
onSubmit={async (password) => {
setStep("joining");
setError(null);
try {
// Register the PUBLIC identity with the bus (token authorizes), then
// encrypt the private key locally and open the per-user session.
const res = await api.register(token, identity.signPub, identity.kexPub);
const user = await saveAndOpen(identity, res.handle, password);
onJoined(user);
} catch (e) {
setError(
e instanceof ApiError ? e.message : "No se pudo completar el alta.",
);
setStep("password");
}
}}
/>
);
}
// ---- sub-screens ----------------------------------------------------------
function ShowSeed({
words,
onContinue,
}: {
words: string[];
onContinue: () => void;
}) {
const [acknowledged, setAcknowledged] = useState(false);
const phrase = words.join(" ");
return (
<AuthCard>
<AuthHeader
icon={<IconShieldLock size={30} />}
title="Guarda tu frase de recuperación"
subtitle="Estas 12 palabras son tu ÚNICA forma de recuperar tu cuenta si olvidas la contraseña o cambias de dispositivo. No las compartas con nadie."
/>
<Card bg="dark.8" radius="md" p="md" withBorder>
<SimpleGrid cols={3} spacing="xs" verticalSpacing="xs">
{words.map((w, i) => (
<Group gap={6} wrap="nowrap" key={i}>
<Text size="xs" c="dimmed" w={18} ta="right">
{i + 1}
</Text>
<Text size="sm" ff="monospace" fw={600}>
{w}
</Text>
</Group>
))}
</SimpleGrid>
</Card>
<Group justify="space-between">
<CopyButton value={phrase}>
{({ copied, copy }) => (
<Button
variant="subtle"
size="xs"
color={copied ? "teal" : "gray"}
leftSection={
copied ? <IconCheck size={14} /> : <IconCopy size={14} />
}
onClick={copy}
>
{copied ? "Copiada" : "Copiar"}
</Button>
)}
</CopyButton>
</Group>
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
unibus NO guarda esta frase. Si la pierdes y olvidas la contraseña, solo
el administrador podrá darte de alta de nuevo.
</Alert>
<Checkbox
checked={acknowledged}
onChange={(e) => setAcknowledged(e.currentTarget.checked)}
label="He guardado mi frase de recuperación en un lugar seguro"
/>
<Button disabled={!acknowledged} onClick={onContinue}>
Continuar
</Button>
</AuthCard>
);
}
function ConfirmSeed({
words,
onBack,
onConfirmed,
}: {
words: string[];
onBack: () => void;
onConfirmed: () => void;
}) {
// Ask the user to re-type 3 random words from their phrase. This proves they
// actually wrote the seed down rather than clicking through.
const positions = useMemo(() => pickPositions(words.length, 3), [words.length]);
const [inputs, setInputs] = useState<Record<number, string>>({});
const allCorrect = positions.every(
(p) => (inputs[p] ?? "").trim().toLowerCase() === words[p],
);
const anyTyped = positions.some((p) => (inputs[p] ?? "").length > 0);
return (
<AuthCard>
<AuthHeader
icon={<IconCheck size={30} />}
title="Confirma tu frase"
subtitle="Escribe las palabras solicitadas para confirmar que la guardaste bien."
/>
<Stack gap="sm">
{positions.map((p) => (
<TextInput
key={p}
label={`Palabra #${p + 1}`}
placeholder={`palabra ${p + 1}`}
value={inputs[p] ?? ""}
error={
(inputs[p] ?? "").length > 0 &&
(inputs[p] ?? "").trim().toLowerCase() !== words[p]
? "No coincide"
: undefined
}
onChange={(e) => {
// Capture the value synchronously: React nulls e.currentTarget
// after dispatch, so reading it inside the state updater (which runs
// later) would throw "Cannot read properties of null".
const v = e.currentTarget.value;
setInputs((prev) => ({ ...prev, [p]: v }));
}}
autoComplete="off"
spellCheck={false}
/>
))}
</Stack>
{!allCorrect && anyTyped && (
<Text size="xs" c="dimmed">
Revisa el orden y la ortografía de las palabras.
</Text>
)}
<Group grow>
<Button variant="default" onClick={onBack}>
Volver a ver
</Button>
<Button disabled={!allCorrect} onClick={onConfirmed}>
Confirmar
</Button>
</Group>
</AuthCard>
);
}
function SetPassword({
busy,
error,
onSubmit,
}: {
busy: boolean;
error: string | null;
onSubmit: (password: string) => void;
}) {
const [pw, setPw] = useState("");
const [pw2, setPw2] = useState("");
const tooShort = pw.length > 0 && pw.length < 8;
const mismatch = pw2.length > 0 && pw !== pw2;
const ready = pw.length >= 8 && pw === pw2 && !busy;
return (
<AuthCard>
<AuthHeader
icon={<IconKey size={30} />}
title="Protege tu identidad"
subtitle="Elige una contraseña para cifrar tu clave en ESTE dispositivo. No se guarda ni se envía a ningún servidor; solo desbloquea tu clave local."
/>
<PasswordInput
label="Contraseña"
description="Mínimo 8 caracteres"
leftSection={<IconKey size={16} />}
value={pw}
error={tooShort ? "Demasiado corta" : undefined}
onChange={(e) => setPw(e.currentTarget.value)}
data-autofocus
/>
<PasswordInput
label="Repite la contraseña"
leftSection={<IconKey size={16} />}
value={pw2}
error={mismatch ? "No coincide" : undefined}
onChange={(e) => setPw2(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && ready && onSubmit(pw)}
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button disabled={!ready} loading={busy} onClick={() => onSubmit(pw)}>
Crear cuenta y entrar
</Button>
</AuthCard>
);
}