1import {2 createEffect,3 createMemo,4 createSignal,5 For,6 onMount,7 Show,8} from "solid-js";9import { sendMessage } from "../App";10import { PeachWordmark } from "../components/PeachMark";11import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";12import { getWordSuggestions } from "../../shared/recovery-words";13import { WORD_LIST } from "../../shared/recovery-word-list";1415const TOTAL_WORDS = 12;1617interface RecoveryWordEntryViewProps {18 onCancel: () => void;19 onRecoveryComplete: () => void;20}2122export function RecoveryWordEntryView(props: RecoveryWordEntryViewProps) {23 24 const [words, setWords] = createSignal<string[]>(Array(TOTAL_WORDS).fill(""));25 26 const [suggestions, setSuggestions] = createSignal<(string | null)[]>(27 Array(TOTAL_WORDS).fill(null)28 );29 const [error, setError] = createSignal("");30 const [loading, setLoading] = createSignal(false);31 32 const [step, setStep] = createSignal<"words" | "new-password">("words");33 34 const [newPassword, setNewPassword] = createSignal("");35 const [confirmPassword, setConfirmPassword] = createSignal("");36 const [newPasswordError, setNewPasswordError] = createSignal("");37 const [newPasswordLoading, setNewPasswordLoading] = createSignal(false);3839 40 const [activeIndex, setActiveIndex] = createSignal<number | null>(null);4142 43 44 let autoAdvanceTimer: ReturnType<typeof setTimeout> | null = null;4546 function onWordInput(idx: number, value: string) {47 const normalised = value.toLowerCase().trim();48 const updated = [...words()];49 updated[idx] = value; 50 setWords(updated);5152 53 const sug = normalised.length >= 2 ? getWordSuggestions(normalised, 5) : [];54 const newSug = [...suggestions()];55 newSug[idx] = sug.length > 0 ? sug[0] : null;56 setSuggestions(newSug);5758 59 if (error()) setError("");6061 62 63 if (sug.length === 1 && sug[0]!.startsWith(normalised)) {64 const final = [...words()];65 final[idx] = sug[0]!;66 setWords(final);67 const emptySug = [...suggestions()];68 emptySug[idx] = null;69 setSuggestions(emptySug);70 71 const next = findNextEmpty(final);72 if (next !== null) focusSlot(next);73 }74 }7576 function onWordKeyDown(idx: number, event: KeyboardEvent) {77 if (event.key === "Tab" && !event.shiftKey) {78 event.preventDefault();79 const next = findNextEmpty(words());80 if (next !== null) {81 focusSlot(next);82 } else if (suggestions()[idx]) {83 84 const final = [...words()];85 final[idx] = suggestions()[idx]!;86 setWords(final);87 setSuggestions((s) => {88 const u = [...s];89 u[idx] = null;90 return u;91 });92 focusSlot(idx);93 }94 } else if (event.key === "ArrowDown" && suggestions()[idx]) {95 event.preventDefault();96 setActiveIndex(idx);97 } else if (event.key === "Escape") {98 setSuggestions((s) => {99 const u = [...s];100 u[idx] = null;101 return u;102 });103 setActiveIndex(null);104 } else if (event.key === "Enter") {105 106 if (activeIndex() !== null) {107 const final = [...words()];108 final[activeIndex()!] = suggestions()[activeIndex()!]!;109 setWords(final);110 setActiveIndex(null);111 setSuggestions((s) => {112 const u = [...s];113 u[activeIndex()!] = null;114 return u;115 });116 } else {117 void submitWords();118 }119 }120 }121122 function onSuggestionClick(idx: number, word: string) {123 const final = [...words()];124 final[idx] = word;125 setWords(final);126 setSuggestions((s) => {127 const u = [...s];128 u[idx] = null;129 return u;130 });131 const next = findNextEmpty(final);132 if (next !== null) focusSlot(next);133 }134135 function findNextEmpty(w: string[]): number {136 return w.findIndex((v) => !v.trim());137 }138139 function focusSlot(idx: number) {140 const el = document.getElementById(`recovery-word-${idx}`);141 if (el) setTimeout(() => el.focus(), 30);142 }143144 async function submitWords() {145 if (loading()) return;146 const normalised = words().map((w) => w.toLowerCase().trim());147148 149 const empty = normalised.findIndex((w) => !w);150 if (empty !== -1) {151 setError(`word ${empty + 1} is empty`);152 focusSlot(empty);153 return;154 }155156 setLoading(true);157 setError("");158159 160 const { findInvalidWords } = await import("../../shared/recovery-words");161 const invalid = findInvalidWords(normalised);162 if (invalid.length > 0) {163 setError(`"${invalid[0].word}" is not a valid recovery word`);164 setLoading(false);165 return;166 }167168 169 const res = (await sendMessage({170 type: "VALIDATE_RECOVERY_WORDS",171 payload: { words: normalised },172 })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };173174 if (!res.data?.valid) {175 setError("invalid recovery words — check for typos");176 setLoading(false);177 return;178 }179180 setLoading(false);181 setStep("new-password");182 }183184 async function submitNewPassword() {185 if (!newPassword().trim()) {186 setNewPasswordError("password is required");187 return;188 }189 if (newPassword().length < 12) {190 setNewPasswordError("password must be at least 12 characters");191 return;192 }193 if (newPassword() !== confirmPassword()) {194 setNewPasswordError("passwords do not match");195 return;196 }197198 setNewPasswordLoading(true);199 setNewPasswordError("");200201 const res = (await sendMessage({202 type: "RECOVER_WITH_WORDS",203 payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },204 })) as { ok: boolean; error?: string };205206 if (!res.ok) {207 if (res.error === "recovery_not_setup") {208 setNewPasswordError("this vault was created before recovery was available — restore from backup instead");209 } else if (res.error === "invalid_recovery_words") {210 setNewPasswordError("invalid recovery words");211 } else {212 setNewPasswordError(getDisplayError(res.error, "recovery failed"));213 }214 setNewPasswordLoading(false);215 return;216 }217218 setNewPasswordLoading(false);219 props.onRecoveryComplete();220 }221222 return (223 <section224 class="peach-screen peach-theme lock-screen lock-screen--recovery"225 data-motion-phase="enter"226 >227 <RecoveryRuneBackground />228229 <header class="peach-screen-header">230 <button231 class="peach-newvault-back"232 type="button"233 onClick={() => {234 if (step() === "new-password") {235 setStep("words");236 } else {237 props.onCancel();238 }239 }}240 aria-label="Back"241 >242 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">243 <path d="M7 2L3 5.5L7 9" />244 </svg>245 back246 </button>247 <div class="peach-wordmark-stack">248 <PeachWordmark class="peach-wordmark-wrap" />249 </div>250 </header>251252 <Show when={step() === "words"}>253 <div class="peach-recovery-header">254 <h1 class="peach-onboarding-headline">recover your vault</h1>255 <p class="peach-hint">enter your 12 recovery words</p>256 </div>257258 <div class="peach-recovery-grid">259 <For each={Array.from({ length: TOTAL_WORDS }, (_, i) => i)}>260 {(i) => (261 <div class="peach-recovery-word-slot">262 <span class="peach-recovery-word-index">{i + 1}</span>263 <input264 id={`recovery-word-${i}`}265 class="peach-recovery-word-input"266 type="text"267 autocomplete="off"268 autocorrect="off"269 autocapitalize="none"270 spellcheck={false}271 value={words()[i]}272 placeholder="—"273 onInput={(e) => onWordInput(i, (e.target as HTMLInputElement).value)}274 onKeyDown={(e) => onWordKeyDown(i, e)}275 onFocus={() => setActiveIndex(i)}276 onBlur={() => setTimeout(() => setActiveIndex(null), 150)}277 />278 </div>279 )}280 </For>281 </div>282283 <Show when={error()}>284 <p class="peach-hint peach-hint-error">{error()}</p>285 </Show>286287 <button288 class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"289 onClick={() => void submitWords()}290 disabled={loading()}291 >292 {loading() ? "verifying..." : "verify words"}293 </button>294 </Show>295296 <Show when={step() === "new-password"}>297 <div class="peach-recovery-header">298 <h1 class="peach-onboarding-headline">new master password</h1>299 <p class="peach-hint">your recovery words are valid — now choose a new password</p>300 </div>301302 <div class="peach-recovery-new-password-form">303 <div class="peach-newvault-field">304 <label class="peach-label" for="recovery-new-password">305 new master password306 </label>307 <input308 id="recovery-new-password"309 class="peach-underline-input peach-unlock-input"310 type="password"311 autocomplete="new-password"312 value={newPassword()}313 onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}314 onKeyDown={(e) => {315 if (e.key === "Enter") void submitNewPassword();316 }}317 />318 </div>319320 <div class="peach-newvault-field">321 <label class="peach-label" for="recovery-confirm-password">322 confirm password323 </label>324 <input325 id="recovery-confirm-password"326 class="peach-underline-input peach-unlock-input"327 type="password"328 autocomplete="new-password"329 value={confirmPassword()}330 onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)}331 onKeyDown={(e) => {332 if (e.key === "Enter") void submitNewPassword();333 }}334 />335 </div>336337 <Show when={newPasswordError()}>338 <p class="peach-hint peach-hint-error">{newPasswordError()}</p>339 </Show>340 </div>341342 <button343 class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"344 onClick={() => void submitNewPassword()}345 disabled={newPasswordLoading()}346 >347 {newPasswordLoading() ? "recovering..." : "recover vault"}348 </button>349 </Show>350 </section>351 );352}353354function getDisplayError(error: string | undefined, fallback: string): string {355 if (!error) return fallback;356 switch (error) {357 case "no vault":358 return "no vault found";359 case "recovery_not_setup":360 return "recovery not set up for this vault";361 case "invalid_recovery_words":362 return "invalid recovery words";363 default:364 return fallback;365 }366}