1import {2 Match,3 Show,4 Switch,5 createMemo,6 createSignal,7 onCleanup,8 onMount,9} from "solid-js";10import { sendMessage } from "../App";11import { RuneBackground } from "../components/RuneBackground";12import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";13import { parsePairingInvitation } from "../../shared/pairing-invitation";14import type { PairingQrScannerHandle } from "../pairing-qr-scanner";1516type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";1718export interface AdoptVaultViewProps {19 onCancel(): void;20 onDone(): void | Promise<void>;21 startAdoption(args: {22 password: string;23 connection:24 | { mode: "relay"; invitation: string }25 | { mode: "lan"; code: string };26 confirmSAS(sas: string): Promise<boolean>;27 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;28 }): Promise<void>;29}3031const MIN_LENGTH = 12;32const MIN_ZXCVBN_SCORE = 2;333435function adoptionErrorCopy(err: unknown): string {36 const message = err instanceof Error ? err.message : String(err);37 switch (message) {38 case "invalid_code":39 return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";40 case "no_host_found":41 return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open.";42 case "handshake_failed":43 return "The secure connection failed. Try again with a fresh code.";44 case "adoption_rejected":45 return "Your other device declined the request.";46 default:47 return message;48 }49}5051let zxcvbnLoader: ((password: string) => { score: number }) | null = null;52async function getZxcvbn() {53 if (zxcvbnLoader) return zxcvbnLoader;54 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([55 import("@zxcvbn-ts/core"),56 import("@zxcvbn-ts/language-common"),57 import("@zxcvbn-ts/language-en"),58 ]);59 zxcvbnOptions.setOptions({60 translations: en.translations,61 graphs: common.adjacencyGraphs,62 dictionary: { ...common.dictionary, ...en.dictionary },63 });64 zxcvbnLoader = (password: string) => zxcvbn(password);65 return zxcvbnLoader;66}6768export function AdoptVaultView(props: AdoptVaultViewProps) {69 const [phase, setPhase] = createSignal<AdoptPhase>("password");70 const [password, setPassword] = createSignal("");71 const [confirmPassword, setConfirmPassword] = createSignal("");72 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);73 const [sas, setSas] = createSignal("");74 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);75 const [error, setError] = createSignal("");76 const [code, setCode] = createSignal("");77 const [invitation, setInvitation] = createSignal("");78 const [lanOnly, setLanOnly] = createSignal(false);79 const [manualEntry, setManualEntry] = createSignal(false);80 const [scanning, setScanning] = createSignal(false);81 const [scanError, setScanError] = createSignal("");82 const [connecting, setConnecting] = createSignal(false);83 let videoEl: HTMLVideoElement | undefined;84 let qrScanner: PairingQrScannerHandle | null = null;8586 const codeReady = createMemo(() => parsePairingCode(code()) !== null);87 const invitationReady = createMemo(() =>88 parsePairingInvitation(invitation()) !== null89 );9091 onCleanup(() => qrScanner?.stop());92 onMount(() => {93 void sendMessage({ type: "GET_RELAY_SETTINGS" })94 .then((response) => {95 const result = response as {96 ok: boolean;97 data?: { lanOnly?: boolean };98 };99 if (result.ok) setLanOnly(result.data?.lanOnly === true);100 })101 .catch(() => {102 103 104 });105 });106107 createMemo(() => {108 const value = password();109 if (!value) {110 setStrengthScore(null);111 return;112 }113 getZxcvbn().then((zxcvbn) => {114 if (password() === value) setStrengthScore(zxcvbn(value).score);115 }).catch(() => {116 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);117 });118 });119120 const canContinue = createMemo(() =>121 password().length >= MIN_LENGTH &&122 confirmPassword().length >= MIN_LENGTH &&123 password() === confirmPassword() &&124 strengthScore() !== null &&125 strengthScore()! >= MIN_ZXCVBN_SCORE126 );127128 const createBlocker = createMemo(() => {129 if (!password()) return null;130 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;131 if (!confirmPassword()) return "confirm your password";132 if (password() !== confirmPassword()) return "passwords don't match";133 if (strengthScore() === null) return "checking strength...";134 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";135 return null;136 });137138 async function beginPairing() {139 const connection = lanOnly()140 ? (codeReady() ? { mode: "lan" as const, code: code() } : null)141 : (invitationReady()142 ? { mode: "relay" as const, invitation: invitation() }143 : null);144 if (!connection || connecting()) return;145 stopScanning();146 setPhase("pair");147 setError("");148 setConnecting(true);149 try {150 await props.startAdoption({151 password: password(),152 connection,153 confirmSAS: (sasCode) => new Promise<boolean>((resolve) => {154 setSas(sasCode);155 setSasResolve(() => resolve);156 setPhase("sas");157 }),158 createVault: async (rootKeyHex) => {159 const res = await sendMessage({160 type: "CREATE_VAULT_FROM_ROOT_KEY",161 payload: { password: password(), rootKeyHex },162 }) as { ok: boolean; vaultId?: string; error?: string };163 if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");164 return { vaultId: res.vaultId };165 },166 });167 setPhase("success");168 } catch (err) {169 if (err instanceof Error && err.message === "sas_rejected") return;170 setError(adoptionErrorCopy(err));171 setPhase("error");172 } finally {173 setConnecting(false);174 }175 }176177 function confirmSas(ok: boolean) {178 const resolve = sasResolve();179 setSasResolve(null);180 resolve?.(ok);181 if (!ok) {182 setPhase("password");183 setPassword("");184 setConfirmPassword("");185 setStrengthScore(null);186 187 188 setCode("");189 setInvitation("");190 }191 }192193 async function startScanning() {194 if (scanning()) return;195 setScanError("");196 setScanning(true);197 await Promise.resolve();198 if (!videoEl) {199 setScanning(false);200 setScanError("Camera preview could not start. Enter the invitation instead.");201 return;202 }203 try {204 const { startPairingQrScanner } = await import("../pairing-qr-scanner");205 qrScanner = await startPairingQrScanner(206 videoEl,207 (value) => {208 if (!parsePairingInvitation(value)) return;209 setInvitation(value);210 stopScanning();211 void beginPairing();212 },213 () => setScanError("Couldn't read that QR code. Hold it steady or enter the invitation."),214 );215 } catch {216 stopScanning();217 setScanError("Camera access is unavailable. Enter the invitation instead.");218 setManualEntry(true);219 }220 }221222 function stopScanning() {223 qrScanner?.stop();224 qrScanner = null;225 setScanning(false);226 }227228 async function chooseLanOnly(next: boolean) {229 stopScanning();230 setLanOnly(next);231 setManualEntry(false);232 const response = (await sendMessage({233 type: "SET_RELAY_SETTINGS",234 payload: { lanOnly: next },235 })) as { ok: boolean; error?: string };236 if (!response.ok) setScanError(response.error ?? "Could not update sync mode.");237 }238239 return (240 <section class="peach-screen peach-theme peach-adopt-vault">241 <RuneBackground opacity={0.07} />242 <div class="peach-adopt-card">243 <Switch>244 <Match when={phase() === "password"}>245 <h1>Set a password for this device.</h1>246 <p>This protects your vault on this device only. It can be different from your other devices.</p>247 <label class="peach-label" for="adopt-password">password</label>248 <input249 id="adopt-password"250 class="peach-underline-input peach-adopt-input"251 type="password"252 autocomplete="new-password"253 value={password()}254 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}255 />256 <label class="peach-label" for="adopt-confirm-password">confirm password</label>257 <input258 id="adopt-confirm-password"259 class="peach-underline-input peach-adopt-input"260 type="password"261 autocomplete="new-password"262 value={confirmPassword()}263 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}264 />265 <Show when={createBlocker()}>266 <p class="peach-hint">{createBlocker()}</p>267 </Show>268 <div class="peach-adopt-actions">269 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>270 Continue271 </button>272 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>273 </div>274 </Match>275276 <Match when={phase() === "pair"}>277 <div class="peach-adopt-birds" aria-hidden="true">278 <span />279 <span />280 </div>281 <h1>Pair with your other device.</h1>282 <Show283 when={!lanOnly()}284 fallback={285 <>286 <p>287 Local network only. Both devices must be on the same Wi-Fi;288 this will not work over VPN or from different locations.289 </p>290 <label class="peach-label" for="adopt-pair-code">local pairing code</label>291 <input292 id="adopt-pair-code"293 class="peach-underline-input peach-adopt-input peach-code-input"294 type="text"295 autocapitalize="characters"296 autocomplete="one-time-code"297 spellcheck={false}298 maxlength={32}299 placeholder="A74H-48J3-KI32-OEP4"300 aria-label="Pairing code"301 disabled={connecting()}302 value={code()}303 onInput={(event) =>304 setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32))305 }306 onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}307 />308 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(false)}>309 Use secure relay instead310 </button>311 </>312 }313 >314 <p>Connecting via secure relay — works anywhere.</p>315 <Show when={scanning()}>316 <video317 ref={videoEl}318 class="peach-adopt-qr-video"319 autoplay320 muted321 playsinline322 aria-label="Pairing QR camera preview"323 />324 </Show>325 <Show when={!scanning()}>326 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>327 Scan pairing QR328 </button>329 </Show>330 <button331 class="peach-btn peach-btn--ghost peach-btn--sm"332 onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}333 >334 {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}335 </button>336 <Show when={manualEntry()}>337 <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>338 <textarea339 id="adopt-relay-invitation"340 class="peach-underline-input peach-adopt-input"341 rows={3}342 autocomplete="off"343 spellcheck={false}344 aria-label="Pairing invitation"345 disabled={connecting()}346 value={invitation()}347 onInput={(event) =>348 setInvitation((event.target as HTMLTextAreaElement).value.trim())349 }350 />351 </Show>352 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(true)}>353 Switch to local network only354 </button>355 </Show>356 <Show when={scanError()}>357 <p class="peach-hint peach-hint-error">{scanError()}</p>358 </Show>359 <Show when={connecting()}>360 <p class="peach-hint">connecting to your other device...</p>361 </Show>362 <div class="peach-adopt-actions">363 <button364 class="peach-btn peach-btn--accent peach-btn--md"365 disabled={(lanOnly() ? !codeReady() : !invitationReady()) || connecting()}366 onClick={() => void beginPairing()}367 >368 Connect securely369 </button>370 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>371 </div>372 </Match>373374 <Match when={phase() === "sas"}>375 <h1>Confirm this code matches on your other device.</h1>376 <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>377 <div class="peach-adopt-actions">378 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>379 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>380 </div>381 </Match>382383 <Match when={phase() === "success"}>384 <div class="peach-adopt-birds" aria-hidden="true">385 <span />386 <span />387 </div>388 <h1>Joined your vault.</h1>389 <p>Your passwords will sync shortly.</p>390 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>391 Open Peach392 </button>393 </Match>394395 <Match when={phase() === "error"}>396 <h1>Could not join your vault.</h1>397 <p class="peach-hint peach-hint-error">{error()}</p>398 <div class="peach-adopt-actions">399 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>400 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>401 </div>402 </Match>403 </Switch>404 </div>405 </section>406 );407}