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 [peerFingerprint, setPeerFingerprint] = createSignal("");62 const [receiverError, setReceiverError] = createSignal("");63 const [receiverComplete, setReceiverComplete] = createSignal(false);64 let completionTimer: number | undefined;6566 onCleanup(() => {67 if (completionTimer !== undefined) window.clearTimeout(completionTimer);68 69 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);70 });7172 73 74 75 76 77 createEffect(() => {78 if (phase() !== "pair") return;79 if (receiverInvitation() || receiverError()) return;80 void (async () => {81 const response = (await sendMessage({82 type: "START_ADOPTION_RECEIVER",83 payload: { password: password() },84 })) as { ok: boolean; data?: { invitation?: string }; error?: string };85 if (response.ok && response.data?.invitation) {86 setReceiverInvitation(response.data.invitation);87 } else {88 setReceiverError(89 describeAdoptionError(90 response.error,91 "could not start joining this vault. please try again.",92 ),93 );94 }95 })();96 });9798 const [receiverSasId, setReceiverSasId] = createSignal("");99 const [receiverSasDecision, setReceiverSasDecision] = createSignal(false);100101 function confirmReceiverSas(): void {102 if (!receiverSas() || !receiverSasId()) return;103 void sendMessage({104 type: "RECEIVER_SAS_DECISION",105 payload: { id: receiverSasId(), sas: receiverSas(), ok: true },106 });107 setReceiverSasDecision(true);108 }109110 111112 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {113 const handler = (msg: { type?: string; payload?: unknown }) => {114 if (msg?.type === "ADOPTION_SOURCE_SAS") {115 const p = msg.payload as {116 id?: string;117 sas?: string;118 displayOnly?: boolean;119 peerFingerprint?: string;120 } | undefined;121 if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") {122 setReceiverSas(p.sas);123 setReceiverSasId((p as { id: string }).id);124 setReceiverSasDecision(false);125 setPeerFingerprint(typeof p.peerFingerprint === "string" ? p.peerFingerprint : "");126 }127 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {128 const p = msg.payload as {129 status?: "complete" | "error";130 error?: string;131 } | undefined;132 if (p?.status === "complete") {133 if (receiverComplete()) return;134 setReceiverComplete(true);135 completionTimer = window.setTimeout(() => {136 completionTimer = undefined;137 void props.onDone();138 }, PAIRING_CELEBRATION_MS);139 } else if (p?.status === "error") {140 setReceiverError(141 describeAdoptionError(142 p.error,143 "the other device could not finish the transfer. please try again.",144 ),145 );146 setPhase("error");147 }148 }149 };150 chrome.runtime.onMessage.addListener(handler);151 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));152 }153154 createMemo(() => {155 const value = password();156 if (!value) {157 setStrengthScore(null);158 return;159 }160 getZxcvbn().then((zxcvbn) => {161 if (password() === value) setStrengthScore(zxcvbn(value).score);162 }).catch(() => {163 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);164 });165 });166167 const canContinue = createMemo(() =>168 password().length >= MIN_LENGTH &&169 confirmPassword().length >= MIN_LENGTH &&170 password() === confirmPassword() &&171 strengthScore() !== null &&172 strengthScore()! >= MIN_ZXCVBN_SCORE173 );174175 const createBlocker = createMemo(() => {176 if (!password()) return null;177 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;178 if (!confirmPassword()) return "confirm your password";179 if (password() !== confirmPassword()) return "passwords don't match";180 if (strengthScore() === null) return "checking strength...";181 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";182 return null;183 });184185 function tryAgain(): void {186 setReceiverError("");187 setReceiverInvitation("");188 setReceiverSas("");189 setPhase("pair");190 }191192 return (193 <section194 class="peach-screen peach-theme peach-adopt-vault"195 classList={{ "peach-adopt-vault--pair": phase() === "pair" }}196 >197 <RuneBackground opacity={0.07} />198 <div199 class="peach-adopt-card"200 classList={{ "peach-adopt-card--pair": phase() === "pair" }}201 >202 <Switch>203 <Match when={phase() === "password"}>204 <h1>Set a password for this device.</h1>205 <p>This protects your vault on this device only. It can be different from your other devices.</p>206 <label class="peach-label" for="adopt-password">password</label>207 <input208 id="adopt-password"209 class="peach-underline-input peach-adopt-input"210 type="password"211 autocomplete="new-password"212 value={password()}213 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}214 onKeyDown={(event) => {215 if (event.key === "Enter" && canContinue()) setPhase("pair");216 }}217 />218 <label class="peach-label" for="adopt-confirm-password">confirm password</label>219 <input220 id="adopt-confirm-password"221 class="peach-underline-input peach-adopt-input"222 type="password"223 autocomplete="new-password"224 value={confirmPassword()}225 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}226 onKeyDown={(event) => {227 if (event.key === "Enter" && canContinue()) setPhase("pair");228 }}229 />230 <Show when={createBlocker()}>231 <p class="peach-hint">{createBlocker()}</p>232 </Show>233 <div class="peach-adopt-actions">234 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>235 Continue236 </button>237 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>238 </div>239 </Match>240241 <Match when={phase() === "pair"}>242 <div243 class="peach-adopt-pairing"244 classList={{245 "has-sas": Boolean(receiverSas()),246 "is-complete": receiverComplete(),247 }}248 >249 <div250 class={`peach-pair-stage peach-adopt-bird-stage ${251 receiverComplete()252 ? "act-paired"253 : receiverSas()254 ? "act-handshake"255 : "act-listening"256 }`}257 aria-hidden="true"258 >259 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">260 <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />261 </div>262 <Show when={receiverComplete()}>263 <span class="peach-adopt-connection" />264 </Show>265 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">266 <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />267 </div>268 </div>269270 <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>271 <p class="peach-adopt-pair-lede" aria-live="polite">272 {receiverComplete()273 ? "Your devices are connected. Getting your vault ready…"274 : receiverSas()275 ? "Make sure this code matches the one on your phone."276 : "Scan this QR in Peach on the device that already has your vault."}277 </p>278279 <Show280 when={!receiverComplete()}281 fallback={282 <div class="peach-adopt-paired-status" role="status">283 <span />284 Secure connection established285 </div>286 }287 >288 <Show289 when={receiverSas()}290 fallback={291 <div class="peach-adopt-qr-wrap" aria-live="polite">292 <Show293 when={receiverInvitation()}294 fallback={295 <Show296 when={receiverError()}297 fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}298 >299 <p class="peach-hint peach-hint-error">{receiverError()}</p>300 </Show>301 }302 >303 <PairingQrCode value={receiverInvitation()} size={144} />304 </Show>305 </div>306 }307 >308 <div class="peach-adopt-confirmation">309 <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>310 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">311 <strong>{receiverSas()}</strong>312 </div>313 <p class="peach-adopt-confirmation-help">314 Confirm this code and session ID {peerFingerprint()} match315 the phone you intend to trust. Approving replaces this316 empty device with a complete copy of its vault, including317 existing passwords, passkeys, notes, and recovery access.318 </p>319 <button320 class="peach-btn peach-btn--accent peach-btn--md"321 disabled={receiverSasDecision()}322 onClick={confirmReceiverSas}323 >324 {receiverSasDecision() ? "Code confirmed" : "Confirm code"}325 </button>326 </div>327 </Show>328329 <div class="peach-adopt-privacy">330 <svg viewBox="0 0 24 24" aria-hidden="true">331 <path d="M7 10V8a5 5 0 0 1 10 0v2" />332 <rect x="5" y="10" width="14" height="10" rx="3" />333 <path d="M12 14v2" />334 </svg>335 <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>336 </div>337338 <div class="peach-adopt-actions peach-adopt-pair-actions">339 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>340 </div>341 </Show>342 </div>343 </Match>344345 <Match when={phase() === "error"}>346 <Show347 when={receiverError() === "ADOPT_PRO_REQUIRED"}348 fallback={349 <>350 <h1>Could not join the vault.</h1>351 <p class="peach-hint peach-hint-error">{receiverError()}</p>352 <div class="peach-adopt-actions">353 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>354 Try again355 </button>356 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>357 </div>358 </>359 }360 >361 <PairingProRequired onRetry={tryAgain} />362 </Show>363 </Match>364 </Switch>365 </div>366 </section>367 );368}