RecoveryWordEntry.tsx

11.4 KB · raw

1import {2  createSignal,3  For,4  Show,5} from "solid-js";6import { sendMessage } from "../App";7import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";8import { getWordSuggestions } from "../../shared/recovery-words";910const TOTAL_WORDS = 12;1112interface RecoveryWordEntryViewProps {13  onCancel: () => void;14  onRecoveryComplete: () => void | Promise<void>;15}1617export function RecoveryWordEntryView(props: RecoveryWordEntryViewProps) {18  // Each word slot: the full trimmed input value19  const [words, setWords] = createSignal<string[]>(Array(TOTAL_WORDS).fill(""));20  // Autocomplete suggestions for each slot21  const [suggestions, setSuggestions] = createSignal<(string | null)[]>(22    Array(TOTAL_WORDS).fill(null)23  );24  const [error, setError] = createSignal("");25  const [loading, setLoading] = createSignal(false);26  // Step: "words" | "new-password"27  const [step, setStep] = createSignal<"words" | "new-password">("words");28  // New password for step 229  const [newPassword, setNewPassword] = createSignal("");30  const [confirmPassword, setConfirmPassword] = createSignal("");31  const [newPasswordError, setNewPasswordError] = createSignal("");32  const [newPasswordLoading, setNewPasswordLoading] = createSignal(false);3334  // Which word index (0-based) is currently focused for autocomplete35  const [activeIndex, setActiveIndex] = createSignal<number | null>(null);3637  function onWordInput(idx: number, value: string) {38    const normalised = value.toLowerCase().trim();39    const updated = [...words()];40    updated[idx] = value; // keep raw for input display41    setWords(updated);4243    // Update suggestions for this slot44    const sug = normalised.length >= 2 ? getWordSuggestions(normalised, 5) : [];45    const newSug = [...suggestions()];46    newSug[idx] = sug.length > 0 ? sug[0] : null;47    setSuggestions(newSug);4849    // Clear error on typing50    if (error()) setError("");5152    // Auto-fill suggestion on tab-like behaviour: if user types first chars53    // that match exactly one word, auto-complete54    if (sug.length === 1 && sug[0]!.startsWith(normalised)) {55      const final = [...words()];56      final[idx] = sug[0]!;57      setWords(final);58      const emptySug = [...suggestions()];59      emptySug[idx] = null;60      setSuggestions(emptySug);61      // Move focus to next empty slot62      const next = findNextEmpty(final);63      if (next !== null) focusSlot(next);64    }65  }6667  function onWordKeyDown(idx: number, event: KeyboardEvent) {68    if (event.key === "Tab" && !event.shiftKey) {69      event.preventDefault();70      const next = findNextEmpty(words());71      if (next !== null) {72        focusSlot(next);73      } else if (suggestions()[idx]) {74        // Tab on a slot with a suggestion: accept it75        const final = [...words()];76        final[idx] = suggestions()[idx]!;77        setWords(final);78        setSuggestions((s) => {79          const u = [...s];80          u[idx] = null;81          return u;82        });83        focusSlot(idx);84      }85    } else if (event.key === "ArrowDown" && suggestions()[idx]) {86      event.preventDefault();87      setActiveIndex(idx);88    } else if (event.key === "Escape") {89      setSuggestions((s) => {90        const u = [...s];91        u[idx] = null;92        return u;93      });94      setActiveIndex(null);95    } else if (event.key === "Enter") {96      // Accept suggestion if one is active97      if (activeIndex() !== null) {98        const final = [...words()];99        final[activeIndex()!] = suggestions()[activeIndex()!]!;100        setWords(final);101        setActiveIndex(null);102        setSuggestions((s) => {103          const u = [...s];104          u[activeIndex()!] = null;105          return u;106        });107      } else {108        void submitWords();109      }110    }111  }112113114  function findNextEmpty(w: string[]): number {115    return w.findIndex((v) => !v.trim());116  }117118  function focusSlot(idx: number) {119    const el = document.getElementById(`recovery-word-${idx}`);120    if (el) setTimeout(() => el.focus(), 30);121  }122123  async function submitWords() {124    if (loading()) return;125    const normalised = words().map((w) => w.toLowerCase().trim());126127    // Validate all filled128    const empty = normalised.findIndex((w) => !w);129    if (empty !== -1) {130      setError(`word ${empty + 1} is empty`);131      focusSlot(empty);132      return;133    }134135    setLoading(true);136    setError("");137138    try {139      // Validate against the word list first so we can reject obvious typos140      // before asking the background to do the checksum/recovery work.141      const { findInvalidWords } = await import("../../shared/recovery-words");142      const invalid = findInvalidWords(normalised);143      if (invalid.length > 0) {144        setError(`"${invalid[0].word}" is not a valid recovery word`);145        return;146      }147148      // Ask the background to validate the checksum + try to unwrap.149      const res = (await sendMessage({150        type: "VALIDATE_RECOVERY_WORDS",151        payload: { words: normalised },152      })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };153154      if (!res.ok) {155        setError("could not verify recovery words");156        return;157      }158      if (!res.data?.valid) {159        setError("invalid recovery words — check for typos");160        return;161      }162163      setStep("new-password");164    } catch {165      setError("could not verify recovery words");166    } finally {167      setLoading(false);168    }169  }170171  async function submitNewPassword() {172    if (!newPassword().trim()) {173      setNewPasswordError("password is required");174      return;175    }176    if (newPassword().length < 12) {177      setNewPasswordError("password must be at least 12 characters");178      return;179    }180    if (newPassword() !== confirmPassword()) {181      setNewPasswordError("passwords do not match");182      return;183    }184185    setNewPasswordLoading(true);186    setNewPasswordError("");187188    try {189      const res = (await sendMessage({190        type: "RECOVER_WITH_WORDS",191        payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },192      })) as { ok: boolean; error?: string };193194      if (!res.ok) {195        if (res.error === "recovery_not_setup") {196          setNewPasswordError("this vault was created before recovery was available — restore from backup instead");197        } else if (res.error === "invalid_recovery_words") {198          setNewPasswordError("invalid recovery words");199        } else {200          setNewPasswordError(getDisplayError(res.error, "recovery failed"));201        }202        return;203      }204205      await Promise.resolve(props.onRecoveryComplete());206    } catch {207      setNewPasswordError("recovery failed");208    } finally {209      setNewPasswordLoading(false);210    }211  }212213  return (214    <section215      class="peach-screen peach-theme lock-screen lock-screen--recovery"216      data-motion-phase="enter"217    >218      <RecoveryRuneBackground />219220      <header class="peach-screen-header peach-recovery-nav">221        <button222          class="peach-newvault-back"223          type="button"224          onClick={() => {225            if (step() === "new-password") {226              setStep("words");227            } else {228              props.onCancel();229            }230          }}231          aria-label="Back"232        >233          <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">234            <path d="M7 2L3 5.5L7 9" />235          </svg>236          back237        </button>238      </header>239240      <Show when={step() === "words"}>241        <div class="peach-recovery-header">242          <h1 class="peach-onboarding-headline">recover your vault</h1>243          <p class="peach-hint">enter your 12 recovery words</p>244        </div>245246        <div class="peach-recovery-grid">247          <For each={Array.from({ length: TOTAL_WORDS }, (_, i) => i)}>248            {(i) => (249              <div class="peach-recovery-word-slot">250                <span class="peach-recovery-word-index">{i + 1}</span>251                <input252                  id={`recovery-word-${i}`}253                  class="peach-recovery-word-input"254                  type="text"255                  autocomplete="off"256                  autocorrect="off"257                  autocapitalize="none"258                  spellcheck={false}259                  value={words()[i]}260                  placeholder="—"261                  onInput={(e) => onWordInput(i, (e.target as HTMLInputElement).value)}262                  onKeyDown={(e) => onWordKeyDown(i, e)}263                  onFocus={() => setActiveIndex(i)}264                  onBlur={() => setTimeout(() => setActiveIndex(null), 150)}265                />266              </div>267            )}268          </For>269        </div>270271        <Show when={error()}>272          <p class="peach-hint peach-hint-error">{error()}</p>273        </Show>274275        <button276          class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"277          onClick={() => void submitWords()}278          disabled={loading()}279        >280          {loading() ? "verifying..." : "verify words"}281        </button>282      </Show>283284      <Show when={step() === "new-password"}>285        <div class="peach-recovery-header">286          <h1 class="peach-onboarding-headline">new master password</h1>287          <p class="peach-hint">your recovery words are valid — now choose a new password</p>288        </div>289290        <div class="peach-recovery-new-password-form">291          <div class="peach-newvault-field">292            <label class="peach-label" for="recovery-new-password">293              new master password294            </label>295            <input296              id="recovery-new-password"297              class="peach-underline-input peach-unlock-input"298              type="password"299              autocomplete="new-password"300              value={newPassword()}301              onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}302              onKeyDown={(e) => {303                if (e.key === "Enter") void submitNewPassword();304              }}305            />306          </div>307308          <div class="peach-newvault-field">309            <label class="peach-label" for="recovery-confirm-password">310              confirm password311            </label>312            <input313              id="recovery-confirm-password"314              class="peach-underline-input peach-unlock-input"315              type="password"316              autocomplete="new-password"317              value={confirmPassword()}318              onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)}319              onKeyDown={(e) => {320                if (e.key === "Enter") void submitNewPassword();321              }}322            />323          </div>324325          <Show when={newPasswordError()}>326            <p class="peach-hint peach-hint-error">{newPasswordError()}</p>327          </Show>328        </div>329330        <button331          class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"332          onClick={() => void submitNewPassword()}333          disabled={newPasswordLoading()}334        >335          {newPasswordLoading() ? "recovering..." : "recover vault"}336        </button>337      </Show>338    </section>339  );340}341342function getDisplayError(error: string | undefined, fallback: string): string {343  if (!error) return fallback;344  switch (error) {345    case "no_vault":346      return "no vault found";347    case "recovery_not_setup":348      return "recovery not set up for this vault";349    case "invalid_recovery_words":350      return "invalid recovery words";351    default:352      return fallback;353  }354}