AdoptVaultView.tsx

12.4 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  // The receiver auto-accepts the SAS, but still displays its locally derived84  // code so the user can visually compare it with the phone. The phone remains85  // the single confirmation gate — there is no second button on this screen.86  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {87    const handler = (msg: { type?: string; payload?: unknown }) => {88      if (msg?.type === "ADOPTION_SOURCE_SAS") {89        const p = msg.payload as { sas?: string; displayOnly?: boolean } | undefined;90        if (p?.displayOnly === true && typeof p.sas === "string") {91          setReceiverSas(p.sas);92        }93      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {94        const p = msg.payload as {95          status?: "complete" | "error";96          error?: string;97        } | undefined;98        if (p?.status === "complete") {99          if (receiverComplete()) return;100          setReceiverComplete(true);101          completionTimer = window.setTimeout(() => {102            completionTimer = undefined;103            void props.onDone();104          }, PAIRING_CELEBRATION_MS);105        } else if (p?.status === "error") {106          setReceiverError(p.error ?? "adoption_failed");107          setPhase("error");108        }109      }110    };111    chrome.runtime.onMessage.addListener(handler);112    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));113  }114115  createMemo(() => {116    const value = password();117    if (!value) {118      setStrengthScore(null);119      return;120    }121    getZxcvbn().then((zxcvbn) => {122      if (password() === value) setStrengthScore(zxcvbn(value).score);123    }).catch(() => {124      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);125    });126  });127128  const canContinue = createMemo(() =>129    password().length >= MIN_LENGTH &&130    confirmPassword().length >= MIN_LENGTH &&131    password() === confirmPassword() &&132    strengthScore() !== null &&133    strengthScore()! >= MIN_ZXCVBN_SCORE134  );135136  const createBlocker = createMemo(() => {137    if (!password()) return null;138    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;139    if (!confirmPassword()) return "confirm your password";140    if (password() !== confirmPassword()) return "passwords don't match";141    if (strengthScore() === null) return "checking strength...";142    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";143    return null;144  });145146  function tryAgain(): void {147    setReceiverError("");148    setReceiverInvitation("");149    setReceiverSas("");150    setPhase("pair");151  }152153  return (154    <section155      class="peach-screen peach-theme peach-adopt-vault"156      classList={{ "peach-adopt-vault--pair": phase() === "pair" }}157    >158      <RuneBackground opacity={0.07} />159      <div160        class="peach-adopt-card"161        classList={{ "peach-adopt-card--pair": phase() === "pair" }}162      >163        <Switch>164          <Match when={phase() === "password"}>165            <h1>Set a password for this device.</h1>166            <p>This protects your vault on this device only. It can be different from your other devices.</p>167            <label class="peach-label" for="adopt-password">password</label>168            <input169              id="adopt-password"170              class="peach-underline-input peach-adopt-input"171              type="password"172              autocomplete="new-password"173              value={password()}174              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}175              onKeyDown={(event) => {176                if (event.key === "Enter" && canContinue()) setPhase("pair");177              }}178            />179            <label class="peach-label" for="adopt-confirm-password">confirm password</label>180            <input181              id="adopt-confirm-password"182              class="peach-underline-input peach-adopt-input"183              type="password"184              autocomplete="new-password"185              value={confirmPassword()}186              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}187              onKeyDown={(event) => {188                if (event.key === "Enter" && canContinue()) setPhase("pair");189              }}190            />191            <Show when={createBlocker()}>192              <p class="peach-hint">{createBlocker()}</p>193            </Show>194            <div class="peach-adopt-actions">195              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>196                Continue197              </button>198              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>199            </div>200          </Match>201202          <Match when={phase() === "pair"}>203            <div204              class="peach-adopt-pairing"205              classList={{206                "has-sas": Boolean(receiverSas()),207                "is-complete": receiverComplete(),208              }}209            >210              <div211                class={`peach-pair-stage peach-adopt-bird-stage ${212                  receiverComplete()213                    ? "act-paired"214                    : receiverSas()215                      ? "act-handshake"216                      : "act-listening"217                }`}218                aria-hidden="true"219              >220                <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">221                  <DesktopBird pose={receiverComplete() ? "happy" : "chirping"} />222                </div>223                <Show when={receiverComplete()}>224                  <span class="peach-adopt-connection" />225                </Show>226                <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">227                  <MobileBird pose={receiverComplete() ? "happy" : "attentive"} />228                </div>229              </div>230231              <h1>{receiverComplete() ? "Paired" : "Pair a device"}</h1>232              <p class="peach-adopt-pair-lede" aria-live="polite">233                {receiverComplete()234                  ? "Your devices are connected. Getting your vault ready…"235                  : receiverSas()236                    ? "Make sure this code matches the one on your phone."237                    : "Scan this QR in Peach on the device that already has your vault."}238              </p>239240              <Show241                when={!receiverComplete()}242                fallback={243                  <div class="peach-adopt-paired-status" role="status">244                    <span />245                    Secure connection established246                  </div>247                }248              >249                <Show250                  when={receiverSas()}251                  fallback={252                    <div class="peach-adopt-qr-wrap" aria-live="polite">253                      <Show254                        when={receiverInvitation()}255                        fallback={256                          <Show257                            when={receiverError()}258                            fallback={<p class="peach-adopt-pair-status">Preparing your secure pairing code…</p>}259                          >260                            <p class="peach-hint peach-hint-error">{receiverError()}</p>261                          </Show>262                        }263                      >264                        <PairingQrCode value={receiverInvitation()} size={144} />265                      </Show>266                    </div>267                  }268                >269                  <div class="peach-adopt-confirmation">270                    <p class="peach-adopt-confirmation-label">8-DIGIT SAFETY CODE</p>271                    <div class="peach-adoption-sas" aria-label="Pairing confirmation code">272                      <strong>{receiverSas()}</strong>273                    </div>274                    <p class="peach-adopt-confirmation-help">275                      Confirm this code on your phone to finish pairing.276                    </p>277                  </div>278                </Show>279280                <div class="peach-adopt-privacy">281                  <svg viewBox="0 0 24 24" aria-hidden="true">282                    <path d="M7 10V8a5 5 0 0 1 10 0v2" />283                    <rect x="5" y="10" width="14" height="10" rx="3" />284                    <path d="M12 14v2" />285                  </svg>286                  <p><strong>Private by design.</strong> {RELAY_DISCLOSURE}</p>287                </div>288289                <div class="peach-adopt-actions peach-adopt-pair-actions">290                  <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>291                </div>292              </Show>293            </div>294          </Match>295296          <Match when={phase() === "error"}>297            <Show298              when={receiverError() === "ADOPT_PRO_REQUIRED"}299              fallback={300                <>301                  <h1>Could not join the vault.</h1>302                  <p class="peach-hint peach-hint-error">{receiverError()}</p>303                  <div class="peach-adopt-actions">304                    <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>305                      Try again306                    </button>307                    <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>308                  </div>309                </>310              }311            >312              <PairingProRequired onRetry={tryAgain} />313            </Show>314          </Match>315        </Switch>316      </div>317    </section>318  );319}