AdoptVaultView.tsx

10.7 KB · raw

1import {2  Match,3  Show,4  Switch,5  createEffect,6  createMemo,7  createSignal,8  onCleanup,9} from "solid-js";10import { sendMessage } from "../App";11import { RuneBackground } from "../components/RuneBackground";12import { PairingQrCode } from "../components/PairingQrCode";13import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1415type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";1617export interface AdoptVaultViewProps {18  onCancel(): void;19  onDone(): void | Promise<void>;20}2122const MIN_LENGTH = 12;23const MIN_ZXCVBN_SCORE = 2;2425let zxcvbnLoader: ((password: string) => { score: number }) | null = null;26async function getZxcvbn() {27  if (zxcvbnLoader) return zxcvbnLoader;28  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([29    import("@zxcvbn-ts/core"),30    import("@zxcvbn-ts/language-common"),31    import("@zxcvbn-ts/language-en"),32  ]);33  zxcvbnOptions.setOptions({34    translations: en.translations,35    graphs: common.adjacencyGraphs,36    dictionary: { ...common.dictionary, ...en.dictionary },37  });38  zxcvbnLoader = (password: string) => zxcvbn(password);39  return zxcvbnLoader;40}4142export function AdoptVaultView(props: AdoptVaultViewProps) {43  const [phase, setPhase] = createSignal<AdoptPhase>("password");44  const [password, setPassword] = createSignal("");45  const [confirmPassword, setConfirmPassword] = createSignal("");46  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);47  const [receiverInvitation, setReceiverInvitation] = createSignal("");48  const [receiverError, setReceiverError] = createSignal("");49  const [pendingSasId, setPendingSasId] = createSignal("");50  const [pendingSas, setPendingSas] = createSignal("");51  const [senderDeviceName, setSenderDeviceName] = createSignal("Other device");5253  onCleanup(() => {54    // Tell the background to release the relay room if we still hold it.55    void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);56    // Any unresolved SAS approval would otherwise pin the receiver session57    // open until its timeout — tell the background the user bailed.58    const id = pendingSasId();59    if (id) {60      void sendMessage({61        type: "RECEIVER_SAS_DECISION",62        payload: { id, ok: false },63      }).catch(() => undefined);64    }65  });6667  // When the user enters the pair phase, the empty extension becomes the68  // source of a "pairing request" QR. The populated phone scans it and the69  // phone is responsible for sending the vault back over the Noise channel.70  // The password is shipped to the background once so the adopted root key71  // can be wrapped at rest without asking the user to retype it later.72  createEffect(() => {73    if (phase() !== "pair") return;74    if (receiverInvitation() || receiverError()) return;75    void (async () => {76      const response = (await sendMessage({77        type: "START_ADOPTION_RECEIVER",78        payload: { password: password() },79      })) as { ok: boolean; data?: { invitation?: string }; error?: string };80      if (response.ok && response.data?.invitation) {81        setReceiverInvitation(response.data.invitation);82      } else {83        setReceiverError(response.error ?? "adoption_receiver_failed");84      }85    })();86  });8788  // SAS push from the background → show the code, wait for Confirm/Cancel.89  // The receiver's `confirmSas` blocks until the popup answers via90  // RECEIVER_SAS_DECISION or the timeout fires.91  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {92    const handler = (msg: { type?: string; payload?: unknown }) => {93      if (msg?.type === "ADOPTION_SOURCE_SAS") {94        const p = msg.payload as {95          id?: string;96          sas?: string;97          receiverDeviceName?: string;98        } | undefined;99        if (typeof p?.id === "string" && typeof p?.sas === "string") {100          setPendingSasId(p.id);101          setPendingSas(p.sas);102          if (typeof p.receiverDeviceName === "string" && p.receiverDeviceName) {103            setSenderDeviceName(p.receiverDeviceName);104          }105          setPhase("sas");106        }107      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {108        const p = msg.payload as {109          status?: "complete" | "error";110          error?: string;111        } | undefined;112        if (p?.status === "complete") {113          setPendingSasId("");114          setPhase("success");115        } else if (p?.status === "error") {116          setPendingSasId("");117          setReceiverError(p.error ?? "adoption_failed");118          setPhase("error");119        }120      }121    };122    chrome.runtime.onMessage.addListener(handler);123    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));124  }125126  function answerSas(ok: boolean): void {127    const id = pendingSasId();128    if (!id) return;129    setPendingSasId("");130    void sendMessage({131      type: "RECEIVER_SAS_DECISION",132      payload: { id, ok },133    }).catch(() => undefined);134    if (!ok) {135      setReceiverError("adoption_rejected");136      setPhase("error");137    }138  }139140  createMemo(() => {141    const value = password();142    if (!value) {143      setStrengthScore(null);144      return;145    }146    getZxcvbn().then((zxcvbn) => {147      if (password() === value) setStrengthScore(zxcvbn(value).score);148    }).catch(() => {149      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);150    });151  });152153  const canContinue = createMemo(() =>154    password().length >= MIN_LENGTH &&155    confirmPassword().length >= MIN_LENGTH &&156    password() === confirmPassword() &&157    strengthScore() !== null &&158    strengthScore()! >= MIN_ZXCVBN_SCORE159  );160161  const createBlocker = createMemo(() => {162    if (!password()) return null;163    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;164    if (!confirmPassword()) return "confirm your password";165    if (password() !== confirmPassword()) return "passwords don't match";166    if (strengthScore() === null) return "checking strength...";167    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";168    return null;169  });170171  function tryAgain(): void {172    setReceiverError("");173    setReceiverInvitation("");174    setPendingSas("");175    setPendingSasId("");176    setPhase("pair");177  }178179  return (180    <section class="peach-screen peach-theme peach-adopt-vault">181      <RuneBackground opacity={0.07} />182      <div class="peach-adopt-card">183        <Switch>184          <Match when={phase() === "password"}>185            <h1>Set a password for this device.</h1>186            <p>This protects your vault on this device only. It can be different from your other devices.</p>187            <label class="peach-label" for="adopt-password">password</label>188            <input189              id="adopt-password"190              class="peach-underline-input peach-adopt-input"191              type="password"192              autocomplete="new-password"193              value={password()}194              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}195            />196            <label class="peach-label" for="adopt-confirm-password">confirm password</label>197            <input198              id="adopt-confirm-password"199              class="peach-underline-input peach-adopt-input"200              type="password"201              autocomplete="new-password"202              value={confirmPassword()}203              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}204            />205            <Show when={createBlocker()}>206              <p class="peach-hint">{createBlocker()}</p>207            </Show>208            <div class="peach-adopt-actions">209              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>210                Continue211              </button>212              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>213            </div>214          </Match>215216          <Match when={phase() === "pair"}>217            <h1>Pair with your other device.</h1>218            <p>219              Scan this code on the device that holds your vault. That220              device will send the vault to this extension once the codes221              match.222            </p>223            <p class="peach-hint">{RELAY_DISCLOSURE}</p>224            <Show225              when={receiverInvitation()}226              fallback={227                <Show228                  when={receiverError()}229                  fallback={<p class="peach-hint">Preparing secure pairing room…</p>}230                >231                  <p class="peach-hint peach-hint-error">{receiverError()}</p>232                </Show>233              }234            >235              <PairingQrCode value={receiverInvitation()} size={200} />236            </Show>237            <div class="peach-adopt-actions">238              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>239            </div>240          </Match>241242          <Match when={phase() === "sas"}>243            <h1>Confirm the code matches.</h1>244            <p>245              {senderDeviceName()} is showing the same code. If the digits246              match, confirm below — the vault will be sent to this247              extension.248            </p>249            <div class="peach-adoption-sas" aria-label="Pairing confirmation code">250              <strong>{pendingSas()}</strong>251            </div>252            <div class="peach-adopt-actions">253              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => answerSas(true)}>254                They match255              </button>256              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => answerSas(false)}>257                Cancel258              </button>259            </div>260          </Match>261262          <Match when={phase() === "success"}>263            <h1>Joined your vault.</h1>264            <p>265              This extension now holds the same vault as your other266              devices. You can unlock it with the password you just set.267            </p>268            <div class="peach-adopt-actions">269              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>270                Open Peach271              </button>272            </div>273          </Match>274275          <Match when={phase() === "error"}>276            <h1>Could not join the vault.</h1>277            <p class="peach-hint peach-hint-error">{receiverError()}</p>278            <div class="peach-adopt-actions">279              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>280                Try again281              </button>282              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>283            </div>284          </Match>285        </Switch>286      </div>287    </section>288  );289}