AdoptVaultView.tsx

8.5 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 { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1415type AdoptPhase = "password" | "pair" | "success" | "error";1617export interface AdoptVaultViewProps {18  onCancel(): void;19  onDone(): void | Promise<void>;20}2122const MIN_LENGTH = 12;23const MIN_ZXCVBN_SCORE = 2;2425let zxcvbnLoader: ((password: string) => { score: number }) | null = null;26async function getZxcvbn() {27  if (zxcvbnLoader) return zxcvbnLoader;28  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([29    import("@zxcvbn-ts/core"),30    import("@zxcvbn-ts/language-common"),31    import("@zxcvbn-ts/language-en"),32  ]);33  zxcvbnOptions.setOptions({34    translations: en.translations,35    graphs: common.adjacencyGraphs,36    dictionary: { ...common.dictionary, ...en.dictionary },37  });38  zxcvbnLoader = (password: string) => zxcvbn(password);39  return zxcvbnLoader;40}4142export function AdoptVaultView(props: AdoptVaultViewProps) {43  const [phase, setPhase] = createSignal<AdoptPhase>("password");44  const [password, setPassword] = createSignal("");45  const [confirmPassword, setConfirmPassword] = createSignal("");46  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);47  const [receiverInvitation, setReceiverInvitation] = createSignal("");48  const [receiverError, setReceiverError] = createSignal("");4950  onCleanup(() => {51    // Tell the background to release the relay room if we still hold it.52    void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);53  });5455  // When the user enters the pair phase, the empty extension becomes the56  // source of a "pairing request" QR. The populated phone scans it and the57  // phone is responsible for sending the vault back over the Noise channel.58  // The password is shipped to the background once so the adopted root key59  // can be wrapped at rest without asking the user to retype it later.60  createEffect(() => {61    if (phase() !== "pair") return;62    if (receiverInvitation() || receiverError()) return;63    void (async () => {64      const response = (await sendMessage({65        type: "START_ADOPTION_RECEIVER",66        payload: { password: password() },67      })) as { ok: boolean; data?: { invitation?: string }; error?: string };68      if (response.ok && response.data?.invitation) {69        setReceiverInvitation(response.data.invitation);70      } else {71        setReceiverError(response.error ?? "adoption_receiver_failed");72      }73    })();74  });7576  // The receiver auto-accepts the SAS on both sides (the codes match by77  // construction because both derive from the same Noise handshake hash),78  // so the popup only needs to listen for the terminal result. The phone79  // is the single user-facing gate — they tap "They match" on the phone.80  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {81    const handler = (msg: { type?: string; payload?: unknown }) => {82      if (msg?.type !== "ADOPTION_SOURCE_RESULT") return;83      const p = msg.payload as {84        status?: "complete" | "error";85        error?: string;86      } | undefined;87      if (p?.status === "complete") {88        setPhase("success");89      } else if (p?.status === "error") {90        setReceiverError(p.error ?? "adoption_failed");91        setPhase("error");92      }93    };94    chrome.runtime.onMessage.addListener(handler);95    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));96  }9798  createMemo(() => {99    const value = password();100    if (!value) {101      setStrengthScore(null);102      return;103    }104    getZxcvbn().then((zxcvbn) => {105      if (password() === value) setStrengthScore(zxcvbn(value).score);106    }).catch(() => {107      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);108    });109  });110111  const canContinue = createMemo(() =>112    password().length >= MIN_LENGTH &&113    confirmPassword().length >= MIN_LENGTH &&114    password() === confirmPassword() &&115    strengthScore() !== null &&116    strengthScore()! >= MIN_ZXCVBN_SCORE117  );118119  const createBlocker = createMemo(() => {120    if (!password()) return null;121    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;122    if (!confirmPassword()) return "confirm your password";123    if (password() !== confirmPassword()) return "passwords don't match";124    if (strengthScore() === null) return "checking strength...";125    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";126    return null;127  });128129  function tryAgain(): void {130    setReceiverError("");131    setReceiverInvitation("");132    setPhase("pair");133  }134135  return (136    <section class="peach-screen peach-theme peach-adopt-vault">137      <RuneBackground opacity={0.07} />138      <div class="peach-adopt-card">139        <Switch>140          <Match when={phase() === "password"}>141            <h1>Set a password for this device.</h1>142            <p>This protects your vault on this device only. It can be different from your other devices.</p>143            <label class="peach-label" for="adopt-password">password</label>144            <input145              id="adopt-password"146              class="peach-underline-input peach-adopt-input"147              type="password"148              autocomplete="new-password"149              value={password()}150              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}151            />152            <label class="peach-label" for="adopt-confirm-password">confirm password</label>153            <input154              id="adopt-confirm-password"155              class="peach-underline-input peach-adopt-input"156              type="password"157              autocomplete="new-password"158              value={confirmPassword()}159              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}160            />161            <Show when={createBlocker()}>162              <p class="peach-hint">{createBlocker()}</p>163            </Show>164            <div class="peach-adopt-actions">165              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>166                Continue167              </button>168              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>169            </div>170          </Match>171172          <Match when={phase() === "pair"}>173            <h1>Pair with your other device.</h1>174            <p>175              Scan this code on the device that holds your vault. That176              device will send the vault to this extension once the codes177              match.178            </p>179            <p class="peach-hint">{RELAY_DISCLOSURE}</p>180            <Show181              when={receiverInvitation()}182              fallback={183                <Show184                  when={receiverError()}185                  fallback={<p class="peach-hint">Preparing secure pairing room…</p>}186                >187                  <p class="peach-hint peach-hint-error">{receiverError()}</p>188                </Show>189              }190            >191              <PairingQrCode value={receiverInvitation()} size={200} />192            </Show>193            <div class="peach-adopt-actions">194              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>195            </div>196          </Match>197198          <Match when={phase() === "success"}>199            <h1>Joined your vault.</h1>200            <p>201              This extension now holds the same vault as your other202              devices. You can unlock it with the password you just set.203            </p>204            <div class="peach-adopt-actions">205              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>206                Open Peach207              </button>208            </div>209          </Match>210211          <Match when={phase() === "error"}>212            <h1>Could not join the vault.</h1>213            <p class="peach-hint peach-hint-error">{receiverError()}</p>214            <div class="peach-adopt-actions">215              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>216                Try again217              </button>218              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>219            </div>220          </Match>221        </Switch>222      </div>223    </section>224  );225}