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>
140 lines
3.7 KiB
TypeScript
140 lines
3.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Center, Loader } from "@mantine/core";
|
|
import { ChatShell } from "./ChatShell";
|
|
import { Join } from "./Join";
|
|
import { Recover } from "./Recover";
|
|
import { WalletLogin } from "./WalletLogin";
|
|
import { Welcome } from "./Welcome";
|
|
import { api } from "./api";
|
|
import { localIdentity } from "./wallet/account";
|
|
import type { User } from "./types";
|
|
|
|
type Route = "loading" | "join" | "welcome" | "login" | "recover" | "chat";
|
|
|
|
// readJoinToken returns the invite token if the current URL is /join?token=XXX.
|
|
function readJoinToken(): string | null {
|
|
if (window.location.pathname !== "/join") return null;
|
|
return new URLSearchParams(window.location.search).get("token");
|
|
}
|
|
|
|
// clearUrl drops any /join?token from the address bar once consumed, so a refresh
|
|
// or a shared screenshot does not replay the (single-use) token.
|
|
function clearUrl() {
|
|
if (window.location.pathname !== "/") {
|
|
window.history.replaceState(null, "", "/");
|
|
}
|
|
}
|
|
|
|
export function App() {
|
|
const [route, setRoute] = useState<Route>("loading");
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [token, setToken] = useState("");
|
|
const [storedHandle, setStoredHandle] = useState("");
|
|
|
|
// Decide the entry screen on mount: an invite link goes straight to join; a live
|
|
// gateway session resumes the chat; a device with a stored identity shows the
|
|
// password unlock; an empty device shows the welcome chooser.
|
|
useEffect(() => {
|
|
const t = readJoinToken();
|
|
if (t) {
|
|
setToken(t);
|
|
setRoute("join");
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const me = await api.me();
|
|
if (cancelled) return;
|
|
setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) });
|
|
setRoute("chat");
|
|
return;
|
|
} catch {
|
|
// no live session — fall through
|
|
}
|
|
const stored = await localIdentity();
|
|
if (cancelled) return;
|
|
if (stored) {
|
|
setStoredHandle(stored.handle);
|
|
setRoute("login");
|
|
} else {
|
|
setRoute("welcome");
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const enterChat = (u: User) => {
|
|
setUser(u);
|
|
setRoute("chat");
|
|
clearUrl();
|
|
};
|
|
|
|
const logout = () => {
|
|
void api.logout().catch(() => {});
|
|
setUser(null);
|
|
// Keep the encrypted identity on the device: logging out returns to the
|
|
// password unlock, not a full reset.
|
|
void localIdentity().then((stored) => {
|
|
if (stored) {
|
|
setStoredHandle(stored.handle);
|
|
setRoute("login");
|
|
} else {
|
|
setRoute("welcome");
|
|
}
|
|
});
|
|
};
|
|
|
|
switch (route) {
|
|
case "loading":
|
|
return (
|
|
<Center h="100vh" bg="dark.9">
|
|
<Loader color="brand" />
|
|
</Center>
|
|
);
|
|
case "join":
|
|
return (
|
|
<Join
|
|
token={token}
|
|
onJoined={enterChat}
|
|
onRecover={() => setRoute("recover")}
|
|
/>
|
|
);
|
|
case "welcome":
|
|
return (
|
|
<Welcome
|
|
onJoinToken={(t) => {
|
|
setToken(t);
|
|
setRoute("join");
|
|
}}
|
|
onRecover={() => setRoute("recover")}
|
|
/>
|
|
);
|
|
case "login":
|
|
return (
|
|
<WalletLogin
|
|
handle={storedHandle}
|
|
onLoggedIn={enterChat}
|
|
onRecover={() => setRoute("recover")}
|
|
/>
|
|
);
|
|
case "recover":
|
|
return (
|
|
<Recover
|
|
onRecovered={enterChat}
|
|
onBack={() => setRoute(storedHandle ? "login" : "welcome")}
|
|
/>
|
|
);
|
|
case "chat":
|
|
return user ? (
|
|
<ChatShell user={user} onLogout={logout} />
|
|
) : (
|
|
<Center h="100vh" bg="dark.9">
|
|
<Loader color="brand" />
|
|
</Center>
|
|
);
|
|
}
|
|
}
|