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 { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1415type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";1617export interface AdoptVaultViewProps {18 onCancel(): void;19 onDone(): void | Promise<void>;20}2122const MIN_LENGTH = 12;23const MIN_ZXCVBN_SCORE = 2;2425let zxcvbnLoader: ((password: string) => { score: number }) | null = null;26async function getZxcvbn() {27 if (zxcvbnLoader) return zxcvbnLoader;28 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([29 import("@zxcvbn-ts/core"),30 import("@zxcvbn-ts/language-common"),31 import("@zxcvbn-ts/language-en"),32 ]);33 zxcvbnOptions.setOptions({34 translations: en.translations,35 graphs: common.adjacencyGraphs,36 dictionary: { ...common.dictionary, ...en.dictionary },37 });38 zxcvbnLoader = (password: string) => zxcvbn(password);39 return zxcvbnLoader;40}4142export function AdoptVaultView(props: AdoptVaultViewProps) {43 const [phase, setPhase] = createSignal<AdoptPhase>("password");44 const [password, setPassword] = createSignal("");45 const [confirmPassword, setConfirmPassword] = createSignal("");46 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);47 const [receiverInvitation, setReceiverInvitation] = createSignal("");48 const [receiverError, setReceiverError] = createSignal("");49 const [pendingSasId, setPendingSasId] = createSignal("");50 const [pendingSas, setPendingSas] = createSignal("");51 const [senderDeviceName, setSenderDeviceName] = createSignal("Other device");5253 onCleanup(() => {54 55 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);56 57 58 const id = pendingSasId();59 if (id) {60 void sendMessage({61 type: "RECEIVER_SAS_DECISION",62 payload: { id, ok: false },63 }).catch(() => undefined);64 }65 });6667 68 69 70 71 72 createEffect(() => {73 if (phase() !== "pair") return;74 if (receiverInvitation() || receiverError()) return;75 void (async () => {76 const response = (await sendMessage({77 type: "START_ADOPTION_RECEIVER",78 payload: { password: password() },79 })) as { ok: boolean; data?: { invitation?: string }; error?: string };80 if (response.ok && response.data?.invitation) {81 setReceiverInvitation(response.data.invitation);82 } else {83 setReceiverError(response.error ?? "adoption_receiver_failed");84 }85 })();86 });8788 89 90 91 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {92 const handler = (msg: { type?: string; payload?: unknown }) => {93 if (msg?.type === "ADOPTION_SOURCE_SAS") {94 const p = msg.payload as {95 id?: string;96 sas?: string;97 receiverDeviceName?: string;98 } | undefined;99 if (typeof p?.id === "string" && typeof p?.sas === "string") {100 setPendingSasId(p.id);101 setPendingSas(p.sas);102 if (typeof p.receiverDeviceName === "string" && p.receiverDeviceName) {103 setSenderDeviceName(p.receiverDeviceName);104 }105 setPhase("sas");106 }107 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {108 const p = msg.payload as {109 status?: "complete" | "error";110 error?: string;111 } | undefined;112 if (p?.status === "complete") {113 setPendingSasId("");114 setPhase("success");115 } else if (p?.status === "error") {116 setPendingSasId("");117 setReceiverError(p.error ?? "adoption_failed");118 setPhase("error");119 }120 }121 };122 chrome.runtime.onMessage.addListener(handler);123 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));124 }125126 function answerSas(ok: boolean): void {127 const id = pendingSasId();128 if (!id) return;129 setPendingSasId("");130 void sendMessage({131 type: "RECEIVER_SAS_DECISION",132 payload: { id, ok },133 }).catch(() => undefined);134 if (!ok) {135 setReceiverError("adoption_rejected");136 setPhase("error");137 }138 }139140 createMemo(() => {141 const value = password();142 if (!value) {143 setStrengthScore(null);144 return;145 }146 getZxcvbn().then((zxcvbn) => {147 if (password() === value) setStrengthScore(zxcvbn(value).score);148 }).catch(() => {149 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);150 });151 });152153 const canContinue = createMemo(() =>154 password().length >= MIN_LENGTH &&155 confirmPassword().length >= MIN_LENGTH &&156 password() === confirmPassword() &&157 strengthScore() !== null &&158 strengthScore()! >= MIN_ZXCVBN_SCORE159 );160161 const createBlocker = createMemo(() => {162 if (!password()) return null;163 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;164 if (!confirmPassword()) return "confirm your password";165 if (password() !== confirmPassword()) return "passwords don't match";166 if (strengthScore() === null) return "checking strength...";167 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";168 return null;169 });170171 function tryAgain(): void {172 setReceiverError("");173 setReceiverInvitation("");174 setPendingSas("");175 setPendingSasId("");176 setPhase("pair");177 }178179 return (180 <section class="peach-screen peach-theme peach-adopt-vault">181 <RuneBackground opacity={0.07} />182 <div class="peach-adopt-card">183 <Switch>184 <Match when={phase() === "password"}>185 <h1>Set a password for this device.</h1>186 <p>This protects your vault on this device only. It can be different from your other devices.</p>187 <label class="peach-label" for="adopt-password">password</label>188 <input189 id="adopt-password"190 class="peach-underline-input peach-adopt-input"191 type="password"192 autocomplete="new-password"193 value={password()}194 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}195 />196 <label class="peach-label" for="adopt-confirm-password">confirm password</label>197 <input198 id="adopt-confirm-password"199 class="peach-underline-input peach-adopt-input"200 type="password"201 autocomplete="new-password"202 value={confirmPassword()}203 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}204 />205 <Show when={createBlocker()}>206 <p class="peach-hint">{createBlocker()}</p>207 </Show>208 <div class="peach-adopt-actions">209 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>210 Continue211 </button>212 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>213 </div>214 </Match>215216 <Match when={phase() === "pair"}>217 <h1>Pair with your other device.</h1>218 <p>219 Scan this code on the device that holds your vault. That220 device will send the vault to this extension once the codes221 match.222 </p>223 <p class="peach-hint">{RELAY_DISCLOSURE}</p>224 <Show225 when={receiverInvitation()}226 fallback={227 <Show228 when={receiverError()}229 fallback={<p class="peach-hint">Preparing secure pairing room…</p>}230 >231 <p class="peach-hint peach-hint-error">{receiverError()}</p>232 </Show>233 }234 >235 <PairingQrCode value={receiverInvitation()} size={200} />236 </Show>237 <div class="peach-adopt-actions">238 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>239 </div>240 </Match>241242 <Match when={phase() === "sas"}>243 <h1>Confirm the code matches.</h1>244 <p>245 {senderDeviceName()} is showing the same code. If the digits246 match, confirm below — the vault will be sent to this247 extension.248 </p>249 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">250 <strong>{pendingSas()}</strong>251 </div>252 <div class="peach-adopt-actions">253 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => answerSas(true)}>254 They match255 </button>256 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => answerSas(false)}>257 Cancel258 </button>259 </div>260 </Match>261262 <Match when={phase() === "success"}>263 <h1>Joined your vault.</h1>264 <p>265 This extension now holds the same vault as your other266 devices. You can unlock it with the password you just set.267 </p>268 <div class="peach-adopt-actions">269 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>270 Open Peach271 </button>272 </div>273 </Match>274275 <Match when={phase() === "error"}>276 <h1>Could not join the vault.</h1>277 <p class="peach-hint peach-hint-error">{receiverError()}</p>278 <div class="peach-adopt-actions">279 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>280 Try again281 </button>282 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>283 </div>284 </Match>285 </Switch>286 </div>287 </section>288 );289}