Lock.tsx

42.2 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={() => setRestoreSurface(null)}306          onUnlock={async () => {307            setRestoreSurface(null);308            await props.onUnlock();309          }}310        />311      </Match>312313      {/* ── Recovery words display after fresh vault creation ────────── */}314      <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>315        <RecoveryWordDisplayView316          onDone={props.onUnlock}317        />318      </Match>319320      {/* ── Recovery word entry ─────────────────────────────────────── */}321      <Match when={recoverSurface() === "words"}>322        <RecoveryWordEntryView323          onCancel={() => setRecoverSurface(null)}324          onRecoveryComplete={props.onUnlock}325        />326      </Match>327328      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}329      <Match when={props.hasVault}>330        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">331          <RuneBackground opacity={0.07} />332333          <header class="peach-screen-header lock-screen-header">334            <div class="peach-wordmark-stack">335              <PeachWordmark class="peach-wordmark-wrap" />336              <Show when={props.isPro}>337                <span class="peach-pro-mini-badge">pro</span>338              </Show>339            </div>340          </header>341342          <div class="peach-lock-hero peach-lock-hero--unlock">343            <PeachMark class="peach-lock-mark" />344            <span class="peach-lock-eyebrow">vault locked</span>345            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">346              unlock347              <br />348              your vault.349            </h1>350          </div>351352          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">353            <Show when={usingPin()} fallback={354              <>355                <label class="peach-label" for="unlock-password">356                  master password357                </label>358                <input359                  id="unlock-password"360                  class="peach-underline-input peach-unlock-input lock-password-input"361                  type="password"362                  placeholder="••••••••••••"363                  autocomplete="current-password"364                  value={password()}365                  disabled={!props.ready}366                  ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}367                  onInput={(event) => setPassword((event.target as HTMLInputElement).value)}368                  onKeyDown={(event) => {369                    if (event.key !== "Enter" || !props.ready) return;370                    void handleUnlock();371                  }}372                />373              </>374            }>375              <label class="peach-label" for="unlock-pin">376                PIN377              </label>378              <input379                id="unlock-pin"380                class="peach-underline-input peach-unlock-input lock-pin-input"381                type="password"382                inputmode="numeric"383                maxlength="12"384                placeholder="••••••••••••"385                autocomplete="off"386                value={pin()}387                disabled={!props.ready}388                ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}389                onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}390                onKeyDown={(event) => {391                  if (event.key !== "Enter" || !props.ready) return;392                  void handlePinUnlock();393                }}394              />395              <button396                class="peach-lock-recover-link"397                type="button"398                onClick={() => { setUsingPin(false); setError(""); }}399              >400                use master password401              </button>402            </Show>403404            <Show when={error()}>405              <p class="peach-hint peach-hint-error">{error()}</p>406            </Show>407408            <button409              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"410              onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}411              disabled={!props.ready || loading()}412            >413              {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}414            </button>415416            <Show when={!usingPin() && pinAvailable()}>417              <button418                class="peach-lock-recover-link"419                type="button"420                onClick={() => { setUsingPin(true); setError(""); }}421              >422                use PIN423              </button>424            </Show>425426            <Show when={biometricAvailable()}>427              <button428                class="peach-biometric-btn"429                type="button"430                onClick={() => void handleBiometricUnlock()}431                disabled={biometricBusy() || loading()}432                aria-label="Unlock with biometrics"433              >434                <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">435                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>436                  <path d="M15 17v-3"/>437                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>438                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>439                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>440                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>441                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>442                </svg>443                {biometricBusy() ? "verifying…" : "use biometrics"}444              </button>445            </Show>446447            <button448              class="peach-lock-recover-link"449              type="button"450              onClick={() => setRecoverSurface("words")}451            >452              forgot your password?453            </button>454          </section>455        </section>456      </Match>457458      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}459      <Match when={true}>460        <section461          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"462          data-motion-phase="enter"463        >464          <header class="peach-screen-header peach-newvault-header">465            <div class="peach-wordmark-stack">466              <PeachWordmark class="peach-wordmark-wrap" />467              <Show when={props.isPro}>468                <span class="peach-pro-mini-badge">pro</span>469              </Show>470            </div>471            <Show when={props.onBackToOnboarding}>472              <button473                class="peach-newvault-back"474                type="button"475                onClick={() => props.onBackToOnboarding?.()}476                aria-label="Back to onboarding"477              >478                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">479                  <path d="M7 2L3 5.5L7 9" />480                </svg>481                back482              </button>483            </Show>484          </header>485486          <div class="peach-newvault-mark" aria-hidden="true">487            <PeachMark class="peach-newvault-mark-art" />488          </div>489490          <div class="peach-newvault-card">491            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>492            <hr class="peach-newvault-divider" />493494            <div class="peach-newvault-field">495              <div class="peach-newvault-label-row">496                <label class="peach-label" for="master-password">497                  master password498                </label>499                <button500                  class="peach-lock-reveal-btn"501                  type="button"502                  onClick={() => setShowPassword((value) => !value)}503                  aria-label={showPassword() ? "Hide password" : "Show password"}504                >505                  <Show506                    when={showPassword()}507                    fallback={508                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">509                        <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" />510                        <circle cx="7" cy="5" r="1.75" />511                      </svg>512                    }513                  >514                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">515                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />516                      <path d="M3.5 5.5L2.5 7.5" />517                      <path d="M7 6.5V8.5" />518                      <path d="M10.5 5.5L11.5 7.5" />519                    </svg>520                  </Show>521                  {showPassword() ? "hide" : "show"}522                </button>523              </div>524              <input525                id="master-password"526                class="peach-underline-input peach-newvault-input lock-password-input"527                type={showPassword() ? "text" : "password"}528                placeholder="••••••••••••"529                autocomplete="new-password"530                value={password()}531                disabled={!props.ready}532                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}533                onKeyDown={(event) => {534                  if (event.key !== "Enter" || !props.ready) return;535                  void handleCreate();536                }}537              />538              <Show539                when={password().length > 0 && passwordStrength() !== null}540                fallback={541                  <p class="peach-newvault-hint">542                    At least 12 characters. The vault is created once both fields match and pass the strength check.543                  </p>544                }545              >546                <div class="peach-password-strength">547                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>548                    <div class="peach-strength-fill"></div>549                  </div>550                  <span class="peach-strength-label">{passwordStrength()}</span>551                </div>552              </Show>553            </div>554555            <div class="peach-newvault-field">556              <label class="peach-label" for="confirm-password">557                confirm password558              </label>559              <input560                id="confirm-password"561                class="peach-underline-input peach-newvault-input"562                type={showPassword() ? "text" : "password"}563                placeholder="••••••••••••"564                autocomplete="new-password"565                value={confirmPassword()}566                disabled={!props.ready}567                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}568                onKeyDown={(event) => {569                  if (event.key === "Enter" && props.ready) void handleCreate();570                }}571              />572            </div>573574            <Show when={error()}>575              <p class="peach-hint peach-hint-error">{error()}</p>576            </Show>577578            <button579              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"580              onClick={() => void handleCreate()}581              disabled={!props.ready || loading() || !canCreate()}582              aria-live="polite"583            >584              <Show585                when={!canCreate() && createBlockerMessage()}586                fallback={loading() ? "creating..." : "create vault"}587              >588                {createBlockerMessage()}589              </Show>590            </button>591592            <p class="peach-newvault-warning">593              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>594              <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>595            </p>596          </div>597        </section>598      </Match>599    </Switch>600  );601}602603// ── Restore tab view ───────────────────────────────────────────────────────604605type RestoreTab = "symbols" | "codex" | "csv";606607function tabLabel(tab: RestoreTab): string {608  return (609    { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]610  );611}612613function RestoreTabView(props: {614  hasVault: boolean;615  onOpenCodexRestore?: () => void;616  onBack: () => void;617  onUnlock: () => void | Promise<void>;618}) {619  const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");620  const [showIntro, setShowIntro] = createSignal(false);621  const [pendingUnlock] = createSignal(false);622623  async function handleRestoreSuccess() {624    const seen = await storageGet("peach_shown_restore_intro").catch(() => null);625    if (!seen) {626      setShowIntro(true);627    } else {628      await props.onUnlock();629    }630  }631632  async function dismissIntro() {633    await storageSet("peach_shown_restore_intro", "true").catch(() => {});634    setShowIntro(false);635    await props.onUnlock();636  }637638  return (639    <section640      class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"641      data-motion-phase="enter"642    >643      <RuneBackground opacity={0.05} />644645      <header class="peach-screen-header lock-screen-header">646        <PeachWordmark class="peach-wordmark-wrap" />647        <button class="peach-lock-restore-link" onClick={props.onBack}>648          ‹ back649        </button>650      </header>651652      <div class="peach-newvault-card peach-restore-tabcard">653        <div class="peach-restore-tabs" role="tablist">654          <For each={["symbols", "codex", "csv"] as RestoreTab[]}>655            {(tab) => (656              <button657                class="peach-restore-tab"658                classList={{ "is-active": activeTab() === tab }}659                onClick={() => setActiveTab(tab)}660                role="tab"661                aria-selected={activeTab() === tab}662              >663                {tabLabel(tab)}664              </button>665            )}666          </For>667        </div>668        <div class="peach-restore-content">669          <Show when={activeTab() === "symbols"}>670            <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />671          </Show>672          <Show when={activeTab() === "codex"}>673            <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />674          </Show>675          <Show when={activeTab() === "csv"}>676            <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />677          </Show>678        </div>679      </div>680681      {/* One-time intro popup after first restore */}682      <Show when={showIntro()}>683        <div class="peach-restore-intro-overlay">684          <div class="peach-restore-intro-card">685            <p class="peach-restore-intro-headline">welcome to peach.</p>686            <div class="peach-restore-intro-body">687              <p>Your vault lives only on this device. No server has a copy.</p>688              <p>The inline bubble fills passwords automatically when you visit sites.</p>689              <p>Export your vault from Settings before switching devices.</p>690            </div>691            <button692              class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"693              onClick={() => void dismissIntro()}694              disabled={pendingUnlock()}695            >696              got it697            </button>698          </div>699        </div>700      </Show>701    </section>702  );703}704705// ── Peach Codex tab ────────────────────────────────────────────────────────706707function RestoreCodex(props: { onOpen: () => void }) {708  return (709    <div class="peach-restore-codex">710      <p class="peach-restore-codex-title">Restore from Peach Codex</p>711      <p class="peach-restore-codex-copy">712        Scan printed Codex pages or import the Codex PDF. Codex restore is713        always available, even before you create a vault.714      </p>715      <button716        type="button"717        class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"718        onClick={props.onOpen}719      >720        open Codex restore →721      </button>722    </div>723  );724}725726// ── PeachScript tab ────────────────────────────────────────────────────────727728function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {729  const [scriptInput, setScriptInput] = createSignal("");730  const [backupPass, setBackupPass] = createSignal("");731  const [working, setWorking] = createSignal(false);732  const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);733  const [error, setError] = createSignal<string | null>(null);734  let canvasRef: HTMLCanvasElement | undefined;735  let animFrameId = 0;736  let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];737738  const scriptValid = createMemo(() => isPeachScript(scriptInput()));739740  function stopAnim() {741    cancelAnimationFrame(animFrameId);742    animFrameId = 0;743  }744745  function startChaos(canvas: HTMLCanvasElement) {746    const ctx = canvas.getContext("2d")!;747    const W = canvas.width;748    const H = canvas.height;749    const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>750      String.fromCodePoint(0xe000 + i)751    );752    if (runes.length === 0) {753      runes = Array.from({ length: 60 }, () => ({754        x: Math.random() * W,755        y: Math.random() * H,756        vx: (Math.random() - 0.5) * 3,757        vy: (Math.random() - 0.5) * 3,758        char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,759        size: 14 + Math.random() * 20,760        angle: Math.random() * Math.PI * 2,761        va: (Math.random() - 0.5) * 0.1,762      }));763    }764765    function draw() {766      ctx.clearRect(0, 0, W, H);767      for (const r of runes) {768        r.x = (r.x + r.vx + W) % W;769        r.y = (r.y + r.vy + H) % H;770        r.angle += r.va;771        ctx.save();772        ctx.translate(r.x, r.y);773        ctx.rotate(r.angle);774        ctx.font = `${r.size}px PeachScript, monospace`;775        const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);776        ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;777        ctx.shadowColor = "rgba(232, 149, 109, 0.7)";778        ctx.shadowBlur = 8;779        ctx.fillText(r.char, 0, 0);780        ctx.restore();781      }782      animFrameId = requestAnimationFrame(draw);783    }784    draw();785  }786787  function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {788    stopAnim();789    const ctx = canvas.getContext("2d")!;790    const W = canvas.width;791    const H = canvas.height;792    const cols = 8;793    const rows = 6;794    const cellW = W / cols;795    const cellH = H / rows;796    const targets = runes.slice(0, cols * rows).map((_, i) => ({797      x: (i % cols) * cellW + cellW / 2,798      y: Math.floor(i / cols) * cellH + cellH / 2,799    }));800    const start = performance.now();801    const DURATION = 400;802803    function drawSnap(now: number) {804      const t = Math.min((now - start) / DURATION, 1);805      const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;806      ctx.clearRect(0, 0, W, H);807      runes.slice(0, targets.length).forEach((r, i) => {808        const tx = targets[i]!.x;809        const ty = targets[i]!.y;810        const cx = r.x + (tx - r.x) * ease;811        const cy = r.y + (ty - r.y) * ease;812        ctx.font = `16px PeachScript, monospace`;813        ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;814        ctx.shadowColor = "rgba(232, 149, 109, 0.6)";815        ctx.shadowBlur = 6;816        ctx.fillText(r.char, cx - 8, cy + 8);817      });818      if (t < 1) {819        animFrameId = requestAnimationFrame(drawSnap);820      } else {821        onDone();822      }823    }824    animFrameId = requestAnimationFrame(drawSnap);825  }826827  function bloom(canvas: HTMLCanvasElement, onDone: () => void) {828    stopAnim();829    const ctx = canvas.getContext("2d")!;830    const W = canvas.width;831    const H = canvas.height;832    const start = performance.now();833    const DURATION = 900;834835    function drawBloom(now: number) {836      const t = Math.min((now - start) / DURATION, 1);837      ctx.clearRect(0, 0, W, H);838      // Runes dissolve839      const runeAlpha = 1 - Math.min(t / 0.4, 1);840      if (runeAlpha > 0) {841        for (const r of runes.slice(0, 48)) {842          ctx.font = `16px PeachScript, monospace`;843          ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;844          ctx.fillText(r.char, r.x - 8, r.y + 8);845        }846      }847      // Peach mark bloom848      const markT = Math.max(0, (t - 0.2) / 0.5);849      const markScale = Math.min(markT * 1.2, 1);850      const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;851      if (markAlpha > 0) {852        ctx.save();853        ctx.globalAlpha = markAlpha;854        ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;855        ctx.shadowBlur = 30;856        ctx.translate(W / 2, H / 2);857        ctx.scale(markScale, markScale);858        ctx.drawImage(peachMarkImg, -32, -32, 64, 64);859        ctx.restore();860      }861      if (t < 1) {862        animFrameId = requestAnimationFrame(drawBloom);863      } else {864        onDone();865      }866    }867    animFrameId = requestAnimationFrame(drawBloom);868  }869870  onCleanup(() => stopAnim());871872  async function handleRestore() {873    if (!scriptValid() || !backupPass().trim() || working()) return;874    setError(null);875    setWorking(true);876    setAnimating("chaos");877878    const canvas = canvasRef;879    if (canvas) startChaos(canvas);880881    try {882      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));883      const payload = await parsePayload(bytes, backupPass());884885      if (!props.hasVault) {886        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });887        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");888      }889890      const rr = await sendMessage({891        type: "RESTORE_ENTRIES",892        payload: { entries: payload.entries, mode: "merge", settings: payload.settings },893      });894      if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");895896      if (payload.license?.key) {897        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});898      }899900      setAnimating("snap");901      stopAnim();902      if (canvas) {903        snapToGrid(canvas, () => {904          setAnimating("bloom");905          bloom(canvas, async () => {906            setAnimating(null);907            await props.onSuccess();908          });909        });910      } else {911        await props.onSuccess();912      }913    } catch (err) {914      stopAnim();915      setAnimating(null);916      const code = err instanceof Error ? err.message : String(err);917      setError(918        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"919          ? "That passphrase didn't work."920          : code === "CREATE_VAULT_FAILED"921            ? "Couldn't create a new local vault."922            : "Could not read this backup.",923      );924    } finally {925      setWorking(false);926    }927  }928929  return (930    <div class="peach-rt-symbols">931      <Show when={animating() !== null}>932        <canvas933          ref={(el) => { canvasRef = el; }}934          class="peach-rt-anim-canvas"935          width={280}936          height={200}937        />938      </Show>939      <Show when={animating() === null}>940        <textarea941          class="peach-restore-symbols-textarea"942          placeholder="paste peachscript…"943          rows={3}944          value={scriptInput()}945          onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}946        />947        <div class="peach-rt-divider">— or —</div>948        <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">949          open .peach file950          <input951            type="file"952            accept=".peach,text/plain"953            style="display:none"954            onChange={async (e) => {955              const file = (e.currentTarget as HTMLInputElement).files?.[0];956              if (!file) return;957              try {958                setScriptInput(await file.text());959              } catch {960                setError("Couldn't read file.");961              } finally {962                (e.currentTarget as HTMLInputElement).value = "";963              }964            }}965          />966        </label>967        <input968          class="peach-restore-symbols-pass"969          type="password"970          placeholder="backup password"971          value={backupPass()}972          onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}973          onKeyDown={(e) => {974            if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {975              e.preventDefault();976              void handleRestore();977            }978          }}979        />980        <button981          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"982          onClick={() => void handleRestore()}983          disabled={!scriptValid() || !backupPass().trim() || working()}984        >985          restore vault →986        </button>987        <Show when={error()}>988          <p class="peach-rt-error">{error()}</p>989        </Show>990      </Show>991    </div>992  );993}994995// ── CSV tab ────────────────────────────────────────────────────────────────996997function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {998  const [importing, setImporting] = createSignal(false);999  const [error, setError] = createSignal<string | null>(null);1000  // CSV/JSON imports are plaintext and carry no password, so when there is no1001  // vault yet the user MUST choose a master password here — otherwise the1002  // imported credentials would be encrypted under an empty key and offer zero1003  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.1004  const CSV_MIN_PASSWORD_LENGTH = 12;1005  const [newPass, setNewPass] = createSignal("");1006  const [confirmPass, setConfirmPass] = createSignal("");1007  const needsPassword = () => props.hasVault === false;1008  const passwordReady = () =>1009    !needsPassword() ||1010    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());10111012  async function handleFile(e: Event) {1013    const input = e.currentTarget as HTMLInputElement;1014    const file = input.files?.[0];1015    input.value = "";1016    if (!file) return;1017    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1018      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1019      return;1020    }1021    if (needsPassword() && newPass() !== confirmPass()) {1022      setError("Passwords do not match.");1023      return;1024    }1025    setImporting(true);1026    setError(null);1027    try {1028      const text = await file.text();1029      const parsed = await parseImportSource(text);1030      if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10311032      if (props.hasVault === false) {1033        // Create the vault under the user-chosen master password — never an1034        // empty one (an empty key derives a publicly-known vault key).1035        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1036        if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1037      }10381039      const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1040      if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1041      if (parsed.licenseKey) {1042        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1043      }1044      await props.onSuccess();1045    } catch (err) {1046      const code = err instanceof Error ? err.message : String(err);1047      setError(1048        code === "NO_ENTRIES" ? "No entries found in this file." :1049        code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1050        "Import failed. Check the file format and try again."1051      );1052    } finally {1053      setImporting(false);1054    }1055  }10561057  return (1058    <div class="peach-rt-csv">1059      <Show when={needsPassword()}>1060        <input1061          class="peach-restore-symbols-pass"1062          type="password"1063          placeholder="new master password"1064          value={newPass()}1065          onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1066        />1067        <input1068          class="peach-restore-symbols-pass"1069          type="password"1070          placeholder="confirm master password"1071          value={confirmPass()}1072          onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1073        />1074      </Show>1075      <label1076        class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1077        classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1078      >1079        {importing() ? "importing…" : "open csv file"}1080        <input1081          type="file"1082          accept=".csv,.json,.peach,application/x-peach"1083          style="display:none"1084          onChange={handleFile}1085          disabled={importing() || !passwordReady()}1086        />1087      </label>1088      <p class="peach-rt-csv-sources">1089        Imports from Bitwarden, 1Password, Proton Pass,<br />1090        Chrome, Safari, and Edge.1091      </p>1092      <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1093      <Show when={error()}>1094        <p class="peach-rt-error">{error()}</p>1095      </Show>1096    </div>1097  );1098}10991100// ── Helpers ────────────────────────────────────────────────────────────────11011102function getDisplayError(error: string | undefined, fallback: string): string {1103  if (!error) return fallback;1104  if (1105    error === "SERVICE_WORKER_UNAVAILABLE" ||1106    error.includes("Receiving end does not exist") ||1107    error.includes("message port closed")1108  ) {1109    return "background service unavailable";1110  }1111  return error;1112}