| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- "use client";
- /**
- * mavi create + 24-word backup + MetaMask-style 6-blank confirm quiz.
- * All secrets stay in this browser.
- */
- import {
- buildRecoveryQuiz,
- createWallet,
- MAVI_DEFAULT_WORD_COUNT,
- MAVI_MIN_PASSWORD_LENGTH,
- MAVI_QUIZ_BLANK_COUNT,
- quizAnswersMatch,
- quizProgress,
- type CreatedWallet,
- type RecoveryQuizItem,
- } from "@krypco/mavi-core";
- import Link from "next/link";
- import { useRouter } from "next/navigation";
- import { useCallback, useMemo, useState } from "react";
- import { MaviWalletLogo } from "@/components/wallet/wallet-logos";
- import { Button, buttonVariants } from "@/components/ui/button";
- import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
- } from "@/components/ui/card";
- import { ErrorBanner } from "@/components/ui/error-banner";
- import { PasswordInput } from "@/components/ui/password-input";
- import { saveNewVault, shortAddress } from "@/lib/mavi/storage";
- import { cn } from "@/lib/utils";
- type Step = "setup" | "backup" | "confirm" | "done";
- export function CreateWalletFlow() {
- const router = useRouter();
- const [step, setStep] = useState<Step>("setup");
- const [password, setPassword] = useState("");
- const [password2, setPassword2] = useState("");
- const [wallet, setWallet] = useState<CreatedWallet | null>(null);
- const [revealed, setRevealed] = useState(false);
- const [quiz, setQuiz] = useState<RecoveryQuizItem[]>([]);
- /** answers keyed by word index in the full phrase */
- const [answers, setAnswers] = useState<Record<number, string>>({});
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState<string | null>(null);
- const words = useMemo(
- () => (wallet ? wallet.mnemonic.split(" ") : []),
- [wallet],
- );
- const setupValid =
- password.length >= MAVI_MIN_PASSWORD_LENGTH && password === password2;
- const progress = quizProgress(quiz, answers);
- const quizComplete =
- quiz.length > 0 && progress.filled === progress.total;
- const startQuiz = useCallback((mnemonic: string) => {
- const q = buildRecoveryQuiz(mnemonic, MAVI_QUIZ_BLANK_COUNT);
- setQuiz(q);
- setAnswers({});
- }, []);
- const onGenerate = useCallback(() => {
- setError(null);
- if (password.length < MAVI_MIN_PASSWORD_LENGTH) {
- setError(
- `Password must be at least ${MAVI_MIN_PASSWORD_LENGTH} characters.`,
- );
- return;
- }
- if (password !== password2) {
- setError("Passwords do not match.");
- return;
- }
- try {
- const w = createWallet(MAVI_DEFAULT_WORD_COUNT);
- setWallet(w);
- setRevealed(false);
- setQuiz([]);
- setAnswers({});
- setStep("backup");
- } catch (e) {
- setError(e instanceof Error ? e.message : "Could not create wallet.");
- }
- }, [password, password2]);
- const goToConfirm = useCallback(() => {
- if (!wallet) return;
- setError(null);
- startQuiz(wallet.mnemonic);
- setStep("confirm");
- }, [startQuiz, wallet]);
- const onConfirmBackup = useCallback(async () => {
- if (!wallet) return;
- setError(null);
- if (!quizAnswersMatch(quiz, answers)) {
- setError(
- "Some words are wrong. Pick the correct word for each blank, or go back and check your list.",
- );
- return;
- }
- setBusy(true);
- try {
- await saveNewVault(password, wallet);
- setStep("done");
- } catch (e) {
- setError(e instanceof Error ? e.message : "Could not save wallet.");
- } finally {
- setBusy(false);
- }
- }, [answers, password, quiz, wallet]);
- function pickChoice(index: number, word: string) {
- setAnswers((prev) => ({ ...prev, [index]: word }));
- setError(null);
- }
- return (
- <div className="mx-auto max-w-xl space-y-4">
- <StepDots step={step} />
- {error ? (
- <ErrorBanner title="Something went wrong">{error}</ErrorBanner>
- ) : null}
- {step === "setup" ? (
- <Card>
- <CardHeader>
- <div className="mb-2 flex items-center gap-2">
- <MaviWalletLogo size={28} />
- <CardTitle>Create mavi</CardTitle>
- </div>
- <CardDescription>
- Set a password that locks mavi on{" "}
- <strong className="text-foreground">this browser only</strong>.
- You will get a <strong className="text-foreground">24-word</strong>{" "}
- recovery phrase — never sent to krypco servers.
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-5">
- <div className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground leading-relaxed">
- Recovery phrase: <strong className="text-foreground">24 words</strong>
- . Next you write them down, then confirm{" "}
- <strong className="text-foreground">6 random words</strong> (like
- MetaMask).
- </div>
- <PasswordInput
- id="mavi-pw"
- label="Password"
- value={password}
- onChange={setPassword}
- placeholder={`At least ${MAVI_MIN_PASSWORD_LENGTH} characters`}
- autoComplete="new-password"
- />
- <PasswordInput
- id="mavi-pw2"
- label="Confirm password"
- value={password2}
- onChange={setPassword2}
- placeholder="Type it again"
- autoComplete="new-password"
- />
- <Button
- type="button"
- className="w-full"
- disabled={!setupValid}
- onClick={onGenerate}
- >
- Continue — show recovery phrase
- </Button>
- <p className="text-xs text-muted-foreground leading-relaxed">
- Anyone with your 24 words can control this wallet. krypco cannot
- reset them for you.
- </p>
- </CardContent>
- </Card>
- ) : null}
- {step === "backup" && wallet ? (
- <Card>
- <CardHeader>
- <CardTitle>Write down your Secret Recovery Phrase</CardTitle>
- <CardDescription>
- 24 words, in order. Store them offline. Do not email them or post
- them online.
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- {!revealed ? (
- <div className="rounded-xl border border-pending/40 bg-pending/5 px-4 py-8 text-center">
- <p className="mb-4 text-sm text-muted-foreground">
- Phrase is hidden until you are ready to write it down.
- </p>
- <Button type="button" onClick={() => setRevealed(true)}>
- I am ready — reveal phrase
- </Button>
- </div>
- ) : (
- <ol className="grid grid-cols-2 gap-2 sm:grid-cols-3">
- {words.map((w, i) => (
- <li
- key={`${i}-${w}`}
- className="flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-2 font-mono text-sm"
- >
- <span className="w-6 shrink-0 text-xs text-muted-foreground">
- {i + 1}.
- </span>
- <span>{w}</span>
- </li>
- ))}
- </ol>
- )}
- <div className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground leading-relaxed">
- Next you will confirm{" "}
- <strong className="text-foreground">6 random words</strong> from
- this list (different words each time you try).
- </div>
- <div className="flex flex-col gap-2 sm:flex-row">
- <Button
- type="button"
- variant="outline"
- className="sm:flex-1"
- onClick={() => {
- setWallet(null);
- setRevealed(false);
- setStep("setup");
- }}
- >
- Back
- </Button>
- <Button
- type="button"
- className="sm:flex-1"
- disabled={!revealed}
- onClick={goToConfirm}
- >
- I wrote it down
- </Button>
- </div>
- </CardContent>
- </Card>
- ) : null}
- {step === "confirm" && wallet && quiz.length > 0 ? (
- <Card>
- <CardHeader>
- <CardTitle>Confirm Secret Recovery Phrase</CardTitle>
- <CardDescription>
- Select each word in the order it was presented to you.
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-5">
- <p className="text-sm font-medium text-muted-foreground">
- {progress.filled} of {progress.total}
- </p>
- <div className="space-y-5">
- {quiz.map((q) => {
- const selected = answers[q.index];
- return (
- <div key={q.index} className="space-y-2">
- <p className="text-sm font-medium text-foreground">
- Word #{q.wordNumber}
- </p>
- <div className="flex flex-wrap gap-2">
- {q.choices.map((choice) => {
- const isSelected = selected === choice;
- return (
- <button
- key={`${q.index}-${choice}`}
- type="button"
- onClick={() => pickChoice(q.index, choice)}
- className={cn(
- "rounded-full border px-4 py-2 font-mono text-sm transition-colors",
- isSelected
- ? "border-accent bg-accent/15 text-accent-hover ring-1 ring-accent/40"
- : "border-border bg-muted/20 text-foreground hover:bg-muted/50",
- )}
- >
- {choice}
- </button>
- );
- })}
- </div>
- </div>
- );
- })}
- </div>
- <div className="flex flex-col gap-2 sm:flex-row">
- <Button
- type="button"
- variant="outline"
- className="sm:flex-1"
- disabled={busy}
- onClick={() => {
- setError(null);
- setStep("backup");
- }}
- >
- Back to phrase
- </Button>
- <Button
- type="button"
- variant="ghost"
- className="sm:flex-1"
- disabled={busy}
- onClick={() => {
- if (wallet) startQuiz(wallet.mnemonic);
- setError(null);
- }}
- >
- New random words
- </Button>
- <Button
- type="button"
- className="sm:flex-1"
- disabled={busy || !quizComplete}
- onClick={() => void onConfirmBackup()}
- >
- {busy ? "Saving…" : "Continue"}
- </Button>
- </div>
- </CardContent>
- </Card>
- ) : null}
- {step === "done" && wallet ? (
- <Card>
- <CardHeader>
- <div className="mb-2 flex items-center gap-2">
- <MaviWalletLogo size={28} />
- <CardTitle>mavi is ready</CardTitle>
- </div>
- <CardDescription>
- Your wallet is encrypted on this browser. Use your password to
- unlock later. Keep your paper backup safe.
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="rounded-xl border border-proof/30 bg-proof/5 px-4 py-3">
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
- Address
- </p>
- <p className="mt-1 break-all font-mono text-sm text-foreground">
- {wallet.address}
- </p>
- <p className="mt-1 text-xs text-muted-foreground">
- Short form: {shortAddress(wallet.address)}
- </p>
- </div>
- <div className="flex flex-col gap-2">
- <Link
- href="/portal/mavi"
- className={cn(
- buttonVariants({ variant: "primary" }),
- "inline-flex w-full items-center justify-center gap-2",
- )}
- >
- <MaviWalletLogo size={20} />
- mavi wallet
- </Link>
- <Button
- type="button"
- variant="outline"
- className="w-full"
- onClick={() => router.push("/portal/dashboard")}
- >
- Go to dashboard
- </Button>
- </div>
- </CardContent>
- </Card>
- ) : null}
- </div>
- );
- }
- function StepDots({ step }: { step: Step }) {
- const order: Step[] = ["setup", "backup", "confirm", "done"];
- const labels = ["Password", "Write phrase", "Confirm 6", "Ready"];
- const idx = order.indexOf(step);
- return (
- <ol className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
- {labels.map((label, i) => (
- <li key={label} className="flex items-center gap-2">
- <span
- className={cn(
- "inline-flex h-6 min-w-6 items-center justify-center rounded-full border px-1.5 font-medium",
- i <= idx
- ? "border-accent/50 bg-accent/10 text-accent-hover"
- : "border-border",
- )}
- >
- {i + 1}
- </span>
- <span className={i === idx ? "text-foreground" : undefined}>
- {label}
- </span>
- {i < labels.length - 1 ? (
- <span className="text-border" aria-hidden>
- →
- </span>
- ) : null}
- </li>
- ))}
- </ol>
- );
- }
|