RecoveryWordDisplay.tsx

8.9 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  const [copied, setCopied] = createSignal(false);2425  async function copyRecoveryWords() {26    try {27      await navigator.clipboard.writeText(recoveryWordsText(words()));28      setCopied(true);29      setTimeout(() => setCopied(false), 1800);30    } catch {31      /* clipboard denied — the words remain on screen for transcription */32    }33  }34  let countdownTimer: ReturnType<typeof setInterval> | null = null;3536  onMount(async () => {37    // Fetch the recovery words from the background (vault must be unlocked).38    const res = (await sendMessage({39      type: "GET_RECOVERY_WORDS",40    })) as { ok: boolean; data?: string[]; error?: string };4142    if (res.ok && res.data) {43      setWords(res.data);44    } else {45      setError("could not retrieve recovery words");46    }47    setLoading(false);4849    // Start 5-second countdown50    let remaining = 5;51    countdownTimer = setInterval(() => {52      remaining -= 1;53      setCountdown(remaining);54      if (remaining <= 0 && countdownTimer) {55        clearInterval(countdownTimer);56        countdownTimer = null;57      }58    }, 1000);59  });6061  onCleanup(() => {62    if (countdownTimer) clearInterval(countdownTimer);63  });6465  async function handleConfirm() {66    const confirmed = confirmWords().map((w) => w.toLowerCase().trim());67    if (confirmed.some((w) => !w)) {68      setConfirmError("all three words are required");69      return;70    }7172    // Verify against stored words73    const stored = words();74    for (let i = 0; i < CONFIRM_WORD_INDICES.length; i++) {75      const pos = CONFIRM_WORD_INDICES[i];76      if (confirmed[i] !== stored[pos]) {77        setConfirmError(`word ${pos + 1} is incorrect`);78        return;79      }80    }8182    setConfirmed(true);83    setConfirmError("");84    // Clear the recovery-pending flag so lock-on-close / auto-lock resume85    // their normal behavior and future unlocks no longer re-route here.86    // Once the user has confirmed the words, losing the in-memory key is87    // no longer a data-loss event.88    try {89      await chrome.storage.local?.remove("peach_recovery_pending");90    } catch { /* best-effort */ }91    // Small pause so user sees the success state, then proceed.92    await new Promise((r) => setTimeout(r, 600));93    props.onDone();94  }9596  const canConfirm = createMemo(() => countdown() <= 0 && !confirmed());9798  return (99    <section class="peach-screen peach-theme lock-screen lock-screen--recovery lock-screen--recovery-display">100      <RecoveryRuneBackground />101102      <header class="peach-screen-header">103        <div class="peach-wordmark-stack">104          <PeachWordmark class="peach-wordmark-wrap" />105        </div>106      </header>107108      <div class="peach-recovery-header">109        <h1 class="peach-onboarding-headline">save your recovery words</h1>110        <p class="peach-hint peach-hint--warning">111          write these down — you will never see them again112        </p>113      </div>114115      <Show when={loading()}>116        <p class="peach-hint">loading...</p>117      </Show>118119      <Show when={error()}>120        <p class="peach-hint peach-hint-error">{error()}</p>121      </Show>122123      <Show when={!loading() && !error()}>124        <div class="peach-recovery-words-display">125          <div class="peach-recovery-words-label">your 12 recovery words</div>126          <ol class="peach-recovery-word-list" start={1}>127            <For each={words()}>128              {(word, i) => (129                <li class="peach-recovery-word-item">130                  <span class="peach-recovery-word-num">{i() + 1}</span>131                  <span class="peach-recovery-word-text">{word}</span>132                </li>133              )}134            </For>135          </ol>136          {/* On native we must NOT write the master recovery secret to a file:137              the Android download sink lands in world-readable public Downloads138              (audit MEDIUM). Offer a transient clipboard copy the user routes139              into their own secure store instead. Desktop keeps the140              user-chosen-location .txt download. */}141          <Show142            when={isNativePlatform()}143            fallback={144              <button145                type="button"146                class="peach-recovery-download-btn"147                onClick={() => void saveRecoveryWords(words())}148              >149                download as .txt150              </button>151            }152          >153            <button154              type="button"155              class="peach-recovery-download-btn"156              onClick={() => void copyRecoveryWords()}157            >158              {copied() ? "copied ✓ — paste somewhere safe" : "copy words"}159            </button>160          </Show>161        </div>162163        {/* Confirmation step */}164        <div class="peach-recovery-confirm">165          <Show when={!confirmed()}>166            <p class="peach-recovery-confirm-label">167              {countdown() > 0168                ? `type the highlighted words to confirm (${countdown()}s)`169                : "type the highlighted words to confirm"}170            </p>171          </Show>172          <Show when={confirmed()}>173            <p class="peach-hint peach-hint--success">✓ words verified</p>174          </Show>175176          <div class="peach-recovery-confirm-fields">177            <For each={CONFIRM_WORD_INDICES}>178              {(pos, i) => (179                <div class="peach-recovery-confirm-field">180                  <span class="peach-recovery-confirm-num">181                    word {pos + 1}182                  </span>183                  <input184                    class="peach-recovery-word-input peach-recovery-confirm-input"185                    type="text"186                    autocomplete="off"187                    autocorrect="off"188                    autocapitalize="none"189                    spellcheck={false}190                    disabled={!canConfirm()}191                    value={confirmWords()[i()]}192                    onInput={(e) => {193                      const updated = [...confirmWords()];194                      updated[i()] = (e.target as HTMLInputElement).value;195                      setConfirmWords(updated);196                      if (confirmError()) setConfirmError("");197                    }}198                    onKeyDown={(e) => {199                      if (e.key === "Enter" && canConfirm()) void handleConfirm();200                    }}201                  />202                </div>203              )}204            </For>205          </div>206207          <Show when={confirmError()}>208            <p class="peach-hint peach-hint-error">{confirmError()}</p>209          </Show>210211          <button212            class="peach-btn peach-btn--accent peach-btn--md peach-recovery-confirm-btn"213            disabled={!canConfirm() || confirmed()}214            onClick={() => void handleConfirm()}215          >216            {confirmed() ? "verified ✓" : countdown() > 0 ? `wait ${countdown()}s` : "i wrote them down"}217          </button>218        </div>219      </Show>220    </section>221  );222}223224function recoveryWordsText(words: string[]): string {225  const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n");226  return (227    "PEACH RECOVERY WORDS\n" +228    "====================\n\n" +229    "Anyone with these 12 words can recover your vault.\n" +230    "Store this somewhere only you can access.\n\n" +231    numbered + "\n"232  );233}234235// Desktop only — writes via a blob to a user-chosen save location. The native236// path deliberately does NOT use a file download: on Android the data:-URL237// download sink writes to world-readable public Downloads (audit MEDIUM), so238// native offers a clipboard copy (copyRecoveryWords) the user routes instead.239function saveRecoveryWords(words: string[]): void {240  const blob = new Blob([recoveryWordsText(words)], { type: "text/plain" });241  const a = document.createElement("a");242  a.download = "peach-recovery-words.txt";243  a.href = URL.createObjectURL(blob);244  setTimeout(() => URL.revokeObjectURL(a.href), 1000);245  document.body.appendChild(a);246  a.click();247  a.remove();248}