AdoptVaultView.tsx

12.2 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 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;2930/** Turn a dial/adoption failure into something a person can act on. */31function 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      // The code is single-use on the other device's side too — a rejected SAS162      // means starting over with a fresh one, not retrying this one.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}