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 = 1_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={{ "is-complete": receiverComplete() }}206 >207 <div208 class={`peach-pair-stage peach-adopt-bird-stage ${209 receiverComplete()210 ? "act-paired"211 : receiverSas()212 ? "act-handshake"213 : "act-listening"214 }`}215 aria-hidden="true"216 >217 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">218 <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />219 </div>220 <Show when={receiverComplete()}>221 <span class="peach-adopt-connection" />222 </Show>223 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">224 <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />225 </div>226 </div>227228 <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>229 <p class="peach-adopt-pair-lede" aria-live="polite">230 {receiverComplete()231 ? "Your devices are connected. Getting your vault ready…"232 : "Scan this QR in Peach on the device that already has your vault."}233 </p>234235 <Show236 when={!receiverComplete()}237 fallback={238 <div class="peach-adopt-paired-status" role="status">239 <span />240 Secure connection established241 </div>242 }243 >244 <div class="peach-adopt-qr-wrap" aria-live="polite">245 <Show246 when={receiverInvitation()}247 fallback={248 <Show249 when={receiverError()}250 fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}251 >252 <p class="peach-hint peach-hint-error">{receiverError()}</p>253 </Show>254 }255 >256 <PairingQrCode value={receiverInvitation()} size={168} />257 </Show>258 </div>259260 <Show when={receiverSas()}>261 <div class="peach-adopt-confirmation">262 <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>263 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">264 <strong>{receiverSas()}</strong>265 </div>266 <p class="peach-adopt-confirmation-help">267 Confirm this code on your phone to finish pairing.268 </p>269 </div>270 </Show>271272 <div class="peach-adopt-privacy">273 <svg viewBox="0 0 24 24" aria-hidden="true">274 <path d="M7 10V8a5 5 0 0 1 10 0v2" />275 <rect x="5" y="10" width="14" height="10" rx="3" />276 <path d="M12 14v2" />277 </svg>278 <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>279 </div>280281 <div class="peach-adopt-actions peach-adopt-pair-actions">282 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>283 </div>284 </Show>285 </div>286 </Match>287288 <Match when={phase() === "error"}>289 <Show290 when={receiverError() === "ADOPT_PRO_REQUIRED"}291 fallback={292 <>293 <h1>Could not join the vault.</h1>294 <p class="peach-hint peach-hint-error">{receiverError()}</p>295 <div class="peach-adopt-actions">296 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>297 Try again298 </button>299 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>300 </div>301 </>302 }303 >304 <PairingProRequired onRetry={tryAgain} />305 </Show>306 </Match>307 </Switch>308 </div>309 </section>310 );311}