1import {2 Match,3 Show,4 Switch,5 createEffect,6 createMemo,7 createSignal,8 onCleanup,9} from "solid-js";10import { sendMessage } from "../App";11import { RuneBackground } from "../components/RuneBackground";12import { PairingQrCode } from "../components/PairingQrCode";13import { PairingProRequired } from "../components/PairingProRequired";14import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1516type AdoptPhase = "password" | "pair" | "success" | "error";1718export interface AdoptVaultViewProps {19 onCancel(): void;20 onDone(): void | Promise<void>;21}2223const MIN_LENGTH = 12;24const MIN_ZXCVBN_SCORE = 2;2526let zxcvbnLoader: ((password: string) => { score: number }) | null = null;27async function getZxcvbn() {28 if (zxcvbnLoader) return zxcvbnLoader;29 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([30 import("@zxcvbn-ts/core"),31 import("@zxcvbn-ts/language-common"),32 import("@zxcvbn-ts/language-en"),33 ]);34 zxcvbnOptions.setOptions({35 translations: en.translations,36 graphs: common.adjacencyGraphs,37 dictionary: { ...common.dictionary, ...en.dictionary },38 });39 zxcvbnLoader = (password: string) => zxcvbn(password);40 return zxcvbnLoader;41}4243export function AdoptVaultView(props: AdoptVaultViewProps) {44 const [phase, setPhase] = createSignal<AdoptPhase>("password");45 const [password, setPassword] = createSignal("");46 const [confirmPassword, setConfirmPassword] = createSignal("");47 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);48 const [receiverInvitation, setReceiverInvitation] = createSignal("");49 const [receiverSas, setReceiverSas] = createSignal("");50 const [receiverError, setReceiverError] = createSignal("");5152 onCleanup(() => {53 54 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);55 });5657 58 59 60 61 62 createEffect(() => {63 if (phase() !== "pair") return;64 if (receiverInvitation() || receiverError()) return;65 void (async () => {66 const response = (await sendMessage({67 type: "START_ADOPTION_RECEIVER",68 payload: { password: password() },69 })) as { ok: boolean; data?: { invitation?: string }; error?: string };70 if (response.ok && response.data?.invitation) {71 setReceiverInvitation(response.data.invitation);72 } else {73 setReceiverError(response.error ?? "adoption_receiver_failed");74 }75 })();76 });7778 79 80 81 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {82 const handler = (msg: { type?: string; payload?: unknown }) => {83 if (msg?.type === "ADOPTION_SOURCE_SAS") {84 const p = msg.payload as { sas?: string; displayOnly?: boolean } | undefined;85 if (p?.displayOnly === true && typeof p.sas === "string") {86 setReceiverSas(p.sas);87 }88 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {89 const p = msg.payload as {90 status?: "complete" | "error";91 error?: string;92 } | undefined;93 if (p?.status === "complete") {94 setPhase("success");95 } else if (p?.status === "error") {96 setReceiverError(p.error ?? "adoption_failed");97 setPhase("error");98 }99 }100 };101 chrome.runtime.onMessage.addListener(handler);102 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));103 }104105 createMemo(() => {106 const value = password();107 if (!value) {108 setStrengthScore(null);109 return;110 }111 getZxcvbn().then((zxcvbn) => {112 if (password() === value) setStrengthScore(zxcvbn(value).score);113 }).catch(() => {114 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);115 });116 });117118 const canContinue = createMemo(() =>119 password().length >= MIN_LENGTH &&120 confirmPassword().length >= MIN_LENGTH &&121 password() === confirmPassword() &&122 strengthScore() !== null &&123 strengthScore()! >= MIN_ZXCVBN_SCORE124 );125126 const createBlocker = createMemo(() => {127 if (!password()) return null;128 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;129 if (!confirmPassword()) return "confirm your password";130 if (password() !== confirmPassword()) return "passwords don't match";131 if (strengthScore() === null) return "checking strength...";132 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";133 return null;134 });135136 function tryAgain(): void {137 setReceiverError("");138 setReceiverInvitation("");139 setReceiverSas("");140 setPhase("pair");141 }142143 return (144 <section class="peach-screen peach-theme peach-adopt-vault">145 <RuneBackground opacity={0.07} />146 <div class="peach-adopt-card">147 <Switch>148 <Match when={phase() === "password"}>149 <h1>Set a password for this device.</h1>150 <p>This protects your vault on this device only. It can be different from your other devices.</p>151 <label class="peach-label" for="adopt-password">password</label>152 <input153 id="adopt-password"154 class="peach-underline-input peach-adopt-input"155 type="password"156 autocomplete="new-password"157 value={password()}158 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}159 onKeyDown={(event) => {160 if (event.key === "Enter" && canContinue()) setPhase("pair");161 }}162 />163 <label class="peach-label" for="adopt-confirm-password">confirm password</label>164 <input165 id="adopt-confirm-password"166 class="peach-underline-input peach-adopt-input"167 type="password"168 autocomplete="new-password"169 value={confirmPassword()}170 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}171 onKeyDown={(event) => {172 if (event.key === "Enter" && canContinue()) setPhase("pair");173 }}174 />175 <Show when={createBlocker()}>176 <p class="peach-hint">{createBlocker()}</p>177 </Show>178 <div class="peach-adopt-actions">179 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>180 Continue181 </button>182 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>183 </div>184 </Match>185186 <Match when={phase() === "pair"}>187 <h1>Pair with your other device.</h1>188 <p>Scan on the device that holds your vault. It will send the vault here.</p>189 <Show190 when={receiverInvitation()}191 fallback={192 <Show193 when={receiverError()}194 fallback={<p class="peach-hint">Preparing secure pairing room…</p>}195 >196 <p class="peach-hint peach-hint-error">{receiverError()}</p>197 </Show>198 }199 >200 <PairingQrCode value={receiverInvitation()} size={160} />201 </Show>202 <Show when={receiverSas()}>203 <p class="peach-hint">204 Make sure this matches the 8-digit safety code on your phone.205 Pairing continues when you confirm there.206 </p>207 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">208 <strong>{receiverSas()}</strong>209 </div>210 </Show>211 <p class="peach-hint peach-hint-relay">{RELAY_DISCLOSURE}</p>212 <div class="peach-adopt-actions">213 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>214 </div>215 </Match>216217 <Match when={phase() === "success"}>218 <h1>Joined your vault.</h1>219 <p>220 This extension now holds the same vault as your other221 devices. You can unlock it with the password you just set.222 </p>223 <div class="peach-adopt-actions">224 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>225 Open Peach226 </button>227 </div>228 </Match>229230 <Match when={phase() === "error"}>231 <Show232 when={receiverError() === "ADOPT_PRO_REQUIRED"}233 fallback={234 <>235 <h1>Could not join the vault.</h1>236 <p class="peach-hint peach-hint-error">{receiverError()}</p>237 <div class="peach-adopt-actions">238 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>239 Try again240 </button>241 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>242 </div>243 </>244 }245 >246 <PairingProRequired onRetry={tryAgain} />247 </Show>248 </Match>249 </Switch>250 </div>251 </section>252 );253}