RecoveryWordEntry.tsx

10.9 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;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    // Validate against the word list139    const { findInvalidWords } = await import("../../shared/recovery-words");140    const invalid = findInvalidWords(normalised);141    if (invalid.length > 0) {142      setError(`"${invalid[0].word}" is not a valid recovery word`);143      setLoading(false);144      return;145    }146147    // Ask the background to validate the checksum + try to unwrap148    const res = (await sendMessage({149      type: "VALIDATE_RECOVERY_WORDS",150      payload: { words: normalised },151    })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };152153    if (!res.data?.valid) {154      setError("invalid recovery words — check for typos");155      setLoading(false);156      return;157    }158159    setLoading(false);160    setStep("new-password");161  }162163  async function submitNewPassword() {164    if (!newPassword().trim()) {165      setNewPasswordError("password is required");166      return;167    }168    if (newPassword().length < 12) {169      setNewPasswordError("password must be at least 12 characters");170      return;171    }172    if (newPassword() !== confirmPassword()) {173      setNewPasswordError("passwords do not match");174      return;175    }176177    setNewPasswordLoading(true);178    setNewPasswordError("");179180    const res = (await sendMessage({181      type: "RECOVER_WITH_WORDS",182      payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },183    })) as { ok: boolean; error?: string };184185    if (!res.ok) {186      if (res.error === "recovery_not_setup") {187        setNewPasswordError("this vault was created before recovery was available — restore from backup instead");188      } else if (res.error === "invalid_recovery_words") {189        setNewPasswordError("invalid recovery words");190      } else {191        setNewPasswordError(getDisplayError(res.error, "recovery failed"));192      }193      setNewPasswordLoading(false);194      return;195    }196197    setNewPasswordLoading(false);198    props.onRecoveryComplete();199  }200201  return (202    <section203      class="peach-screen peach-theme lock-screen lock-screen--recovery"204      data-motion-phase="enter"205    >206      <RecoveryRuneBackground />207208      <header class="peach-screen-header peach-recovery-nav">209        <button210          class="peach-newvault-back"211          type="button"212          onClick={() => {213            if (step() === "new-password") {214              setStep("words");215            } else {216              props.onCancel();217            }218          }}219          aria-label="Back"220        >221          <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">222            <path d="M7 2L3 5.5L7 9" />223          </svg>224          back225        </button>226      </header>227228      <Show when={step() === "words"}>229        <div class="peach-recovery-header">230          <h1 class="peach-onboarding-headline">recover your vault</h1>231          <p class="peach-hint">enter your 12 recovery words</p>232        </div>233234        <div class="peach-recovery-grid">235          <For each={Array.from({ length: TOTAL_WORDS }, (_, i) => i)}>236            {(i) => (237              <div class="peach-recovery-word-slot">238                <span class="peach-recovery-word-index">{i + 1}</span>239                <input240                  id={`recovery-word-${i}`}241                  class="peach-recovery-word-input"242                  type="text"243                  autocomplete="off"244                  autocorrect="off"245                  autocapitalize="none"246                  spellcheck={false}247                  value={words()[i]}248                  placeholder="—"249                  onInput={(e) => onWordInput(i, (e.target as HTMLInputElement).value)}250                  onKeyDown={(e) => onWordKeyDown(i, e)}251                  onFocus={() => setActiveIndex(i)}252                  onBlur={() => setTimeout(() => setActiveIndex(null), 150)}253                />254              </div>255            )}256          </For>257        </div>258259        <Show when={error()}>260          <p class="peach-hint peach-hint-error">{error()}</p>261        </Show>262263        <button264          class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"265          onClick={() => void submitWords()}266          disabled={loading()}267        >268          {loading() ? "verifying..." : "verify words"}269        </button>270      </Show>271272      <Show when={step() === "new-password"}>273        <div class="peach-recovery-header">274          <h1 class="peach-onboarding-headline">new master password</h1>275          <p class="peach-hint">your recovery words are valid — now choose a new password</p>276        </div>277278        <div class="peach-recovery-new-password-form">279          <div class="peach-newvault-field">280            <label class="peach-label" for="recovery-new-password">281              new master password282            </label>283            <input284              id="recovery-new-password"285              class="peach-underline-input peach-unlock-input"286              type="password"287              autocomplete="new-password"288              value={newPassword()}289              onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}290              onKeyDown={(e) => {291                if (e.key === "Enter") void submitNewPassword();292              }}293            />294          </div>295296          <div class="peach-newvault-field">297            <label class="peach-label" for="recovery-confirm-password">298              confirm password299            </label>300            <input301              id="recovery-confirm-password"302              class="peach-underline-input peach-unlock-input"303              type="password"304              autocomplete="new-password"305              value={confirmPassword()}306              onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)}307              onKeyDown={(e) => {308                if (e.key === "Enter") void submitNewPassword();309              }}310            />311          </div>312313          <Show when={newPasswordError()}>314            <p class="peach-hint peach-hint-error">{newPasswordError()}</p>315          </Show>316        </div>317318        <button319          class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"320          onClick={() => void submitNewPassword()}321          disabled={newPasswordLoading()}322        >323          {newPasswordLoading() ? "recovering..." : "recover vault"}324        </button>325      </Show>326    </section>327  );328}329330function getDisplayError(error: string | undefined, fallback: string): string {331  if (!error) return fallback;332  switch (error) {333    case "no_vault":334      return "no vault found";335    case "recovery_not_setup":336      return "recovery not set up for this vault";337    case "invalid_recovery_words":338      return "invalid recovery words";339    default:340      return fallback;341  }342}