import { Match, Show, Switch, createMemo, createSignal } from "solid-js"; import { sendMessage } from "../App"; import { RuneBackground } from "../components/RuneBackground"; import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code"; type AdoptPhase = "password" | "pair" | "sas" | "success" | "error"; export interface AdoptVaultViewProps { onCancel(): void; onDone(): void | Promise; startAdoption(args: { password: string; /** The pairing code shown by the device that already holds the vault. */ code: string; confirmSAS(sas: string): Promise; createVault(rootKeyHex: string): Promise<{ vaultId: string }>; }): Promise; } const MIN_LENGTH = 12; const MIN_ZXCVBN_SCORE = 2; /** Turn a dial/adoption failure into something a person can act on. */ function adoptionErrorCopy(err: unknown): string { const message = err instanceof Error ? err.message : String(err); switch (message) { case "invalid_code": return "That code wasn't accepted — it may have expired. Get a fresh one from your other device."; case "no_host_found": return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open."; case "handshake_failed": return "The secure connection failed. Try again with a fresh code."; case "adoption_rejected": return "Your other device declined the request."; default: return message; } } let zxcvbnLoader: ((password: string) => { score: number }) | null = null; async function getZxcvbn() { if (zxcvbnLoader) return zxcvbnLoader; const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([ import("@zxcvbn-ts/core"), import("@zxcvbn-ts/language-common"), import("@zxcvbn-ts/language-en"), ]); zxcvbnOptions.setOptions({ translations: en.translations, graphs: common.adjacencyGraphs, dictionary: { ...common.dictionary, ...en.dictionary }, }); zxcvbnLoader = (password: string) => zxcvbn(password); return zxcvbnLoader; } export function AdoptVaultView(props: AdoptVaultViewProps) { const [phase, setPhase] = createSignal("password"); const [password, setPassword] = createSignal(""); const [confirmPassword, setConfirmPassword] = createSignal(""); const [strengthScore, setStrengthScore] = createSignal(null); const [sas, setSas] = createSignal(""); const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null); const [error, setError] = createSignal(""); const [code, setCode] = createSignal(""); const [connecting, setConnecting] = createSignal(false); const codeReady = createMemo(() => parsePairingCode(code()) !== null); createMemo(() => { const value = password(); if (!value) { setStrengthScore(null); return; } getZxcvbn().then((zxcvbn) => { if (password() === value) setStrengthScore(zxcvbn(value).score); }).catch(() => { if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1); }); }); const canContinue = createMemo(() => password().length >= MIN_LENGTH && confirmPassword().length >= MIN_LENGTH && password() === confirmPassword() && strengthScore() !== null && strengthScore()! >= MIN_ZXCVBN_SCORE ); const createBlocker = createMemo(() => { if (!password()) return null; if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`; if (!confirmPassword()) return "confirm your password"; if (password() !== confirmPassword()) return "passwords don't match"; if (strengthScore() === null) return "checking strength..."; if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase"; return null; }); async function beginPairing() { if (!codeReady() || connecting()) return; setPhase("pair"); setError(""); setConnecting(true); try { await props.startAdoption({ password: password(), code: code(), confirmSAS: (sasCode) => new Promise((resolve) => { setSas(sasCode); setSasResolve(() => resolve); setPhase("sas"); }), createVault: async (rootKeyHex) => { const res = await sendMessage({ type: "CREATE_VAULT_FROM_ROOT_KEY", payload: { password: password(), rootKeyHex }, }) as { ok: boolean; vaultId?: string; error?: string }; if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed"); return { vaultId: res.vaultId }; }, }); setPhase("success"); } catch (err) { if (err instanceof Error && err.message === "sas_rejected") return; setError(adoptionErrorCopy(err)); setPhase("error"); } finally { setConnecting(false); } } function confirmSas(ok: boolean) { const resolve = sasResolve(); setSasResolve(null); resolve?.(ok); if (!ok) { setPhase("password"); setPassword(""); setConfirmPassword(""); setStrengthScore(null); // The code is single-use on the other device's side too — a rejected SAS // means starting over with a fresh one, not retrying this one. setCode(""); } } return (

Set a password for this device.

This protects your vault on this device only. It can be different from your other devices.

setPassword((event.target as HTMLInputElement).value)} /> setConfirmPassword((event.target as HTMLInputElement).value)} />

{createBlocker()}

Pair with your other device.

Open Peach on the device that has your vault and tap “Pair a device”. Type the code it shows. Both devices need to be on the same Wi-Fi.

setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32)) } onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }} />

connecting to your other device...

Confirm this code matches on your other device.

{sas().replace(/(\d)(?=\d)/g, "$1 ")}

Joined your vault.

Your passwords will sync shortly.

Could not join your vault.

{error()}

); }