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 | Promise<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 try {139 140 141 const { findInvalidWords } = await import("../../shared/recovery-words");142 const invalid = findInvalidWords(normalised);143 if (invalid.length > 0) {144 setError(`"${invalid[0].word}" is not a valid recovery word`);145 return;146 }147148 149 const res = (await sendMessage({150 type: "VALIDATE_RECOVERY_WORDS",151 payload: { words: normalised },152 })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };153154 if (!res.ok) {155 setError("could not verify recovery words");156 return;157 }158 if (!res.data?.valid) {159 setError("invalid recovery words — check for typos");160 return;161 }162163 setStep("new-password");164 } catch {165 setError("could not verify recovery words");166 } finally {167 setLoading(false);168 }169 }170171 async function submitNewPassword() {172 if (!newPassword().trim()) {173 setNewPasswordError("password is required");174 return;175 }176 if (newPassword().length < 12) {177 setNewPasswordError("password must be at least 12 characters");178 return;179 }180 if (newPassword() !== confirmPassword()) {181 setNewPasswordError("passwords do not match");182 return;183 }184185 setNewPasswordLoading(true);186 setNewPasswordError("");187188 try {189 const res = (await sendMessage({190 type: "RECOVER_WITH_WORDS",191 payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },192 })) as { ok: boolean; error?: string };193194 if (!res.ok) {195 if (res.error === "recovery_not_setup") {196 setNewPasswordError("this vault was created before recovery was available — restore from backup instead");197 } else if (res.error === "invalid_recovery_words") {198 setNewPasswordError("invalid recovery words");199 } else {200 setNewPasswordError(getDisplayError(res.error, "recovery failed"));201 }202 return;203 }204205 await Promise.resolve(props.onRecoveryComplete());206 } catch {207 setNewPasswordError("recovery failed");208 } finally {209 setNewPasswordLoading(false);210 }211 }212213 return (214 <section215 class="peach-screen peach-theme lock-screen lock-screen--recovery"216 data-motion-phase="enter"217 >218 <RecoveryRuneBackground />219220 <header class="peach-screen-header peach-recovery-nav">221 <button222 class="peach-newvault-back"223 type="button"224 onClick={() => {225 if (step() === "new-password") {226 setStep("words");227 } else {228 props.onCancel();229 }230 }}231 aria-label="Back"232 >233 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">234 <path d="M7 2L3 5.5L7 9" />235 </svg>236 back237 </button>238 </header>239240 <Show when={step() === "words"}>241 <div class="peach-recovery-header">242 <h1 class="peach-onboarding-headline">recover your vault</h1>243 <p class="peach-hint">enter your 12 recovery words</p>244 </div>245246 <div class="peach-recovery-grid">247 <For each={Array.from({ length: TOTAL_WORDS }, (_, i) => i)}>248 {(i) => (249 <div class="peach-recovery-word-slot">250 <span class="peach-recovery-word-index">{i + 1}</span>251 <input252 id={`recovery-word-${i}`}253 class="peach-recovery-word-input"254 type="text"255 autocomplete="off"256 autocorrect="off"257 autocapitalize="none"258 spellcheck={false}259 value={words()[i]}260 placeholder="—"261 onInput={(e) => onWordInput(i, (e.target as HTMLInputElement).value)}262 onKeyDown={(e) => onWordKeyDown(i, e)}263 onFocus={() => setActiveIndex(i)}264 onBlur={() => setTimeout(() => setActiveIndex(null), 150)}265 />266 </div>267 )}268 </For>269 </div>270271 <Show when={error()}>272 <p class="peach-hint peach-hint-error">{error()}</p>273 </Show>274275 <button276 class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"277 onClick={() => void submitWords()}278 disabled={loading()}279 >280 {loading() ? "verifying..." : "verify words"}281 </button>282 </Show>283284 <Show when={step() === "new-password"}>285 <div class="peach-recovery-header">286 <h1 class="peach-onboarding-headline">new master password</h1>287 <p class="peach-hint">your recovery words are valid — now choose a new password</p>288 </div>289290 <div class="peach-recovery-new-password-form">291 <div class="peach-newvault-field">292 <label class="peach-label" for="recovery-new-password">293 new master password294 </label>295 <input296 id="recovery-new-password"297 class="peach-underline-input peach-unlock-input"298 type="password"299 autocomplete="new-password"300 value={newPassword()}301 onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}302 onKeyDown={(e) => {303 if (e.key === "Enter") void submitNewPassword();304 }}305 />306 </div>307308 <div class="peach-newvault-field">309 <label class="peach-label" for="recovery-confirm-password">310 confirm password311 </label>312 <input313 id="recovery-confirm-password"314 class="peach-underline-input peach-unlock-input"315 type="password"316 autocomplete="new-password"317 value={confirmPassword()}318 onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)}319 onKeyDown={(e) => {320 if (e.key === "Enter") void submitNewPassword();321 }}322 />323 </div>324325 <Show when={newPasswordError()}>326 <p class="peach-hint peach-hint-error">{newPasswordError()}</p>327 </Show>328 </div>329330 <button331 class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"332 onClick={() => void submitNewPassword()}333 disabled={newPasswordLoading()}334 >335 {newPasswordLoading() ? "recovering..." : "recover vault"}336 </button>337 </Show>338 </section>339 );340}341342function getDisplayError(error: string | undefined, fallback: string): string {343 if (!error) return fallback;344 switch (error) {345 case "no_vault":346 return "no vault found";347 case "recovery_not_setup":348 return "recovery not set up for this vault";349 case "invalid_recovery_words":350 return "invalid recovery words";351 default:352 return fallback;353 }354}