import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, } from "solid-js"; import { sendMessage } from "../App"; import { DesktopBird, MobileBird } from "../components/PairingBirds"; import { RuneBackground } from "../components/RuneBackground"; import { PairingQrCode } from "../components/PairingQrCode"; import { PairingProRequired } from "../components/PairingProRequired"; import { RELAY_DISCLOSURE } from "../components/SyncModeSelector"; type AdoptPhase = "password" | "pair" | "error"; export interface AdoptVaultViewProps { onCancel(): void; onDone(): void | Promise; } /** * PB-26-607: never echo a raw background error string into the UI. * ADOPT_PRO_REQUIRED survives because the error phase branches on it to show * the Pro upsell instead of the generic failure copy. */ function describeAdoptionError(error: string | undefined, fallback: string): string { return error === "ADOPT_PRO_REQUIRED" ? error : fallback; } const MIN_LENGTH = 12; const MIN_ZXCVBN_SCORE = 2; const PAIRING_CELEBRATION_MS = 5_000; 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 [receiverInvitation, setReceiverInvitation] = createSignal(""); const [receiverSas, setReceiverSas] = createSignal(""); const [receiverError, setReceiverError] = createSignal(""); const [receiverComplete, setReceiverComplete] = createSignal(false); let completionTimer: number | undefined; onCleanup(() => { if (completionTimer !== undefined) window.clearTimeout(completionTimer); // Tell the background to release the relay room if we still hold it. void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined); }); // When the user enters the pair phase, the empty extension becomes the // source of a "pairing request" QR. The populated phone scans it and the // phone is responsible for sending the vault back over the Noise channel. // The password is shipped to the background once so the adopted root key // can be wrapped at rest without asking the user to retype it later. createEffect(() => { if (phase() !== "pair") return; if (receiverInvitation() || receiverError()) return; void (async () => { const response = (await sendMessage({ type: "START_ADOPTION_RECEIVER", payload: { password: password() }, })) as { ok: boolean; data?: { invitation?: string }; error?: string }; if (response.ok && response.data?.invitation) { setReceiverInvitation(response.data.invitation); } else { setReceiverError( describeAdoptionError( response.error, "could not start joining this vault. please try again.", ), ); } })(); }); const [receiverSasId, setReceiverSasId] = createSignal(""); const [receiverSasDecision, setReceiverSasDecision] = createSignal(false); function confirmReceiverSas(): void { if (!receiverSas() || !receiverSasId()) return; void sendMessage({ type: "RECEIVER_SAS_DECISION", payload: { id: receiverSasId(), sas: receiverSas(), ok: true }, }); setReceiverSasDecision(true); } // The receiver must explicitly confirm the displayed SAS before adoption. if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) { const handler = (msg: { type?: string; payload?: unknown }) => { if (msg?.type === "ADOPTION_SOURCE_SAS") { const p = msg.payload as { id?: string; sas?: string; displayOnly?: boolean; } | undefined; if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") { setReceiverSas(p.sas); setReceiverSasId((p as { id: string }).id); setReceiverSasDecision(false); } } else if (msg?.type === "ADOPTION_SOURCE_RESULT") { const p = msg.payload as { status?: "complete" | "error"; error?: string; } | undefined; if (p?.status === "complete") { if (receiverComplete()) return; setReceiverComplete(true); completionTimer = window.setTimeout(() => { completionTimer = undefined; void props.onDone(); }, PAIRING_CELEBRATION_MS); } else if (p?.status === "error") { setReceiverError( describeAdoptionError( p.error, "the other device could not finish the transfer. please try again.", ), ); setPhase("error"); } } }; chrome.runtime.onMessage.addListener(handler); onCleanup(() => chrome.runtime.onMessage.removeListener(handler)); } 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; }); function tryAgain(): void { setReceiverError(""); setReceiverInvitation(""); setReceiverSas(""); setPhase("pair"); } 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)} onKeyDown={(event) => { if (event.key === "Enter" && canContinue()) setPhase("pair"); }} /> setConfirmPassword((event.target as HTMLInputElement).value)} onKeyDown={(event) => { if (event.key === "Enter" && canContinue()) setPhase("pair"); }} />

{createBlocker()}

{receiverComplete() ? "Paired" : "Pair a device"}

{receiverComplete() ? "Your devices are connected. Getting your vault ready…" : receiverSas() ? "Make sure this code matches the one on your phone." : "Scan this QR in Peach on the device that already has your vault."}

Secure connection established
} > Preparing your secure pairing code…

} >

{receiverError()}

} >
} >

8-DIGIT SAFETY CODE

{receiverSas()}

Confirm this 8-digit code matches the phone you intend to trust. Approving replaces this empty device with a complete copy of its vault, including existing passwords, passkeys, notes, and recovery access.

Private by design. {RELAY_DISCLOSURE}

Could not join the vault.

{receiverError()}

} >
); }