RecoveryWordEntry.tsx

11.6 KB · raw

1import {2  createEffect,3  createMemo,4  createSignal,5  For,6  onMount,7  Show,8} from "solid-js";9import { sendMessage } from "../App";10import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";11import { getWordSuggestions } from "../../shared/recovery-words";12import { WORD_LIST } from "../../shared/recovery-word-list";1314const TOTAL_WORDS = 12;1516interface RecoveryWordEntryViewProps {17  onCancel: () => void;18  onRecoveryComplete: () => void;19}2021export function RecoveryWordEntryView(props: RecoveryWordEntryViewProps) {22  // Each word slot: the full trimmed input value23  const [words, setWords] = createSignal<string[]>(Array(TOTAL_WORDS).fill(""));24  // Autocomplete suggestions for each slot25  const [suggestions, setSuggestions] = createSignal<(string | null)[]>(26    Array(TOTAL_WORDS).fill(null)27  );28  const [error, setError] = createSignal("");29  const [loading, setLoading] = createSignal(false);30  // Step: "words" | "new-password"31  const [step, setStep] = createSignal<"words" | "new-password">("words");32  // New password for step 233  const [newPassword, setNewPassword] = createSignal("");34  const [confirmPassword, setConfirmPassword] = createSignal("");35  const [newPasswordError, setNewPasswordError] = createSignal("");36  const [newPasswordLoading, setNewPasswordLoading] = createSignal(false);3738  // Which word index (0-based) is currently focused for autocomplete39  const [activeIndex, setActiveIndex] = createSignal<number | null>(null);4041  // When all 12 words are filled, auto-advance after a short debounce.42  // But only auto-advance once we've confirmed they're all valid-looking.43  let autoAdvanceTimer: ReturnType<typeof setTimeout> | null = null;4445  function onWordInput(idx: number, value: string) {46    const normalised = value.toLowerCase().trim();47    const updated = [...words()];48    updated[idx] = value; // keep raw for input display49    setWords(updated);5051    // Update suggestions for this slot52    const sug = normalised.length >= 2 ? getWordSuggestions(normalised, 5) : [];53    const newSug = [...suggestions()];54    newSug[idx] = sug.length > 0 ? sug[0] : null;55    setSuggestions(newSug);5657    // Clear error on typing58    if (error()) setError("");5960    // Auto-fill suggestion on tab-like behaviour: if user types first chars61    // that match exactly one word, auto-complete62    if (sug.length === 1 && sug[0]!.startsWith(normalised)) {63      const final = [...words()];64      final[idx] = sug[0]!;65      setWords(final);66      const emptySug = [...suggestions()];67      emptySug[idx] = null;68      setSuggestions(emptySug);69      // Move focus to next empty slot70      const next = findNextEmpty(final);71      if (next !== null) focusSlot(next);72    }73  }7475  function onWordKeyDown(idx: number, event: KeyboardEvent) {76    if (event.key === "Tab" && !event.shiftKey) {77      event.preventDefault();78      const next = findNextEmpty(words());79      if (next !== null) {80        focusSlot(next);81      } else if (suggestions()[idx]) {82        // Tab on a slot with a suggestion: accept it83        const final = [...words()];84        final[idx] = suggestions()[idx]!;85        setWords(final);86        setSuggestions((s) => {87          const u = [...s];88          u[idx] = null;89          return u;90        });91        focusSlot(idx);92      }93    } else if (event.key === "ArrowDown" && suggestions()[idx]) {94      event.preventDefault();95      setActiveIndex(idx);96    } else if (event.key === "Escape") {97      setSuggestions((s) => {98        const u = [...s];99        u[idx] = null;100        return u;101      });102      setActiveIndex(null);103    } else if (event.key === "Enter") {104      // Accept suggestion if one is active105      if (activeIndex() !== null) {106        const final = [...words()];107        final[activeIndex()!] = suggestions()[activeIndex()!]!;108        setWords(final);109        setActiveIndex(null);110        setSuggestions((s) => {111          const u = [...s];112          u[activeIndex()!] = null;113          return u;114        });115      } else {116        void submitWords();117      }118    }119  }120121  function onSuggestionClick(idx: number, word: string) {122    const final = [...words()];123    final[idx] = word;124    setWords(final);125    setSuggestions((s) => {126      const u = [...s];127      u[idx] = null;128      return u;129    });130    const next = findNextEmpty(final);131    if (next !== null) focusSlot(next);132  }133134  function findNextEmpty(w: string[]): number {135    return w.findIndex((v) => !v.trim());136  }137138  function focusSlot(idx: number) {139    const el = document.getElementById(`recovery-word-${idx}`);140    if (el) setTimeout(() => el.focus(), 30);141  }142143  async function submitWords() {144    if (loading()) return;145    const normalised = words().map((w) => w.toLowerCase().trim());146147    // Validate all filled148    const empty = normalised.findIndex((w) => !w);149    if (empty !== -1) {150      setError(`word ${empty + 1} is empty`);151      focusSlot(empty);152      return;153    }154155    setLoading(true);156    setError("");157158    // Validate against the word list159    const { findInvalidWords } = await import("../../shared/recovery-words");160    const invalid = findInvalidWords(normalised);161    if (invalid.length > 0) {162      setError(`"${invalid[0].word}" is not a valid recovery word`);163      setLoading(false);164      return;165    }166167    // Ask the background to validate the checksum + try to unwrap168    const res = (await sendMessage({169      type: "VALIDATE_RECOVERY_WORDS",170      payload: { words: normalised },171    })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };172173    if (!res.data?.valid) {174      setError("invalid recovery words — check for typos");175      setLoading(false);176      return;177    }178179    setLoading(false);180    setStep("new-password");181  }182183  async function submitNewPassword() {184    if (!newPassword().trim()) {185      setNewPasswordError("password is required");186      return;187    }188    if (newPassword().length < 12) {189      setNewPasswordError("password must be at least 12 characters");190      return;191    }192    if (newPassword() !== confirmPassword()) {193      setNewPasswordError("passwords do not match");194      return;195    }196197    setNewPasswordLoading(true);198    setNewPasswordError("");199200    const res = (await sendMessage({201      type: "RECOVER_WITH_WORDS",202      payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },203    })) as { ok: boolean; error?: string };204205    if (!res.ok) {206      if (res.error === "recovery_not_setup") {207        setNewPasswordError("this vault was created before recovery was available — restore from backup instead");208      } else if (res.error === "invalid_recovery_words") {209        setNewPasswordError("invalid recovery words");210      } else {211        setNewPasswordError(getDisplayError(res.error, "recovery failed"));212      }213      setNewPasswordLoading(false);214      return;215    }216217    setNewPasswordLoading(false);218    props.onRecoveryComplete();219  }220221  return (222    <section223      class="peach-screen peach-theme lock-screen lock-screen--recovery"224      data-motion-phase="enter"225    >226      <RecoveryRuneBackground />227228      <header class="peach-screen-header peach-recovery-nav">229        <button230          class="peach-newvault-back"231          type="button"232          onClick={() => {233            if (step() === "new-password") {234              setStep("words");235            } else {236              props.onCancel();237            }238          }}239          aria-label="Back"240        >241          <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">242            <path d="M7 2L3 5.5L7 9" />243          </svg>244          back245        </button>246      </header>247248      <Show when={step() === "words"}>249        <div class="peach-recovery-header">250          <h1 class="peach-onboarding-headline">recover your vault</h1>251          <p class="peach-hint">enter your 12 recovery words</p>252        </div>253254        <div class="peach-recovery-grid">255          <For each={Array.from({ length: TOTAL_WORDS }, (_, i) => i)}>256            {(i) => (257              <div class="peach-recovery-word-slot">258                <span class="peach-recovery-word-index">{i + 1}</span>259                <input260                  id={`recovery-word-${i}`}261                  class="peach-recovery-word-input"262                  type="text"263                  autocomplete="off"264                  autocorrect="off"265                  autocapitalize="none"266                  spellcheck={false}267                  value={words()[i]}268                  placeholder="—"269                  onInput={(e) => onWordInput(i, (e.target as HTMLInputElement).value)}270                  onKeyDown={(e) => onWordKeyDown(i, e)}271                  onFocus={() => setActiveIndex(i)}272                  onBlur={() => setTimeout(() => setActiveIndex(null), 150)}273                />274              </div>275            )}276          </For>277        </div>278279        <Show when={error()}>280          <p class="peach-hint peach-hint-error">{error()}</p>281        </Show>282283        <button284          class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"285          onClick={() => void submitWords()}286          disabled={loading()}287        >288          {loading() ? "verifying..." : "verify words"}289        </button>290      </Show>291292      <Show when={step() === "new-password"}>293        <div class="peach-recovery-header">294          <h1 class="peach-onboarding-headline">new master password</h1>295          <p class="peach-hint">your recovery words are valid — now choose a new password</p>296        </div>297298        <div class="peach-recovery-new-password-form">299          <div class="peach-newvault-field">300            <label class="peach-label" for="recovery-new-password">301              new master password302            </label>303            <input304              id="recovery-new-password"305              class="peach-underline-input peach-unlock-input"306              type="password"307              autocomplete="new-password"308              value={newPassword()}309              onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}310              onKeyDown={(e) => {311                if (e.key === "Enter") void submitNewPassword();312              }}313            />314          </div>315316          <div class="peach-newvault-field">317            <label class="peach-label" for="recovery-confirm-password">318              confirm password319            </label>320            <input321              id="recovery-confirm-password"322              class="peach-underline-input peach-unlock-input"323              type="password"324              autocomplete="new-password"325              value={confirmPassword()}326              onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)}327              onKeyDown={(e) => {328                if (e.key === "Enter") void submitNewPassword();329              }}330            />331          </div>332333          <Show when={newPasswordError()}>334            <p class="peach-hint peach-hint-error">{newPasswordError()}</p>335          </Show>336        </div>337338        <button339          class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"340          onClick={() => void submitNewPassword()}341          disabled={newPasswordLoading()}342        >343          {newPasswordLoading() ? "recovering..." : "recover vault"}344        </button>345      </Show>346    </section>347  );348}349350function getDisplayError(error: string | undefined, fallback: string): string {351  if (!error) return fallback;352  switch (error) {353    case "no vault":354      return "no vault found";355    case "recovery_not_setup":356      return "recovery not set up for this vault";357    case "invalid_recovery_words":358      return "invalid recovery words";359    default:360      return fallback;361  }362}