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 19 const [words, setWords] = createSignal<string[]>(Array(TOTAL_WORDS).fill(""));20 21 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 27 const [step, setStep] = createSignal<"words" | "new-password">("words");28 29 const [newPassword, setNewPassword] = createSignal("");30 const [confirmPassword, setConfirmPassword] = createSignal("");31 const [newPasswordError, setNewPasswordError] = createSignal("");32 const [newPasswordLoading, setNewPasswordLoading] = createSignal(false);3334 35 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; 41 setWords(updated);4243 44 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 50 if (error()) setError("");5152 53 54 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 62 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 75 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 97 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 128 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 139 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 148 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}