import { Match, Show, Switch, createMemo, createSignal, onCleanup, onMount, } from "solid-js"; import { sendMessage } from "../App"; import { RuneBackground } from "../components/RuneBackground"; import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code"; import { parsePairingInvitation } from "../../shared/pairing-invitation"; import type { PairingQrScannerHandle } from "../pairing-qr-scanner"; type AdoptPhase = "password" | "pair" | "sas" | "success" | "error"; export interface AdoptVaultViewProps { onCancel(): void; onDone(): void | Promise; startAdoption(args: { password: string; connection: | { mode: "relay"; invitation: string } | { mode: "lan"; code: string }; confirmSAS(sas: string): Promise; createVault(rootKeyHex: string): Promise<{ vaultId: string }>; }): Promise; } const MIN_LENGTH = 12; const MIN_ZXCVBN_SCORE = 2; /** Turn a dial/adoption failure into something a person can act on. */ function adoptionErrorCopy(err: unknown): string { const message = err instanceof Error ? err.message : String(err); switch (message) { case "invalid_code": return "That code wasn't accepted — it may have expired. Get a fresh one from your other device."; case "no_host_found": return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open."; case "handshake_failed": return "The secure connection failed. Try again with a fresh code."; case "adoption_rejected": return "Your other device declined the request."; default: return message; } } let zxcvbnLoader: ((password: string) => { score: number }) | null = null; async function getZxcvbn() { if (zxcvbnLoader) return zxcvbnLoader; const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([ import("@zxcvbn-ts/core"), import("@zxcvbn-ts/language-common"), import("@zxcvbn-ts/language-en"), ]); zxcvbnOptions.setOptions({ translations: en.translations, graphs: common.adjacencyGraphs, dictionary: { ...common.dictionary, ...en.dictionary }, }); zxcvbnLoader = (password: string) => zxcvbn(password); return zxcvbnLoader; } export function AdoptVaultView(props: AdoptVaultViewProps) { const [phase, setPhase] = createSignal("password"); const [password, setPassword] = createSignal(""); const [confirmPassword, setConfirmPassword] = createSignal(""); const [strengthScore, setStrengthScore] = createSignal(null); const [sas, setSas] = createSignal(""); const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null); const [error, setError] = createSignal(""); const [code, setCode] = createSignal(""); const [invitation, setInvitation] = createSignal(""); const [lanOnly, setLanOnly] = createSignal(false); const [manualEntry, setManualEntry] = createSignal(false); const [scanning, setScanning] = createSignal(false); const [scanError, setScanError] = createSignal(""); const [connecting, setConnecting] = createSignal(false); let videoEl: HTMLVideoElement | undefined; let qrScanner: PairingQrScannerHandle | null = null; const codeReady = createMemo(() => parsePairingCode(code()) !== null); const invitationReady = createMemo(() => parsePairingInvitation(invitation()) !== null ); onCleanup(() => qrScanner?.stop()); onMount(() => { void sendMessage({ type: "GET_RELAY_SETTINGS" }) .then((response) => { const result = response as { ok: boolean; data?: { lanOnly?: boolean }; }; if (result.ok) setLanOnly(result.data?.lanOnly === true); }) .catch(() => { // Fresh installs default to relay mode if the background is still // starting; the save path will reconcile before pairing completes. }); }); createMemo(() => { const value = password(); if (!value) { setStrengthScore(null); return; } getZxcvbn().then((zxcvbn) => { if (password() === value) setStrengthScore(zxcvbn(value).score); }).catch(() => { if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1); }); }); const canContinue = createMemo(() => password().length >= MIN_LENGTH && confirmPassword().length >= MIN_LENGTH && password() === confirmPassword() && strengthScore() !== null && strengthScore()! >= MIN_ZXCVBN_SCORE ); const createBlocker = createMemo(() => { if (!password()) return null; if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`; if (!confirmPassword()) return "confirm your password"; if (password() !== confirmPassword()) return "passwords don't match"; if (strengthScore() === null) return "checking strength..."; if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase"; return null; }); async function beginPairing() { const connection = lanOnly() ? (codeReady() ? { mode: "lan" as const, code: code() } : null) : (invitationReady() ? { mode: "relay" as const, invitation: invitation() } : null); if (!connection || connecting()) return; stopScanning(); setPhase("pair"); setError(""); setConnecting(true); try { await props.startAdoption({ password: password(), connection, confirmSAS: (sasCode) => new Promise((resolve) => { setSas(sasCode); setSasResolve(() => resolve); setPhase("sas"); }), createVault: async (rootKeyHex) => { const res = await sendMessage({ type: "CREATE_VAULT_FROM_ROOT_KEY", payload: { password: password(), rootKeyHex }, }) as { ok: boolean; vaultId?: string; error?: string }; if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed"); return { vaultId: res.vaultId }; }, }); setPhase("success"); } catch (err) { if (err instanceof Error && err.message === "sas_rejected") return; setError(adoptionErrorCopy(err)); setPhase("error"); } finally { setConnecting(false); } } function confirmSas(ok: boolean) { const resolve = sasResolve(); setSasResolve(null); resolve?.(ok); if (!ok) { setPhase("password"); setPassword(""); setConfirmPassword(""); setStrengthScore(null); // The code is single-use on the other device's side too — a rejected SAS // means starting over with a fresh one, not retrying this one. setCode(""); setInvitation(""); } } async function startScanning() { if (scanning()) return; setScanError(""); setScanning(true); await Promise.resolve(); if (!videoEl) { setScanning(false); setScanError("Camera preview could not start. Enter the invitation instead."); return; } try { const { startPairingQrScanner } = await import("../pairing-qr-scanner"); qrScanner = await startPairingQrScanner( videoEl, (value) => { if (!parsePairingInvitation(value)) return; setInvitation(value); stopScanning(); void beginPairing(); }, () => setScanError("Couldn't read that QR code. Hold it steady or enter the invitation."), ); } catch { stopScanning(); setScanError("Camera access is unavailable. Enter the invitation instead."); setManualEntry(true); } } function stopScanning() { qrScanner?.stop(); qrScanner = null; setScanning(false); } async function chooseLanOnly(next: boolean) { stopScanning(); setLanOnly(next); setManualEntry(false); const response = (await sendMessage({ type: "SET_RELAY_SETTINGS", payload: { lanOnly: next }, })) as { ok: boolean; error?: string }; if (!response.ok) setScanError(response.error ?? "Could not update sync mode."); } return (

Set a password for this device.

This protects your vault on this device only. It can be different from your other devices.

setPassword((event.target as HTMLInputElement).value)} /> setConfirmPassword((event.target as HTMLInputElement).value)} />

{createBlocker()}

Pair with your other device.

Local network only. Both devices must be on the same Wi-Fi; this will not work over VPN or from different locations.

setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32)) } onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }} /> } >

Connecting via secure relay — works anywhere.