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 type { PairingQrScannerHandle } from "../pairing-qr-scanner";1314type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";1516export interface AdoptVaultViewProps {17 onCancel(): void;18 onDone(): void | Promise<void>;19 startAdoption(args: {20 password: string;21 connection: { invitation: string };22 confirmSAS(sas: string): Promise<boolean>;23 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;24 }): Promise<void>;25}2627const MIN_LENGTH = 12;28const MIN_ZXCVBN_SCORE = 2;293031function adoptionErrorCopy(err: unknown): string {32 const message = err instanceof Error ? err.message : String(err);33 switch (message) {34 case "invalid_code":35 return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";36 case "no_host_found":37 return "Couldn't reach the relay. Check your connection and that the other device's pairing screen is still open.";38 case "handshake_failed":39 return "The secure connection failed. Try again with a fresh code.";40 case "adoption_rejected":41 return "Your other device declined the request.";42 default:43 return message;44 }45}4647let zxcvbnLoader: ((password: string) => { score: number }) | null = null;48async function getZxcvbn() {49 if (zxcvbnLoader) return zxcvbnLoader;50 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([51 import("@zxcvbn-ts/core"),52 import("@zxcvbn-ts/language-common"),53 import("@zxcvbn-ts/language-en"),54 ]);55 zxcvbnOptions.setOptions({56 translations: en.translations,57 graphs: common.adjacencyGraphs,58 dictionary: { ...common.dictionary, ...en.dictionary },59 });60 zxcvbnLoader = (password: string) => zxcvbn(password);61 return zxcvbnLoader;62}6364export function AdoptVaultView(props: AdoptVaultViewProps) {65 const [phase, setPhase] = createSignal<AdoptPhase>("password");66 const [password, setPassword] = createSignal("");67 const [confirmPassword, setConfirmPassword] = createSignal("");68 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);69 const [sas, setSas] = createSignal("");70 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);71 const [error, setError] = createSignal("");72 const [invitation, setInvitation] = createSignal("");73 const [manualEntry, setManualEntry] = createSignal(false);74 const [scanning, setScanning] = createSignal(false);75 const [scanError, setScanError] = createSignal("");76 const [connecting, setConnecting] = createSignal(false);77 let videoEl: HTMLVideoElement | undefined;78 let qrScanner: PairingQrScannerHandle | null = null;7980 const invitationReady = createMemo(() =>81 parsePairingInvitation(invitation()) !== null82 );8384 onCleanup(() => qrScanner?.stop());8586 createMemo(() => {87 const value = password();88 if (!value) {89 setStrengthScore(null);90 return;91 }92 getZxcvbn().then((zxcvbn) => {93 if (password() === value) setStrengthScore(zxcvbn(value).score);94 }).catch(() => {95 if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);96 });97 });9899 const canContinue = createMemo(() =>100 password().length >= MIN_LENGTH &&101 confirmPassword().length >= MIN_LENGTH &&102 password() === confirmPassword() &&103 strengthScore() !== null &&104 strengthScore()! >= MIN_ZXCVBN_SCORE105 );106107 const createBlocker = createMemo(() => {108 if (!password()) return null;109 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;110 if (!confirmPassword()) return "confirm your password";111 if (password() !== confirmPassword()) return "passwords don't match";112 if (strengthScore() === null) return "checking strength...";113 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";114 return null;115 });116117 async function beginPairing() {118 if (!invitationReady() || connecting()) return;119 const connection = { invitation: invitation() };120 stopScanning();121 setPhase("pair");122 setError("");123 setConnecting(true);124 try {125 await props.startAdoption({126 password: password(),127 connection,128 confirmSAS: (sasCode) => new Promise<boolean>((resolve) => {129 setSas(sasCode);130 setSasResolve(() => resolve);131 setPhase("sas");132 }),133 createVault: async (rootKeyHex) => {134 const res = await sendMessage({135 type: "CREATE_VAULT_FROM_ROOT_KEY",136 payload: { password: password(), rootKeyHex },137 }) as { ok: boolean; vaultId?: string; error?: string };138 if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");139 return { vaultId: res.vaultId };140 },141 });142 setPhase("success");143 } catch (err) {144 if (err instanceof Error && err.message === "sas_rejected") return;145 setError(adoptionErrorCopy(err));146 setPhase("error");147 } finally {148 setConnecting(false);149 }150 }151152 function confirmSas(ok: boolean) {153 const resolve = sasResolve();154 setSasResolve(null);155 resolve?.(ok);156 if (!ok) {157 setPhase("password");158 setPassword("");159 setConfirmPassword("");160 setStrengthScore(null);161 162 163 setInvitation("");164 }165 }166167 async function startScanning() {168 if (scanning()) return;169 setScanError("");170 setScanning(true);171 await Promise.resolve();172 if (!videoEl) {173 setScanning(false);174 setScanError("Camera preview could not start. Enter the invitation instead.");175 return;176 }177 try {178 const { startPairingQrScanner } = await import("../pairing-qr-scanner");179 qrScanner = await startPairingQrScanner(180 videoEl,181 (value) => {182 if (!parsePairingInvitation(value)) return;183 setInvitation(value);184 stopScanning();185 void beginPairing();186 },187 () => setScanError("Couldn't read that QR code. Hold it steady or enter the invitation."),188 );189 } catch {190 stopScanning();191 setScanError("Camera access is unavailable. Enter the invitation instead.");192 setManualEntry(true);193 }194 }195196 function stopScanning() {197 qrScanner?.stop();198 qrScanner = null;199 setScanning(false);200 }201202 return (203 <section class="peach-screen peach-theme peach-adopt-vault">204 <RuneBackground opacity={0.07} />205 <div class="peach-adopt-card">206 <Switch>207 <Match when={phase() === "password"}>208 <h1>Set a password for this device.</h1>209 <p>This protects your vault on this device only. It can be different from your other devices.</p>210 <label class="peach-label" for="adopt-password">password</label>211 <input212 id="adopt-password"213 class="peach-underline-input peach-adopt-input"214 type="password"215 autocomplete="new-password"216 value={password()}217 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}218 />219 <label class="peach-label" for="adopt-confirm-password">confirm password</label>220 <input221 id="adopt-confirm-password"222 class="peach-underline-input peach-adopt-input"223 type="password"224 autocomplete="new-password"225 value={confirmPassword()}226 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}227 />228 <Show when={createBlocker()}>229 <p class="peach-hint">{createBlocker()}</p>230 </Show>231 <div class="peach-adopt-actions">232 <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>233 Continue234 </button>235 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>236 </div>237 </Match>238239 <Match when={phase() === "pair"}>240 <div class="peach-adopt-birds" aria-hidden="true">241 <span />242 <span />243 </div>244 <h1>Pair with your other device.</h1>245 <p>Connecting via secure relay — works anywhere.</p>246 <Show when={scanning()}>247 <video248 ref={videoEl}249 class="peach-adopt-qr-video"250 autoplay251 muted252 playsinline253 aria-label="Pairing QR camera preview"254 />255 </Show>256 <Show when={!scanning()}>257 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>258 Scan pairing QR259 </button>260 </Show>261 <button262 class="peach-btn peach-btn--ghost peach-btn--sm"263 onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}264 >265 {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}266 </button>267 <Show when={manualEntry()}>268 <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>269 <textarea270 id="adopt-relay-invitation"271 class="peach-underline-input peach-adopt-input"272 rows={3}273 autocomplete="off"274 spellcheck={false}275 aria-label="Pairing invitation"276 disabled={connecting()}277 value={invitation()}278 onInput={(event) =>279 setInvitation((event.target as HTMLTextAreaElement).value.trim())280 }281 />282 </Show>283 <Show when={scanError()}>284 <p class="peach-hint peach-hint-error">{scanError()}</p>285 </Show>286 <Show when={connecting()}>287 <p class="peach-hint">connecting to your other device...</p>288 </Show>289 <div class="peach-adopt-actions">290 <button291 class="peach-btn peach-btn--accent peach-btn--md"292 disabled={!invitationReady() || connecting()}293 onClick={() => void beginPairing()}294 >295 Connect securely296 </button>297 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>298 </div>299 </Match>300301 <Match when={phase() === "sas"}>302 <h1>Confirm this code matches on your other device.</h1>303 <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>304 <div class="peach-adopt-actions">305 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>306 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>307 </div>308 </Match>309310 <Match when={phase() === "success"}>311 <div class="peach-adopt-birds" aria-hidden="true">312 <span />313 <span />314 </div>315 <h1>Joined your vault.</h1>316 <p>Your passwords will sync shortly.</p>317 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>318 Open Peach319 </button>320 </Match>321322 <Match when={phase() === "error"}>323 <h1>Could not join your vault.</h1>324 <p class="peach-hint peach-hint-error">{error()}</p>325 <div class="peach-adopt-actions">326 <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>327 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>328 </div>329 </Match>330 </Switch>331 </div>332 </section>333 );334}