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 84 85 86 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {87 const handler = (msg: { type?: string; payload?: unknown }) => {88 if (msg?.type === "ADOPTION_SOURCE_SAS") {89 const p = msg.payload as { sas?: string; displayOnly?: boolean } | undefined;90 if (p?.displayOnly === true && typeof p.sas === "string") {91 setReceiverSas(p.sas);92 }93 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {94 const p = msg.payload as {95 status?: "complete" | "error";96 error?: string;97 } | undefined;98 if (p?.status === "complete") {99 if (receiverComplete()) return;100 setReceiverComplete(true);101 completionTimer = window.setTimeout(() => {102 completionTimer = undefined;103 void props.onDone();104 }, PAIRING_CELEBRATION_MS);105 } else if (p?.status === "error") {106 setReceiverError(p.error ?? "adoption_failed");107 setPhase("error");108 }109 }110 };111 chrome.runtime.onMessage.addListener(handler);112 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));113 }114115 createMemo(() => {116 const value = password();117 if (!value) {118 setStrengthScore(null);119 return;120 }121 getZxcvbn().then((zxcvbn) => {122 if (password() === value) setStrengthScore(zxcvbn(value).score);123 }).catch(() => {124 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);125 });126 });127128 const canContinue = createMemo(() =>129 password().length >= MIN_LENGTH &&130 confirmPassword().length >= MIN_LENGTH &&131 password() === confirmPassword() &&132 strengthScore() !== null &&133 strengthScore()! >= MIN_ZXCVBN_SCORE134 );135136 const createBlocker = createMemo(() => {137 if (!password()) return null;138 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;139 if (!confirmPassword()) return "confirm your password";140 if (password() !== confirmPassword()) return "passwords don't match";141 if (strengthScore() === null) return "checking strength...";142 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";143 return null;144 });145146 function tryAgain(): void {147 setReceiverError("");148 setReceiverInvitation("");149 setReceiverSas("");150 setPhase("pair");151 }152153 return (154 <section155 class="peach-screen peach-theme peach-adopt-vault"156 classList={{ "peach-adopt-vault--pair": phase() === "pair" }}157 >158 <RuneBackground opacity={0.07} />159 <div160 class="peach-adopt-card"161 classList={{ "peach-adopt-card--pair": phase() === "pair" }}162 >163 <Switch>164 <Match when={phase() === "password"}>165 <h1>Set a password for this device.</h1>166 <p>This protects your vault on this device only. It can be different from your other devices.</p>167 <label class="peach-label" for="adopt-password">password</label>168 <input169 id="adopt-password"170 class="peach-underline-input peach-adopt-input"171 type="password"172 autocomplete="new-password"173 value={password()}174 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}175 onKeyDown={(event) => {176 if (event.key === "Enter" && canContinue()) setPhase("pair");177 }}178 />179 <label class="peach-label" for="adopt-confirm-password">confirm password</label>180 <input181 id="adopt-confirm-password"182 class="peach-underline-input peach-adopt-input"183 type="password"184 autocomplete="new-password"185 value={confirmPassword()}186 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}187 onKeyDown={(event) => {188 if (event.key === "Enter" && canContinue()) setPhase("pair");189 }}190 />191 <Show when={createBlocker()}>192 <p class="peach-hint">{createBlocker()}</p>193 </Show>194 <div class="peach-adopt-actions">195 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>196 Continue197 </button>198 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>199 </div>200 </Match>201202 <Match when={phase() === "pair"}>203 <div204 class="peach-adopt-pairing"205 classList={{206 "has-sas": Boolean(receiverSas()),207 "is-complete": receiverComplete(),208 }}209 >210 <div211 class={`peach-pair-stage peach-adopt-bird-stage ${212 receiverComplete()213 ? "act-paired"214 : receiverSas()215 ? "act-handshake"216 : "act-listening"217 }`}218 aria-hidden="true"219 >220 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">221 <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />222 </div>223 <Show when={receiverComplete()}>224 <span class="peach-adopt-connection" />225 </Show>226 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">227 <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />228 </div>229 </div>230231 <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>232 <p class="peach-adopt-pair-lede" aria-live="polite">233 {receiverComplete()234 ? "Your devices are connected. Getting your vault ready…"235 : receiverSas()236 ? "Make sure this code matches the one on your phone."237 : "Scan this QR in Peach on the device that already has your vault."}238 </p>239240 <Show241 when={!receiverComplete()}242 fallback={243 <div class="peach-adopt-paired-status" role="status">244 <span />245 Secure connection established246 </div>247 }248 >249 <Show250 when={receiverSas()}251 fallback={252 <div class="peach-adopt-qr-wrap" aria-live="polite">253 <Show254 when={receiverInvitation()}255 fallback={256 <Show257 when={receiverError()}258 fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}259 >260 <p class="peach-hint peach-hint-error">{receiverError()}</p>261 </Show>262 }263 >264 <PairingQrCode value={receiverInvitation()} size={144} />265 </Show>266 </div>267 }268 >269 <div class="peach-adopt-confirmation">270 <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>271 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">272 <strong>{receiverSas()}</strong>273 </div>274 <p class="peach-adopt-confirmation-help">275 Confirm this code on your phone to finish pairing.276 </p>277 </div>278 </Show>279280 <div class="peach-adopt-privacy">281 <svg viewBox="0 0 24 24" aria-hidden="true">282 <path d="M7 10V8a5 5 0 0 1 10 0v2" />283 <rect x="5" y="10" width="14" height="10" rx="3" />284 <path d="M12 14v2" />285 </svg>286 <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>287 </div>288289 <div class="peach-adopt-actions peach-adopt-pair-actions">290 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>291 </div>292 </Show>293 </div>294 </Match>295296 <Match when={phase() === "error"}>297 <Show298 when={receiverError() === "ADOPT_PRO_REQUIRED"}299 fallback={300 <>301 <h1>Could not join the vault.</h1>302 <p class="peach-hint peach-hint-error">{receiverError()}</p>303 <div class="peach-adopt-actions">304 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>305 Try again306 </button>307 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>308 </div>309 </>310 }311 >312 <PairingProRequired onRetry={tryAgain} />313 </Show>314 </Match>315 </Switch>316 </div>317 </section>318 );319}