AdoptVaultView.tsx

10.1 KB · raw

1import { Match, Show, Switch, createMemo, createSignal } from "solid-js";2import { sendMessage } from "../App";3import { RuneBackground } from "../components/RuneBackground";4import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";56type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";78export interface AdoptVaultViewProps {9  onCancel(): void;10  onDone(): void | Promise<void>;11  startAdoption(args: {12    password: string;13    /** The pairing code shown by the device that already holds the vault. */14    code: string;15    confirmSAS(sas: string): Promise<boolean>;16    createVault(rootKeyHex: string): Promise<{ vaultId: string }>;17  }): Promise<void>;18}1920const MIN_LENGTH = 12;21const MIN_ZXCVBN_SCORE = 2;2223/** Turn a dial/adoption failure into something a person can act on. */24function adoptionErrorCopy(err: unknown): string {25  const message = err instanceof Error ? err.message : String(err);26  switch (message) {27    case "invalid_code":28      return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";29    case "no_host_found":30      return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open.";31    case "handshake_failed":32      return "The secure connection failed. Try again with a fresh code.";33    case "adoption_rejected":34      return "Your other device declined the request.";35    default:36      return message;37  }38}3940let zxcvbnLoader: ((password: string) => { score: number }) | null = null;41async function getZxcvbn() {42  if (zxcvbnLoader) return zxcvbnLoader;43  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([44    import("@zxcvbn-ts/core"),45    import("@zxcvbn-ts/language-common"),46    import("@zxcvbn-ts/language-en"),47  ]);48  zxcvbnOptions.setOptions({49    translations: en.translations,50    graphs: common.adjacencyGraphs,51    dictionary: { ...common.dictionary, ...en.dictionary },52  });53  zxcvbnLoader = (password: string) => zxcvbn(password);54  return zxcvbnLoader;55}5657export function AdoptVaultView(props: AdoptVaultViewProps) {58  const [phase, setPhase] = createSignal<AdoptPhase>("password");59  const [password, setPassword] = createSignal("");60  const [confirmPassword, setConfirmPassword] = createSignal("");61  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);62  const [sas, setSas] = createSignal("");63  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);64  const [error, setError] = createSignal("");65  const [code, setCode] = createSignal("");66  const [connecting, setConnecting] = createSignal(false);6768  const codeReady = createMemo(() => parsePairingCode(code()) !== null);6970  createMemo(() => {71    const value = password();72    if (!value) {73      setStrengthScore(null);74      return;75    }76    getZxcvbn().then((zxcvbn) => {77      if (password() === value) setStrengthScore(zxcvbn(value).score);78    }).catch(() => {79      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);80    });81  });8283  const canContinue = createMemo(() =>84    password().length >= MIN_LENGTH &&85    confirmPassword().length >= MIN_LENGTH &&86    password() === confirmPassword() &&87    strengthScore() !== null &&88    strengthScore()! >= MIN_ZXCVBN_SCORE89  );9091  const createBlocker = createMemo(() => {92    if (!password()) return null;93    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;94    if (!confirmPassword()) return "confirm your password";95    if (password() !== confirmPassword()) return "passwords don't match";96    if (strengthScore() === null) return "checking strength...";97    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";98    return null;99  });100101  async function beginPairing() {102    if (!codeReady() || connecting()) return;103    setPhase("pair");104    setError("");105    setConnecting(true);106    try {107      await props.startAdoption({108        password: password(),109        code: code(),110        confirmSAS: (sasCode) => new Promise<boolean>((resolve) => {111          setSas(sasCode);112          setSasResolve(() => resolve);113          setPhase("sas");114        }),115        createVault: async (rootKeyHex) => {116          const res = await sendMessage({117            type: "CREATE_VAULT_FROM_ROOT_KEY",118            payload: { password: password(), rootKeyHex },119          }) as { ok: boolean; vaultId?: string; error?: string };120          if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");121          return { vaultId: res.vaultId };122        },123      });124      setPhase("success");125    } catch (err) {126      if (err instanceof Error && err.message === "sas_rejected") return;127      setError(adoptionErrorCopy(err));128      setPhase("error");129    } finally {130      setConnecting(false);131    }132  }133134  function confirmSas(ok: boolean) {135    const resolve = sasResolve();136    setSasResolve(null);137    resolve?.(ok);138    if (!ok) {139      setPhase("password");140      setPassword("");141      setConfirmPassword("");142      setStrengthScore(null);143      // The code is single-use on the other device's side too — a rejected SAS144      // means starting over with a fresh one, not retrying this one.145      setCode("");146    }147  }148149  return (150    <section class="peach-screen peach-theme peach-adopt-vault">151      <RuneBackground opacity={0.07} />152      <div class="peach-adopt-card">153        <Switch>154          <Match when={phase() === "password"}>155            <h1>Set a password for this device.</h1>156            <p>This protects your vault on this device only. It can be different from your other devices.</p>157            <label class="peach-label" for="adopt-password">password</label>158            <input159              id="adopt-password"160              class="peach-underline-input peach-adopt-input"161              type="password"162              autocomplete="new-password"163              value={password()}164              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}165            />166            <label class="peach-label" for="adopt-confirm-password">confirm password</label>167            <input168              id="adopt-confirm-password"169              class="peach-underline-input peach-adopt-input"170              type="password"171              autocomplete="new-password"172              value={confirmPassword()}173              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}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            <div class="peach-adopt-birds" aria-hidden="true">188              <span />189              <span />190            </div>191            <h1>Pair with your other device.</h1>192            <p>193              Open Peach on the device that has your vault and tap “Pair a device”.194              Type the code it shows. Both devices need to be on the same Wi-Fi.195            </p>196            <label class="peach-label" for="adopt-pair-code">pairing code</label>197            <input198              id="adopt-pair-code"199              class="peach-underline-input peach-adopt-input peach-code-input"200              type="text"201              autocapitalize="characters"202              autocomplete="one-time-code"203              spellcheck={false}204              maxlength={32}205              placeholder="A74H-48J3-KI32-OEP4"206              aria-label="Pairing code"207              disabled={connecting()}208              value={code()}209              onInput={(event) =>210                setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32))211              }212              onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}213            />214            <Show when={connecting()}>215              <p class="peach-hint">connecting to your other device...</p>216            </Show>217            <div class="peach-adopt-actions">218              <button219                class="peach-btn peach-btn--accent peach-btn--md"220                disabled={!codeReady() || connecting()}221                onClick={() => void beginPairing()}222              >223                Pair a device224              </button>225              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>226            </div>227          </Match>228229          <Match when={phase() === "sas"}>230            <h1>Confirm this code matches on your other device.</h1>231            <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>232            <div class="peach-adopt-actions">233              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>234              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>235            </div>236          </Match>237238          <Match when={phase() === "success"}>239            <div class="peach-adopt-birds" aria-hidden="true">240              <span />241              <span />242            </div>243            <h1>Joined your vault.</h1>244            <p>Your passwords will sync shortly.</p>245            <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>246              Open Peach247            </button>248          </Match>249250          <Match when={phase() === "error"}>251            <h1>Could not join your vault.</h1>252            <p class="peach-hint peach-hint-error">{error()}</p>253            <div class="peach-adopt-actions">254              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>255              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>256            </div>257          </Match>258        </Switch>259      </div>260    </section>261  );262}