AdoptVaultView.tsx

12.7 KB · raw

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;3031/** Turn a dial/adoption failure into something a person can act on. */32function 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      // The code is single-use on the other device's side too — a rejected SAS163      // means starting over with a fresh one, not retrying this one.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            {/* No relay selector here, deliberately. The code carries the247                relay, and joining a vault means joining its mesh — a choice248                made on this screen would be overwritten by the code seconds249                later. Say which relay is in use instead of pretending to ask250                (docs/sync-relay-surfacing.md). */}251            <p>This device joins on the relay carried in the code you scan.</p>252            <p class="peach-hint">{RELAY_DISCLOSURE}</p>253            <Show when={scanning()}>254              <video255                ref={videoEl}256                class="peach-adopt-qr-video"257                autoplay258                muted259                playsinline260                aria-label="Pairing QR camera preview"261              />262            </Show>263            <Show when={!scanning()}>264              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>265                Scan pairing QR266              </button>267            </Show>268            <button269              class="peach-btn peach-btn--ghost peach-btn--sm"270              onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}271            >272              {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}273            </button>274            <Show when={manualEntry()}>275              <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>276              <textarea277                id="adopt-relay-invitation"278                class="peach-underline-input peach-adopt-input"279                rows={3}280                autocomplete="off"281                spellcheck={false}282                aria-label="Pairing invitation"283                disabled={connecting()}284                value={invitation()}285                onInput={(event) =>286                  setInvitation((event.target as HTMLTextAreaElement).value.trim())287                }288              />289            </Show>290            <Show when={scanError()}>291              <p class="peach-hint peach-hint-error">{scanError()}</p>292            </Show>293            <Show when={connecting()}>294              <p class="peach-hint">connecting to your other device...</p>295            </Show>296            <div class="peach-adopt-actions">297              <button298                class="peach-btn peach-btn--accent peach-btn--md"299                disabled={!invitationReady() || connecting()}300                onClick={() => void beginPairing()}301              >302                Connect securely303              </button>304              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>305            </div>306          </Match>307308          <Match when={phase() === "sas"}>309            <h1>Confirm this code matches on your other device.</h1>310            <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>311            <div class="peach-adopt-actions">312              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>313              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>314            </div>315          </Match>316317          <Match when={phase() === "success"}>318            <div class="peach-adopt-birds" aria-hidden="true">319              <span />320              <span />321            </div>322            <h1>Joined your vault.</h1>323            <p>Your passwords will sync shortly.</p>324            <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>325              Open Peach326            </button>327          </Match>328329          <Match when={phase() === "error"}>330            <h1>Could not join your vault.</h1>331            <p class="peach-hint peach-hint-error">{error()}</p>332            <div class="peach-adopt-actions">333              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>334              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>335            </div>336          </Match>337        </Switch>338      </div>339    </section>340  );341}