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" | "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 [receiverSas, setReceiverSas] = createSignal("");49 const [receiverError, setReceiverError] = createSignal("");5051 onCleanup(() => {52 53 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);54 });5556 57 58 59 60 61 createEffect(() => {62 if (phase() !== "pair") return;63 if (receiverInvitation() || receiverError()) return;64 void (async () => {65 const response = (await sendMessage({66 type: "START_ADOPTION_RECEIVER",67 payload: { password: password() },68 })) as { ok: boolean; data?: { invitation?: string }; error?: string };69 if (response.ok && response.data?.invitation) {70 setReceiverInvitation(response.data.invitation);71 } else {72 setReceiverError(response.error ?? "adoption_receiver_failed");73 }74 })();75 });7677 78 79 80 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {81 const handler = (msg: { type?: string; payload?: unknown }) => {82 if (msg?.type === "ADOPTION_SOURCE_SAS") {83 const p = msg.payload as { sas?: string; displayOnly?: boolean } | undefined;84 if (p?.displayOnly === true && typeof p.sas === "string") {85 setReceiverSas(p.sas);86 }87 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {88 const p = msg.payload as {89 status?: "complete" | "error";90 error?: string;91 } | undefined;92 if (p?.status === "complete") {93 setPhase("success");94 } else if (p?.status === "error") {95 setReceiverError(p.error ?? "adoption_failed");96 setPhase("error");97 }98 }99 };100 chrome.runtime.onMessage.addListener(handler);101 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));102 }103104 createMemo(() => {105 const value = password();106 if (!value) {107 setStrengthScore(null);108 return;109 }110 getZxcvbn().then((zxcvbn) => {111 if (password() === value) setStrengthScore(zxcvbn(value).score);112 }).catch(() => {113 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);114 });115 });116117 const canContinue = createMemo(() =>118 password().length >= MIN_LENGTH &&119 confirmPassword().length >= MIN_LENGTH &&120 password() === confirmPassword() &&121 strengthScore() !== null &&122 strengthScore()! >= MIN_ZXCVBN_SCORE123 );124125 const createBlocker = createMemo(() => {126 if (!password()) return null;127 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;128 if (!confirmPassword()) return "confirm your password";129 if (password() !== confirmPassword()) return "passwords don't match";130 if (strengthScore() === null) return "checking strength...";131 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";132 return null;133 });134135 function tryAgain(): void {136 setReceiverError("");137 setReceiverInvitation("");138 setReceiverSas("");139 setPhase("pair");140 }141142 return (143 <section class="peach-screen peach-theme peach-adopt-vault">144 <RuneBackground opacity={0.07} />145 <div class="peach-adopt-card">146 <Switch>147 <Match when={phase() === "password"}>148 <h1>Set a password for this device.</h1>149 <p>This protects your vault on this device only. It can be different from your other devices.</p>150 <label class="peach-label" for="adopt-password">password</label>151 <input152 id="adopt-password"153 class="peach-underline-input peach-adopt-input"154 type="password"155 autocomplete="new-password"156 value={password()}157 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}158 onKeyDown={(event) => {159 if (event.key === "Enter" && canContinue()) setPhase("pair");160 }}161 />162 <label class="peach-label" for="adopt-confirm-password">confirm password</label>163 <input164 id="adopt-confirm-password"165 class="peach-underline-input peach-adopt-input"166 type="password"167 autocomplete="new-password"168 value={confirmPassword()}169 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}170 onKeyDown={(event) => {171 if (event.key === "Enter" && canContinue()) setPhase("pair");172 }}173 />174 <Show when={createBlocker()}>175 <p class="peach-hint">{createBlocker()}</p>176 </Show>177 <div class="peach-adopt-actions">178 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>179 Continue180 </button>181 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>182 </div>183 </Match>184185 <Match when={phase() === "pair"}>186 <h1>Pair with your other device.</h1>187 <p>Scan on the device that holds your vault. It will send the vault here.</p>188 <Show189 when={receiverInvitation()}190 fallback={191 <Show192 when={receiverError()}193 fallback={<p class="peach-hint">Preparing secure pairing room…</p>}194 >195 <p class="peach-hint peach-hint-error">{receiverError()}</p>196 </Show>197 }198 >199 <PairingQrCode value={receiverInvitation()} size={160} />200 </Show>201 <Show when={receiverSas()}>202 <p class="peach-hint">203 Make sure this matches the 8-digit safety code on your phone.204 Pairing continues when you confirm there.205 </p>206 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">207 <strong>{receiverSas()}</strong>208 </div>209 </Show>210 <p class="peach-hint peach-hint-relay">{RELAY_DISCLOSURE}</p>211 <div class="peach-adopt-actions">212 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>213 </div>214 </Match>215216 <Match when={phase() === "success"}>217 <h1>Joined your vault.</h1>218 <p>219 This extension now holds the same vault as your other220 devices. You can unlock it with the password you just set.221 </p>222 <div class="peach-adopt-actions">223 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>224 Open Peach225 </button>226 </div>227 </Match>228229 <Match when={phase() === "error"}>230 <h1>Could not join the vault.</h1>231 <p class="peach-hint peach-hint-error">{receiverError()}</p>232 <div class="peach-adopt-actions">233 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>234 Try again235 </button>236 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>237 </div>238 </Match>239 </Switch>240 </div>241 </section>242 );243}