AdoptVaultView.tsx

14.2 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 { DesktopBird, MobileBird } from "../components/PairingBirds";12import { RuneBackground } from "../components/RuneBackground";13import { PairingQrCode } from "../components/PairingQrCode";14import { PairingProRequired } from "../components/PairingProRequired";15import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1617type AdoptPhase = "password" | "pair" | "error";1819export interface AdoptVaultViewProps {20  onCancel(): void;21  onDone(): void | Promise<void>;22}2324/**25 * PB-26-607: never echo a raw background error string into the UI.26 * ADOPT_PRO_REQUIRED survives because the error phase branches on it to show27 * the Pro upsell instead of the generic failure copy.28 */29function describeAdoptionError(error: string | undefined, fallback: string): string {30  return error === "ADOPT_PRO_REQUIRED" ? error : fallback;31}3233const MIN_LENGTH = 12;34const MIN_ZXCVBN_SCORE = 2;35const PAIRING_CELEBRATION_MS = 5_000;3637let zxcvbnLoader: ((password: string) => { score: number }) | null = null;38async function getZxcvbn() {39  if (zxcvbnLoader) return zxcvbnLoader;40  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([41    import("@zxcvbn-ts/core"),42    import("@zxcvbn-ts/language-common"),43    import("@zxcvbn-ts/language-en"),44  ]);45  zxcvbnOptions.setOptions({46    translations: en.translations,47    graphs: common.adjacencyGraphs,48    dictionary: { ...common.dictionary, ...en.dictionary },49  });50  zxcvbnLoader = (password: string) => zxcvbn(password);51  return zxcvbnLoader;52}5354export function AdoptVaultView(props: AdoptVaultViewProps) {55  const [phase, setPhase] = createSignal<AdoptPhase>("password");56  const [password, setPassword] = createSignal("");57  const [confirmPassword, setConfirmPassword] = createSignal("");58  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);59  const [receiverInvitation, setReceiverInvitation] = createSignal("");60  const [receiverSas, setReceiverSas] = createSignal("");61  const [peerFingerprint, setPeerFingerprint] = createSignal("");62  const [receiverError, setReceiverError] = createSignal("");63  const [receiverComplete, setReceiverComplete] = createSignal(false);64  let completionTimer: number | undefined;6566  onCleanup(() => {67    if (completionTimer !== undefined) window.clearTimeout(completionTimer);68    // Tell the background to release the relay room if we still hold it.69    void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);70  });7172  // When the user enters the pair phase, the empty extension becomes the73  // source of a "pairing request" QR. The populated phone scans it and the74  // phone is responsible for sending the vault back over the Noise channel.75  // The password is shipped to the background once so the adopted root key76  // can be wrapped at rest without asking the user to retype it later.77  createEffect(() => {78    if (phase() !== "pair") return;79    if (receiverInvitation() || receiverError()) return;80    void (async () => {81      const response = (await sendMessage({82        type: "START_ADOPTION_RECEIVER",83        payload: { password: password() },84      })) as { ok: boolean; data?: { invitation?: string }; error?: string };85      if (response.ok && response.data?.invitation) {86        setReceiverInvitation(response.data.invitation);87      } else {88        setReceiverError(89          describeAdoptionError(90            response.error,91            "could not start joining this vault. please try again.",92          ),93        );94      }95    })();96  });9798  const [receiverSasId, setReceiverSasId] = createSignal("");99  const [receiverSasDecision, setReceiverSasDecision] = createSignal(false);100101  function confirmReceiverSas(): void {102    if (!receiverSas() || !receiverSasId()) return;103    void sendMessage({104      type: "RECEIVER_SAS_DECISION",105      payload: { id: receiverSasId(), sas: receiverSas(), ok: true },106    });107    setReceiverSasDecision(true);108  }109110  // The receiver must explicitly confirm the displayed SAS before adoption.111112  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {113    const handler = (msg: { type?: string; payload?: unknown }) => {114      if (msg?.type === "ADOPTION_SOURCE_SAS") {115        const p = msg.payload as {116          id?: string;117          sas?: string;118          displayOnly?: boolean;119          peerFingerprint?: string;120        } | undefined;121        if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") {122          setReceiverSas(p.sas);123          setReceiverSasId((p as { id: string }).id);124          setReceiverSasDecision(false);125          setPeerFingerprint(typeof p.peerFingerprint === "string" ? p.peerFingerprint : "");126        }127      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {128        const p = msg.payload as {129          status?: "complete" | "error";130          error?: string;131        } | undefined;132        if (p?.status === "complete") {133          if (receiverComplete()) return;134          setReceiverComplete(true);135          completionTimer = window.setTimeout(() => {136            completionTimer = undefined;137            void props.onDone();138          }, PAIRING_CELEBRATION_MS);139        } else if (p?.status === "error") {140          setReceiverError(141            describeAdoptionError(142              p.error,143              "the other device could not finish the transfer. please try again.",144            ),145          );146          setPhase("error");147        }148      }149    };150    chrome.runtime.onMessage.addListener(handler);151    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));152  }153154  createMemo(() => {155    const value = password();156    if (!value) {157      setStrengthScore(null);158      return;159    }160    getZxcvbn().then((zxcvbn) => {161      if (password() === value) setStrengthScore(zxcvbn(value).score);162    }).catch(() => {163      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);164    });165  });166167  const canContinue = createMemo(() =>168    password().length >= MIN_LENGTH &&169    confirmPassword().length >= MIN_LENGTH &&170    password() === confirmPassword() &&171    strengthScore() !== null &&172    strengthScore()! >= MIN_ZXCVBN_SCORE173  );174175  const createBlocker = createMemo(() => {176    if (!password()) return null;177    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;178    if (!confirmPassword()) return "confirm your password";179    if (password() !== confirmPassword()) return "passwords don't match";180    if (strengthScore() === null) return "checking strength...";181    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";182    return null;183  });184185  function tryAgain(): void {186    setReceiverError("");187    setReceiverInvitation("");188    setReceiverSas("");189    setPhase("pair");190  }191192  return (193    <section194      class="peach-screen peach-theme peach-adopt-vault"195      classList={{ "peach-adopt-vault--pair": phase() === "pair" }}196    >197      <RuneBackground opacity={0.07} />198      <div199        class="peach-adopt-card"200        classList={{ "peach-adopt-card--pair": phase() === "pair" }}201      >202        <Switch>203          <Match when={phase() === "password"}>204            <h1>Set a password for this device.</h1>205            <p>This protects your vault on this device only. It can be different from your other devices.</p>206            <label class="peach-label" for="adopt-password">password</label>207            <input208              id="adopt-password"209              class="peach-underline-input peach-adopt-input"210              type="password"211              autocomplete="new-password"212              value={password()}213              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}214              onKeyDown={(event) => {215                if (event.key === "Enter" && canContinue()) setPhase("pair");216              }}217            />218            <label class="peach-label" for="adopt-confirm-password">confirm password</label>219            <input220              id="adopt-confirm-password"221              class="peach-underline-input peach-adopt-input"222              type="password"223              autocomplete="new-password"224              value={confirmPassword()}225              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}226              onKeyDown={(event) => {227                if (event.key === "Enter" && canContinue()) setPhase("pair");228              }}229            />230            <Show when={createBlocker()}>231              <p class="peach-hint">{createBlocker()}</p>232            </Show>233            <div class="peach-adopt-actions">234              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>235                Continue236              </button>237              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>238            </div>239          </Match>240241          <Match when={phase() === "pair"}>242            <div243              class="peach-adopt-pairing"244              classList={{245                "has-sas": Boolean(receiverSas()),246                "is-complete": receiverComplete(),247              }}248            >249              <div250                class={`peach-pair-stage peach-adopt-bird-stage ${251                  receiverComplete()252                    ? "act-paired"253                    : receiverSas()254                      ? "act-handshake"255                      : "act-listening"256                }`}257                aria-hidden="true"258              >259                <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">260                  <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />261                </div>262                <Show when={receiverComplete()}>263                  <span class="peach-adopt-connection" />264                </Show>265                <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">266                  <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />267                </div>268              </div>269270              <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>271              <p class="peach-adopt-pair-lede" aria-live="polite">272                {receiverComplete()273                  ? "Your devices are connected. Getting your vault ready…"274                  : receiverSas()275                    ? "Make sure this code matches the one on your phone."276                    : "Scan this QR in Peach on the device that already has your vault."}277              </p>278279              <Show280                when={!receiverComplete()}281                fallback={282                  <div class="peach-adopt-paired-status" role="status">283                    <span />284                    Secure connection established285                  </div>286                }287              >288                <Show289                  when={receiverSas()}290                  fallback={291                    <div class="peach-adopt-qr-wrap" aria-live="polite">292                      <Show293                        when={receiverInvitation()}294                        fallback={295                          <Show296                            when={receiverError()}297                            fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}298                          >299                            <p class="peach-hint peach-hint-error">{receiverError()}</p>300                          </Show>301                        }302                      >303                        <PairingQrCode value={receiverInvitation()} size={144} />304                      </Show>305                    </div>306                  }307                >308                  <div class="peach-adopt-confirmation">309                    <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>310                    <div class="peach-adoption-sas" aria-label="Pairing confirmation code">311                      <strong>{receiverSas()}</strong>312                    </div>313                    <p class="peach-adopt-confirmation-help">314                      Confirm this code and session ID {peerFingerprint()} match315                      the phone you intend to trust. Approving replaces this316                      empty device with a complete copy of its vault, including317                      existing passwords, passkeys, notes, and recovery access.318                    </p>319                    <button320                      class="peach-btn peach-btn--accent peach-btn--md"321                      disabled={receiverSasDecision()}322                      onClick={confirmReceiverSas}323                    >324                      {receiverSasDecision() ? "Code confirmed" : "Confirm code"}325                    </button>326                  </div>327                </Show>328329                <div class="peach-adopt-privacy">330                  <svg viewBox="0 0 24 24" aria-hidden="true">331                    <path d="M7 10V8a5 5 0 0 1 10 0v2" />332                    <rect x="5" y="10" width="14" height="10" rx="3" />333                    <path d="M12 14v2" />334                  </svg>335                  <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>336                </div>337338                <div class="peach-adopt-actions peach-adopt-pair-actions">339                  <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>340                </div>341              </Show>342            </div>343          </Match>344345          <Match when={phase() === "error"}>346            <Show347              when={receiverError() === "ADOPT_PRO_REQUIRED"}348              fallback={349                <>350                  <h1>Could not join the vault.</h1>351                  <p class="peach-hint peach-hint-error">{receiverError()}</p>352                  <div class="peach-adopt-actions">353                    <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>354                      Try again355                    </button>356                    <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>357                  </div>358                </>359              }360            >361              <PairingProRequired onRetry={tryAgain} />362            </Show>363          </Match>364        </Switch>365      </div>366    </section>367  );368}