1import {2 Match,3 Show,4 Switch,5 createEffect,6 createMemo,7 createSignal,8 onCleanup,9} from "solid-js";10import { sendMessage } from "../App";11import { DesktopBird, MobileBird } from "../components/PairingBirds";12import { RuneBackground } from "../components/RuneBackground";13import { PairingQrCode } from "../components/PairingQrCode";14import { PairingProRequired } from "../components/PairingProRequired";15import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1617type AdoptPhase = "password" | "pair" | "error";1819export interface AdoptVaultViewProps {20 onCancel(): void;21 onDone(): void | Promise<void>;22}23242526272829function describeAdoptionError(error: string | undefined, fallback: string): string {30 return error === "ADOPT_PRO_REQUIRED" ? error : fallback;31}3233const MIN_LENGTH = 12;34const MIN_ZXCVBN_SCORE = 2;35const PAIRING_CELEBRATION_MS = 5_000;3637let zxcvbnLoader: ((password: string) => { score: number }) | null = null;38async function getZxcvbn() {39 if (zxcvbnLoader) return zxcvbnLoader;40 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([41 import("@zxcvbn-ts/core"),42 import("@zxcvbn-ts/language-common"),43 import("@zxcvbn-ts/language-en"),44 ]);45 zxcvbnOptions.setOptions({46 translations: en.translations,47 graphs: common.adjacencyGraphs,48 dictionary: { ...common.dictionary, ...en.dictionary },49 });50 zxcvbnLoader = (password: string) => zxcvbn(password);51 return zxcvbnLoader;52}5354export function AdoptVaultView(props: AdoptVaultViewProps) {55 const [phase, setPhase] = createSignal<AdoptPhase>("password");56 const [password, setPassword] = createSignal("");57 const [confirmPassword, setConfirmPassword] = createSignal("");58 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);59 const [receiverInvitation, setReceiverInvitation] = createSignal("");60 const [receiverSas, setReceiverSas] = createSignal("");61 const [receiverError, setReceiverError] = createSignal("");62 const [receiverComplete, setReceiverComplete] = createSignal(false);63 let completionTimer: number | undefined;6465 onCleanup(() => {66 if (completionTimer !== undefined) window.clearTimeout(completionTimer);67 68 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);69 });7071 72 73 74 75 76 createEffect(() => {77 if (phase() !== "pair") return;78 if (receiverInvitation() || receiverError()) return;79 void (async () => {80 const response = (await sendMessage({81 type: "START_ADOPTION_RECEIVER",82 payload: { password: password() },83 })) as { ok: boolean; data?: { invitation?: string }; error?: string };84 if (response.ok && response.data?.invitation) {85 setReceiverInvitation(response.data.invitation);86 } else {87 setReceiverError(88 describeAdoptionError(89 response.error,90 "could not start joining this vault. please try again.",91 ),92 );93 }94 })();95 });9697 const [receiverSasId, setReceiverSasId] = createSignal("");98 const [receiverSasDecision, setReceiverSasDecision] = createSignal(false);99100 function confirmReceiverSas(): void {101 if (!receiverSas() || !receiverSasId()) return;102 void sendMessage({103 type: "RECEIVER_SAS_DECISION",104 payload: { id: receiverSasId(), sas: receiverSas(), ok: true },105 });106 setReceiverSasDecision(true);107 }108109 110111 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {112 const handler = (msg: { type?: string; payload?: unknown }) => {113 if (msg?.type === "ADOPTION_SOURCE_SAS") {114 const p = msg.payload as {115 id?: string;116 sas?: string;117 displayOnly?: boolean;118 } | undefined;119 if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") {120 setReceiverSas(p.sas);121 setReceiverSasId((p as { id: string }).id);122 setReceiverSasDecision(false);123 }124 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {125 const p = msg.payload as {126 status?: "complete" | "error";127 error?: string;128 } | undefined;129 if (p?.status === "complete") {130 if (receiverComplete()) return;131 setReceiverComplete(true);132 completionTimer = window.setTimeout(() => {133 completionTimer = undefined;134 void props.onDone();135 }, PAIRING_CELEBRATION_MS);136 } else if (p?.status === "error") {137 setReceiverError(138 describeAdoptionError(139 p.error,140 "the other device could not finish the transfer. please try again.",141 ),142 );143 setPhase("error");144 }145 }146 };147 chrome.runtime.onMessage.addListener(handler);148 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));149 }150151 createMemo(() => {152 const value = password();153 if (!value) {154 setStrengthScore(null);155 return;156 }157 getZxcvbn().then((zxcvbn) => {158 if (password() === value) setStrengthScore(zxcvbn(value).score);159 }).catch(() => {160 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);161 });162 });163164 const canContinue = createMemo(() =>165 password().length >= MIN_LENGTH &&166 confirmPassword().length >= MIN_LENGTH &&167 password() === confirmPassword() &&168 strengthScore() !== null &&169 strengthScore()! >= MIN_ZXCVBN_SCORE170 );171172 const createBlocker = createMemo(() => {173 if (!password()) return null;174 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;175 if (!confirmPassword()) return "confirm your password";176 if (password() !== confirmPassword()) return "passwords don't match";177 if (strengthScore() === null) return "checking strength...";178 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";179 return null;180 });181182 function tryAgain(): void {183 setReceiverError("");184 setReceiverInvitation("");185 setReceiverSas("");186 setPhase("pair");187 }188189 return (190 <section191 class="peach-screen peach-theme peach-adopt-vault"192 classList={{ "peach-adopt-vault--pair": phase() === "pair" }}193 >194 <RuneBackground opacity={0.07} />195 <div196 class="peach-adopt-card"197 classList={{ "peach-adopt-card--pair": phase() === "pair" }}198 >199 <Switch>200 <Match when={phase() === "password"}>201 <h1>Set a password for this device.</h1>202 <p>This protects your vault on this device only. It can be different from your other devices.</p>203 <label class="peach-label" for="adopt-password">password</label>204 <input205 id="adopt-password"206 class="peach-underline-input peach-adopt-input"207 type="password"208 autocomplete="new-password"209 value={password()}210 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}211 onKeyDown={(event) => {212 if (event.key === "Enter" && canContinue()) setPhase("pair");213 }}214 />215 <label class="peach-label" for="adopt-confirm-password">confirm password</label>216 <input217 id="adopt-confirm-password"218 class="peach-underline-input peach-adopt-input"219 type="password"220 autocomplete="new-password"221 value={confirmPassword()}222 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}223 onKeyDown={(event) => {224 if (event.key === "Enter" && canContinue()) setPhase("pair");225 }}226 />227 <Show when={createBlocker()}>228 <p class="peach-hint">{createBlocker()}</p>229 </Show>230 <div class="peach-adopt-actions">231 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>232 Continue233 </button>234 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>235 </div>236 </Match>237238 <Match when={phase() === "pair"}>239 <div240 class="peach-adopt-pairing"241 classList={{242 "has-sas": Boolean(receiverSas()),243 "is-complete": receiverComplete(),244 }}245 >246 <div247 class={`peach-pair-stage peach-adopt-bird-stage ${248 receiverComplete()249 ? "act-paired"250 : receiverSas()251 ? "act-handshake"252 : "act-listening"253 }`}254 aria-hidden="true"255 >256 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">257 <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />258 </div>259 <Show when={receiverComplete()}>260 <span class="peach-adopt-connection" />261 </Show>262 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">263 <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />264 </div>265 </div>266267 <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>268 <p class="peach-adopt-pair-lede" aria-live="polite">269 {receiverComplete()270 ? "Your devices are connected. Getting your vault ready…"271 : receiverSas()272 ? "Make sure this code matches the one on your phone."273 : "Scan this QR in Peach on the device that already has your vault."}274 </p>275276 <Show277 when={!receiverComplete()}278 fallback={279 <div class="peach-adopt-paired-status" role="status">280 <span />281 Secure connection established282 </div>283 }284 >285 <Show286 when={receiverSas()}287 fallback={288 <div class="peach-adopt-qr-wrap" aria-live="polite">289 <Show290 when={receiverInvitation()}291 fallback={292 <Show293 when={receiverError()}294 fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}295 >296 <p class="peach-hint peach-hint-error">{receiverError()}</p>297 </Show>298 }299 >300 <PairingQrCode value={receiverInvitation()} size={144} />301 </Show>302 </div>303 }304 >305 <div class="peach-adopt-confirmation">306 <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>307 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">308 <strong>{receiverSas()}</strong>309 </div>310 <p class="peach-adopt-confirmation-help">311 Confirm this 8-digit code matches the phone you intend to312 trust. Approving replaces this empty device with a complete313 copy of its vault, including existing passwords, passkeys,314 notes, and recovery access.315 </p>316 <button317 class="peach-btn peach-btn--accent peach-btn--md"318 disabled={receiverSasDecision()}319 onClick={confirmReceiverSas}320 >321 {receiverSasDecision() ? "Code confirmed" : "Confirm code"}322 </button>323 </div>324 </Show>325326 <div class="peach-adopt-privacy">327 <svg viewBox="0 0 24 24" aria-hidden="true">328 <path d="M7 10V8a5 5 0 0 1 10 0v2" />329 <rect x="5" y="10" width="14" height="10" rx="3" />330 <path d="M12 14v2" />331 </svg>332 <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>333 </div>334335 <div class="peach-adopt-actions peach-adopt-pair-actions">336 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>337 </div>338 </Show>339 </div>340 </Match>341342 <Match when={phase() === "error"}>343 <Show344 when={receiverError() === "ADOPT_PRO_REQUIRED"}345 fallback={346 <>347 <h1>Could not join the vault.</h1>348 <p class="peach-hint peach-hint-error">{receiverError()}</p>349 <div class="peach-adopt-actions">350 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>351 Try again352 </button>353 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>354 </div>355 </>356 }357 >358 <PairingProRequired onRetry={tryAgain} />359 </Show>360 </Match>361 </Switch>362 </div>363 </section>364 );365}