create-wallet-flow.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. "use client";
  2. /**
  3. * mavi create + 24-word backup + MetaMask-style 6-blank confirm quiz.
  4. * All secrets stay in this browser.
  5. */
  6. import {
  7. buildRecoveryQuiz,
  8. createWallet,
  9. MAVI_DEFAULT_WORD_COUNT,
  10. MAVI_MIN_PASSWORD_LENGTH,
  11. MAVI_QUIZ_BLANK_COUNT,
  12. quizAnswersMatch,
  13. quizProgress,
  14. type CreatedWallet,
  15. type RecoveryQuizItem,
  16. } from "@krypco/mavi-core";
  17. import Link from "next/link";
  18. import { useRouter } from "next/navigation";
  19. import { useCallback, useMemo, useState } from "react";
  20. import { MaviWalletLogo } from "@/components/wallet/wallet-logos";
  21. import { Button, buttonVariants } from "@/components/ui/button";
  22. import {
  23. Card,
  24. CardContent,
  25. CardDescription,
  26. CardHeader,
  27. CardTitle,
  28. } from "@/components/ui/card";
  29. import { ErrorBanner } from "@/components/ui/error-banner";
  30. import { PasswordInput } from "@/components/ui/password-input";
  31. import { saveNewVault, shortAddress } from "@/lib/mavi/storage";
  32. import { cn } from "@/lib/utils";
  33. type Step = "setup" | "backup" | "confirm" | "done";
  34. export function CreateWalletFlow() {
  35. const router = useRouter();
  36. const [step, setStep] = useState<Step>("setup");
  37. const [password, setPassword] = useState("");
  38. const [password2, setPassword2] = useState("");
  39. const [wallet, setWallet] = useState<CreatedWallet | null>(null);
  40. const [revealed, setRevealed] = useState(false);
  41. const [quiz, setQuiz] = useState<RecoveryQuizItem[]>([]);
  42. /** answers keyed by word index in the full phrase */
  43. const [answers, setAnswers] = useState<Record<number, string>>({});
  44. const [busy, setBusy] = useState(false);
  45. const [error, setError] = useState<string | null>(null);
  46. const words = useMemo(
  47. () => (wallet ? wallet.mnemonic.split(" ") : []),
  48. [wallet],
  49. );
  50. const setupValid =
  51. password.length >= MAVI_MIN_PASSWORD_LENGTH && password === password2;
  52. const progress = quizProgress(quiz, answers);
  53. const quizComplete =
  54. quiz.length > 0 && progress.filled === progress.total;
  55. const startQuiz = useCallback((mnemonic: string) => {
  56. const q = buildRecoveryQuiz(mnemonic, MAVI_QUIZ_BLANK_COUNT);
  57. setQuiz(q);
  58. setAnswers({});
  59. }, []);
  60. const onGenerate = useCallback(() => {
  61. setError(null);
  62. if (password.length < MAVI_MIN_PASSWORD_LENGTH) {
  63. setError(
  64. `Password must be at least ${MAVI_MIN_PASSWORD_LENGTH} characters.`,
  65. );
  66. return;
  67. }
  68. if (password !== password2) {
  69. setError("Passwords do not match.");
  70. return;
  71. }
  72. try {
  73. const w = createWallet(MAVI_DEFAULT_WORD_COUNT);
  74. setWallet(w);
  75. setRevealed(false);
  76. setQuiz([]);
  77. setAnswers({});
  78. setStep("backup");
  79. } catch (e) {
  80. setError(e instanceof Error ? e.message : "Could not create wallet.");
  81. }
  82. }, [password, password2]);
  83. const goToConfirm = useCallback(() => {
  84. if (!wallet) return;
  85. setError(null);
  86. startQuiz(wallet.mnemonic);
  87. setStep("confirm");
  88. }, [startQuiz, wallet]);
  89. const onConfirmBackup = useCallback(async () => {
  90. if (!wallet) return;
  91. setError(null);
  92. if (!quizAnswersMatch(quiz, answers)) {
  93. setError(
  94. "Some words are wrong. Pick the correct word for each blank, or go back and check your list.",
  95. );
  96. return;
  97. }
  98. setBusy(true);
  99. try {
  100. await saveNewVault(password, wallet);
  101. setStep("done");
  102. } catch (e) {
  103. setError(e instanceof Error ? e.message : "Could not save wallet.");
  104. } finally {
  105. setBusy(false);
  106. }
  107. }, [answers, password, quiz, wallet]);
  108. function pickChoice(index: number, word: string) {
  109. setAnswers((prev) => ({ ...prev, [index]: word }));
  110. setError(null);
  111. }
  112. return (
  113. <div className="mx-auto max-w-xl space-y-4">
  114. <StepDots step={step} />
  115. {error ? (
  116. <ErrorBanner title="Something went wrong">{error}</ErrorBanner>
  117. ) : null}
  118. {step === "setup" ? (
  119. <Card>
  120. <CardHeader>
  121. <div className="mb-2 flex items-center gap-2">
  122. <MaviWalletLogo size={28} />
  123. <CardTitle>Create mavi</CardTitle>
  124. </div>
  125. <CardDescription>
  126. Set a password that locks mavi on{" "}
  127. <strong className="text-foreground">this browser only</strong>.
  128. You will get a <strong className="text-foreground">24-word</strong>{" "}
  129. recovery phrase — never sent to krypco servers.
  130. </CardDescription>
  131. </CardHeader>
  132. <CardContent className="space-y-5">
  133. <div className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground leading-relaxed">
  134. Recovery phrase: <strong className="text-foreground">24 words</strong>
  135. . Next you write them down, then confirm{" "}
  136. <strong className="text-foreground">6 random words</strong> (like
  137. MetaMask).
  138. </div>
  139. <PasswordInput
  140. id="mavi-pw"
  141. label="Password"
  142. value={password}
  143. onChange={setPassword}
  144. placeholder={`At least ${MAVI_MIN_PASSWORD_LENGTH} characters`}
  145. autoComplete="new-password"
  146. />
  147. <PasswordInput
  148. id="mavi-pw2"
  149. label="Confirm password"
  150. value={password2}
  151. onChange={setPassword2}
  152. placeholder="Type it again"
  153. autoComplete="new-password"
  154. />
  155. <Button
  156. type="button"
  157. className="w-full"
  158. disabled={!setupValid}
  159. onClick={onGenerate}
  160. >
  161. Continue — show recovery phrase
  162. </Button>
  163. <p className="text-xs text-muted-foreground leading-relaxed">
  164. Anyone with your 24 words can control this wallet. krypco cannot
  165. reset them for you.
  166. </p>
  167. </CardContent>
  168. </Card>
  169. ) : null}
  170. {step === "backup" && wallet ? (
  171. <Card>
  172. <CardHeader>
  173. <CardTitle>Write down your Secret Recovery Phrase</CardTitle>
  174. <CardDescription>
  175. 24 words, in order. Store them offline. Do not email them or post
  176. them online.
  177. </CardDescription>
  178. </CardHeader>
  179. <CardContent className="space-y-4">
  180. {!revealed ? (
  181. <div className="rounded-xl border border-pending/40 bg-pending/5 px-4 py-8 text-center">
  182. <p className="mb-4 text-sm text-muted-foreground">
  183. Phrase is hidden until you are ready to write it down.
  184. </p>
  185. <Button type="button" onClick={() => setRevealed(true)}>
  186. I am ready — reveal phrase
  187. </Button>
  188. </div>
  189. ) : (
  190. <ol className="grid grid-cols-2 gap-2 sm:grid-cols-3">
  191. {words.map((w, i) => (
  192. <li
  193. key={`${i}-${w}`}
  194. className="flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-2 font-mono text-sm"
  195. >
  196. <span className="w-6 shrink-0 text-xs text-muted-foreground">
  197. {i + 1}.
  198. </span>
  199. <span>{w}</span>
  200. </li>
  201. ))}
  202. </ol>
  203. )}
  204. <div className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground leading-relaxed">
  205. Next you will confirm{" "}
  206. <strong className="text-foreground">6 random words</strong> from
  207. this list (different words each time you try).
  208. </div>
  209. <div className="flex flex-col gap-2 sm:flex-row">
  210. <Button
  211. type="button"
  212. variant="outline"
  213. className="sm:flex-1"
  214. onClick={() => {
  215. setWallet(null);
  216. setRevealed(false);
  217. setStep("setup");
  218. }}
  219. >
  220. Back
  221. </Button>
  222. <Button
  223. type="button"
  224. className="sm:flex-1"
  225. disabled={!revealed}
  226. onClick={goToConfirm}
  227. >
  228. I wrote it down
  229. </Button>
  230. </div>
  231. </CardContent>
  232. </Card>
  233. ) : null}
  234. {step === "confirm" && wallet && quiz.length > 0 ? (
  235. <Card>
  236. <CardHeader>
  237. <CardTitle>Confirm Secret Recovery Phrase</CardTitle>
  238. <CardDescription>
  239. Select each word in the order it was presented to you.
  240. </CardDescription>
  241. </CardHeader>
  242. <CardContent className="space-y-5">
  243. <p className="text-sm font-medium text-muted-foreground">
  244. {progress.filled} of {progress.total}
  245. </p>
  246. <div className="space-y-5">
  247. {quiz.map((q) => {
  248. const selected = answers[q.index];
  249. return (
  250. <div key={q.index} className="space-y-2">
  251. <p className="text-sm font-medium text-foreground">
  252. Word #{q.wordNumber}
  253. </p>
  254. <div className="flex flex-wrap gap-2">
  255. {q.choices.map((choice) => {
  256. const isSelected = selected === choice;
  257. return (
  258. <button
  259. key={`${q.index}-${choice}`}
  260. type="button"
  261. onClick={() => pickChoice(q.index, choice)}
  262. className={cn(
  263. "rounded-full border px-4 py-2 font-mono text-sm transition-colors",
  264. isSelected
  265. ? "border-accent bg-accent/15 text-accent-hover ring-1 ring-accent/40"
  266. : "border-border bg-muted/20 text-foreground hover:bg-muted/50",
  267. )}
  268. >
  269. {choice}
  270. </button>
  271. );
  272. })}
  273. </div>
  274. </div>
  275. );
  276. })}
  277. </div>
  278. <div className="flex flex-col gap-2 sm:flex-row">
  279. <Button
  280. type="button"
  281. variant="outline"
  282. className="sm:flex-1"
  283. disabled={busy}
  284. onClick={() => {
  285. setError(null);
  286. setStep("backup");
  287. }}
  288. >
  289. Back to phrase
  290. </Button>
  291. <Button
  292. type="button"
  293. variant="ghost"
  294. className="sm:flex-1"
  295. disabled={busy}
  296. onClick={() => {
  297. if (wallet) startQuiz(wallet.mnemonic);
  298. setError(null);
  299. }}
  300. >
  301. New random words
  302. </Button>
  303. <Button
  304. type="button"
  305. className="sm:flex-1"
  306. disabled={busy || !quizComplete}
  307. onClick={() => void onConfirmBackup()}
  308. >
  309. {busy ? "Saving…" : "Continue"}
  310. </Button>
  311. </div>
  312. </CardContent>
  313. </Card>
  314. ) : null}
  315. {step === "done" && wallet ? (
  316. <Card>
  317. <CardHeader>
  318. <div className="mb-2 flex items-center gap-2">
  319. <MaviWalletLogo size={28} />
  320. <CardTitle>mavi is ready</CardTitle>
  321. </div>
  322. <CardDescription>
  323. Your wallet is encrypted on this browser. Use your password to
  324. unlock later. Keep your paper backup safe.
  325. </CardDescription>
  326. </CardHeader>
  327. <CardContent className="space-y-4">
  328. <div className="rounded-xl border border-proof/30 bg-proof/5 px-4 py-3">
  329. <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
  330. Address
  331. </p>
  332. <p className="mt-1 break-all font-mono text-sm text-foreground">
  333. {wallet.address}
  334. </p>
  335. <p className="mt-1 text-xs text-muted-foreground">
  336. Short form: {shortAddress(wallet.address)}
  337. </p>
  338. </div>
  339. <div className="flex flex-col gap-2">
  340. <Link
  341. href="/portal/mavi"
  342. className={cn(
  343. buttonVariants({ variant: "primary" }),
  344. "inline-flex w-full items-center justify-center gap-2",
  345. )}
  346. >
  347. <MaviWalletLogo size={20} />
  348. mavi wallet
  349. </Link>
  350. <Button
  351. type="button"
  352. variant="outline"
  353. className="w-full"
  354. onClick={() => router.push("/portal/dashboard")}
  355. >
  356. Go to dashboard
  357. </Button>
  358. </div>
  359. </CardContent>
  360. </Card>
  361. ) : null}
  362. </div>
  363. );
  364. }
  365. function StepDots({ step }: { step: Step }) {
  366. const order: Step[] = ["setup", "backup", "confirm", "done"];
  367. const labels = ["Password", "Write phrase", "Confirm 6", "Ready"];
  368. const idx = order.indexOf(step);
  369. return (
  370. <ol className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
  371. {labels.map((label, i) => (
  372. <li key={label} className="flex items-center gap-2">
  373. <span
  374. className={cn(
  375. "inline-flex h-6 min-w-6 items-center justify-center rounded-full border px-1.5 font-medium",
  376. i <= idx
  377. ? "border-accent/50 bg-accent/10 text-accent-hover"
  378. : "border-border",
  379. )}
  380. >
  381. {i + 1}
  382. </span>
  383. <span className={i === idx ? "text-foreground" : undefined}>
  384. {label}
  385. </span>
  386. {i < labels.length - 1 ? (
  387. <span className="text-border" aria-hidden>
  388. </span>
  389. ) : null}
  390. </li>
  391. ))}
  392. </ol>
  393. );
  394. }