AdoptVaultView.tsx

7.7 KB · raw

1import { Match, Show, Switch, createMemo, createSignal } from "solid-js";2import { sendMessage } from "../App";3import { RuneBackground } from "../components/RuneBackground";45type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";67export interface AdoptVaultViewProps {8  onCancel(): void;9  onDone(): void | Promise<void>;10  startAdoption(args: {11    password: string;12    confirmSAS(sas: string): Promise<boolean>;13    createVault(rootKeyHex: string): Promise<{ vaultId: string }>;14  }): Promise<void>;15}1617const MIN_LENGTH = 12;18const MIN_ZXCVBN_SCORE = 2;1920let zxcvbnLoader: ((password: string) => { score: number }) | null = null;21async function getZxcvbn() {22  if (zxcvbnLoader) return zxcvbnLoader;23  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([24    import("@zxcvbn-ts/core"),25    import("@zxcvbn-ts/language-common"),26    import("@zxcvbn-ts/language-en"),27  ]);28  zxcvbnOptions.setOptions({29    translations: en.translations,30    graphs: common.adjacencyGraphs,31    dictionary: { ...common.dictionary, ...en.dictionary },32  });33  zxcvbnLoader = (password: string) => zxcvbn(password);34  return zxcvbnLoader;35}3637export function AdoptVaultView(props: AdoptVaultViewProps) {38  const [phase, setPhase] = createSignal<AdoptPhase>("password");39  const [password, setPassword] = createSignal("");40  const [confirmPassword, setConfirmPassword] = createSignal("");41  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);42  const [sas, setSas] = createSignal("");43  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);44  const [error, setError] = createSignal("");4546  createMemo(() => {47    const value = password();48    if (!value) {49      setStrengthScore(null);50      return;51    }52    getZxcvbn().then((zxcvbn) => {53      if (password() === value) setStrengthScore(zxcvbn(value).score);54    }).catch(() => {55      if (password() === value) setStrengthScore(value.length >= 16 ? 2 : 1);56    });57  });5859  const canContinue = createMemo(() =>60    password().length >= MIN_LENGTH &&61    confirmPassword().length >= MIN_LENGTH &&62    password() === confirmPassword() &&63    strengthScore() !== null &&64    strengthScore()! >= MIN_ZXCVBN_SCORE65  );6667  const createBlocker = createMemo(() => {68    if (!password()) return null;69    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;70    if (!confirmPassword()) return "confirm your password";71    if (password() !== confirmPassword()) return "passwords don't match";72    if (strengthScore() === null) return "checking strength...";73    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak - use a longer passphrase";74    return null;75  });7677  async function beginPairing() {78    setPhase("pair");79    setError("");80    try {81      await props.startAdoption({82        password: password(),83        confirmSAS: (code) => new Promise<boolean>((resolve) => {84          setSas(code);85          setSasResolve(() => resolve);86          setPhase("sas");87        }),88        createVault: async (rootKeyHex) => {89          const res = await sendMessage({90            type: "CREATE_VAULT_FROM_ROOT_KEY",91            payload: { password: password(), rootKeyHex },92          }) as { ok: boolean; vaultId?: string; error?: string };93          if (!res.ok || !res.vaultId) throw new Error(res.error ?? "adoption_create_failed");94          return { vaultId: res.vaultId };95        },96      });97      setPhase("success");98    } catch (err) {99      if (err instanceof Error && err.message === "sas_rejected") return;100      setError(err instanceof Error ? err.message : String(err));101      setPhase("error");102    }103  }104105  function confirmSas(ok: boolean) {106    const resolve = sasResolve();107    setSasResolve(null);108    resolve?.(ok);109    if (!ok) {110      setPhase("password");111      setPassword("");112      setConfirmPassword("");113      setStrengthScore(null);114    }115  }116117  return (118    <section class="peach-screen peach-theme peach-adopt-vault">119      <RuneBackground opacity={0.07} />120      <div class="peach-adopt-card">121        <Switch>122          <Match when={phase() === "password"}>123            <h1>Set a password for this device.</h1>124            <p>This protects your vault on this device only. It can be different from your other devices.</p>125            <label class="peach-label" for="adopt-password">password</label>126            <input127              id="adopt-password"128              class="peach-underline-input peach-adopt-input"129              type="password"130              autocomplete="new-password"131              value={password()}132              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}133            />134            <label class="peach-label" for="adopt-confirm-password">confirm password</label>135            <input136              id="adopt-confirm-password"137              class="peach-underline-input peach-adopt-input"138              type="password"139              autocomplete="new-password"140              value={confirmPassword()}141              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}142            />143            <Show when={createBlocker()}>144              <p class="peach-hint">{createBlocker()}</p>145            </Show>146            <div class="peach-adopt-actions">147              <button class="peach-btn peach-btn--accent peach-btn--md" disabled={!canContinue()} onClick={() => setPhase("pair")}>148                Continue149              </button>150              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>151            </div>152          </Match>153154          <Match when={phase() === "pair"}>155            <div class="peach-adopt-birds" aria-hidden="true">156              <span />157              <span />158            </div>159            <h1>Pair with your other device.</h1>160            <p>Open Peach on the device that has your vault. Bring both devices close together.</p>161            <div class="peach-adopt-actions">162              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void beginPairing()}>163                Pair a device164              </button>165              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>166            </div>167          </Match>168169          <Match when={phase() === "sas"}>170            <h1>Confirm this code matches on your other device.</h1>171            <div class="peach-adopt-sas">{sas().replace(/(\d)(?=\d)/g, "$1 ")}</div>172            <div class="peach-adopt-actions">173              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => confirmSas(true)}>Confirm</button>174              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => confirmSas(false)}>Cancel</button>175            </div>176          </Match>177178          <Match when={phase() === "success"}>179            <div class="peach-adopt-birds" aria-hidden="true">180              <span />181              <span />182            </div>183            <h1>Joined your vault.</h1>184            <p>Your passwords will sync shortly.</p>185            <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>186              Open Peach187            </button>188          </Match>189190          <Match when={phase() === "error"}>191            <h1>Could not join your vault.</h1>192            <p class="peach-hint peach-hint-error">{error()}</p>193            <div class="peach-adopt-actions">194              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => setPhase("password")}>Try again</button>195              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>196            </div>197          </Match>198        </Switch>199      </div>200    </section>201  );202}