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("generating"); const [mnemonic, setMnemonic] = useState(""); const [identity, setIdentity] = useState(null); const [error, setError] = useState(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 ( } title="Error"> {error} ); } if (step === "generating" || !identity) { return (
); } if (step === "show-seed") { return ( setStep("confirm-seed")} /> ); } if (step === "confirm-seed") { return ( setStep("show-seed")} onConfirmed={() => setStep("password")} /> ); } // step === "password" | "joining" return ( { 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 ( } 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." /> {words.map((w, i) => ( {i + 1} {w} ))} {({ copied, copy }) => ( )} }> unibus NO guarda esta frase. Si la pierdes y olvidas la contraseña, solo el administrador podrá darte de alta de nuevo. setAcknowledged(e.currentTarget.checked)} label="He guardado mi frase de recuperación en un lugar seguro" /> ); } 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>({}); const allCorrect = positions.every( (p) => (inputs[p] ?? "").trim().toLowerCase() === words[p], ); const anyTyped = positions.some((p) => (inputs[p] ?? "").length > 0); return ( } title="Confirma tu frase" subtitle="Escribe las palabras solicitadas para confirmar que la guardaste bien." /> {positions.map((p) => ( 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} /> ))} {!allCorrect && anyTyped && ( Revisa el orden y la ortografía de las palabras. )} ); } 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 ( } 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." /> } value={pw} error={tooShort ? "Demasiado corta" : undefined} onChange={(e) => setPw(e.currentTarget.value)} data-autofocus /> } value={pw2} error={mismatch ? "No coincide" : undefined} onChange={(e) => setPw2(e.currentTarget.value)} onKeyDown={(e) => e.key === "Enter" && ready && onSubmit(pw)} /> {error && ( {error} )} ); }