AdoptVaultView.tsx

8.6 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              onKeyDown={(event) => {152                if (event.key === "Enter" && canContinue()) setPhase("pair");153              }}154            />155            <label class="peach-label" for="adopt-confirm-password">confirm password</label>156            <input157              id="adopt-confirm-password"158              class="peach-underline-input peach-adopt-input"159              type="password"160              autocomplete="new-password"161              value={confirmPassword()}162              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}163              onKeyDown={(event) => {164                if (event.key === "Enter" && canContinue()) setPhase("pair");165              }}166            />167            <Show when={createBlocker()}>168              <p class="peach-hint">{createBlocker()}</p>169            </Show>170            <div class="peach-adopt-actions">171              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>172                Continue173              </button>174              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>175            </div>176          </Match>177178          <Match when={phase() === "pair"}>179            <h1>Pair with your other device.</h1>180            <p>Scan on the device that holds your vault. It will send the vault here.</p>181            <Show182              when={receiverInvitation()}183              fallback={184                <Show185                  when={receiverError()}186                  fallback={<p class="peach-hint">Preparing secure pairing room…</p>}187                >188                  <p class="peach-hint peach-hint-error">{receiverError()}</p>189                </Show>190              }191            >192              <PairingQrCode value={receiverInvitation()} size={160} />193            </Show>194            <p class="peach-hint peach-hint-relay">{RELAY_DISCLOSURE}</p>195            <div class="peach-adopt-actions">196              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>197            </div>198          </Match>199200          <Match when={phase() === "success"}>201            <h1>Joined your vault.</h1>202            <p>203              This extension now holds the same vault as your other204              devices. You can unlock it with the password you just set.205            </p>206            <div class="peach-adopt-actions">207              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>208                Open Peach209              </button>210            </div>211          </Match>212213          <Match when={phase() === "error"}>214            <h1>Could not join the vault.</h1>215            <p class="peach-hint peach-hint-error">{receiverError()}</p>216            <div class="peach-adopt-actions">217              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>218                Try again219              </button>220              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>221            </div>222          </Match>223        </Switch>224      </div>225    </section>226  );227}