RecoveryWordDisplay.tsx

7.8 KB · raw

1import { createSignal, createMemo, For, onCleanup, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import { PeachWordmark } from "../components/PeachMark";4import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";5import { isNativePlatform } from "../../shared/native";67interface RecoveryWordDisplayViewProps {8  onDone: () => void;9}1011// After showing the words, user must type the 3 words at these positions12const CONFIRM_WORD_INDICES = [2, 6, 10]; // 0-indexed → positions 3, 7, 111314export function RecoveryWordDisplayView(props: RecoveryWordDisplayViewProps) {15  const [words, setWords] = createSignal<string[]>([]);16  const [error, setError] = createSignal("");17  const [loading, setLoading] = createSignal(true);18  const [confirmWords, setConfirmWords] = createSignal(["", "", ""]);19  const [confirmError, setConfirmError] = createSignal("");20  const [confirmed, setConfirmed] = createSignal(false);21  // 5-second countdown before confirmation input is enabled22  const [countdown, setCountdown] = createSignal(5);23  let countdownTimer: ReturnType<typeof setInterval> | null = null;2425  onMount(async () => {26    // Fetch the recovery words from the background (vault must be unlocked).27    const res = (await sendMessage({28      type: "GET_RECOVERY_WORDS",29    })) as { ok: boolean; data?: string[]; error?: string };3031    if (res.ok && res.data) {32      setWords(res.data);33    } else {34      setError("could not retrieve recovery words");35    }36    setLoading(false);3738    // Start 5-second countdown39    let remaining = 5;40    countdownTimer = setInterval(() => {41      remaining -= 1;42      setCountdown(remaining);43      if (remaining <= 0 && countdownTimer) {44        clearInterval(countdownTimer);45        countdownTimer = null;46      }47    }, 1000);48  });4950  onCleanup(() => {51    if (countdownTimer) clearInterval(countdownTimer);52  });5354  async function handleConfirm() {55    const confirmed = confirmWords().map((w) => w.toLowerCase().trim());56    if (confirmed.some((w) => !w)) {57      setConfirmError("all three words are required");58      return;59    }6061    // Verify against stored words62    const stored = words();63    for (let i = 0; i < CONFIRM_WORD_INDICES.length; i++) {64      const pos = CONFIRM_WORD_INDICES[i];65      if (confirmed[i] !== stored[pos]) {66        setConfirmError(`word ${pos + 1} is incorrect`);67        return;68      }69    }7071    setConfirmed(true);72    setConfirmError("");73    // Clear the recovery-pending flag so lock-on-close / auto-lock resume74    // their normal behavior and future unlocks no longer re-route here.75    // Once the user has confirmed the words, losing the in-memory key is76    // no longer a data-loss event.77    try {78      await chrome.storage.local?.remove("peach_recovery_pending");79    } catch { /* best-effort */ }80    // Small pause so user sees the success state, then proceed.81    await new Promise((r) => setTimeout(r, 600));82    props.onDone();83  }8485  const canConfirm = createMemo(() => countdown() <= 0 && !confirmed());8687  return (88    <section class="peach-screen peach-theme lock-screen lock-screen--recovery lock-screen--recovery-display">89      <RecoveryRuneBackground />9091      <header class="peach-screen-header">92        <div class="peach-wordmark-stack">93          <PeachWordmark class="peach-wordmark-wrap" />94        </div>95      </header>9697      <div class="peach-recovery-header">98        <h1 class="peach-onboarding-headline">save your recovery words</h1>99        <p class="peach-hint peach-hint--warning">100          write these down — you will never see them again101        </p>102      </div>103104      <Show when={loading()}>105        <p class="peach-hint">loading...</p>106      </Show>107108      <Show when={error()}>109        <p class="peach-hint peach-hint-error">{error()}</p>110      </Show>111112      <Show when={!loading() && !error()}>113        <div class="peach-recovery-words-display">114          <div class="peach-recovery-words-label">your 12 recovery words</div>115          <ol class="peach-recovery-word-list" start={1}>116            <For each={words()}>117              {(word, i) => (118                <li class="peach-recovery-word-item">119                  <span class="peach-recovery-word-num">{i() + 1}</span>120                  <span class="peach-recovery-word-text">{word}</span>121                </li>122              )}123            </For>124          </ol>125          <button126            type="button"127            class="peach-recovery-download-btn"128            onClick={() => void saveRecoveryWords(words())}129          >130            download as .txt131          </button>132        </div>133134        {/* Confirmation step */}135        <div class="peach-recovery-confirm">136          <Show when={!confirmed()}>137            <p class="peach-recovery-confirm-label">138              {countdown() > 0139                ? `type the highlighted words to confirm (${countdown()}s)`140                : "type the highlighted words to confirm"}141            </p>142          </Show>143          <Show when={confirmed()}>144            <p class="peach-hint peach-hint--success">✓ words verified</p>145          </Show>146147          <div class="peach-recovery-confirm-fields">148            <For each={CONFIRM_WORD_INDICES}>149              {(pos, i) => (150                <div class="peach-recovery-confirm-field">151                  <span class="peach-recovery-confirm-num">152                    word {pos + 1}153                  </span>154                  <input155                    class="peach-recovery-word-input peach-recovery-confirm-input"156                    type="text"157                    autocomplete="off"158                    autocorrect="off"159                    autocapitalize="none"160                    spellcheck={false}161                    disabled={!canConfirm()}162                    value={confirmWords()[i()]}163                    onInput={(e) => {164                      const updated = [...confirmWords()];165                      updated[i()] = (e.target as HTMLInputElement).value;166                      setConfirmWords(updated);167                      if (confirmError()) setConfirmError("");168                    }}169                    onKeyDown={(e) => {170                      if (e.key === "Enter" && canConfirm()) void handleConfirm();171                    }}172                  />173                </div>174              )}175            </For>176          </div>177178          <Show when={confirmError()}>179            <p class="peach-hint peach-hint-error">{confirmError()}</p>180          </Show>181182          <button183            class="peach-btn peach-btn--accent peach-btn--md peach-recovery-confirm-btn"184            disabled={!canConfirm() || confirmed()}185            onClick={() => void handleConfirm()}186          >187            {confirmed() ? "verified ✓" : countdown() > 0 ? `wait ${countdown()}s` : "i wrote them down"}188          </button>189        </div>190      </Show>191    </section>192  );193}194195function saveRecoveryWords(words: string[]): void {196  const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n");197  const body =198    "PEACH RECOVERY WORDS\n" +199    "====================\n\n" +200    "Anyone with these 12 words can recover your vault.\n" +201    "Store this file somewhere only you can access.\n\n" +202    numbered + "\n";203204  const a = document.createElement("a");205  a.download = "peach-recovery-words.txt";206  if (isNativePlatform()) {207    // Capacitor WebView: blob: URLs are opaque to native, but data: URLs go208    // through MainActivity.setDownloadListener which writes them to public209    // Downloads via MediaStore. Encode as base64 to keep the data URL210    // intact through the WebView's URL plumbing.211    const b64 = btoa(unescape(encodeURIComponent(body)));212    a.href = `data:text/plain;base64,${b64}`;213  } else {214    const blob = new Blob([body], { type: "text/plain" });215    a.href = URL.createObjectURL(blob);216    setTimeout(() => URL.revokeObjectURL(a.href), 1000);217  }218  document.body.appendChild(a);219  a.click();220  a.remove();221}