AdoptVaultView.tsx

15.2 KB · raw

1import {2  Match,3  Show,4  Switch,5  createMemo,6  createSignal,7  onCleanup,8  onMount,9} from "solid-js";10import { sendMessage } from "../App";11import { RuneBackground } from "../components/RuneBackground";12import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";13import { parsePairingInvitation } from "../../shared/pairing-invitation";14import type { PairingQrScannerHandle } from "../pairing-qr-scanner";1516type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";1718export interface AdoptVaultViewProps {19  onCancel(): void;20  onDone(): void | Promise<void>;21  startAdoption(args: {22    password: string;23    connection:24      | { mode: "relay"; invitation: string }25      | { mode: "lan"; code: string };26    confirmSAS(sas: string): Promise<boolean>;27    createVault(rootKeyHex: string): Promise<{ vaultId: string }>;28  }): Promise<void>;29}3031const MIN_LENGTH = 12;32const MIN_ZXCVBN_SCORE = 2;3334/** Turn a dial/adoption failure into something a person can act on. */35function adoptionErrorCopy(err: unknown): string {36  const message = err instanceof Error ? err.message : String(err);37  switch (message) {38    case "invalid_code":39      return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";40    case "no_host_found":41      return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open.";42    case "handshake_failed":43      return "The secure connection failed. Try again with a fresh code.";44    case "adoption_rejected":45      return "Your other device declined the request.";46    default:47      return message;48  }49}5051let zxcvbnLoader: ((password: string) => { score: number }) | null = null;52async function getZxcvbn() {53  if (zxcvbnLoader) return zxcvbnLoader;54  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([55    import("@zxcvbn-ts/core"),56    import("@zxcvbn-ts/language-common"),57    import("@zxcvbn-ts/language-en"),58  ]);59  zxcvbnOptions.setOptions({60    translations: en.translations,61    graphs: common.adjacencyGraphs,62    dictionary: { ...common.dictionary, ...en.dictionary },63  });64  zxcvbnLoader = (password: string) => zxcvbn(password);65  return zxcvbnLoader;66}6768export function AdoptVaultView(props: AdoptVaultViewProps) {69  const [phase, setPhase] = createSignal<AdoptPhase>("password");70  const [password, setPassword] = createSignal("");71  const [confirmPassword, setConfirmPassword] = createSignal("");72  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);73  const [sas, setSas] = createSignal("");74  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);75  const [error, setError] = createSignal("");76  const [code, setCode] = createSignal("");77  const [invitation, setInvitation] = createSignal("");78  const [lanOnly, setLanOnly] = createSignal(false);79  const [manualEntry, setManualEntry] = createSignal(false);80  const [scanning, setScanning] = createSignal(false);81  const [scanError, setScanError] = createSignal("");82  const [connecting, setConnecting] = createSignal(false);83  let videoEl: HTMLVideoElement | undefined;84  let qrScanner: PairingQrScannerHandle | null = null;8586  const codeReady = createMemo(() => parsePairingCode(code()) !== null);87  const invitationReady = createMemo(() =>88    parsePairingInvitation(invitation()) !== null89  );9091  onCleanup(() => qrScanner?.stop());92  onMount(() => {93    void sendMessage({ type: "GET_RELAY_SETTINGS" })94      .then((response) => {95        const result = response as {96          ok: boolean;97          data?: { lanOnly?: boolean };98        };99        if (result.ok) setLanOnly(result.data?.lanOnly === true);100      })101      .catch(() => {102        // Fresh installs default to relay mode if the background is still103        // starting; the save path will reconcile before pairing completes.104      });105  });106107  createMemo(() => {108    const value = password();109    if (!value) {110      setStrengthScore(null);111      return;112    }113    getZxcvbn().then((zxcvbn) => {114      if (password() === value) setStrengthScore(zxcvbn(value).score);115    }).catch(() => {116      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);117    });118  });119120  const canContinue = createMemo(() =>121    password().length >= MIN_LENGTH &&122    confirmPassword().length >= MIN_LENGTH &&123    password() === confirmPassword() &&124    strengthScore() !== null &&125    strengthScore()! >= MIN_ZXCVBN_SCORE126  );127128  const createBlocker = createMemo(() => {129    if (!password()) return null;130    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;131    if (!confirmPassword()) return "confirm your password";132    if (password() !== confirmPassword()) return "passwords don't match";133    if (strengthScore() === null) return "checking strength...";134    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";135    return null;136  });137138  async function beginPairing() {139    const connection = lanOnly()140      ? (codeReady() ? { mode: "lan" as const, code: code() } : null)141      : (invitationReady()142        ? { mode: "relay" as const, invitation: invitation() }143        : null);144    if (!connection || connecting()) return;145    stopScanning();146    setPhase("pair");147    setError("");148    setConnecting(true);149    try {150      await props.startAdoption({151        password: password(),152        connection,153        confirmSAS: (sasCode) => new Promise<boolean>((resolve) => {154          setSas(sasCode);155          setSasResolve(() => resolve);156          setPhase("sas");157        }),158        createVault: async (rootKeyHex) => {159          const res = await sendMessage({160            type: "CREATE_VAULT_FROM_ROOT_KEY",161            payload: { password: password(), rootKeyHex },162          }) as { ok: boolean; vaultId?: string; error?: string };163          if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");164          return { vaultId: res.vaultId };165        },166      });167      setPhase("success");168    } catch (err) {169      if (err instanceof Error && err.message === "sas_rejected") return;170      setError(adoptionErrorCopy(err));171      setPhase("error");172    } finally {173      setConnecting(false);174    }175  }176177  function confirmSas(ok: boolean) {178    const resolve = sasResolve();179    setSasResolve(null);180    resolve?.(ok);181    if (!ok) {182      setPhase("password");183      setPassword("");184      setConfirmPassword("");185      setStrengthScore(null);186      // The code is single-use on the other device's side too — a rejected SAS187      // means starting over with a fresh one, not retrying this one.188      setCode("");189      setInvitation("");190    }191  }192193  async function startScanning() {194    if (scanning()) return;195    setScanError("");196    setScanning(true);197    await Promise.resolve();198    if (!videoEl) {199      setScanning(false);200      setScanError("Camera preview could not start. Enter the invitation instead.");201      return;202    }203    try {204      const { startPairingQrScanner } = await import("../pairing-qr-scanner");205      qrScanner = await startPairingQrScanner(206        videoEl,207        (value) => {208          if (!parsePairingInvitation(value)) return;209          setInvitation(value);210          stopScanning();211          void beginPairing();212        },213        () => setScanError("Couldn't read that QR code. Hold it steady or enter the invitation."),214      );215    } catch {216      stopScanning();217      setScanError("Camera access is unavailable. Enter the invitation instead.");218      setManualEntry(true);219    }220  }221222  function stopScanning() {223    qrScanner?.stop();224    qrScanner = null;225    setScanning(false);226  }227228  async function chooseLanOnly(next: boolean) {229    stopScanning();230    setLanOnly(next);231    setManualEntry(false);232    const response = (await sendMessage({233      type: "SET_RELAY_SETTINGS",234      payload: { lanOnly: next },235    })) as { ok: boolean; error?: string };236    if (!response.ok) setScanError(response.error ?? "Could not update sync mode.");237  }238239  return (240    <section class="peach-screen peach-theme peach-adopt-vault">241      <RuneBackground opacity={0.07} />242      <div class="peach-adopt-card">243        <Switch>244          <Match when={phase() === "password"}>245            <h1>Set a password for this device.</h1>246            <p>This protects your vault on this device only. It can be different from your other devices.</p>247            <label class="peach-label" for="adopt-password">password</label>248            <input249              id="adopt-password"250              class="peach-underline-input peach-adopt-input"251              type="password"252              autocomplete="new-password"253              value={password()}254              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}255            />256            <label class="peach-label" for="adopt-confirm-password">confirm password</label>257            <input258              id="adopt-confirm-password"259              class="peach-underline-input peach-adopt-input"260              type="password"261              autocomplete="new-password"262              value={confirmPassword()}263              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}264            />265            <Show when={createBlocker()}>266              <p class="peach-hint">{createBlocker()}</p>267            </Show>268            <div class="peach-adopt-actions">269              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>270                Continue271              </button>272              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>273            </div>274          </Match>275276          <Match when={phase() === "pair"}>277            <div class="peach-adopt-birds" aria-hidden="true">278              <span />279              <span />280            </div>281            <h1>Pair with your other device.</h1>282            <Show283              when={!lanOnly()}284              fallback={285                <>286                  <p>287                    Local network only. Both devices must be on the same Wi-Fi;288                    this will not work over VPN or from different locations.289                  </p>290                  <label class="peach-label" for="adopt-pair-code">local pairing code</label>291                  <input292                    id="adopt-pair-code"293                    class="peach-underline-input peach-adopt-input peach-code-input"294                    type="text"295                    autocapitalize="characters"296                    autocomplete="one-time-code"297                    spellcheck={false}298                    maxlength={32}299                    placeholder="A74H-48J3-KI32-OEP4"300                    aria-label="Pairing code"301                    disabled={connecting()}302                    value={code()}303                    onInput={(event) =>304                      setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32))305                    }306                    onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}307                  />308                  <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(false)}>309                    Use secure relay instead310                  </button>311                </>312              }313            >314              <p>Connecting via secure relay — works anywhere.</p>315              <Show when={scanning()}>316                <video317                  ref={videoEl}318                  class="peach-adopt-qr-video"319                  autoplay320                  muted321                  playsinline322                  aria-label="Pairing QR camera preview"323                />324              </Show>325              <Show when={!scanning()}>326                <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>327                  Scan pairing QR328                </button>329              </Show>330              <button331                class="peach-btn peach-btn--ghost peach-btn--sm"332                onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}333              >334                {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}335              </button>336              <Show when={manualEntry()}>337                <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>338                <textarea339                  id="adopt-relay-invitation"340                  class="peach-underline-input peach-adopt-input"341                  rows={3}342                  autocomplete="off"343                  spellcheck={false}344                  aria-label="Pairing invitation"345                  disabled={connecting()}346                  value={invitation()}347                  onInput={(event) =>348                    setInvitation((event.target as HTMLTextAreaElement).value.trim())349                  }350                />351              </Show>352              <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(true)}>353                Switch to local network only354              </button>355            </Show>356            <Show when={scanError()}>357              <p class="peach-hint peach-hint-error">{scanError()}</p>358            </Show>359            <Show when={connecting()}>360              <p class="peach-hint">connecting to your other device...</p>361            </Show>362            <div class="peach-adopt-actions">363              <button364                class="peach-btn peach-btn--accent peach-btn--md"365                disabled={(lanOnly() ? !codeReady() : !invitationReady()) || connecting()}366                onClick={() => void beginPairing()}367              >368                Connect securely369              </button>370              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>371            </div>372          </Match>373374          <Match when={phase() === "sas"}>375            <h1>Confirm this code matches on your other device.</h1>376            <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>377            <div class="peach-adopt-actions">378              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>379              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>380            </div>381          </Match>382383          <Match when={phase() === "success"}>384            <div class="peach-adopt-birds" aria-hidden="true">385              <span />386              <span />387            </div>388            <h1>Joined your vault.</h1>389            <p>Your passwords will sync shortly.</p>390            <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>391              Open Peach392            </button>393          </Match>394395          <Match when={phase() === "error"}>396            <h1>Could not join your vault.</h1>397            <p class="peach-hint peach-hint-error">{error()}</p>398            <div class="peach-adopt-actions">399              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>400              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>401            </div>402          </Match>403        </Switch>404      </div>405    </section>406  );407}