AdoptVaultView.tsx

9.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 { PairingProRequired } from "../components/PairingProRequired";14import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1516type AdoptPhase = "password" | "pair" | "success" | "error";1718export interface AdoptVaultViewProps {19  onCancel(): void;20  onDone(): void | Promise<void>;21}2223const MIN_LENGTH = 12;24const MIN_ZXCVBN_SCORE = 2;2526let zxcvbnLoader: ((password: string) => { score: number }) | null = null;27async function getZxcvbn() {28  if (zxcvbnLoader) return zxcvbnLoader;29  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([30    import("@zxcvbn-ts/core"),31    import("@zxcvbn-ts/language-common"),32    import("@zxcvbn-ts/language-en"),33  ]);34  zxcvbnOptions.setOptions({35    translations: en.translations,36    graphs: common.adjacencyGraphs,37    dictionary: { ...common.dictionary, ...en.dictionary },38  });39  zxcvbnLoader = (password: string) => zxcvbn(password);40  return zxcvbnLoader;41}4243export function AdoptVaultView(props: AdoptVaultViewProps) {44  const [phase, setPhase] = createSignal<AdoptPhase>("password");45  const [password, setPassword] = createSignal("");46  const [confirmPassword, setConfirmPassword] = createSignal("");47  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);48  const [receiverInvitation, setReceiverInvitation] = createSignal("");49  const [receiverSas, setReceiverSas] = createSignal("");50  const [receiverError, setReceiverError] = createSignal("");5152  onCleanup(() => {53    // Tell the background to release the relay room if we still hold it.54    void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);55  });5657  // When the user enters the pair phase, the empty extension becomes the58  // source of a "pairing request" QR. The populated phone scans it and the59  // phone is responsible for sending the vault back over the Noise channel.60  // The password is shipped to the background once so the adopted root key61  // can be wrapped at rest without asking the user to retype it later.62  createEffect(() => {63    if (phase() !== "pair") return;64    if (receiverInvitation() || receiverError()) return;65    void (async () => {66      const response = (await sendMessage({67        type: "START_ADOPTION_RECEIVER",68        payload: { password: password() },69      })) as { ok: boolean; data?: { invitation?: string }; error?: string };70      if (response.ok && response.data?.invitation) {71        setReceiverInvitation(response.data.invitation);72      } else {73        setReceiverError(response.error ?? "adoption_receiver_failed");74      }75    })();76  });7778  // The receiver auto-accepts the SAS, but still displays its locally derived79  // code so the user can visually compare it with the phone. The phone remains80  // the single confirmation gate — there is no second button on this screen.81  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {82    const handler = (msg: { type?: string; payload?: unknown }) => {83      if (msg?.type === "ADOPTION_SOURCE_SAS") {84        const p = msg.payload as { sas?: string; displayOnly?: boolean } | undefined;85        if (p?.displayOnly === true && typeof p.sas === "string") {86          setReceiverSas(p.sas);87        }88      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {89        const p = msg.payload as {90          status?: "complete" | "error";91          error?: string;92        } | undefined;93        if (p?.status === "complete") {94          setPhase("success");95        } else if (p?.status === "error") {96          setReceiverError(p.error ?? "adoption_failed");97          setPhase("error");98        }99      }100    };101    chrome.runtime.onMessage.addListener(handler);102    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));103  }104105  createMemo(() => {106    const value = password();107    if (!value) {108      setStrengthScore(null);109      return;110    }111    getZxcvbn().then((zxcvbn) => {112      if (password() === value) setStrengthScore(zxcvbn(value).score);113    }).catch(() => {114      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);115    });116  });117118  const canContinue = createMemo(() =>119    password().length >= MIN_LENGTH &&120    confirmPassword().length >= MIN_LENGTH &&121    password() === confirmPassword() &&122    strengthScore() !== null &&123    strengthScore()! >= MIN_ZXCVBN_SCORE124  );125126  const createBlocker = createMemo(() => {127    if (!password()) return null;128    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;129    if (!confirmPassword()) return "confirm your password";130    if (password() !== confirmPassword()) return "passwords don't match";131    if (strengthScore() === null) return "checking strength...";132    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";133    return null;134  });135136  function tryAgain(): void {137    setReceiverError("");138    setReceiverInvitation("");139    setReceiverSas("");140    setPhase("pair");141  }142143  return (144    <section class="peach-screen peach-theme peach-adopt-vault">145      <RuneBackground opacity={0.07} />146      <div class="peach-adopt-card">147        <Switch>148          <Match when={phase() === "password"}>149            <h1>Set a password for this device.</h1>150            <p>This protects your vault on this device only. It can be different from your other devices.</p>151            <label class="peach-label" for="adopt-password">password</label>152            <input153              id="adopt-password"154              class="peach-underline-input peach-adopt-input"155              type="password"156              autocomplete="new-password"157              value={password()}158              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}159              onKeyDown={(event) => {160                if (event.key === "Enter" && canContinue()) setPhase("pair");161              }}162            />163            <label class="peach-label" for="adopt-confirm-password">confirm password</label>164            <input165              id="adopt-confirm-password"166              class="peach-underline-input peach-adopt-input"167              type="password"168              autocomplete="new-password"169              value={confirmPassword()}170              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}171              onKeyDown={(event) => {172                if (event.key === "Enter" && canContinue()) setPhase("pair");173              }}174            />175            <Show when={createBlocker()}>176              <p class="peach-hint">{createBlocker()}</p>177            </Show>178            <div class="peach-adopt-actions">179              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>180                Continue181              </button>182              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>183            </div>184          </Match>185186          <Match when={phase() === "pair"}>187            <h1>Pair with your other device.</h1>188            <p>Scan on the device that holds your vault. It will send the vault here.</p>189            <Show190              when={receiverInvitation()}191              fallback={192                <Show193                  when={receiverError()}194                  fallback={<p class="peach-hint">Preparing secure pairing room…</p>}195                >196                  <p class="peach-hint peach-hint-error">{receiverError()}</p>197                </Show>198              }199            >200              <PairingQrCode value={receiverInvitation()} size={160} />201            </Show>202            <Show when={receiverSas()}>203              <p class="peach-hint">204                Make sure this matches the 8-digit safety code on your phone.205                Pairing continues when you confirm there.206              </p>207              <div class="peach-adoption-sas" aria-label="Pairing confirmation code">208                <strong>{receiverSas()}</strong>209              </div>210            </Show>211            <p class="peach-hint peach-hint-relay">{RELAY_DISCLOSURE}</p>212            <div class="peach-adopt-actions">213              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>214            </div>215          </Match>216217          <Match when={phase() === "success"}>218            <h1>Joined your vault.</h1>219            <p>220              This extension now holds the same vault as your other221              devices. You can unlock it with the password you just set.222            </p>223            <div class="peach-adopt-actions">224              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>225                Open Peach226              </button>227            </div>228          </Match>229230          <Match when={phase() === "error"}>231            <Show232              when={receiverError() === "ADOPT_PRO_REQUIRED"}233              fallback={234                <>235                  <h1>Could not join the vault.</h1>236                  <p class="peach-hint peach-hint-error">{receiverError()}</p>237                  <div class="peach-adopt-actions">238                    <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>239                      Try again240                    </button>241                    <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>242                  </div>243                </>244              }245            >246              <PairingProRequired onRetry={tryAgain} />247            </Show>248          </Match>249        </Switch>250      </div>251    </section>252  );253}