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 23 const [words, setWords] = createSignal<string[]>(Array(TOTAL_WORDS).fill(""));24 25 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 31 const [step, setStep] = createSignal<"words" | "new-password">("words");32 33 const [newPassword, setNewPassword] = createSignal("");34 const [confirmPassword, setConfirmPassword] = createSignal("");35 const [newPasswordError, setNewPasswordError] = createSignal("");36 const [newPasswordLoading, setNewPasswordLoading] = createSignal(false);3738 39 const [activeIndex, setActiveIndex] = createSignal<number | null>(null);4041 42 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; 49 setWords(updated);5051 52 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 58 if (error()) setError("");5960 61 62 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 70 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 83 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 105 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 148 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 159 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 168 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}