4994ea1483
Add the device-local wallet onboarding to the SPA. The user's identity is derived deterministically from a 12-word BIP39 mnemonic and lives on the device; the browser never signs, never talks NATS, and never sends the seed to the server. Wallet layer (web/src/wallet/): - derive.ts: deterministic identity from a mnemonic. seed = BIP39 seed, then HKDF-SHA256 domain-separated into an Ed25519 signing key (info "unibus-sign-v1") and an X25519 key-exchange key (info "unibus-kex-v1"). The same mnemonic always yields the same sign_pub, which is what makes recovery possible without admin intervention. The four halves match cs.Identity on the Go side exactly. - bip39.ts: thin wrappers over @scure/bip39 (generate, validate, normalize) so the checksum logic stays in the audited library. - crypto.ts: at-rest encryption of the private key with WebCrypto only (PBKDF2-SHA256 210k iters -> AES-256-GCM). The password never leaves the device and only protects the local key copy. - store.ts: IndexedDB persistence of the encrypted identity (private key encrypted; public halves + handle in the clear for display). - account.ts: saveAndOpen / unlockAndOpen / localIdentity compose the primitives with the gateway session API. Screens: - Welcome: choose invite link or recover-with-seed on an empty device. - Join: generate seed, show it once behind an acknowledge gate, confirm 3 random words, set a local password, register the PUBLIC key with the bus via the invite token, then open the session. - Recover: paste the 12 words, validate, show the reconstructed sign_pub, set a new local password, open the session. No register (the identity is already in the allowlist). - WalletLogin: unlock the device's stored identity with the password. - AuthShell: shared card/header for all pre-chat screens. - App.tsx: route between join / welcome / login / recover / chat based on the invite link, a live gateway session, and any stored identity. api.ts/types.ts: add register() and session() against the gateway contract; vite dev server on :5183. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
71 lines
2.2 KiB
TypeScript
71 lines
2.2 KiB
TypeScript
import { useState } from "react";
|
|
import { Button, Divider, Stack, Text, TextInput } from "@mantine/core";
|
|
import { IconLink, IconRotateClockwise, IconShieldLock } from "@tabler/icons-react";
|
|
import { AuthCard, AuthHeader } from "./AuthShell";
|
|
|
|
// extractToken pulls the invite token out of whatever the user pastes: a full
|
|
// link (.../join?token=XXX), a bare "token=XXX", or just the token itself.
|
|
function extractToken(input: string): string {
|
|
const s = input.trim();
|
|
if (!s) return "";
|
|
const m = s.match(/[?&]token=([^&\s]+)/);
|
|
if (m) return decodeURIComponent(m[1]);
|
|
if (s.startsWith("token=")) return s.slice("token=".length);
|
|
return s;
|
|
}
|
|
|
|
// Welcome is the entry screen on a device with no local identity. It offers the
|
|
// two ways in: open an invite link (new account) or recover an existing account
|
|
// from its 12-word seed.
|
|
export function Welcome({
|
|
onJoinToken,
|
|
onRecover,
|
|
}: {
|
|
onJoinToken: (token: string) => void;
|
|
onRecover: () => void;
|
|
}) {
|
|
const [link, setLink] = useState("");
|
|
const token = extractToken(link);
|
|
|
|
return (
|
|
<AuthCard width={420}>
|
|
<AuthHeader
|
|
icon={<IconShieldLock size={30} />}
|
|
title="unibus"
|
|
subtitle="Mensajería cifrada de extremo a extremo. Tu identidad vive en tu dispositivo."
|
|
/>
|
|
|
|
<Stack gap="xs">
|
|
<Text size="sm" fw={600}>
|
|
Tengo un enlace de invitación
|
|
</Text>
|
|
<TextInput
|
|
placeholder="Pega aquí tu enlace /join?token=…"
|
|
leftSection={<IconLink size={16} />}
|
|
value={link}
|
|
onChange={(e) => setLink(e.currentTarget.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && token && onJoinToken(token)}
|
|
/>
|
|
<Button disabled={!token} onClick={() => onJoinToken(token)}>
|
|
Crear mi cuenta
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Divider label="o" labelPosition="center" color="dark.4" />
|
|
|
|
<Stack gap="xs">
|
|
<Text size="sm" fw={600}>
|
|
Ya tengo una cuenta
|
|
</Text>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<IconRotateClockwise size={16} />}
|
|
onClick={onRecover}
|
|
>
|
|
Recuperar con mi seed (12 palabras)
|
|
</Button>
|
|
</Stack>
|
|
</AuthCard>
|
|
);
|
|
}
|