3f52167b04
Phase 2 of issue 0001. uniweb becomes a pure frontend (web/ only), like unibus_android: the SPA talks directly to the bus and the Go gateway is gone. - busService.ts: the new data layer over the bus SDK, replacing the old api module. It holds the user's wallet identity and a connected BusClient IN THE BROWSER and opens the session locally — the private key is never sent anywhere (closes the gateway-era hole where the browser POSTed its private key to /api/session). - Wire account/App/ChatShell/ChatPanel/WalletLogin/Recover/Join to busService; subscribeRoom replaces the SSE streamRoom; ApiError -> SessionError. - SDK: ControlPlane.createRoom + listMemberRooms, and fetchRoom mapped to the real control-plane wire shape (snake_case, no id) — all verified by the live round-trip. - Delete cmd/webgw, go.mod, go.sum, src/api.ts and the orphan operator Login. uniweb now has zero Go and no dependency on unibus as a module. - vite: drop the /api proxy, dev server on 5173 to match the bus CORS allowlist; add vite-env typings. app.md: lang ts, no uses_functions, e2e_checks are now web-only. Bump 0.3.0. Onboarding by token is now admin-side (the bus has no self-register endpoint; the gateway only mocked it). tsc + pnpm build + 19/19 unit green.
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
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 { SessionError } from "./busService";
|
|
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 {
|
|
// The bus has no token-register endpoint (that was a gateway mock): a
|
|
// browser cannot self-register on an enforce cluster. The identity must be
|
|
// allow-listed by an admin first. We persist it locally and try to open the
|
|
// session; if the identity is not yet authorized, openSession fails and we
|
|
// tell the user to have an admin authorize their public key.
|
|
const handle = identity.signPub.slice(0, 8);
|
|
const user = await saveAndOpen(identity, handle, password);
|
|
onJoined(user);
|
|
} catch (e) {
|
|
const base =
|
|
e instanceof SessionError || e instanceof Error
|
|
? e.message
|
|
: "No se pudo completar el alta.";
|
|
setError(
|
|
`${base}. Pide a un administrador que autorice tu clave pública: ${identity.signPub}`,
|
|
);
|
|
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>
|
|
);
|
|
}
|