RecoveryWordEntry.tsx

11.7 KB · raw

1import {2  createEffect,3  createMemo,4  createSignal,5  For,6  onMount,7  Show,8} from "solid-js";9import { sendMessage } from "../App";10import { PeachWordmark } from "../components/PeachMark";11import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";12import { getWordSuggestions } from "../../shared/recovery-words";13import { WORD_LIST } from "../../shared/recovery-word-list";1415const TOTAL_WORDS = 12;1617interface RecoveryWordEntryViewProps {18  onCancel: () => void;19  onRecoveryComplete: () => void;20}2122export function RecoveryWordEntryView(props: RecoveryWordEntryViewProps) {23  // Each word slot: the full trimmed input value24  const [words, setWords] = createSignal<string[]>(Array(TOTAL_WORDS).fill(""));25  // Autocomplete suggestions for each slot26  const [suggestions, setSuggestions] = createSignal<(string | null)[]>(27    Array(TOTAL_WORDS).fill(null)28  );29  const [error, setError] = createSignal("");30  const [loading, setLoading] = createSignal(false);31  // Step: "words" | "new-password"32  const [step, setStep] = createSignal<"words" | "new-password">("words");33  // New password for step 234  const [newPassword, setNewPassword] = createSignal("");35  const [confirmPassword, setConfirmPassword] = createSignal("");36  const [newPasswordError, setNewPasswordError] = createSignal("");37  const [newPasswordLoading, setNewPasswordLoading] = createSignal(false);3839  // Which word index (0-based) is currently focused for autocomplete40  const [activeIndex, setActiveIndex] = createSignal<number | null>(null);4142  // When all 12 words are filled, auto-advance after a short debounce.43  // But only auto-advance once we've confirmed they're all valid-looking.44  let autoAdvanceTimer: ReturnType<typeof setTimeout> | null = null;4546  function onWordInput(idx: number, value: string) {47    const normalised = value.toLowerCase().trim();48    const updated = [...words()];49    updated[idx] = value; // keep raw for input display50    setWords(updated);5152    // Update suggestions for this slot53    const sug = normalised.length >= 2 ? getWordSuggestions(normalised, 5) : [];54    const newSug = [...suggestions()];55    newSug[idx] = sug.length > 0 ? sug[0] : null;56    setSuggestions(newSug);5758    // Clear error on typing59    if (error()) setError("");6061    // Auto-fill suggestion on tab-like behaviour: if user types first chars62    // that match exactly one word, auto-complete63    if (sug.length === 1 && sug[0]!.startsWith(normalised)) {64      const final = [...words()];65      final[idx] = sug[0]!;66      setWords(final);67      const emptySug = [...suggestions()];68      emptySug[idx] = null;69      setSuggestions(emptySug);70      // Move focus to next empty slot71      const next = findNextEmpty(final);72      if (next !== null) focusSlot(next);73    }74  }7576  function onWordKeyDown(idx: number, event: KeyboardEvent) {77    if (event.key === "Tab" && !event.shiftKey) {78      event.preventDefault();79      const next = findNextEmpty(words());80      if (next !== null) {81        focusSlot(next);82      } else if (suggestions()[idx]) {83        // Tab on a slot with a suggestion: accept it84        const final = [...words()];85        final[idx] = suggestions()[idx]!;86        setWords(final);87        setSuggestions((s) => {88          const u = [...s];89          u[idx] = null;90          return u;91        });92        focusSlot(idx);93      }94    } else if (event.key === "ArrowDown" && suggestions()[idx]) {95      event.preventDefault();96      setActiveIndex(idx);97    } else if (event.key === "Escape") {98      setSuggestions((s) => {99        const u = [...s];100        u[idx] = null;101        return u;102      });103      setActiveIndex(null);104    } else if (event.key === "Enter") {105      // Accept suggestion if one is active106      if (activeIndex() !== null) {107        const final = [...words()];108        final[activeIndex()!] = suggestions()[activeIndex()!]!;109        setWords(final);110        setActiveIndex(null);111        setSuggestions((s) => {112          const u = [...s];113          u[activeIndex()!] = null;114          return u;115        });116      } else {117        void submitWords();118      }119    }120  }121122  function onSuggestionClick(idx: number, word: string) {123    const final = [...words()];124    final[idx] = word;125    setWords(final);126    setSuggestions((s) => {127      const u = [...s];128      u[idx] = null;129      return u;130    });131    const next = findNextEmpty(final);132    if (next !== null) focusSlot(next);133  }134135  function findNextEmpty(w: string[]): number {136    return w.findIndex((v) => !v.trim());137  }138139  function focusSlot(idx: number) {140    const el = document.getElementById(`recovery-word-${idx}`);141    if (el) setTimeout(() => el.focus(), 30);142  }143144  async function submitWords() {145    if (loading()) return;146    const normalised = words().map((w) => w.toLowerCase().trim());147148    // Validate all filled149    const empty = normalised.findIndex((w) => !w);150    if (empty !== -1) {151      setError(`word ${empty + 1} is empty`);152      focusSlot(empty);153      return;154    }155156    setLoading(true);157    setError("");158159    // Validate against the word list160    const { findInvalidWords } = await import("../../shared/recovery-words");161    const invalid = findInvalidWords(normalised);162    if (invalid.length > 0) {163      setError(`"${invalid[0].word}" is not a valid recovery word`);164      setLoading(false);165      return;166    }167168    // Ask the background to validate the checksum + try to unwrap169    const res = (await sendMessage({170      type: "VALIDATE_RECOVERY_WORDS",171      payload: { words: normalised },172    })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };173174    if (!res.data?.valid) {175      setError("invalid recovery words — check for typos");176      setLoading(false);177      return;178    }179180    setLoading(false);181    setStep("new-password");182  }183184  async function submitNewPassword() {185    if (!newPassword().trim()) {186      setNewPasswordError("password is required");187      return;188    }189    if (newPassword().length < 12) {190      setNewPasswordError("password must be at least 12 characters");191      return;192    }193    if (newPassword() !== confirmPassword()) {194      setNewPasswordError("passwords do not match");195      return;196    }197198    setNewPasswordLoading(true);199    setNewPasswordError("");200201    const res = (await sendMessage({202      type: "RECOVER_WITH_WORDS",203      payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },204    })) as { ok: boolean; error?: string };205206    if (!res.ok) {207      if (res.error === "recovery_not_setup") {208        setNewPasswordError("this vault was created before recovery was available — restore from backup instead");209      } else if (res.error === "invalid_recovery_words") {210        setNewPasswordError("invalid recovery words");211      } else {212        setNewPasswordError(getDisplayError(res.error, "recovery failed"));213      }214      setNewPasswordLoading(false);215      return;216    }217218    setNewPasswordLoading(false);219    props.onRecoveryComplete();220  }221222  return (223    <section224      class="peach-screen peach-theme lock-screen lock-screen--recovery"225      data-motion-phase="enter"226    >227      <RecoveryRuneBackground />228229      <header class="peach-screen-header">230        <button231          class="peach-newvault-back"232          type="button"233          onClick={() => {234            if (step() === "new-password") {235              setStep("words");236            } else {237              props.onCancel();238            }239          }}240          aria-label="Back"241        >242          <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">243            <path d="M7 2L3 5.5L7 9" />244          </svg>245          back246        </button>247        <div class="peach-wordmark-stack">248          <PeachWordmark class="peach-wordmark-wrap" />249        </div>250      </header>251252      <Show when={step() === "words"}>253        <div class="peach-recovery-header">254          <h1 class="peach-onboarding-headline">recover your vault</h1>255          <p class="peach-hint">enter your 12 recovery words</p>256        </div>257258        <div class="peach-recovery-grid">259          <For each={Array.from({ length: TOTAL_WORDS }, (_, i) => i)}>260            {(i) => (261              <div class="peach-recovery-word-slot">262                <span class="peach-recovery-word-index">{i + 1}</span>263                <input264                  id={`recovery-word-${i}`}265                  class="peach-recovery-word-input"266                  type="text"267                  autocomplete="off"268                  autocorrect="off"269                  autocapitalize="none"270                  spellcheck={false}271                  value={words()[i]}272                  placeholder="—"273                  onInput={(e) => onWordInput(i, (e.target as HTMLInputElement).value)}274                  onKeyDown={(e) => onWordKeyDown(i, e)}275                  onFocus={() => setActiveIndex(i)}276                  onBlur={() => setTimeout(() => setActiveIndex(null), 150)}277                />278              </div>279            )}280          </For>281        </div>282283        <Show when={error()}>284          <p class="peach-hint peach-hint-error">{error()}</p>285        </Show>286287        <button288          class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"289          onClick={() => void submitWords()}290          disabled={loading()}291        >292          {loading() ? "verifying..." : "verify words"}293        </button>294      </Show>295296      <Show when={step() === "new-password"}>297        <div class="peach-recovery-header">298          <h1 class="peach-onboarding-headline">new master password</h1>299          <p class="peach-hint">your recovery words are valid — now choose a new password</p>300        </div>301302        <div class="peach-recovery-new-password-form">303          <div class="peach-newvault-field">304            <label class="peach-label" for="recovery-new-password">305              new master password306            </label>307            <input308              id="recovery-new-password"309              class="peach-underline-input peach-unlock-input"310              type="password"311              autocomplete="new-password"312              value={newPassword()}313              onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}314              onKeyDown={(e) => {315                if (e.key === "Enter") void submitNewPassword();316              }}317            />318          </div>319320          <div class="peach-newvault-field">321            <label class="peach-label" for="recovery-confirm-password">322              confirm password323            </label>324            <input325              id="recovery-confirm-password"326              class="peach-underline-input peach-unlock-input"327              type="password"328              autocomplete="new-password"329              value={confirmPassword()}330              onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)}331              onKeyDown={(e) => {332                if (e.key === "Enter") void submitNewPassword();333              }}334            />335          </div>336337          <Show when={newPasswordError()}>338            <p class="peach-hint peach-hint-error">{newPasswordError()}</p>339          </Show>340        </div>341342        <button343          class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"344          onClick={() => void submitNewPassword()}345          disabled={newPasswordLoading()}346        >347          {newPasswordLoading() ? "recovering..." : "recover vault"}348        </button>349      </Show>350    </section>351  );352}353354function getDisplayError(error: string | undefined, fallback: string): string {355  if (!error) return fallback;356  switch (error) {357    case "no vault":358      return "no vault found";359    case "recovery_not_setup":360      return "recovery not set up for this vault";361    case "invalid_recovery_words":362      return "invalid recovery words";363    default:364      return fallback;365  }366}