import { createSignal, For, Show, } from "solid-js"; import { sendMessage } from "../App"; import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground"; import { getWordSuggestions } from "../../shared/recovery-words"; const TOTAL_WORDS = 12; interface RecoveryWordEntryViewProps { onCancel: () => void; onRecoveryComplete: () => void | Promise; } export function RecoveryWordEntryView(props: RecoveryWordEntryViewProps) { // Each word slot: the full trimmed input value const [words, setWords] = createSignal(Array(TOTAL_WORDS).fill("")); // Autocomplete suggestions for each slot const [suggestions, setSuggestions] = createSignal<(string | null)[]>( Array(TOTAL_WORDS).fill(null) ); const [error, setError] = createSignal(""); const [loading, setLoading] = createSignal(false); // Step: "words" | "new-password" const [step, setStep] = createSignal<"words" | "new-password">("words"); // New password for step 2 const [newPassword, setNewPassword] = createSignal(""); const [confirmPassword, setConfirmPassword] = createSignal(""); const [newPasswordError, setNewPasswordError] = createSignal(""); const [newPasswordLoading, setNewPasswordLoading] = createSignal(false); // Which word index (0-based) is currently focused for autocomplete const [activeIndex, setActiveIndex] = createSignal(null); function onWordInput(idx: number, value: string) { const normalised = value.toLowerCase().trim(); const updated = [...words()]; updated[idx] = value; // keep raw for input display setWords(updated); // Update suggestions for this slot const sug = normalised.length >= 2 ? getWordSuggestions(normalised, 5) : []; const newSug = [...suggestions()]; newSug[idx] = sug.length > 0 ? sug[0] : null; setSuggestions(newSug); // Clear error on typing if (error()) setError(""); // Auto-fill suggestion on tab-like behaviour: if user types first chars // that match exactly one word, auto-complete if (sug.length === 1 && sug[0]!.startsWith(normalised)) { const final = [...words()]; final[idx] = sug[0]!; setWords(final); const emptySug = [...suggestions()]; emptySug[idx] = null; setSuggestions(emptySug); // Move focus to next empty slot const next = findNextEmpty(final); if (next !== null) focusSlot(next); } } function onWordKeyDown(idx: number, event: KeyboardEvent) { if (event.key === "Tab" && !event.shiftKey) { event.preventDefault(); const next = findNextEmpty(words()); if (next !== null) { focusSlot(next); } else if (suggestions()[idx]) { // Tab on a slot with a suggestion: accept it const final = [...words()]; final[idx] = suggestions()[idx]!; setWords(final); setSuggestions((s) => { const u = [...s]; u[idx] = null; return u; }); focusSlot(idx); } } else if (event.key === "ArrowDown" && suggestions()[idx]) { event.preventDefault(); setActiveIndex(idx); } else if (event.key === "Escape") { setSuggestions((s) => { const u = [...s]; u[idx] = null; return u; }); setActiveIndex(null); } else if (event.key === "Enter") { // Accept suggestion if one is active if (activeIndex() !== null) { const final = [...words()]; final[activeIndex()!] = suggestions()[activeIndex()!]!; setWords(final); setActiveIndex(null); setSuggestions((s) => { const u = [...s]; u[activeIndex()!] = null; return u; }); } else { void submitWords(); } } } function findNextEmpty(w: string[]): number { return w.findIndex((v) => !v.trim()); } function focusSlot(idx: number) { const el = document.getElementById(`recovery-word-${idx}`); if (el) setTimeout(() => el.focus(), 30); } async function submitWords() { if (loading()) return; const normalised = words().map((w) => w.toLowerCase().trim()); // Validate all filled const empty = normalised.findIndex((w) => !w); if (empty !== -1) { setError(`word ${empty + 1} is empty`); focusSlot(empty); return; } setLoading(true); setError(""); try { // Validate against the word list first so we can reject obvious typos // before asking the background to do the checksum/recovery work. const { findInvalidWords } = await import("../../shared/recovery-words"); const invalid = findInvalidWords(normalised); if (invalid.length > 0) { setError(`"${invalid[0].word}" is not a valid recovery word`); return; } // Ask the background to validate the checksum + try to unwrap. const res = (await sendMessage({ type: "VALIDATE_RECOVERY_WORDS", payload: { words: normalised }, })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } }; if (!res.ok) { setError("could not verify recovery words"); return; } if (!res.data?.valid) { setError("invalid recovery words — check for typos"); return; } setStep("new-password"); } catch { setError("could not verify recovery words"); } finally { setLoading(false); } } async function submitNewPassword() { if (!newPassword().trim()) { setNewPasswordError("password is required"); return; } if (newPassword().length < 12) { setNewPasswordError("password must be at least 12 characters"); return; } if (newPassword() !== confirmPassword()) { setNewPasswordError("passwords do not match"); return; } setNewPasswordLoading(true); setNewPasswordError(""); try { const res = (await sendMessage({ type: "RECOVER_WITH_WORDS", payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() }, })) as { ok: boolean; error?: string }; if (!res.ok) { if (res.error === "recovery_not_setup") { setNewPasswordError("this vault was created before recovery was available — restore from backup instead"); } else if (res.error === "invalid_recovery_words") { setNewPasswordError("invalid recovery words"); } else { setNewPasswordError(getDisplayError(res.error, "recovery failed")); } return; } await Promise.resolve(props.onRecoveryComplete()); } catch { setNewPasswordError("recovery failed"); } finally { setNewPasswordLoading(false); } } return (

recover your vault

enter your 12 recovery words

i)}> {(i) => (
{i + 1} onWordInput(i, (e.target as HTMLInputElement).value)} onKeyDown={(e) => onWordKeyDown(i, e)} onFocus={() => setActiveIndex(i)} onBlur={() => setTimeout(() => setActiveIndex(null), 150)} />
)}

{error()}

new master password

your recovery words are valid — now choose a new password

setNewPassword((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") void submitNewPassword(); }} />
setConfirmPassword((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") void submitNewPassword(); }} />

{newPasswordError()}

); } function getDisplayError(error: string | undefined, fallback: string): string { if (!error) return fallback; switch (error) { case "no_vault": return "no vault found"; case "recovery_not_setup": return "recovery not set up for this vault"; case "invalid_recovery_words": return "invalid recovery words"; default: return fallback; } }