AdoptVaultView.tsx

14.0 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 [receiverError, setReceiverError] = createSignal("");62  const [receiverComplete, setReceiverComplete] = createSignal(false);63  let completionTimer: number | undefined;6465  onCleanup(() => {66    if (completionTimer !== undefined) window.clearTimeout(completionTimer);67    // Tell the background to release the relay room if we still hold it.68    void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);69  });7071  // When the user enters the pair phase, the empty extension becomes the72  // source of a "pairing request" QR. The populated phone scans it and the73  // phone is responsible for sending the vault back over the Noise channel.74  // The password is shipped to the background once so the adopted root key75  // can be wrapped at rest without asking the user to retype it later.76  createEffect(() => {77    if (phase() !== "pair") return;78    if (receiverInvitation() || receiverError()) return;79    void (async () => {80      const response = (await sendMessage({81        type: "START_ADOPTION_RECEIVER",82        payload: { password: password() },83      })) as { ok: boolean; data?: { invitation?: string }; error?: string };84      if (response.ok && response.data?.invitation) {85        setReceiverInvitation(response.data.invitation);86      } else {87        setReceiverError(88          describeAdoptionError(89            response.error,90            "could not start joining this vault. please try again.",91          ),92        );93      }94    })();95  });9697  const [receiverSasId, setReceiverSasId] = createSignal("");98  const [receiverSasDecision, setReceiverSasDecision] = createSignal(false);99100  function confirmReceiverSas(): void {101    if (!receiverSas() || !receiverSasId()) return;102    void sendMessage({103      type: "RECEIVER_SAS_DECISION",104      payload: { id: receiverSasId(), sas: receiverSas(), ok: true },105    });106    setReceiverSasDecision(true);107  }108109  // The receiver must explicitly confirm the displayed SAS before adoption.110111  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {112    const handler = (msg: { type?: string; payload?: unknown }) => {113      if (msg?.type === "ADOPTION_SOURCE_SAS") {114        const p = msg.payload as {115          id?: string;116          sas?: string;117          displayOnly?: boolean;118        } | undefined;119        if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") {120          setReceiverSas(p.sas);121          setReceiverSasId((p as { id: string }).id);122          setReceiverSasDecision(false);123        }124      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {125        const p = msg.payload as {126          status?: "complete" | "error";127          error?: string;128        } | undefined;129        if (p?.status === "complete") {130          if (receiverComplete()) return;131          setReceiverComplete(true);132          completionTimer = window.setTimeout(() => {133            completionTimer = undefined;134            void props.onDone();135          }, PAIRING_CELEBRATION_MS);136        } else if (p?.status === "error") {137          setReceiverError(138            describeAdoptionError(139              p.error,140              "the other device could not finish the transfer. please try again.",141            ),142          );143          setPhase("error");144        }145      }146    };147    chrome.runtime.onMessage.addListener(handler);148    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));149  }150151  createMemo(() => {152    const value = password();153    if (!value) {154      setStrengthScore(null);155      return;156    }157    getZxcvbn().then((zxcvbn) => {158      if (password() === value) setStrengthScore(zxcvbn(value).score);159    }).catch(() => {160      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);161    });162  });163164  const canContinue = createMemo(() =>165    password().length >= MIN_LENGTH &&166    confirmPassword().length >= MIN_LENGTH &&167    password() === confirmPassword() &&168    strengthScore() !== null &&169    strengthScore()! >= MIN_ZXCVBN_SCORE170  );171172  const createBlocker = createMemo(() => {173    if (!password()) return null;174    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;175    if (!confirmPassword()) return "confirm your password";176    if (password() !== confirmPassword()) return "passwords don't match";177    if (strengthScore() === null) return "checking strength...";178    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";179    return null;180  });181182  function tryAgain(): void {183    setReceiverError("");184    setReceiverInvitation("");185    setReceiverSas("");186    setPhase("pair");187  }188189  return (190    <section191      class="peach-screen peach-theme peach-adopt-vault"192      classList={{ "peach-adopt-vault--pair": phase() === "pair" }}193    >194      <RuneBackground opacity={0.07} />195      <div196        class="peach-adopt-card"197        classList={{ "peach-adopt-card--pair": phase() === "pair" }}198      >199        <Switch>200          <Match when={phase() === "password"}>201            <h1>Set a password for this device.</h1>202            <p>This protects your vault on this device only. It can be different from your other devices.</p>203            <label class="peach-label" for="adopt-password">password</label>204            <input205              id="adopt-password"206              class="peach-underline-input peach-adopt-input"207              type="password"208              autocomplete="new-password"209              value={password()}210              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}211              onKeyDown={(event) => {212                if (event.key === "Enter" && canContinue()) setPhase("pair");213              }}214            />215            <label class="peach-label" for="adopt-confirm-password">confirm password</label>216            <input217              id="adopt-confirm-password"218              class="peach-underline-input peach-adopt-input"219              type="password"220              autocomplete="new-password"221              value={confirmPassword()}222              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}223              onKeyDown={(event) => {224                if (event.key === "Enter" && canContinue()) setPhase("pair");225              }}226            />227            <Show when={createBlocker()}>228              <p class="peach-hint">{createBlocker()}</p>229            </Show>230            <div class="peach-adopt-actions">231              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>232                Continue233              </button>234              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>235            </div>236          </Match>237238          <Match when={phase() === "pair"}>239            <div240              class="peach-adopt-pairing"241              classList={{242                "has-sas": Boolean(receiverSas()),243                "is-complete": receiverComplete(),244              }}245            >246              <div247                class={`peach-pair-stage peach-adopt-bird-stage ${248                  receiverComplete()249                    ? "act-paired"250                    : receiverSas()251                      ? "act-handshake"252                      : "act-listening"253                }`}254                aria-hidden="true"255              >256                <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">257                  <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />258                </div>259                <Show when={receiverComplete()}>260                  <span class="peach-adopt-connection" />261                </Show>262                <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">263                  <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />264                </div>265              </div>266267              <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>268              <p class="peach-adopt-pair-lede" aria-live="polite">269                {receiverComplete()270                  ? "Your devices are connected. Getting your vault ready…"271                  : receiverSas()272                    ? "Make sure this code matches the one on your phone."273                    : "Scan this QR in Peach on the device that already has your vault."}274              </p>275276              <Show277                when={!receiverComplete()}278                fallback={279                  <div class="peach-adopt-paired-status" role="status">280                    <span />281                    Secure connection established282                  </div>283                }284              >285                <Show286                  when={receiverSas()}287                  fallback={288                    <div class="peach-adopt-qr-wrap" aria-live="polite">289                      <Show290                        when={receiverInvitation()}291                        fallback={292                          <Show293                            when={receiverError()}294                            fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}295                          >296                            <p class="peach-hint peach-hint-error">{receiverError()}</p>297                          </Show>298                        }299                      >300                        <PairingQrCode value={receiverInvitation()} size={144} />301                      </Show>302                    </div>303                  }304                >305                  <div class="peach-adopt-confirmation">306                    <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>307                    <div class="peach-adoption-sas" aria-label="Pairing confirmation code">308                      <strong>{receiverSas()}</strong>309                    </div>310                    <p class="peach-adopt-confirmation-help">311                      Confirm this 8-digit code matches the phone you intend to312                      trust. Approving replaces this empty device with a complete313                      copy of its vault, including existing passwords, passkeys,314                      notes, and recovery access.315                    </p>316                    <button317                      class="peach-btn peach-btn--accent peach-btn--md"318                      disabled={receiverSasDecision()}319                      onClick={confirmReceiverSas}320                    >321                      {receiverSasDecision() ? "Code confirmed" : "Confirm code"}322                    </button>323                  </div>324                </Show>325326                <div class="peach-adopt-privacy">327                  <svg viewBox="0 0 24 24" aria-hidden="true">328                    <path d="M7 10V8a5 5 0 0 1 10 0v2" />329                    <rect x="5" y="10" width="14" height="10" rx="3" />330                    <path d="M12 14v2" />331                  </svg>332                  <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>333                </div>334335                <div class="peach-adopt-actions peach-adopt-pair-actions">336                  <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>337                </div>338              </Show>339            </div>340          </Match>341342          <Match when={phase() === "error"}>343            <Show344              when={receiverError() === "ADOPT_PRO_REQUIRED"}345              fallback={346                <>347                  <h1>Could not join the vault.</h1>348                  <p class="peach-hint peach-hint-error">{receiverError()}</p>349                  <div class="peach-adopt-actions">350                    <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>351                      Try again352                    </button>353                    <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>354                  </div>355                </>356              }357            >358              <PairingProRequired onRetry={tryAgain} />359            </Show>360          </Match>361        </Switch>362      </div>363    </section>364  );365}