1import {2 Match,3 Show,4 Switch,5 createMemo,6 createSignal,7 onCleanup,8} from "solid-js";9import { sendMessage } from "../App";10import { RuneBackground } from "../components/RuneBackground";11import { parsePairingInvitation } from "../../shared/pairing-invitation";12import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";13import type { PairingQrScannerHandle } from "../pairing-qr-scanner";1415type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";1617export interface AdoptVaultViewProps {18 onCancel(): void;19 onDone(): void | Promise<void>;20 startAdoption(args: {21 password: string;22 connection: { invitation: string };23 confirmSAS(sas: string): Promise<boolean>;24 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;25 }): Promise<void>;26}2728const MIN_LENGTH = 12;29const MIN_ZXCVBN_SCORE = 2;303132function adoptionErrorCopy(err: unknown): string {33 const message = err instanceof Error ? err.message : String(err);34 switch (message) {35 case "invalid_code":36 return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";37 case "no_host_found":38 return "Couldn't reach the relay. Check your connection and that the other device's pairing screen is still open.";39 case "handshake_failed":40 return "The secure connection failed. Try again with a fresh code.";41 case "adoption_rejected":42 return "Your other device declined the request.";43 default:44 return message;45 }46}4748let zxcvbnLoader: ((password: string) => { score: number }) | null = null;49async function getZxcvbn() {50 if (zxcvbnLoader) return zxcvbnLoader;51 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([52 import("@zxcvbn-ts/core"),53 import("@zxcvbn-ts/language-common"),54 import("@zxcvbn-ts/language-en"),55 ]);56 zxcvbnOptions.setOptions({57 translations: en.translations,58 graphs: common.adjacencyGraphs,59 dictionary: { ...common.dictionary, ...en.dictionary },60 });61 zxcvbnLoader = (password: string) => zxcvbn(password);62 return zxcvbnLoader;63}6465export function AdoptVaultView(props: AdoptVaultViewProps) {66 const [phase, setPhase] = createSignal<AdoptPhase>("password");67 const [password, setPassword] = createSignal("");68 const [confirmPassword, setConfirmPassword] = createSignal("");69 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);70 const [sas, setSas] = createSignal("");71 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);72 const [error, setError] = createSignal("");73 const [invitation, setInvitation] = createSignal("");74 const [manualEntry, setManualEntry] = createSignal(false);75 const [scanning, setScanning] = createSignal(false);76 const [scanError, setScanError] = createSignal("");77 const [connecting, setConnecting] = createSignal(false);78 let videoEl: HTMLVideoElement | undefined;79 let qrScanner: PairingQrScannerHandle | null = null;8081 const invitationReady = createMemo(() =>82 parsePairingInvitation(invitation()) !== null83 );8485 onCleanup(() => qrScanner?.stop());8687 createMemo(() => {88 const value = password();89 if (!value) {90 setStrengthScore(null);91 return;92 }93 getZxcvbn().then((zxcvbn) => {94 if (password() === value) setStrengthScore(zxcvbn(value).score);95 }).catch(() => {96 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);97 });98 });99100 const canContinue = createMemo(() =>101 password().length >= MIN_LENGTH &&102 confirmPassword().length >= MIN_LENGTH &&103 password() === confirmPassword() &&104 strengthScore() !== null &&105 strengthScore()! >= MIN_ZXCVBN_SCORE106 );107108 const createBlocker = createMemo(() => {109 if (!password()) return null;110 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;111 if (!confirmPassword()) return "confirm your password";112 if (password() !== confirmPassword()) return "passwords don't match";113 if (strengthScore() === null) return "checking strength...";114 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";115 return null;116 });117118 async function beginPairing() {119 if (!invitationReady() || connecting()) return;120 const connection = { invitation: invitation() };121 stopScanning();122 setPhase("pair");123 setError("");124 setConnecting(true);125 try {126 await props.startAdoption({127 password: password(),128 connection,129 confirmSAS: (sasCode) => new Promise<boolean>((resolve) => {130 setSas(sasCode);131 setSasResolve(() => resolve);132 setPhase("sas");133 }),134 createVault: async (rootKeyHex) => {135 const res = await sendMessage({136 type: "CREATE_VAULT_FROM_ROOT_KEY",137 payload: { password: password(), rootKeyHex },138 }) as { ok: boolean; vaultId?: string; error?: string };139 if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");140 return { vaultId: res.vaultId };141 },142 });143 setPhase("success");144 } catch (err) {145 if (err instanceof Error && err.message === "sas_rejected") return;146 setError(adoptionErrorCopy(err));147 setPhase("error");148 } finally {149 setConnecting(false);150 }151 }152153 function confirmSas(ok: boolean) {154 const resolve = sasResolve();155 setSasResolve(null);156 resolve?.(ok);157 if (!ok) {158 setPhase("password");159 setPassword("");160 setConfirmPassword("");161 setStrengthScore(null);162 163 164 setInvitation("");165 }166 }167168 async function startScanning() {169 if (scanning()) return;170 setScanError("");171 setScanning(true);172 await Promise.resolve();173 if (!videoEl) {174 setScanning(false);175 setScanError("Camera preview could not start. Enter the invitation instead.");176 return;177 }178 try {179 const { startPairingQrScanner } = await import("../pairing-qr-scanner");180 qrScanner = await startPairingQrScanner(181 videoEl,182 (value) => {183 if (!parsePairingInvitation(value)) return;184 setInvitation(value);185 stopScanning();186 void beginPairing();187 },188 () => setScanError("Couldn't read that QR code. Hold it steady or enter the invitation."),189 );190 } catch {191 stopScanning();192 setScanError("Camera access is unavailable. Enter the invitation instead.");193 setManualEntry(true);194 }195 }196197 function stopScanning() {198 qrScanner?.stop();199 qrScanner = null;200 setScanning(false);201 }202203 return (204 <section class="peach-screen peach-theme peach-adopt-vault">205 <RuneBackground opacity={0.07} />206 <div class="peach-adopt-card">207 <Switch>208 <Match when={phase() === "password"}>209 <h1>Set a password for this device.</h1>210 <p>This protects your vault on this device only. It can be different from your other devices.</p>211 <label class="peach-label" for="adopt-password">password</label>212 <input213 id="adopt-password"214 class="peach-underline-input peach-adopt-input"215 type="password"216 autocomplete="new-password"217 value={password()}218 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}219 />220 <label class="peach-label" for="adopt-confirm-password">confirm password</label>221 <input222 id="adopt-confirm-password"223 class="peach-underline-input peach-adopt-input"224 type="password"225 autocomplete="new-password"226 value={confirmPassword()}227 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}228 />229 <Show when={createBlocker()}>230 <p class="peach-hint">{createBlocker()}</p>231 </Show>232 <div class="peach-adopt-actions">233 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>234 Continue235 </button>236 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>237 </div>238 </Match>239240 <Match when={phase() === "pair"}>241 <div class="peach-adopt-birds" aria-hidden="true">242 <span />243 <span />244 </div>245 <h1>Pair with your other device.</h1>246 {247248}249 <p>250 This extension is empty. The device with your vault chooses251 Public or Private relay, and this extension joins the relay252 carried in its QR.253 </p>254 <p class="peach-hint">{RELAY_DISCLOSURE}</p>255 <Show when={scanning()}>256 <video257 ref={videoEl}258 class="peach-adopt-qr-video"259 autoplay260 muted261 playsinline262 aria-label="Pairing QR camera preview"263 />264 </Show>265 <Show when={!scanning()}>266 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>267 Scan pairing QR268 </button>269 </Show>270 <button271 class="peach-btn peach-btn--ghost peach-btn--sm"272 onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}273 >274 {manualEntry() ? "Hide manual entry" : "Can't scan? Enter invitation manually"}275 </button>276 <Show when={manualEntry()}>277 <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>278 <textarea279 id="adopt-relay-invitation"280 class="peach-underline-input peach-adopt-input"281 rows={3}282 autocomplete="off"283 spellcheck={false}284 aria-label="Pairing invitation"285 disabled={connecting()}286 value={invitation()}287 onInput={(event) =>288 setInvitation((event.target as HTMLTextAreaElement).value.trim())289 }290 />291 </Show>292 <Show when={scanError()}>293 <p class="peach-hint peach-hint-error">{scanError()}</p>294 </Show>295 <Show when={connecting()}>296 <p class="peach-hint">connecting to your other device...</p>297 </Show>298 <div class="peach-adopt-actions">299 <button300 class="peach-btn peach-btn--accent peach-btn--md"301 disabled={!invitationReady() || connecting()}302 onClick={() => void beginPairing()}303 >304 Connect securely305 </button>306 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>307 </div>308 </Match>309310 <Match when={phase() === "sas"}>311 <h1>Confirm this code matches on your other device.</h1>312 <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>313 <div class="peach-adopt-actions">314 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>315 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>316 </div>317 </Match>318319 <Match when={phase() === "success"}>320 <div class="peach-adopt-birds" aria-hidden="true">321 <span />322 <span />323 </div>324 <h1>Joined your vault.</h1>325 <p>Your passwords will sync shortly.</p>326 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>327 Open Peach328 </button>329 </Match>330331 <Match when={phase() === "error"}>332 <h1>Could not join your vault.</h1>333 <p class="peach-hint peach-hint-error">{error()}</p>334 <div class="peach-adopt-actions">335 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>336 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>337 </div>338 </Match>339 </Switch>340 </div>341 </section>342 );343}