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