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 />152 <label class="peach-label" for="adopt-confirm-password">confirm password</label>153 <input154 id="adopt-confirm-password"155 class="peach-underline-input peach-adopt-input"156 type="password"157 autocomplete="new-password"158 value={confirmPassword()}159 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}160 />161 <Show when={createBlocker()}>162 <p class="peach-hint">{createBlocker()}</p>163 </Show>164 <div class="peach-adopt-actions">165 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>166 Continue167 </button>168 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>169 </div>170 </Match>171172 <Match when={phase() === "pair"}>173 <h1>Pair with your other device.</h1>174 <p>175 Scan this code on the device that holds your vault. That176 device will send the vault to this extension once the codes177 match.178 </p>179 <p class="peach-hint">{RELAY_DISCLOSURE}</p>180 <Show181 when={receiverInvitation()}182 fallback={183 <Show184 when={receiverError()}185 fallback={<p class="peach-hint">Preparing secure pairing room…</p>}186 >187 <p class="peach-hint peach-hint-error">{receiverError()}</p>188 </Show>189 }190 >191 <PairingQrCode value={receiverInvitation()} size={200} />192 </Show>193 <div class="peach-adopt-actions">194 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>195 </div>196 </Match>197198 <Match when={phase() === "success"}>199 <h1>Joined your vault.</h1>200 <p>201 This extension now holds the same vault as your other202 devices. You can unlock it with the password you just set.203 </p>204 <div class="peach-adopt-actions">205 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>206 Open Peach207 </button>208 </div>209 </Match>210211 <Match when={phase() === "error"}>212 <h1>Could not join the vault.</h1>213 <p class="peach-hint peach-hint-error">{receiverError()}</p>214 <div class="peach-adopt-actions">215 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>216 Try again217 </button>218 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>219 </div>220 </Match>221 </Switch>222 </div>223 </section>224 );225}