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}2324const MIN_LENGTH = 12;25const MIN_ZXCVBN_SCORE = 2;26const PAIRING_CELEBRATION_MS = 5_000;2728let zxcvbnLoader: ((password: string) => { score: number }) | null = null;29async function getZxcvbn() {30 if (zxcvbnLoader) return zxcvbnLoader;31 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([32 import("@zxcvbn-ts/core"),33 import("@zxcvbn-ts/language-common"),34 import("@zxcvbn-ts/language-en"),35 ]);36 zxcvbnOptions.setOptions({37 translations: en.translations,38 graphs: common.adjacencyGraphs,39 dictionary: { ...common.dictionary, ...en.dictionary },40 });41 zxcvbnLoader = (password: string) => zxcvbn(password);42 return zxcvbnLoader;43}4445export function AdoptVaultView(props: AdoptVaultViewProps) {46 const [phase, setPhase] = createSignal<AdoptPhase>("password");47 const [password, setPassword] = createSignal("");48 const [confirmPassword, setConfirmPassword] = createSignal("");49 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);50 const [receiverInvitation, setReceiverInvitation] = createSignal("");51 const [receiverSas, setReceiverSas] = createSignal("");52 const [receiverError, setReceiverError] = createSignal("");53 const [receiverComplete, setReceiverComplete] = createSignal(false);54 let completionTimer: number | undefined;5556 onCleanup(() => {57 if (completionTimer !== undefined) window.clearTimeout(completionTimer);58 59 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);60 });6162 63 64 65 66 67 createEffect(() => {68 if (phase() !== "pair") return;69 if (receiverInvitation() || receiverError()) return;70 void (async () => {71 const response = (await sendMessage({72 type: "START_ADOPTION_RECEIVER",73 payload: { password: password() },74 })) as { ok: boolean; data?: { invitation?: string }; error?: string };75 if (response.ok && response.data?.invitation) {76 setReceiverInvitation(response.data.invitation);77 } else {78 setReceiverError(response.error ?? "adoption_receiver_failed");79 }80 })();81 });8283 const [receiverSasId, setReceiverSasId] = createSignal("");84 const [receiverSasDecision, setReceiverSasDecision] = createSignal(false);8586 function confirmReceiverSas(): void {87 if (!receiverSas() || !receiverSasId()) return;88 void sendMessage({89 type: "RECEIVER_SAS_DECISION",90 payload: { id: receiverSasId(), sas: receiverSas(), ok: true },91 });92 setReceiverSasDecision(true);93 }9495 9697 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {98 const handler = (msg: { type?: string; payload?: unknown }) => {99 if (msg?.type === "ADOPTION_SOURCE_SAS") {100 const p = msg.payload as { id?: string; sas?: string; displayOnly?: boolean } | undefined;101 if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") {102 setReceiverSas(p.sas);103 setReceiverSasId((p as { id: string }).id);104 setReceiverSasDecision(false);105 }106 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {107 const p = msg.payload as {108 status?: "complete" | "error";109 error?: string;110 } | undefined;111 if (p?.status === "complete") {112 if (receiverComplete()) return;113 setReceiverComplete(true);114 completionTimer = window.setTimeout(() => {115 completionTimer = undefined;116 void props.onDone();117 }, PAIRING_CELEBRATION_MS);118 } else if (p?.status === "error") {119 setReceiverError(p.error ?? "adoption_failed");120 setPhase("error");121 }122 }123 };124 chrome.runtime.onMessage.addListener(handler);125 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));126 }127128 createMemo(() => {129 const value = password();130 if (!value) {131 setStrengthScore(null);132 return;133 }134 getZxcvbn().then((zxcvbn) => {135 if (password() === value) setStrengthScore(zxcvbn(value).score);136 }).catch(() => {137 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);138 });139 });140141 const canContinue = createMemo(() =>142 password().length >= MIN_LENGTH &&143 confirmPassword().length >= MIN_LENGTH &&144 password() === confirmPassword() &&145 strengthScore() !== null &&146 strengthScore()! >= MIN_ZXCVBN_SCORE147 );148149 const createBlocker = createMemo(() => {150 if (!password()) return null;151 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;152 if (!confirmPassword()) return "confirm your password";153 if (password() !== confirmPassword()) return "passwords don't match";154 if (strengthScore() === null) return "checking strength...";155 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";156 return null;157 });158159 function tryAgain(): void {160 setReceiverError("");161 setReceiverInvitation("");162 setReceiverSas("");163 setPhase("pair");164 }165166 return (167 <section168 class="peach-screen peach-theme peach-adopt-vault"169 classList={{ "peach-adopt-vault--pair": phase() === "pair" }}170 >171 <RuneBackground opacity={0.07} />172 <div173 class="peach-adopt-card"174 classList={{ "peach-adopt-card--pair": phase() === "pair" }}175 >176 <Switch>177 <Match when={phase() === "password"}>178 <h1>Set a password for this device.</h1>179 <p>This protects your vault on this device only. It can be different from your other devices.</p>180 <label class="peach-label" for="adopt-password">password</label>181 <input182 id="adopt-password"183 class="peach-underline-input peach-adopt-input"184 type="password"185 autocomplete="new-password"186 value={password()}187 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}188 onKeyDown={(event) => {189 if (event.key === "Enter" && canContinue()) setPhase("pair");190 }}191 />192 <label class="peach-label" for="adopt-confirm-password">confirm password</label>193 <input194 id="adopt-confirm-password"195 class="peach-underline-input peach-adopt-input"196 type="password"197 autocomplete="new-password"198 value={confirmPassword()}199 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}200 onKeyDown={(event) => {201 if (event.key === "Enter" && canContinue()) setPhase("pair");202 }}203 />204 <Show when={createBlocker()}>205 <p class="peach-hint">{createBlocker()}</p>206 </Show>207 <div class="peach-adopt-actions">208 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>209 Continue210 </button>211 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>212 </div>213 </Match>214215 <Match when={phase() === "pair"}>216 <div217 class="peach-adopt-pairing"218 classList={{219 "has-sas": Boolean(receiverSas()),220 "is-complete": receiverComplete(),221 }}222 >223 <div224 class={`peach-pair-stage peach-adopt-bird-stage ${225 receiverComplete()226 ? "act-paired"227 : receiverSas()228 ? "act-handshake"229 : "act-listening"230 }`}231 aria-hidden="true"232 >233 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">234 <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />235 </div>236 <Show when={receiverComplete()}>237 <span class="peach-adopt-connection" />238 </Show>239 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">240 <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />241 </div>242 </div>243244 <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>245 <p class="peach-adopt-pair-lede" aria-live="polite">246 {receiverComplete()247 ? "Your devices are connected. Getting your vault ready…"248 : receiverSas()249 ? "Make sure this code matches the one on your phone."250 : "Scan this QR in Peach on the device that already has your vault."}251 </p>252253 <Show254 when={!receiverComplete()}255 fallback={256 <div class="peach-adopt-paired-status" role="status">257 <span />258 Secure connection established259 </div>260 }261 >262 <Show263 when={receiverSas()}264 fallback={265 <div class="peach-adopt-qr-wrap" aria-live="polite">266 <Show267 when={receiverInvitation()}268 fallback={269 <Show270 when={receiverError()}271 fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}272 >273 <p class="peach-hint peach-hint-error">{receiverError()}</p>274 </Show>275 }276 >277 <PairingQrCode value={receiverInvitation()} size={144} />278 </Show>279 </div>280 }281 >282 <div class="peach-adopt-confirmation">283 <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>284 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">285 <strong>{receiverSas()}</strong>286 </div>287 <p class="peach-adopt-confirmation-help">288 Confirm this code matches the one on your phone.289 </p>290 <button291 class="peach-btn peach-btn--accent peach-btn--md"292 disabled={receiverSasDecision()}293 onClick={confirmReceiverSas}294 >295 {receiverSasDecision() ? "Code confirmed" : "Confirm code"}296 </button>297 </div>298 </Show>299300 <div class="peach-adopt-privacy">301 <svg viewBox="0 0 24 24" aria-hidden="true">302 <path d="M7 10V8a5 5 0 0 1 10 0v2" />303 <rect x="5" y="10" width="14" height="10" rx="3" />304 <path d="M12 14v2" />305 </svg>306 <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>307 </div>308309 <div class="peach-adopt-actions peach-adopt-pair-actions">310 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>311 </div>312 </Show>313 </div>314 </Match>315316 <Match when={phase() === "error"}>317 <Show318 when={receiverError() === "ADOPT_PRO_REQUIRED"}319 fallback={320 <>321 <h1>Could not join the vault.</h1>322 <p class="peach-hint peach-hint-error">{receiverError()}</p>323 <div class="peach-adopt-actions">324 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>325 Try again326 </button>327 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>328 </div>329 </>330 }331 >332 <PairingProRequired onRetry={tryAgain} />333 </Show>334 </Match>335 </Switch>336 </div>337 </section>338 );339}