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 [receiverError, setReceiverError] = createSignal("");4950 onCleanup(() => {51 52 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);53 });5455 56 57 58 59 60 createEffect(() => {61 if (phase() !== "pair") return;62 if (receiverInvitation() || receiverError()) return;63 void (async () => {64 const response = (await sendMessage({65 type: "START_ADOPTION_RECEIVER",66 payload: { password: password() },67 })) as { ok: boolean; data?: { invitation?: string }; error?: string };68 if (response.ok && response.data?.invitation) {69 setReceiverInvitation(response.data.invitation);70 } else {71 setReceiverError(response.error ?? "adoption_receiver_failed");72 }73 })();74 });7576 77 78 79 80 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {81 const handler = (msg: { type?: string; payload?: unknown }) => {82 if (msg?.type !== "ADOPTION_SOURCE_RESULT") return;83 const p = msg.payload as {84 status?: "complete" | "error";85 error?: string;86 } | undefined;87 if (p?.status === "complete") {88 setPhase("success");89 } else if (p?.status === "error") {90 setReceiverError(p.error ?? "adoption_failed");91 setPhase("error");92 }93 };94 chrome.runtime.onMessage.addListener(handler);95 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));96 }9798 createMemo(() => {99 const value = password();100 if (!value) {101 setStrengthScore(null);102 return;103 }104 getZxcvbn().then((zxcvbn) => {105 if (password() === value) setStrengthScore(zxcvbn(value).score);106 }).catch(() => {107 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);108 });109 });110111 const canContinue = createMemo(() =>112 password().length >= MIN_LENGTH &&113 confirmPassword().length >= MIN_LENGTH &&114 password() === confirmPassword() &&115 strengthScore() !== null &&116 strengthScore()! >= MIN_ZXCVBN_SCORE117 );118119 const createBlocker = createMemo(() => {120 if (!password()) return null;121 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;122 if (!confirmPassword()) return "confirm your password";123 if (password() !== confirmPassword()) return "passwords don't match";124 if (strengthScore() === null) return "checking strength...";125 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";126 return null;127 });128129 function tryAgain(): void {130 setReceiverError("");131 setReceiverInvitation("");132 setPhase("pair");133 }134135 return (136 <section class="peach-screen peach-theme peach-adopt-vault">137 <RuneBackground opacity={0.07} />138 <div class="peach-adopt-card">139 <Switch>140 <Match when={phase() === "password"}>141 <h1>Set a password for this device.</h1>142 <p>This protects your vault on this device only. It can be different from your other devices.</p>143 <label class="peach-label" for="adopt-password">password</label>144 <input145 id="adopt-password"146 class="peach-underline-input peach-adopt-input"147 type="password"148 autocomplete="new-password"149 value={password()}150 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}151 onKeyDown={(event) => {152 if (event.key === "Enter" && canContinue()) setPhase("pair");153 }}154 />155 <label class="peach-label" for="adopt-confirm-password">confirm password</label>156 <input157 id="adopt-confirm-password"158 class="peach-underline-input peach-adopt-input"159 type="password"160 autocomplete="new-password"161 value={confirmPassword()}162 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}163 onKeyDown={(event) => {164 if (event.key === "Enter" && canContinue()) setPhase("pair");165 }}166 />167 <Show when={createBlocker()}>168 <p class="peach-hint">{createBlocker()}</p>169 </Show>170 <div class="peach-adopt-actions">171 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>172 Continue173 </button>174 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>175 </div>176 </Match>177178 <Match when={phase() === "pair"}>179 <h1>Pair with your other device.</h1>180 <p>Scan on the device that holds your vault. It will send the vault here.</p>181 <Show182 when={receiverInvitation()}183 fallback={184 <Show185 when={receiverError()}186 fallback={<p class="peach-hint">Preparing secure pairing room…</p>}187 >188 <p class="peach-hint peach-hint-error">{receiverError()}</p>189 </Show>190 }191 >192 <PairingQrCode value={receiverInvitation()} size={160} />193 </Show>194 <p class="peach-hint peach-hint-relay">{RELAY_DISCLOSURE}</p>195 <div class="peach-adopt-actions">196 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>197 </div>198 </Match>199200 <Match when={phase() === "success"}>201 <h1>Joined your vault.</h1>202 <p>203 This extension now holds the same vault as your other204 devices. You can unlock it with the password you just set.205 </p>206 <div class="peach-adopt-actions">207 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>208 Open Peach209 </button>210 </div>211 </Match>212213 <Match when={phase() === "error"}>214 <h1>Could not join the vault.</h1>215 <p class="peach-hint peach-hint-error">{receiverError()}</p>216 <div class="peach-adopt-actions">217 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>218 Try again219 </button>220 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>221 </div>222 </Match>223 </Switch>224 </div>225 </section>226 );227}