AdoptVaultView.tsx

13.1 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}2324const MIN_LENGTH = 12;25const MIN_ZXCVBN_SCORE = 2;26const PAIRING_CELEBRATION_MS = 5_000;2728let zxcvbnLoader: ((password: string) => { score: number }) | null = null;29async function getZxcvbn() {30  if (zxcvbnLoader) return zxcvbnLoader;31  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([32    import("@zxcvbn-ts/core"),33    import("@zxcvbn-ts/language-common"),34    import("@zxcvbn-ts/language-en"),35  ]);36  zxcvbnOptions.setOptions({37    translations: en.translations,38    graphs: common.adjacencyGraphs,39    dictionary: { ...common.dictionary, ...en.dictionary },40  });41  zxcvbnLoader = (password: string) => zxcvbn(password);42  return zxcvbnLoader;43}4445export function AdoptVaultView(props: AdoptVaultViewProps) {46  const [phase, setPhase] = createSignal<AdoptPhase>("password");47  const [password, setPassword] = createSignal("");48  const [confirmPassword, setConfirmPassword] = createSignal("");49  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);50  const [receiverInvitation, setReceiverInvitation] = createSignal("");51  const [receiverSas, setReceiverSas] = createSignal("");52  const [receiverError, setReceiverError] = createSignal("");53  const [receiverComplete, setReceiverComplete] = createSignal(false);54  let completionTimer: number | undefined;5556  onCleanup(() => {57    if (completionTimer !== undefined) window.clearTimeout(completionTimer);58    // Tell the background to release the relay room if we still hold it.59    void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);60  });6162  // When the user enters the pair phase, the empty extension becomes the63  // source of a "pairing request" QR. The populated phone scans it and the64  // phone is responsible for sending the vault back over the Noise channel.65  // The password is shipped to the background once so the adopted root key66  // can be wrapped at rest without asking the user to retype it later.67  createEffect(() => {68    if (phase() !== "pair") return;69    if (receiverInvitation() || receiverError()) return;70    void (async () => {71      const response = (await sendMessage({72        type: "START_ADOPTION_RECEIVER",73        payload: { password: password() },74      })) as { ok: boolean; data?: { invitation?: string }; error?: string };75      if (response.ok && response.data?.invitation) {76        setReceiverInvitation(response.data.invitation);77      } else {78        setReceiverError(response.error ?? "adoption_receiver_failed");79      }80    })();81  });8283  const [receiverSasId, setReceiverSasId] = createSignal("");84  const [receiverSasDecision, setReceiverSasDecision] = createSignal(false);8586  function confirmReceiverSas(): void {87    if (!receiverSas() || !receiverSasId()) return;88    void sendMessage({89      type: "RECEIVER_SAS_DECISION",90      payload: { id: receiverSasId(), sas: receiverSas(), ok: true },91    });92    setReceiverSasDecision(true);93  }9495  // The receiver must explicitly confirm the displayed SAS before adoption.9697  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {98    const handler = (msg: { type?: string; payload?: unknown }) => {99      if (msg?.type === "ADOPTION_SOURCE_SAS") {100        const p = msg.payload as { id?: string; sas?: string; displayOnly?: boolean } | undefined;101        if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") {102          setReceiverSas(p.sas);103          setReceiverSasId((p as { id: string }).id);104          setReceiverSasDecision(false);105        }106      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {107        const p = msg.payload as {108          status?: "complete" | "error";109          error?: string;110        } | undefined;111        if (p?.status === "complete") {112          if (receiverComplete()) return;113          setReceiverComplete(true);114          completionTimer = window.setTimeout(() => {115            completionTimer = undefined;116            void props.onDone();117          }, PAIRING_CELEBRATION_MS);118        } else if (p?.status === "error") {119          setReceiverError(p.error ?? "adoption_failed");120          setPhase("error");121        }122      }123    };124    chrome.runtime.onMessage.addListener(handler);125    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));126  }127128  createMemo(() => {129    const value = password();130    if (!value) {131      setStrengthScore(null);132      return;133    }134    getZxcvbn().then((zxcvbn) => {135      if (password() === value) setStrengthScore(zxcvbn(value).score);136    }).catch(() => {137      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);138    });139  });140141  const canContinue = createMemo(() =>142    password().length >= MIN_LENGTH &&143    confirmPassword().length >= MIN_LENGTH &&144    password() === confirmPassword() &&145    strengthScore() !== null &&146    strengthScore()! >= MIN_ZXCVBN_SCORE147  );148149  const createBlocker = createMemo(() => {150    if (!password()) return null;151    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;152    if (!confirmPassword()) return "confirm your password";153    if (password() !== confirmPassword()) return "passwords don't match";154    if (strengthScore() === null) return "checking strength...";155    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";156    return null;157  });158159  function tryAgain(): void {160    setReceiverError("");161    setReceiverInvitation("");162    setReceiverSas("");163    setPhase("pair");164  }165166  return (167    <section168      class="peach-screen peach-theme peach-adopt-vault"169      classList={{ "peach-adopt-vault--pair": phase() === "pair" }}170    >171      <RuneBackground opacity={0.07} />172      <div173        class="peach-adopt-card"174        classList={{ "peach-adopt-card--pair": phase() === "pair" }}175      >176        <Switch>177          <Match when={phase() === "password"}>178            <h1>Set a password for this device.</h1>179            <p>This protects your vault on this device only. It can be different from your other devices.</p>180            <label class="peach-label" for="adopt-password">password</label>181            <input182              id="adopt-password"183              class="peach-underline-input peach-adopt-input"184              type="password"185              autocomplete="new-password"186              value={password()}187              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}188              onKeyDown={(event) => {189                if (event.key === "Enter" && canContinue()) setPhase("pair");190              }}191            />192            <label class="peach-label" for="adopt-confirm-password">confirm password</label>193            <input194              id="adopt-confirm-password"195              class="peach-underline-input peach-adopt-input"196              type="password"197              autocomplete="new-password"198              value={confirmPassword()}199              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}200              onKeyDown={(event) => {201                if (event.key === "Enter" && canContinue()) setPhase("pair");202              }}203            />204            <Show when={createBlocker()}>205              <p class="peach-hint">{createBlocker()}</p>206            </Show>207            <div class="peach-adopt-actions">208              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>209                Continue210              </button>211              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>212            </div>213          </Match>214215          <Match when={phase() === "pair"}>216            <div217              class="peach-adopt-pairing"218              classList={{219                "has-sas": Boolean(receiverSas()),220                "is-complete": receiverComplete(),221              }}222            >223              <div224                class={`peach-pair-stage peach-adopt-bird-stage ${225                  receiverComplete()226                    ? "act-paired"227                    : receiverSas()228                      ? "act-handshake"229                      : "act-listening"230                }`}231                aria-hidden="true"232              >233                <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">234                  <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />235                </div>236                <Show when={receiverComplete()}>237                  <span class="peach-adopt-connection" />238                </Show>239                <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">240                  <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />241                </div>242              </div>243244              <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>245              <p class="peach-adopt-pair-lede" aria-live="polite">246                {receiverComplete()247                  ? "Your devices are connected. Getting your vault ready…"248                  : receiverSas()249                    ? "Make sure this code matches the one on your phone."250                    : "Scan this QR in Peach on the device that already has your vault."}251              </p>252253              <Show254                when={!receiverComplete()}255                fallback={256                  <div class="peach-adopt-paired-status" role="status">257                    <span />258                    Secure connection established259                  </div>260                }261              >262                <Show263                  when={receiverSas()}264                  fallback={265                    <div class="peach-adopt-qr-wrap" aria-live="polite">266                      <Show267                        when={receiverInvitation()}268                        fallback={269                          <Show270                            when={receiverError()}271                            fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}272                          >273                            <p class="peach-hint peach-hint-error">{receiverError()}</p>274                          </Show>275                        }276                      >277                        <PairingQrCode value={receiverInvitation()} size={144} />278                      </Show>279                    </div>280                  }281                >282                  <div class="peach-adopt-confirmation">283                    <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>284                    <div class="peach-adoption-sas" aria-label="Pairing confirmation code">285                      <strong>{receiverSas()}</strong>286                    </div>287                    <p class="peach-adopt-confirmation-help">288                      Confirm this code matches the one on your phone.289                    </p>290                    <button291                      class="peach-btn peach-btn--accent peach-btn--md"292                      disabled={receiverSasDecision()}293                      onClick={confirmReceiverSas}294                    >295                      {receiverSasDecision() ? "Code confirmed" : "Confirm code"}296                    </button>297                  </div>298                </Show>299300                <div class="peach-adopt-privacy">301                  <svg viewBox="0 0 24 24" aria-hidden="true">302                    <path d="M7 10V8a5 5 0 0 1 10 0v2" />303                    <rect x="5" y="10" width="14" height="10" rx="3" />304                    <path d="M12 14v2" />305                  </svg>306                  <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>307                </div>308309                <div class="peach-adopt-actions peach-adopt-pair-actions">310                  <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>311                </div>312              </Show>313            </div>314          </Match>315316          <Match when={phase() === "error"}>317            <Show318              when={receiverError() === "ADOPT_PRO_REQUIRED"}319              fallback={320                <>321                  <h1>Could not join the vault.</h1>322                  <p class="peach-hint peach-hint-error">{receiverError()}</p>323                  <div class="peach-adopt-actions">324                    <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>325                      Try again326                    </button>327                    <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>328                  </div>329                </>330              }331            >332              <PairingProRequired onRetry={tryAgain} />333            </Show>334          </Match>335        </Switch>336      </div>337    </section>338  );339}