AdoptVaultView.tsx

6.8 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";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. Until the phone-side reversal58  // lands, this is a UI-only change — the invitation is a valid rendezvous59  // that the existing relay dialer can reach, but no vault crosses the wire60  // yet. The SAS / vault-receive handlers are wired up to the same signals61  // the receiver flow always used, so the phone-side code can plug straight62  // in once it ships.63  createEffect(() => {64    if (phase() !== "pair") return;65    if (receiverInvitation() || receiverError()) return;66    void (async () => {67      const response = (await sendMessage({68        type: "START_ADOPTION_RECEIVER",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  createMemo(() => {79    const value = password();80    if (!value) {81      setStrengthScore(null);82      return;83    }84    getZxcvbn().then((zxcvbn) => {85      if (password() === value) setStrengthScore(zxcvbn(value).score);86    }).catch(() => {87      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);88    });89  });9091  const canContinue = createMemo(() =>92    password().length >= MIN_LENGTH &&93    confirmPassword().length >= MIN_LENGTH &&94    password() === confirmPassword() &&95    strengthScore() !== null &&96    strengthScore()! >= MIN_ZXCVBN_SCORE97  );9899  const createBlocker = createMemo(() => {100    if (!password()) return null;101    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;102    if (!confirmPassword()) return "confirm your password";103    if (password() !== confirmPassword()) return "passwords don't match";104    if (strengthScore() === null) return "checking strength...";105    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";106    return null;107  });108109  return (110    <section class="peach-screen peach-theme peach-adopt-vault">111      <RuneBackground opacity={0.07} />112      <div class="peach-adopt-card">113        <Switch>114          <Match when={phase() === "password"}>115            <h1>Set a password for this device.</h1>116            <p>This protects your vault on this device only. It can be different from your other devices.</p>117            <label class="peach-label" for="adopt-password">password</label>118            <input119              id="adopt-password"120              class="peach-underline-input peach-adopt-input"121              type="password"122              autocomplete="new-password"123              value={password()}124              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}125            />126            <label class="peach-label" for="adopt-confirm-password">confirm password</label>127            <input128              id="adopt-confirm-password"129              class="peach-underline-input peach-adopt-input"130              type="password"131              autocomplete="new-password"132              value={confirmPassword()}133              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}134            />135            <Show when={createBlocker()}>136              <p class="peach-hint">{createBlocker()}</p>137            </Show>138            <div class="peach-adopt-actions">139              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>140                Continue141              </button>142              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>143            </div>144          </Match>145146          <Match when={phase() === "pair"}>147            <h1>Pair with your other device.</h1>148            {/* The browser is empty — the phone (which holds the vault) is the149                one that scans. This QR is the initiation point; the phone150                propagates the vault to the extension once the connection151                completes. */}152            <p>153              Scan this code on the device that holds your vault. That154              device will send the vault to this extension once the codes155              match.156            </p>157            <p class="peach-hint">{RELAY_DISCLOSURE}</p>158            <Show159              when={receiverInvitation()}160              fallback={161                <Show162                  when={receiverError()}163                  fallback={<p class="peach-hint">Preparing secure pairing room…</p>}164                >165                  <p class="peach-hint peach-hint-error">{receiverError()}</p>166                </Show>167              }168            >169              <PairingQrCode value={receiverInvitation()} size={200} />170            </Show>171            <div class="peach-adopt-actions">172              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>173            </div>174          </Match>175        </Switch>176      </div>177    </section>178  );179}