Lock.tsx

42.7 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark, PeachWordmark } from "../components/PeachMark";4import { RecoveryWordEntryView } from "./RecoveryWordEntry";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import { RuneBackground } from "../components/RuneBackground";7import { storageGet, storageSet } from "../../shared/storage";8import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";9import { parsePayload } from "../../crypto/peachPayload";10import { parseImportSource } from "../../shared/import";11import { isNativePlatform, nativePlugin } from "../../shared/native";1213const peachMarkImg = new Image();14peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(15  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +16  '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +17  '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +18  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +19  '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +20  '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +21  '</svg>'22)}`;2324// Lazy-load @zxcvbn-ts — heavy (dictionaries ~700 kB) and only needed during25// vault creation. Replaces the unmaintained [email protected] (LV-8); result.score26// keeps the same 0-4 scale so downstream UI is unchanged.27let _zxcvbn: ((password: string) => { score: number }) | null = null;28async function getZxcvbn() {29  if (_zxcvbn) return _zxcvbn;30  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([31    import("@zxcvbn-ts/core"),32    import("@zxcvbn-ts/language-common"),33    import("@zxcvbn-ts/language-en"),34  ]);35  zxcvbnOptions.setOptions({36    translations: en.translations,37    graphs: common.adjacencyGraphs,38    dictionary: { ...common.dictionary, ...en.dictionary },39  });40  _zxcvbn = (password: string) => zxcvbn(password);41  return _zxcvbn;42}4344interface LockProps {45  hasVault: boolean;46  ready: boolean;47  isPro?: boolean;48  /** When true, render the recovery-words display directly instead of the49   *  unlock UI. Used after a popup close+reopen during the create-vault50   *  recovery confirmation flow (peach_recovery_pending flag). */51  forceRecoveryWords?: boolean;52  onUnlock: () => void | Promise<void>;53  /** Optional escape hatch: rewind from the create-vault screen back to54   *  Onboarding. Only meaningful when the user reached create-vault from55   *  Onboarding in the first place. */56  onBackToOnboarding?: () => void;57  /** Optional: open the Peach Codex restore flow (camera scan of printed58   *  Codex pages). Surfaced as a small footer link on the new-vault screen. */59  onOpenCodexRestore?: () => void;60  /** Open directly on backup restore when onboarding routed the user there. */61  initialRestore?: boolean;62}6364type RestoreSurface = "restore" | null;65type RecoverSurface = "words" | "new-password" | null;6667export function LockView(props: LockProps) {68  const [password, setPassword] = createSignal("");69  const [confirmPassword, setConfirmPassword] = createSignal("");70  const [error, setError] = createSignal("");71  const [loading, setLoading] = createSignal(false);72  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(73    props.initialRestore ? "restore" : null,74  );75  const [showPassword, setShowPassword] = createSignal(false);76  const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);77  // After vault creation succeeds, set this to show the recovery words screen.78  // Only meaningful when hasVault === false.79  const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);8081  const MIN_LENGTH = 12;82  const MIN_ZXCVBN_SCORE = 2;8384  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);85  const [biometricAvailable, setBiometricAvailable] = createSignal(false);86  const [biometricBusy, setBiometricBusy] = createSignal(false);87  const [pinAvailable, setPinAvailable] = createSignal(false);88  const [usingPin, setUsingPin] = createSignal(false);89  const [pin, setPin] = createSignal("");9091  const canCreate = createMemo(92    () =>93      password().length >= MIN_LENGTH &&94      confirmPassword().length >= MIN_LENGTH &&95      password() === confirmPassword() &&96      strengthScore() !== null &&97      strengthScore()! >= MIN_ZXCVBN_SCORE98  );99100  const passwordStrength = createMemo(() => {101    const score = strengthScore();102    if (score === null) return null;103    if (score <= 1) return "weak";104    if (score === 2) return "fair";105    if (score === 3) return "good";106    return "strong";107  });108109  const createBlockerMessage = createMemo(() => {110    if (!password()) return null;111    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;112    if (!confirmPassword()) return "confirm your password";113    if (password() !== confirmPassword()) return "passwords don't match";114    if (strengthScore() === null) return "checking strength…";115    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";116    return null;117  });118119  // Update strength asynchronously when password changes120  createMemo(() => {121    const value = password();122    if (!value) { setStrengthScore(null); return; }123    getZxcvbn().then((zxcvbn) => {124      if (password() === value) setStrengthScore(zxcvbn(value).score);125    }).catch(() => {126      // zxcvbn unavailable — fall back to length-only heuristic127      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);128    });129  });130131  async function handleUnlock() {132    if (!password().trim()) return;133    setLoading(true);134    setError("");135    const response = await sendMessage({136      type: "UNLOCK",137      payload: { password: password() },138    });139    const result = response as { ok: boolean; error?: string };140    if (result.ok) {141      await props.onUnlock();142      setLoading(false);143      return;144    }145    setError(getDisplayError(result.error, "incorrect password"));146    setPassword("");147    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;148    passwordField?.classList.add("shake");149    setTimeout(() => passwordField?.classList.remove("shake"), 300);150    setLoading(false);151  }152153  async function handlePinUnlock() {154    if (!/^\d{4,12}$/.test(pin())) return;155    setLoading(true);156    setError("");157    const response = await sendMessage({158      type: "UNLOCK_WITH_PIN",159      payload: { pin: pin() },160    });161    const result = response as { ok: boolean; error?: string };162    if (result.ok) {163      await props.onUnlock();164      setLoading(false);165      return;166    }167    setError(getDisplayError(result.error, "incorrect PIN"));168    setPin("");169    const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;170    pinField?.classList.add("shake");171    setTimeout(() => pinField?.classList.remove("shake"), 300);172    setLoading(false);173  }174175  async function checkPinAvailable(): Promise<void> {176    if (!props.hasVault) return;177    try {178      const response = await sendMessage({ type: "GET_SETTINGS" });179      const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;180      const enabled = settings?.pinEnabled === true;181      setPinAvailable(enabled);182      if (enabled && settings?.pinPreferred === true) setUsingPin(true);183    } catch { /* settings are optional at the lock screen */ }184  }185186  async function checkBiometricAvailable(): Promise<void> {187    if (!props.hasVault || !isNativePlatform()) return;188    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");189    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");190    if (!keystore || !biometric) return;191    try {192      const stored = await keystore.hasMasterKey();193      if (!stored.exists) return;194      const avail = await biometric.isAvailable();195      if (!avail.available) return;196      setBiometricAvailable(true);197      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);198    } catch { /* missing plugins or runtime error — silently skip */ }199  }200201  async function handleBiometricUnlock(): Promise<void> {202    if (biometricBusy()) return;203    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");204    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");205    if (!biometric || !keystore) return;206    setBiometricBusy(true);207    setError("");208    try {209      await biometric.authenticate({210        title: "Unlock Peach",211        subtitle: "Use biometrics to access your vault",212        negativeButtonText: "Use Password",213      });214      const { masterKeyHex } = await keystore.retrieveMasterKey();215      const res = (await sendMessage({216        type: "UNLOCK_WITH_KEY",217        payload: { masterKeyHex },218      })) as { ok: boolean; error?: string };219      if (res.ok) {220        await props.onUnlock();221        return;222      }223      if (res.error === "biometric_key_invalidated") {224        await sendMessage({225          type: "UPDATE_SETTINGS",226          payload: { biometricsEnabled: false },227        });228        setBiometricAvailable(false);229        setError("biometric key expired — re-enable in settings");230      } else {231        setError("biometric unlock failed");232      }233    } catch (e) {234      const reason = e instanceof Error ? e.message : String(e);235      if (reason.includes("user_cancelled")) return;236      if (reason.includes("biometric_key_invalidated")) {237        await sendMessage({238          type: "UPDATE_SETTINGS",239          payload: { biometricsEnabled: false },240        });241        setBiometricAvailable(false);242        setError("biometric key expired — re-enable in settings");243        return;244      }245      if (reason.includes("biometric_lockout")) {246        setError("biometric locked out — use your password");247      }248    } finally {249      setBiometricBusy(false);250    }251  }252253  onMount(() => {254    void checkBiometricAvailable();255    void checkPinAvailable();256  });257258  async function handleCreate() {259    if (!password().trim()) return;260    if (password().length < MIN_LENGTH) {261      setError(`password must be at least ${MIN_LENGTH} characters`);262      return;263    }264    if (password() !== confirmPassword()) {265      setError("passwords do not match");266      return;267    }268    const score = strengthScore();269    if (score === null || score < MIN_ZXCVBN_SCORE) {270      setError("password is too weak — use a longer passphrase");271      return;272    }273    setLoading(true);274    setError("");275    const response = await sendMessage({276      type: "CREATE_VAULT",277      payload: { password: password() },278    });279    const result = response as { ok: boolean; error?: string };280    if (result.ok) {281      // Don't unlock yet — show the recovery words screen first. Mark the282      // confirmation as pending in chrome.storage.local so it survives both283      // popup close (lock-on-close skips while set) and full browser restart284      // (next unlock re-routes to the recovery surface). Cleared when the285      // user types the 3 confirmation words successfully.286      try {287        await chrome.storage.local?.set({ peach_recovery_pending: "1" });288      } catch { /* best-effort */ }289      setShowRecoveryWords(true);290      setLoading(false);291      return;292    }293    setError(getDisplayError(result.error, "failed to create vault"));294    setLoading(false);295  }296297  // ── All surfaces routed reactively via Switch/Match ───────────────────298  return (299    <Switch>300      {/* ── Restore tab view ──────────────────────────────────────── */}301      <Match when={restoreSurface() === "restore"}>302        <RestoreTabView303          hasVault={props.hasVault}304          onOpenCodexRestore={props.onOpenCodexRestore}305          onBack={() => {306            // The restore tab is only reached from the onboarding carousel,307            // so back returns there — not to the create-vault form.308            if (props.onBackToOnboarding) props.onBackToOnboarding();309            else setRestoreSurface(null);310          }}311          onUnlock={async () => {312            setRestoreSurface(null);313            await props.onUnlock();314          }}315        />316      </Match>317318      {/* ── Recovery words display after fresh vault creation ────────── */}319      {/* Read props.forceRecoveryWords directly, not just the boot-time320          signal copy: when the vault is locked with a pending confirmation,321          App flips the flag after UNLOCK without remounting LockView. */}322      <Match323        when={324          (showRecoveryWords() || props.forceRecoveryWords) &&325          (!props.hasVault || props.forceRecoveryWords)326        }327      >328        <RecoveryWordDisplayView329          onDone={props.onUnlock}330        />331      </Match>332333      {/* ── Recovery word entry ─────────────────────────────────────── */}334      <Match when={recoverSurface() === "words"}>335        <RecoveryWordEntryView336          onCancel={() => setRecoverSurface(null)}337          onRecoveryComplete={props.onUnlock}338        />339      </Match>340341      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}342      <Match when={props.hasVault}>343        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">344          <RuneBackground opacity={0.07} />345346          <header class="peach-screen-header lock-screen-header">347            <div class="peach-wordmark-stack">348              <PeachWordmark class="peach-wordmark-wrap" />349              <Show when={props.isPro}>350                <span class="peach-pro-mini-badge">pro</span>351              </Show>352            </div>353          </header>354355          <div class="peach-lock-hero peach-lock-hero--unlock">356            <PeachMark class="peach-lock-mark" />357            <span class="peach-lock-eyebrow">vault locked</span>358            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">359              unlock360              <br />361              your vault.362            </h1>363          </div>364365          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">366            <Show when={usingPin()} fallback={367              <>368                <label class="peach-label" for="unlock-password">369                  master password370                </label>371                <input372                  id="unlock-password"373                  class="peach-underline-input peach-unlock-input lock-password-input"374                  type="password"375                  placeholder="••••••••••••"376                  autocomplete="current-password"377                  value={password()}378                  disabled={!props.ready}379                  ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}380                  onInput={(event) => setPassword((event.target as HTMLInputElement).value)}381                  onKeyDown={(event) => {382                    if (event.key !== "Enter" || !props.ready) return;383                    void handleUnlock();384                  }}385                />386              </>387            }>388              <label class="peach-label" for="unlock-pin">389                PIN390              </label>391              <input392                id="unlock-pin"393                class="peach-underline-input peach-unlock-input lock-pin-input"394                type="password"395                inputmode="numeric"396                maxlength="12"397                placeholder="••••••••••••"398                autocomplete="off"399                value={pin()}400                disabled={!props.ready}401                ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}402                onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}403                onKeyDown={(event) => {404                  if (event.key !== "Enter" || !props.ready) return;405                  void handlePinUnlock();406                }}407              />408              <button409                class="peach-lock-recover-link"410                type="button"411                onClick={() => { setUsingPin(false); setError(""); }}412              >413                use master password414              </button>415            </Show>416417            <Show when={error()}>418              <p class="peach-hint peach-hint-error">{error()}</p>419            </Show>420421            <button422              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"423              onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}424              disabled={!props.ready || loading()}425            >426              {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}427            </button>428429            <Show when={!usingPin() && pinAvailable()}>430              <button431                class="peach-lock-recover-link"432                type="button"433                onClick={() => { setUsingPin(true); setError(""); }}434              >435                use PIN436              </button>437            </Show>438439            <Show when={biometricAvailable()}>440              <button441                class="peach-biometric-btn"442                type="button"443                onClick={() => void handleBiometricUnlock()}444                disabled={biometricBusy() || loading()}445                aria-label="Unlock with biometrics"446              >447                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">448                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>449                  <path d="M15 17v-3"/>450                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>451                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>452                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>453                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>454                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>455                </svg>456                {biometricBusy() ? "verifying…" : "use biometrics"}457              </button>458            </Show>459460            <button461              class="peach-lock-recover-link"462              type="button"463              onClick={() => setRecoverSurface("words")}464            >465              forgot your password?466            </button>467          </section>468        </section>469      </Match>470471      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}472      <Match when={true}>473        <section474          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"475          data-motion-phase="enter"476        >477          <header class="peach-screen-header peach-newvault-header">478            <div class="peach-wordmark-stack">479              <PeachWordmark class="peach-wordmark-wrap" />480              <Show when={props.isPro}>481                <span class="peach-pro-mini-badge">pro</span>482              </Show>483            </div>484            <Show when={props.onBackToOnboarding}>485              <button486                class="peach-newvault-back"487                type="button"488                onClick={() => props.onBackToOnboarding?.()}489                aria-label="Back to onboarding"490              >491                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">492                  <path d="M7 2L3 5.5L7 9" />493                </svg>494                back495              </button>496            </Show>497          </header>498499          <div class="peach-newvault-mark" aria-hidden="true">500            <PeachMark class="peach-newvault-mark-art" />501          </div>502503          <div class="peach-newvault-card">504            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>505            <hr class="peach-newvault-divider" />506507            <div class="peach-newvault-field">508              <div class="peach-newvault-label-row">509                <label class="peach-label" for="master-password">510                  master password511                </label>512                <button513                  class="peach-lock-reveal-btn"514                  type="button"515                  onClick={() => setShowPassword((value) => !value)}516                  aria-label={showPassword() ? "Hide password" : "Show password"}517                >518                  <Show519                    when={showPassword()}520                    fallback={521                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">522                        <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />523                        <circle cx="7" cy="5" r="1.75" />524                      </svg>525                    }526                  >527                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">528                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />529                      <path d="M3.5 5.5L2.5 7.5" />530                      <path d="M7 6.5V8.5" />531                      <path d="M10.5 5.5L11.5 7.5" />532                    </svg>533                  </Show>534                  {showPassword() ? "hide" : "show"}535                </button>536              </div>537              <input538                id="master-password"539                class="peach-underline-input peach-newvault-input lock-password-input"540                type={showPassword() ? "text" : "password"}541                placeholder="••••••••••••"542                autocomplete="new-password"543                value={password()}544                disabled={!props.ready}545                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}546                onKeyDown={(event) => {547                  if (event.key !== "Enter" || !props.ready) return;548                  void handleCreate();549                }}550              />551              <Show552                when={password().length > 0 && passwordStrength() !== null}553                fallback={554                  <p class="peach-newvault-hint">555                    At least 12 characters.556                  </p>557                }558              >559                <div class="peach-password-strength">560                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>561                    <div class="peach-strength-fill"></div>562                  </div>563                  <span class="peach-strength-label">{passwordStrength()}</span>564                </div>565              </Show>566            </div>567568            <div class="peach-newvault-field">569              <label class="peach-label" for="confirm-password">570                confirm password571              </label>572              <input573                id="confirm-password"574                class="peach-underline-input peach-newvault-input"575                type={showPassword() ? "text" : "password"}576                placeholder="••••••••••••"577                autocomplete="new-password"578                value={confirmPassword()}579                disabled={!props.ready}580                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}581                onKeyDown={(event) => {582                  if (event.key === "Enter" && props.ready) void handleCreate();583                }}584              />585            </div>586587            <Show when={error()}>588              <p class="peach-hint peach-hint-error">{error()}</p>589            </Show>590591            <button592              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"593              onClick={() => void handleCreate()}594              disabled={!props.ready || loading() || !canCreate()}595              aria-live="polite"596            >597              <Show598                when={!canCreate() && createBlockerMessage()}599                fallback={loading() ? "creating..." : "create vault"}600              >601                {createBlockerMessage()}602              </Show>603            </button>604605            <p class="peach-newvault-warning">606              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>607              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>608            </p>609          </div>610        </section>611      </Match>612    </Switch>613  );614}615616// ── Restore tab view ───────────────────────────────────────────────────────617618type RestoreTab = "symbols" | "codex" | "csv";619620function tabLabel(tab: RestoreTab): string {621  return (622    { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]623  );624}625626function RestoreTabView(props: {627  hasVault: boolean;628  onOpenCodexRestore?: () => void;629  onBack: () => void;630  onUnlock: () => void | Promise<void>;631}) {632  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");633  const [showIntro, setShowIntro] = createSignal(false);634  const [pendingUnlock] = createSignal(false);635636  async function handleRestoreSuccess() {637    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);638    if (!seen) {639      setShowIntro(true);640    } else {641      await props.onUnlock();642    }643  }644645  async function dismissIntro() {646    await storageSet("peach_shown_restore_intro", "true").catch(() => {});647    setShowIntro(false);648    await props.onUnlock();649  }650651  return (652    <section653      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"654      data-motion-phase="enter"655    >656      <RuneBackground opacity={0.05} />657658      <header class="peach-screen-header lock-screen-header">659        <PeachWordmark class="peach-wordmark-wrap" />660        <button class="peach-lock-restore-link" onClick={props.onBack}>661          ‹ back662        </button>663      </header>664665      <div class="peach-newvault-card peach-restore-tabcard">666        <div class="peach-restore-tabs" role="tablist">667          <For each={["symbols", "codex", "csv"] as RestoreTab[]}>668            {(tab) => (669              <button670                class="peach-restore-tab"671                classList={{ "is-active": activeTab() === tab }}672                onClick={() => setActiveTab(tab)}673                role="tab"674                aria-selected={activeTab() === tab}675              >676                {tabLabel(tab)}677              </button>678            )}679          </For>680        </div>681        <div class="peach-restore-content">682          <Show when={activeTab() === "symbols"}>683            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />684          </Show>685          <Show when={activeTab() === "codex"}>686            <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />687          </Show>688          <Show when={activeTab() === "csv"}>689            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />690          </Show>691        </div>692      </div>693694      {/* One-time intro popup after first restore */}695      <Show when={showIntro()}>696        <div class="peach-restore-intro-overlay">697          <div class="peach-restore-intro-card">698            <p class="peach-restore-intro-headline">welcome to peach.</p>699            <div class="peach-restore-intro-body">700              <p>Your vault lives only on this device. No server has a copy.</p>701              <p>The inline bubble fills passwords automatically when you visit sites.</p>702              <p>Export your vault from Settings before switching devices.</p>703            </div>704            <button705              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"706              onClick={() => void dismissIntro()}707              disabled={pendingUnlock()}708            >709              got it710            </button>711          </div>712        </div>713      </Show>714    </section>715  );716}717718// ── Peach Codex tab ────────────────────────────────────────────────────────719720function RestoreCodex(props: { onOpen: () => void }) {721  return (722    <div class="peach-restore-codex">723      <p class="peach-restore-codex-title">Restore from Peach Codex</p>724      <p class="peach-restore-codex-copy">725        Scan printed Codex pages or import the Codex PDF. Codex restore is726        always available, even before you create a vault.727      </p>728      <button729        type="button"730        class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"731        onClick={props.onOpen}732      >733        open Codex restore →734      </button>735    </div>736  );737}738739// ── PeachScript tab ────────────────────────────────────────────────────────740741function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {742  const [scriptInput, setScriptInput] = createSignal("");743  const [backupPass, setBackupPass] = createSignal("");744  const [working, setWorking] = createSignal(false);745  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);746  const [error, setError] = createSignal<string | null>(null);747  let canvasRef: HTMLCanvasElement | undefined;748  let animFrameId = 0;749  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];750751  const scriptValid = createMemo(() => isPeachScript(scriptInput()));752753  function stopAnim() {754    cancelAnimationFrame(animFrameId);755    animFrameId = 0;756  }757758  function startChaos(canvas: HTMLCanvasElement) {759    const ctx = canvas.getContext("2d")!;760    const W = canvas.width;761    const H = canvas.height;762    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>763      String.fromCodePoint(0xe000 + i)764    );765    if (runes.length === 0) {766      runes = Array.from({ length: 60 }, () => ({767        x: Math.random() * W,768        y: Math.random() * H,769        vx: (Math.random() - 0.5) * 3,770        vy: (Math.random() - 0.5) * 3,771        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,772        size: 14 + Math.random() * 20,773        angle: Math.random() * Math.PI * 2,774        va: (Math.random() - 0.5) * 0.1,775      }));776    }777778    function draw() {779      ctx.clearRect(0, 0, W, H);780      for (const r of runes) {781        r.x = (r.x + r.vx + W) % W;782        r.y = (r.y + r.vy + H) % H;783        r.angle += r.va;784        ctx.save();785        ctx.translate(r.x, r.y);786        ctx.rotate(r.angle);787        ctx.font = `${r.size}px PeachScript, monospace`;788        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);789        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;790        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";791        ctx.shadowBlur = 8;792        ctx.fillText(r.char, 0, 0);793        ctx.restore();794      }795      animFrameId = requestAnimationFrame(draw);796    }797    draw();798  }799800  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {801    stopAnim();802    const ctx = canvas.getContext("2d")!;803    const W = canvas.width;804    const H = canvas.height;805    const cols = 8;806    const rows = 6;807    const cellW = W / cols;808    const cellH = H / rows;809    const targets = runes.slice(0, cols * rows).map((_, i) => ({810      x: (i % cols) * cellW + cellW / 2,811      y: Math.floor(i / cols) * cellH + cellH / 2,812    }));813    const start = performance.now();814    const DURATION = 400;815816    function drawSnap(now: number) {817      const t = Math.min((now - start) / DURATION, 1);818      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;819      ctx.clearRect(0, 0, W, H);820      runes.slice(0, targets.length).forEach((r, i) => {821        const tx = targets[i]!.x;822        const ty = targets[i]!.y;823        const cx = r.x + (tx - r.x) * ease;824        const cy = r.y + (ty - r.y) * ease;825        ctx.font = `16px PeachScript, monospace`;826        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;827        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";828        ctx.shadowBlur = 6;829        ctx.fillText(r.char, cx - 8, cy + 8);830      });831      if (t < 1) {832        animFrameId = requestAnimationFrame(drawSnap);833      } else {834        onDone();835      }836    }837    animFrameId = requestAnimationFrame(drawSnap);838  }839840  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {841    stopAnim();842    const ctx = canvas.getContext("2d")!;843    const W = canvas.width;844    const H = canvas.height;845    const start = performance.now();846    const DURATION = 900;847848    function drawBloom(now: number) {849      const t = Math.min((now - start) / DURATION, 1);850      ctx.clearRect(0, 0, W, H);851      // Runes dissolve852      const runeAlpha = 1 - Math.min(t / 0.4, 1);853      if (runeAlpha > 0) {854        for (const r of runes.slice(0, 48)) {855          ctx.font = `16px PeachScript, monospace`;856          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;857          ctx.fillText(r.char, r.x - 8, r.y + 8);858        }859      }860      // Peach mark bloom861      const markT = Math.max(0, (t - 0.2) / 0.5);862      const markScale = Math.min(markT * 1.2, 1);863      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;864      if (markAlpha > 0) {865        ctx.save();866        ctx.globalAlpha = markAlpha;867        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;868        ctx.shadowBlur = 30;869        ctx.translate(W / 2, H / 2);870        ctx.scale(markScale, markScale);871        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);872        ctx.restore();873      }874      if (t < 1) {875        animFrameId = requestAnimationFrame(drawBloom);876      } else {877        onDone();878      }879    }880    animFrameId = requestAnimationFrame(drawBloom);881  }882883  onCleanup(() => stopAnim());884885  async function handleRestore() {886    if (!scriptValid() || !backupPass().trim() || working()) return;887    setError(null);888    setWorking(true);889    setAnimating("chaos");890891    const canvas = canvasRef;892    if (canvas) startChaos(canvas);893894    try {895      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));896      const payload = await parsePayload(bytes, backupPass());897898      if (!props.hasVault) {899        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });900        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");901      }902903      const rr = await sendMessage({904        type: "RESTORE_ENTRIES",905        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },906      });907      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");908909      if (payload.license?.key) {910        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});911      }912913      setAnimating("snap");914      stopAnim();915      if (canvas) {916        snapToGrid(canvas, () => {917          setAnimating("bloom");918          bloom(canvas, async () => {919            setAnimating(null);920            await props.onSuccess();921          });922        });923      } else {924        await props.onSuccess();925      }926    } catch (err) {927      stopAnim();928      setAnimating(null);929      const code = err instanceof Error ? err.message : String(err);930      setError(931        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"932          ? "That passphrase didn't work."933          : code === "CREATE_VAULT_FAILED"934            ? "Couldn't create a new local vault."935            : "Could not read this backup.",936      );937    } finally {938      setWorking(false);939    }940  }941942  return (943    <div class="peach-rt-symbols">944      <Show when={animating() !== null}>945        <canvas946          ref={(el) => { canvasRef = el; }}947          class="peach-rt-anim-canvas"948          width={280}949          height={200}950        />951      </Show>952      <Show when={animating() === null}>953        <textarea954          class="peach-restore-symbols-textarea"955          placeholder="paste peachscript…"956          rows={3}957          value={scriptInput()}958          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}959        />960        <div class="peach-rt-divider">— or —</div>961        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">962          open .peach file963          <input964            type="file"965            accept=".peach,text/plain"966            style="display:none"967            onChange={async (e) => {968              const file = (e.currentTarget as HTMLInputElement).files?.[0];969              if (!file) return;970              try {971                setScriptInput(await file.text());972              } catch {973                setError("Couldn't read file.");974              } finally {975                (e.currentTarget as HTMLInputElement).value = "";976              }977            }}978          />979        </label>980        <input981          class="peach-restore-symbols-pass"982          type="password"983          placeholder="backup password"984          value={backupPass()}985          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}986          onKeyDown={(e) => {987            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {988              e.preventDefault();989              void handleRestore();990            }991          }}992        />993        <button994          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"995          onClick={() => void handleRestore()}996          disabled={!scriptValid() || !backupPass().trim() || working()}997        >998          restore vault →999        </button>1000        <Show when={error()}>1001          <p class="peach-rt-error">{error()}</p>1002        </Show>1003      </Show>1004    </div>1005  );1006}10071008// ── CSV tab ────────────────────────────────────────────────────────────────10091010function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {1011  const [importing, setImporting] = createSignal(false);1012  const [error, setError] = createSignal<string | null>(null);1013  // CSV/JSON imports are plaintext and carry no password, so when there is no1014  // vault yet the user MUST choose a master password here — otherwise the1015  // imported credentials would be encrypted under an empty key and offer zero1016  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.1017  const CSV_MIN_PASSWORD_LENGTH = 12;1018  const [newPass, setNewPass] = createSignal("");1019  const [confirmPass, setConfirmPass] = createSignal("");1020  const needsPassword = () => props.hasVault === false;1021  const passwordReady = () =>1022    !needsPassword() ||1023    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());10241025  async function handleFile(e: Event) {1026    const input = e.currentTarget as HTMLInputElement;1027    const file = input.files?.[0];1028    input.value = "";1029    if (!file) return;1030    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1031      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1032      return;1033    }1034    if (needsPassword() && newPass() !== confirmPass()) {1035      setError("Passwords do not match.");1036      return;1037    }1038    setImporting(true);1039    setError(null);1040    try {1041      const text = await file.text();1042      const parsed = await parseImportSource(text);1043      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10441045      if (props.hasVault === false) {1046        // Create the vault under the user-chosen master password — never an1047        // empty one (an empty key derives a publicly-known vault key).1048        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1049        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1050      }10511052      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1053      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1054      if (parsed.licenseKey) {1055        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1056      }1057      await props.onSuccess();1058    } catch (err) {1059      const code = err instanceof Error ? err.message : String(err);1060      setError(1061        code === "NO_ENTRIES" ? "No entries found in this file." :1062        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1063        "Import failed. Check the file format and try again."1064      );1065    } finally {1066      setImporting(false);1067    }1068  }10691070  return (1071    <div class="peach-rt-csv">1072      <Show when={needsPassword()}>1073        <input1074          class="peach-restore-symbols-pass"1075          type="password"1076          placeholder="new master password"1077          value={newPass()}1078          onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1079        />1080        <input1081          class="peach-restore-symbols-pass"1082          type="password"1083          placeholder="confirm master password"1084          value={confirmPass()}1085          onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1086        />1087      </Show>1088      <label1089        class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1090        classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1091      >1092        {importing() ? "importing…" : "open csv file"}1093        <input1094          type="file"1095          accept=".csv,.json,.peach,application/x-peach"1096          style="display:none"1097          onChange={handleFile}1098          disabled={importing() || !passwordReady()}1099        />1100      </label>1101      <p class="peach-rt-csv-sources">1102        Imports from Bitwarden, 1Password, Proton Pass,<br />1103        Chrome, Safari, and Edge.1104      </p>1105      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1106      <Show when={error()}>1107        <p class="peach-rt-error">{error()}</p>1108      </Show>1109    </div>1110  );1111}11121113// ── Helpers ────────────────────────────────────────────────────────────────11141115function getDisplayError(error: string | undefined, fallback: string): string {1116  if (!error) return fallback;1117  if (1118    error === "SERVICE_WORKER_UNAVAILABLE" ||1119    error.includes("Receiving end does not exist") ||1120    error.includes("message port closed")1121  ) {1122    return "background service unavailable";1123  }1124  return error;1125}