import-wallet-flow.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. "use client";
  2. /**
  3. * Restore mavi from an existing recovery phrase (12 or 24 words).
  4. */
  5. import {
  6. countWords,
  7. isValidMnemonic,
  8. MAVI_MIN_PASSWORD_LENGTH,
  9. normalizeMnemonic,
  10. walletFromMnemonic,
  11. } from "@krypco/mavi-core";
  12. import Link from "next/link";
  13. import { useRouter } from "next/navigation";
  14. import { useState } from "react";
  15. import { PasswordInput } from "@/components/ui/password-input";
  16. import { MaviWalletLogo } from "@/components/wallet/wallet-logos";
  17. import { Button, buttonVariants } from "@/components/ui/button";
  18. import {
  19. Card,
  20. CardContent,
  21. CardDescription,
  22. CardHeader,
  23. CardTitle,
  24. } from "@/components/ui/card";
  25. import { ErrorBanner } from "@/components/ui/error-banner";
  26. import {
  27. hasVault,
  28. saveNewVault,
  29. shortAddress,
  30. unlockVault,
  31. } from "@/lib/mavi/storage";
  32. import { writeLastLoginMethod } from "@/lib/last-login";
  33. import { cn } from "@/lib/utils";
  34. type Step = "phrase" | "password" | "done";
  35. export function ImportWalletFlow() {
  36. const router = useRouter();
  37. const [step, setStep] = useState<Step>("phrase");
  38. const [phrase, setPhrase] = useState("");
  39. const [password, setPassword] = useState("");
  40. const [password2, setPassword2] = useState("");
  41. const [busy, setBusy] = useState(false);
  42. const [error, setError] = useState<string | null>(null);
  43. const [address, setAddress] = useState<string | null>(null);
  44. const [wordCount, setWordCount] = useState<12 | 24 | null>(null);
  45. const wordsN = countWords(phrase);
  46. const phraseOk =
  47. (wordsN === 12 || wordsN === 24) && isValidMnemonic(phrase);
  48. function onPhraseContinue() {
  49. setError(null);
  50. const n = countWords(phrase);
  51. if (n !== 12 && n !== 24) {
  52. setError("Enter 12 or 24 recovery words (spaces between them).");
  53. return;
  54. }
  55. if (!isValidMnemonic(phrase)) {
  56. setError(
  57. "That phrase is not valid. Check spelling and order, then try again.",
  58. );
  59. return;
  60. }
  61. setWordCount(n === 24 ? 24 : 12);
  62. setStep("password");
  63. }
  64. async function onImport() {
  65. setError(null);
  66. if (password.length < MAVI_MIN_PASSWORD_LENGTH) {
  67. setError(
  68. `Password must be at least ${MAVI_MIN_PASSWORD_LENGTH} characters.`,
  69. );
  70. return;
  71. }
  72. if (password !== password2) {
  73. setError("Passwords do not match.");
  74. return;
  75. }
  76. if (hasVault()) {
  77. const ok = window.confirm(
  78. "A mavi wallet already exists on this browser. Replace it with the imported one? You will need the old recovery phrase to use the old wallet again.",
  79. );
  80. if (!ok) return;
  81. }
  82. setBusy(true);
  83. try {
  84. const wallet = walletFromMnemonic(normalizeMnemonic(phrase));
  85. await saveNewVault(password, wallet);
  86. await unlockVault(password);
  87. writeLastLoginMethod("mavi");
  88. setAddress(wallet.address);
  89. setStep("done");
  90. } catch (e) {
  91. setError(e instanceof Error ? e.message : "Import failed.");
  92. } finally {
  93. setBusy(false);
  94. }
  95. }
  96. return (
  97. <div className="mx-auto max-w-xl space-y-4">
  98. {error ? (
  99. <ErrorBanner title="Could not import">{error}</ErrorBanner>
  100. ) : null}
  101. {step === "phrase" ? (
  102. <Card>
  103. <CardHeader>
  104. <div className="mb-2 flex items-center gap-2">
  105. <MaviWalletLogo size={28} />
  106. <CardTitle>Import mavi wallet</CardTitle>
  107. </div>
  108. <CardDescription>
  109. Paste or type your secret recovery phrase. Words never leave this
  110. browser. Works with 12 or 24 words.
  111. </CardDescription>
  112. </CardHeader>
  113. <CardContent className="space-y-4">
  114. <div className="space-y-2">
  115. <label htmlFor="mavi-import-phrase" className="text-sm font-medium">
  116. Recovery phrase
  117. </label>
  118. <textarea
  119. id="mavi-import-phrase"
  120. rows={4}
  121. value={phrase}
  122. onChange={(e) => setPhrase(e.target.value)}
  123. spellCheck={false}
  124. autoCapitalize="none"
  125. autoCorrect="off"
  126. className="flex w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
  127. placeholder="word1 word2 word3 …"
  128. />
  129. <p className="text-xs text-muted-foreground">
  130. {wordsN > 0
  131. ? `${wordsN} word${wordsN === 1 ? "" : "s"} detected${phraseOk ? " · looks valid" : ""}`
  132. : "Type or paste all words in order."}
  133. </p>
  134. </div>
  135. <Button
  136. type="button"
  137. className="w-full"
  138. disabled={!phraseOk}
  139. onClick={onPhraseContinue}
  140. >
  141. Continue
  142. </Button>
  143. <Link
  144. href="/portal/mavi/create"
  145. className="block text-center text-sm text-muted-foreground hover:text-foreground"
  146. >
  147. Or create a new mavi wallet
  148. </Link>
  149. </CardContent>
  150. </Card>
  151. ) : null}
  152. {step === "password" ? (
  153. <Card>
  154. <CardHeader>
  155. <CardTitle>Lock imported wallet</CardTitle>
  156. <CardDescription>
  157. Choose a password for this browser
  158. {wordCount ? ` · ${wordCount}-word phrase` : ""}.
  159. </CardDescription>
  160. </CardHeader>
  161. <CardContent className="space-y-4">
  162. <PasswordInput
  163. id="mavi-import-pw"
  164. label="Password"
  165. value={password}
  166. onChange={setPassword}
  167. autoComplete="new-password"
  168. placeholder={`At least ${MAVI_MIN_PASSWORD_LENGTH} characters`}
  169. />
  170. <PasswordInput
  171. id="mavi-import-pw2"
  172. label="Confirm password"
  173. value={password2}
  174. onChange={setPassword2}
  175. autoComplete="new-password"
  176. placeholder="Type it again"
  177. onEnter={() => void onImport()}
  178. />
  179. <div className="flex flex-col gap-2 sm:flex-row">
  180. <Button
  181. type="button"
  182. variant="outline"
  183. className="sm:flex-1"
  184. disabled={busy}
  185. onClick={() => {
  186. setError(null);
  187. setStep("phrase");
  188. }}
  189. >
  190. Back
  191. </Button>
  192. <Button
  193. type="button"
  194. className="sm:flex-1"
  195. disabled={
  196. busy ||
  197. password.length < MAVI_MIN_PASSWORD_LENGTH ||
  198. password !== password2
  199. }
  200. onClick={() => void onImport()}
  201. >
  202. {busy ? "Importing…" : "Import mavi wallet"}
  203. </Button>
  204. </div>
  205. </CardContent>
  206. </Card>
  207. ) : null}
  208. {step === "done" && address ? (
  209. <Card>
  210. <CardHeader>
  211. <div className="mb-2 flex items-center gap-2">
  212. <MaviWalletLogo size={28} />
  213. <CardTitle>Import complete</CardTitle>
  214. </div>
  215. <CardDescription>
  216. Your mavi wallet is restored and unlocked for this tab.
  217. </CardDescription>
  218. </CardHeader>
  219. <CardContent className="space-y-4">
  220. <div className="rounded-xl border border-proof/30 bg-proof/5 px-4 py-3">
  221. <p className="text-xs uppercase tracking-wide text-muted-foreground">
  222. Address
  223. </p>
  224. <p className="mt-1 break-all font-mono text-sm">{address}</p>
  225. <p className="mt-1 text-xs text-muted-foreground">
  226. {shortAddress(address)}
  227. </p>
  228. </div>
  229. <Link
  230. href="/portal/mavi"
  231. className={cn(
  232. buttonVariants({ variant: "primary" }),
  233. "inline-flex w-full items-center justify-center gap-2",
  234. )}
  235. >
  236. <MaviWalletLogo size={20} />
  237. mavi wallet
  238. </Link>
  239. <Button
  240. type="button"
  241. variant="outline"
  242. className="w-full"
  243. onClick={() => router.push("/portal/dashboard")}
  244. >
  245. Go to dashboard
  246. </Button>
  247. </CardContent>
  248. </Card>
  249. ) : null}
  250. </div>
  251. );
  252. }