AdoptVaultView.tsx

9.3 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 [receiverSas, setReceiverSas] = createSignal("");49  const [receiverError, setReceiverError] = createSignal("");5051  onCleanup(() => {52    // Tell the background to release the relay room if we still hold it.53    void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);54  });5556  // When the user enters the pair phase, the empty extension becomes the57  // source of a "pairing request" QR. The populated phone scans it and the58  // phone is responsible for sending the vault back over the Noise channel.59  // The password is shipped to the background once so the adopted root key60  // can be wrapped at rest without asking the user to retype it later.61  createEffect(() => {62    if (phase() !== "pair") return;63    if (receiverInvitation() || receiverError()) return;64    void (async () => {65      const response = (await sendMessage({66        type: "START_ADOPTION_RECEIVER",67        payload: { password: password() },68      })) as { ok: boolean; data?: { invitation?: string }; error?: string };69      if (response.ok && response.data?.invitation) {70        setReceiverInvitation(response.data.invitation);71      } else {72        setReceiverError(response.error ?? "adoption_receiver_failed");73      }74    })();75  });7677  // The receiver auto-accepts the SAS, but still displays its locally derived78  // code so the user can visually compare it with the phone. The phone remains79  // the single confirmation gate — there is no second button on this screen.80  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {81    const handler = (msg: { type?: string; payload?: unknown }) => {82      if (msg?.type === "ADOPTION_SOURCE_SAS") {83        const p = msg.payload as { sas?: string; displayOnly?: boolean } | undefined;84        if (p?.displayOnly === true && typeof p.sas === "string") {85          setReceiverSas(p.sas);86        }87      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {88        const p = msg.payload as {89          status?: "complete" | "error";90          error?: string;91        } | undefined;92        if (p?.status === "complete") {93          setPhase("success");94        } else if (p?.status === "error") {95          setReceiverError(p.error ?? "adoption_failed");96          setPhase("error");97        }98      }99    };100    chrome.runtime.onMessage.addListener(handler);101    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));102  }103104  createMemo(() => {105    const value = password();106    if (!value) {107      setStrengthScore(null);108      return;109    }110    getZxcvbn().then((zxcvbn) => {111      if (password() === value) setStrengthScore(zxcvbn(value).score);112    }).catch(() => {113      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);114    });115  });116117  const canContinue = createMemo(() =>118    password().length >= MIN_LENGTH &&119    confirmPassword().length >= MIN_LENGTH &&120    password() === confirmPassword() &&121    strengthScore() !== null &&122    strengthScore()! >= MIN_ZXCVBN_SCORE123  );124125  const createBlocker = createMemo(() => {126    if (!password()) return null;127    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;128    if (!confirmPassword()) return "confirm your password";129    if (password() !== confirmPassword()) return "passwords don't match";130    if (strengthScore() === null) return "checking strength...";131    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";132    return null;133  });134135  function tryAgain(): void {136    setReceiverError("");137    setReceiverInvitation("");138    setReceiverSas("");139    setPhase("pair");140  }141142  return (143    <section class="peach-screen peach-theme peach-adopt-vault">144      <RuneBackground opacity={0.07} />145      <div class="peach-adopt-card">146        <Switch>147          <Match when={phase() === "password"}>148            <h1>Set a password for this device.</h1>149            <p>This protects your vault on this device only. It can be different from your other devices.</p>150            <label class="peach-label" for="adopt-password">password</label>151            <input152              id="adopt-password"153              class="peach-underline-input peach-adopt-input"154              type="password"155              autocomplete="new-password"156              value={password()}157              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}158              onKeyDown={(event) => {159                if (event.key === "Enter" && canContinue()) setPhase("pair");160              }}161            />162            <label class="peach-label" for="adopt-confirm-password">confirm password</label>163            <input164              id="adopt-confirm-password"165              class="peach-underline-input peach-adopt-input"166              type="password"167              autocomplete="new-password"168              value={confirmPassword()}169              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}170              onKeyDown={(event) => {171                if (event.key === "Enter" && canContinue()) setPhase("pair");172              }}173            />174            <Show when={createBlocker()}>175              <p class="peach-hint">{createBlocker()}</p>176            </Show>177            <div class="peach-adopt-actions">178              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>179                Continue180              </button>181              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>182            </div>183          </Match>184185          <Match when={phase() === "pair"}>186            <h1>Pair with your other device.</h1>187            <p>Scan on the device that holds your vault. It will send the vault here.</p>188            <Show189              when={receiverInvitation()}190              fallback={191                <Show192                  when={receiverError()}193                  fallback={<p class="peach-hint">Preparing secure pairing room…</p>}194                >195                  <p class="peach-hint peach-hint-error">{receiverError()}</p>196                </Show>197              }198            >199              <PairingQrCode value={receiverInvitation()} size={160} />200            </Show>201            <Show when={receiverSas()}>202              <p class="peach-hint">203                Make sure this matches the 8-digit safety code on your phone.204                Pairing continues when you confirm there.205              </p>206              <div class="peach-adoption-sas" aria-label="Pairing confirmation code">207                <strong>{receiverSas()}</strong>208              </div>209            </Show>210            <p class="peach-hint peach-hint-relay">{RELAY_DISCLOSURE}</p>211            <div class="peach-adopt-actions">212              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>213            </div>214          </Match>215216          <Match when={phase() === "success"}>217            <h1>Joined your vault.</h1>218            <p>219              This extension now holds the same vault as your other220              devices. You can unlock it with the password you just set.221            </p>222            <div class="peach-adopt-actions">223              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>224                Open Peach225              </button>226            </div>227          </Match>228229          <Match when={phase() === "error"}>230            <h1>Could not join the vault.</h1>231            <p class="peach-hint peach-hint-error">{receiverError()}</p>232            <div class="peach-adopt-actions">233              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>234                Try again235              </button>236              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>237            </div>238          </Match>239        </Switch>240      </div>241    </section>242  );243}