Settings.tsx

69.7 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import type { Canary } from "../../shared/types";7import { ACCENT_COLORS } from "../../shared/types";8import type {9  AccentColorName,10  PasskeyProviderState,11  ProFeature,12  VaultSnapshot,13} from "../../shared/types";14import {15  DEFAULT_BINDINGS,16  findConflict,17  formatKeybinding,18  keybindingFromEvent,19  SHORTCUT_DESCRIPTORS,20  type Keybinding,21  type ShortcutAction,22} from "../../shared/shortcuts";23import { isNativePlatform, nativePlugin } from "../../shared/native";2425interface LicenseInfo {26  valid: boolean;27  plan: "free" | "pro";28  issued?: string;29}3031interface SettingsProps {32  entriesCount: number;33  breachMonitoring: boolean;34  lockOnClose: boolean;35  autoLockMinutes: number;36  trashRetentionDays: number;37  theme: "dark" | "light";38  accentColor: AccentColorName;39  textSize: "small" | "medium" | "large";40  reduceMotion: boolean;41  isPro?: boolean;42  initialSection?: string | null;43  upgradeHighlight?: ProFeature | null;44  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;45  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;46  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;47  onTrashRetentionChange: (days: number) => void | Promise<void>;48  onThemeChange: (theme: "dark" | "light") => void;49  onAccentChange: (name: AccentColorName) => void | Promise<void>;50  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;51  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;52  shortcuts: Record<ShortcutAction, Keybinding>;53  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;54  onLicenseChange?: () => void | Promise<void>;55  onBack: () => void;56  onImport: () => void;57  onExport: () => void;58  onBreachCheckDone: () => void;59  onOpenHealth?: () => void;60  onOpenSyncPairing?: () => void;61  onOpenFileImport?: () => void;62  onOpenPanic?: () => void;63  onOpenCodexGenerate?: () => void;64  onOpenCodexRestore?: () => void;65  /** Invoked from within Settings when a Pro-gated control is tapped on66   *  free tier. App owns the upgrade navigation + highlight state, so this67   *  callback is the single way for Settings to request that navigation. */68  onProGate?: (feature: ProFeature) => void;69}7071type SectionName =72  | "security"73  | "vault"74  | "appearance"75  | "shortcuts"76  | "license"77  | "feedback";7879const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];8081function formatActivatedSince(issued: string | undefined): string {82  if (!issued) return "Active";83  try {84    const d = new Date(issued);85    if (Number.isNaN(d.getTime())) return "Active";86    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;87  } catch {88    return "Active";89  }90}9192function isSectionName(value: string): value is SectionName {93  return (94    value === "security" ||95    value === "vault" ||96    value === "appearance" ||97    value === "shortcuts" ||98    value === "license" ||99    value === "feedback"100  );101}102103export function SettingsView(props: SettingsProps) {104  const manifest = chrome.runtime.getManifest();105  const version = manifest.version;106  const isAndroid = (manifest as any).version_name === 'mobile';107  const initial = props.initialSection;108  const [activeSection, setActiveSection] = createSignal<SectionName | null>(109    initial && isSectionName(initial) ? (initial as SectionName) : null110  );111112  const [checkingBreaches, setCheckingBreaches] = createSignal(false);113  const [breachResult, setBreachResult] = createSignal<string | null>(null);114  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);115  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");116  const [licenseError, setLicenseError] = createSignal<string | null>(null);117  const [, setLicenseSuccess] = createSignal<string | null>(null);118  const [activatingLicense, setActivatingLicense] = createSignal(false);119120  const isPro = () => props.isPro ?? false;121  const [biometricSupported, setBiometricSupported] = createSignal(false);122  const [biometricStored, setBiometricStored] = createSignal(false);123  const [biometricBusy, setBiometricBusy] = createSignal(false);124  const [biometricError, setBiometricError] = createSignal<string | null>(null);125  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);126  const [passkeySaving, setPasskeySaving] = createSignal(false);127128  const [dedupCount, setDedupCount] = createSignal<number | null>(null);129  const [dedupRunning, setDedupRunning] = createSignal(false);130  const [dedupDone, setDedupDone] = createSignal<number | null>(null);131132  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);133134  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted135  // BEFORE every real-mode vault write so the user can roll back if a sync136  // or import overwrites the wrong state.137  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);138  const [historyLoading, setHistoryLoading] = createSignal(false);139  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);140141  async function loadVaultHistory() {142    setHistoryLoading(true);143    try {144      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });145      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];146      setHistorySnapshots(list);147    } finally {148      setHistoryLoading(false);149    }150  }151152  async function handleRestoreVaultSnapshot(id: string) {153    if (historyRestoring()) return;154    setHistoryRestoring(id);155    const res = await sendMessage({156      type: "RESTORE_VAULT_SNAPSHOT",157      payload: { snapshotId: id },158    });159    setHistoryRestoring(null);160    if ((res as { ok: boolean }).ok) {161      // Vault is now locked — let the parent close Settings; App.tsx's162      // VAULT_LOCKED listener will route back to the lock screen.163      props.onBack();164    }165  }166167  function formatSnapshotTime(savedAt: number): string {168    const diff = Date.now() - savedAt;169    if (diff < 60_000) return "Just now";170    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;171    const d = new Date(savedAt);172    const isToday = new Date().toDateString() === d.toDateString();173    const isYest =174      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();175    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });176    if (isToday) return `Today ${t}`;177    if (isYest) return `Yesterday ${t}`;178    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;179  }180181  function reasonLabel(reason: VaultSnapshot["reason"]): string {182    return (183      {184        local_save: "saved",185        sync_received: "synced",186        import: "imported",187        restore: "restored",188      } as const189    )[reason] ?? reason;190  }191192  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);193194  const [showChangePw, setShowChangePw] = createSignal(false);195  const [changePwCurrent, setChangePwCurrent] = createSignal("");196  const [changePwNew, setChangePwNew] = createSignal("");197  const [changePwConfirm, setChangePwConfirm] = createSignal("");198  const [changePwSaving, setChangePwSaving] = createSignal(false);199  const [changePwError, setChangePwError] = createSignal<string | null>(null);200  const [changePwSuccess] = createSignal(false);201  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);202203  const [duressEnabled, setDuressEnabled] = createSignal(false);204  const [showDuressSetup, setShowDuressSetup] = createSignal(false);205  const [duressPassword, setDuressPassword] = createSignal("");206  const [duressConfirm, setDuressConfirm] = createSignal("");207  const [duressMainPassword, setDuressMainPassword] = createSignal("");208  const [duressSaving, setDuressSaving] = createSignal(false);209  const [duressError, setDuressError] = createSignal<string | null>(null);210  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);211212  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);213214  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);215  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);216217  const [fbCategory, setFbCategory] = createSignal("Bug report");218  const [fbMessage, setFbMessage] = createSignal("");219  const [fbSubmitting, setFbSubmitting] = createSignal(false);220  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);221222  onMount(async () => {223    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([224      sendMessage({ type: "GET_LICENSE" }),225      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),226      sendMessage({ type: "IS_DURESS_ENABLED" }),227      sendMessage({ type: "GET_SETTINGS" }),228      sendMessage({ type: "GET_LICENSE_KEY" }),229    ]);230231    // Vault history loads in the background — the Vault sub-view picks it up232    // when the user navigates in. Loading it lazily keeps Settings boot fast.233    void loadVaultHistory();234235    if ((licenseRes as { ok: boolean }).ok) {236      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);237    }238239    if ((passkeyRes as { ok: boolean }).ok) {240      setPasskeyState(241        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null242      );243    }244245    if ((duressRes as { ok: boolean }).ok) {246      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);247    }248249    if ((settingsRes as { ok: boolean }).ok) {250      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;251      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);252    }253254    if ((keyRes as { ok: boolean }).ok) {255      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);256    }257258    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });259    if ((entriesRes as { ok: boolean }).ok) {260      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];261      setCanaryEntries(262        entries263          .filter((e) => e.canary?.webhookUrl)264          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))265      );266    }267268269    if (isNativePlatform()) {270      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");271      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");272      try {273        if (biometric) {274          const a = await biometric.isAvailable();275          setBiometricSupported(a.available);276        }277        if (keystore) {278          const k = await keystore.hasMasterKey();279          setBiometricStored(k.exists);280        }281      } catch { /* missing plugins — leave defaults */ }282    }283  });284285  async function handleEnableBiometrics() {286    setBiometricBusy(true);287    setBiometricError(null);288    try {289      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {290        ok: boolean;291        data?: { masterKeyHex: string };292        error?: string;293      };294      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {295        setBiometricError(keyRes.error === "vault_locked"296          ? "unlock the vault first"297          : "couldn't read derived key");298        return;299      }300      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");301      if (!keystore) {302        setBiometricError("biometric plugin unavailable");303        return;304      }305      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });306      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });307      setBiometricStored(true);308    } catch (e) {309      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");310    } finally {311      setBiometricBusy(false);312    }313  }314315  async function handleDisableBiometrics() {316    setBiometricBusy(true);317    setBiometricError(null);318    try {319      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");320      if (keystore) await keystore.clearMasterKey();321      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });322      setBiometricStored(false);323    } catch (e) {324      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");325    } finally {326      setBiometricBusy(false);327    }328  }329330  async function handleChangePassword() {331    setChangePwError(null);332    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }333    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }334    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }335    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }336    setChangePwSaving(true);337    const res = await sendMessage({338      type: "CHANGE_PASSWORD",339      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },340    }) as { ok: boolean; error?: string };341    if (res.ok) {342      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");343      setShowChangePw(false);344      setShowNewRecoveryWords(true);345    } else {346      setChangePwError(res.error ?? "failed to change password");347    }348    setChangePwSaving(false);349  }350351  const [copied, setCopied] = createSignal(false);352353  async function copyLicenseKey(key: string) {354    await navigator.clipboard.writeText(key);355    setCopied(true);356    setTimeout(() => setCopied(false), 2000);357  }358359  async function handleActivateLicense() {360    const key = licenseKeyInput().trim();361    if (!key) return;362    setActivatingLicense(true);363    setLicenseError(null);364    setLicenseSuccess(null);365    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });366    setActivatingLicense(false);367    if ((res as { ok: boolean }).ok) {368      const data = (res as { ok: boolean; data?: LicenseInfo }).data;369      if (data?.valid) {370        setLicenseInfo(data);371        setLicenseKey(key);372        setLicenseSuccess("License activated!");373        setLicenseKeyInput("");374      } else {375        setLicenseError("Invalid license key");376      }377    } else {378      setLicenseError("Activation failed");379    }380  }381382  async function handleTogglePasskeys() {383    const current = passkeyState();384    if (!current || !current.supported) return;385    setPasskeySaving(true);386    const res = await sendMessage({387      type: "SET_PASSKEY_PROVIDER_ENABLED",388      payload: { enabled: !current.enabled },389    });390    setPasskeySaving(false);391    if ((res as { ok: boolean }).ok) {392      setPasskeyState(393        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null394      );395    }396  }397398  async function handleCheckAllBreaches() {399    setCheckingBreaches(true);400    setBreachResult(null);401    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });402    setCheckingBreaches(false);403    if ((res as { ok: boolean }).ok) {404      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};405      const breached = Object.values(data).filter((count) => count > 0).length;406      setBreachResult(407        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"408      );409      props.onBreachCheckDone();410    }411  }412413414  async function handleSetupDuress() {415    const mainPw = duressMainPassword().trim();416    const duressPw = duressPassword().trim();417    const confirmPw = duressConfirm().trim();418419    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }420    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }421    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }422    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }423424    setDuressSaving(true);425    setDuressError(null);426427    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };428    if (!verifyRes.ok || !verifyRes.data) {429      setDuressError("incorrect main password");430      setDuressSaving(false);431      return;432    }433434    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };435    setDuressSaving(false);436    if (res.ok) {437      setDuressEnabled(true);438      setShowDuressSetup(false);439      setDuressPassword("");440      setDuressConfirm("");441      setDuressMainPassword("");442      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");443      setTimeout(() => setDuressSuccess(null), 5000);444    } else {445      setDuressError(res.error ?? "failed to set duress password");446    }447  }448449  async function handleClearDeclinedCards() {450    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });451    setDeclinedCardCount(0);452  }453454  async function handleCountDuplicates() {455    setDedupRunning(true);456    setDedupDone(null);457    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };458    setDedupRunning(false);459    if (res.ok) setDedupCount(res.data ?? 0);460  }461462  async function handleDeduplicate() {463    setDedupRunning(true);464    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };465    setDedupRunning(false);466    if (res.ok) {467      setDedupDone(res.data ?? 0);468      setDedupCount(0);469    }470  }471472  async function handleDisableDuress() {473    const mainPw = duressMainPassword().trim();474    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }475476    setDuressSaving(true);477    setDuressError(null);478479    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };480    if (!verifyRes.ok || !verifyRes.data) {481      setDuressError("incorrect main password");482      setDuressSaving(false);483      return;484    }485486    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };487    setDuressSaving(false);488    if (res.ok) {489      setDuressEnabled(false);490      setShowDuressSetup(false);491      setDuressMainPassword("");492      setDuressSuccess("Duress password disabled.");493      setTimeout(() => setDuressSuccess(null), 4000);494    } else {495      setDuressError(res.error ?? "failed to disable duress password");496    }497  }498499  async function handleSubmitFeedback() {500    if (!fbMessage().trim() || fbSubmitting()) return;501    setFbSubmitting(true);502    setFbResult(null);503    const res = await sendMessage({504      type: "SUBMIT_FEEDBACK",505      payload: {506        category: fbCategory(),507        message: fbMessage().trim(),508        version,509        browser: navigator.userAgent,510      },511    }) as { ok: boolean; error?: string };512    setFbSubmitting(false);513    if (res.ok) {514      setFbResult("success");515      setFbMessage("");516      setTimeout(() => setFbResult(null), 4000);517    } else {518      setFbResult("error");519    }520  }521522  function handleHeaderBack() {523    if (activeSection() !== null) {524      setActiveSection(null);525    } else {526      props.onBack();527    }528  }529530  function currentTitle(): string {531    const s = activeSection();532    if (s === null) return "settings";533    if (s === "license") return "account";534    return s;535  }536537  function renderCatRow(opts: {538    section: SectionName;539    name: string;540    desc: string;541    icon: () => any;542    pro?: boolean;543  }) {544    return (545      <button546        class="peach-settings-cat-row"547        onClick={() => setActiveSection(opts.section)}548      >549        <div class="peach-settings-cat-icon">{opts.icon()}</div>550        <div class="peach-settings-cat-content">551          <span class="peach-settings-cat-name">552            {opts.name}553            <Show when={opts.pro && !isPro()}>554              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>555            </Show>556          </span>557          <span class="peach-settings-cat-desc">{opts.desc}</span>558        </div>559        <svg560          class="peach-settings-cat-chevron"561          width="6" height="10" viewBox="0 0 6 10" fill="none"562          stroke="currentColor" stroke-width="1.1"563          stroke-linecap="round" stroke-linejoin="round"564        >565          <path d="M1 1l4 4-4 4" />566        </svg>567      </button>568    );569  }570571  const iconShield = () => (572    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">573      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>574    </svg>575  );576  const iconKey = () => (577    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">578      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>579    </svg>580  );581  const iconLock = () => (582    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">583      <rect x="3" y="7" width="10" height="7" rx="1.5"/>584      <path d="M6 7V5a3 3 0 016 0v2"/>585    </svg>586  );587  const iconSync = () => (588    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">589      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>590      <path d="M14 6l-2 2-2-2"/>591      <path d="M2 10l2-2 2 2"/>592    </svg>593  );594  const iconSun = () => (595    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">596      <circle cx="8" cy="8" r="3"/>597      <path d="M8 2v1m0 10v1M2 8h1m10 0h1m-2.1-4.1-.7.7M4.8 11.3l-.7.7m0-7.7.7.7m6.5 6.3-.7.7"/>598    </svg>599  );600  const iconKeys = () => (601    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">602      <rect x="2" y="5" width="5" height="4" rx="1"/>603      <rect x="9" y="7" width="5" height="4" rx="1"/>604      <path d="M7 7h2m-2 2h2"/>605    </svg>606  );607  const iconBubble = () => (608    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">609      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>610    </svg>611  );612613  function renderHome() {614    return (615      <div class="peach-settings-body">616        <p class="peach-settings-nav-label">PROTECTION</p>617        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}618        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}619620        <p class="peach-settings-nav-label">VAULT</p>621        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}622        <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>623          <div class="peach-settings-cat-icon">{iconSync()}</div>624          <div class="peach-settings-cat-content">625            <span class="peach-settings-cat-name">Sync</span>626            <span class="peach-settings-cat-desc">Pair and sync across devices</span>627          </div>628          <svg class="peach-settings-cat-chevron" width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">629            <path d="M1 1l4 4-4 4" />630          </svg>631        </button>632633        <p class="peach-settings-nav-label">PREFERENCES</p>634        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}635        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}636637        <p class="peach-settings-nav-label">SUPPORT</p>638        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}639640        <div class="peach-settings-footer">641          <span>Peach {version}</span>642          <span class="peach-divider"></span>643          <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>644        </div>645      </div>646    );647  }648649  function renderSecurity() {650    return (651      <div class="peach-settings-body">652        <div class="peach-settings-group">653          <p class="peach-settings-sub-label">LOCKING</p>654          <div class="peach-settings-row">655            <span>Lock on close</span>656            <button657              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}658              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}659              aria-label="Toggle lock on close"660            >661              <span class="peach-toggle-knob"></span>662            </button>663          </div>664          <div class="peach-settings-row">665            <span>Auto-lock after</span>666            <select667              class="peach-settings-select"668              value={String(props.autoLockMinutes)}669              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}670            >671              <option value="1">1 minute</option>672              <option value="5">5 minutes</option>673              <option value="15">15 minutes</option>674              <option value="30">30 minutes</option>675              <option value="0">Never</option>676            </select>677          </div>678679          <Show when={isNativePlatform() && biometricSupported()}>680            <p class="peach-settings-sub-label">BIOMETRICS</p>681            <div class="peach-settings-row">682              <span>Unlock with biometrics</span>683              <button684                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}685                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}686                disabled={biometricBusy()}687                aria-label="Toggle biometric unlock"688              >689                <span class="peach-toggle-knob"></span>690              </button>691            </div>692            <Show when={biometricError()}>693              <div class="peach-settings-row peach-settings-row--meta">694                <span class="peach-hint peach-hint-error">{biometricError()}</span>695              </div>696            </Show>697          </Show>698699          <p class="peach-settings-sub-label">BREACH PROTECTION</p>700          <div class="peach-settings-row">701            <span>Breach monitoring</span>702            <button703              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}704              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}705              aria-label="Toggle breach monitoring"706            >707              <span class="peach-toggle-knob"></span>708            </button>709          </div>710          <div class="peach-settings-row peach-settings-row--meta">711            <span>Check all passwords</span>712            <button713              class="peach-btn peach-btn--subtle peach-btn--sm"714              onClick={() => void handleCheckAllBreaches()}715              disabled={checkingBreaches()}716            >717              {checkingBreaches() ? "checking..." : "run"}718            </button>719          </div>720          <Show when={breachResult()}>721            <div class="peach-settings-row peach-settings-row--meta">722              <span>Last check</span>723              <span class="peach-settings-value">{breachResult()}</span>724            </div>725          </Show>726727          <p class="peach-settings-sub-label">MASTER PASSWORD</p>728          <div class="peach-settings-row peach-settings-row--meta">729            <span>Master password</span>730            <button731              class="peach-btn peach-btn--subtle peach-btn--sm"732              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}733            >734              {showChangePw() ? "cancel" : "change"}735            </button>736          </div>737          <Show when={showChangePw()}>738            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">739              <input740                class="peach-settings-input"741                type="password"742                placeholder="current password"743                value={changePwCurrent()}744                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}745                autocomplete="current-password"746              />747              <input748                class="peach-settings-input"749                type="password"750                placeholder="new password (min 12 chars)"751                value={changePwNew()}752                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}753                autocomplete="new-password"754              />755              <input756                class="peach-settings-input"757                type="password"758                placeholder="confirm new password"759                value={changePwConfirm()}760                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}761                autocomplete="new-password"762                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}763              />764              <Show when={changePwError()}>765                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>766              </Show>767              <Show when={changePwSuccess()}>768                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>769              </Show>770              <button771                class="peach-btn peach-btn--accent peach-btn--sm"772                style="align-self:flex-start"773                onClick={() => void handleChangePassword()}774                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}775              >776                {changePwSaving() ? "saving…" : "save new password"}777              </button>778            </div>779          </Show>780781          <p class="peach-settings-sub-label">782            DURESS PASSWORD783            <Show when={!isPro()}>784              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>785            </Show>786          </p>787          <div class="peach-settings-row peach-settings-row--top">788            <div class="peach-settings-row-main">789              <span>790                Duress password791                <Show when={!isPro()}>792                  <span class="peach-pro-badge">Pro</span>793                </Show>794              </span>795              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">796                If entered under coercion, shows a decoy empty vault instead of your real one.797              </span>798            </div>799            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>800              {duressEnabled() ? "on" : "off"}801            </div>802          </div>803804          <Show when={duressEnabled() && !showDuressSetup()}>805            <div class="peach-settings-row peach-settings-row--meta">806              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">807                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden. The encrypted storage is identical in size whether a hidden vault exists or not, so the hidden vault cannot be proven or disproven without both passwords. This protects against a coercer who demands your password — not against device malware or someone who already has your device unlocked.808              </span>809            </div>810          </Show>811812          <Show when={duressSuccess()}>813            <div class="peach-settings-row peach-settings-row--meta">814              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>815            </div>816          </Show>817818          <Show when={!showDuressSetup()}>819            <div class="peach-settings-row peach-settings-row--meta">820              <button821                class="peach-btn peach-btn--subtle peach-btn--sm"822                onClick={() => {823                  // Pro gate entry-point: free users are redirected to the824                  // upgrade screen before the setup form opens. The server-825                  // side SETUP_DURESS handler is the single enforcement826                  // point; this is UI redirection so the form never appears827                  // for a user who can't complete it.828                  if (!isPro() && !duressEnabled()) {829                    props.onProGate?.("duress_vault");830                    setActiveSection("license");831                    return;832                  }833                  setShowDuressSetup(true);834                  setDuressError(null);835                  setDuressSuccess(null);836                  setDuressPassword("");837                  setDuressConfirm("");838                  setDuressMainPassword("");839                }}840              >841                {duressEnabled() ? "Change / disable" : "Set up"}842              </button>843            </div>844          </Show>845846          <Show when={showDuressSetup()}>847            <div class="peach-duress-form">848              <label class="peach-label">main password (to confirm)</label>849              <input850                class="peach-settings-input"851                type="password"852                placeholder="your current master password"853                autocomplete="current-password"854                value={duressMainPassword()}855                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}856              />857              <Show when={!duressEnabled()}>858                <label class="peach-label">new duress password</label>859                <input860                  class="peach-settings-input"861                  type="password"862                  placeholder="duress password (min 8 chars)"863                  autocomplete="new-password"864                  value={duressPassword()}865                  onInput={(e) => setDuressPassword(e.currentTarget.value)}866                />867                <label class="peach-label">confirm duress password</label>868                <input869                  class="peach-settings-input"870                  type="password"871                  placeholder="confirm duress password"872                  autocomplete="new-password"873                  value={duressConfirm()}874                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}875                />876              </Show>877878              <Show when={duressError()}>879                <p class="peach-hint peach-hint-error">{duressError()}</p>880              </Show>881882              <div class="peach-duress-actions">883                <button884                  class="peach-btn peach-btn--subtle peach-btn--sm"885                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}886                >887                  cancel888                </button>889                <Show when={duressEnabled()}>890                  <button891                    class="peach-btn peach-btn--ghost peach-btn--sm"892                    style="color:var(--peach-breach)"893                    onClick={() => void handleDisableDuress()}894                    disabled={duressSaving()}895                  >896                    {duressSaving() ? "disabling..." : "disable duress"}897                  </button>898                </Show>899                <Show when={!duressEnabled()}>900                  <button901                    class="peach-btn peach-btn--accent peach-btn--sm"902                    onClick={() => void handleSetupDuress()}903                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}904                  >905                    {duressSaving() ? "saving..." : "save"}906                  </button>907                </Show>908              </div>909            </div>910          </Show>911912          <p class="peach-settings-sub-label">913            CANARY ENTRIES914            <Show when={!isPro()}>915              <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>916            </Show>917          </p>918          <div class="peach-settings-row peach-settings-row--top">919            <div class="peach-settings-row-main">920              <span>921                Canary entries922                <Show when={!isPro()}>923                  <span class="peach-pro-badge">Pro</span>924                </Show>925              </span>926              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">927                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.928              </span>929            </div>930            <span class="peach-settings-value" style="flex-shrink:0">931              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}932            </span>933          </div>934          <Show when={canaryEntries().length > 0}>935            <For each={canaryEntries()}>936              {(entry) => (937                <div class="peach-settings-row peach-settings-row--meta">938                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>939                  <button940                    class="peach-btn peach-btn--subtle peach-btn--sm"941                    onClick={() => void handleTestCanary(entry)}942                    disabled={canaryTestingId() === entry.id}943                    style="flex-shrink:0"944                  >945                    {canaryTestingId() === entry.id946                      ? "sending…"947                      : canaryTestResult()?.id === entry.id948                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")949                        : "test"}950                  </button>951                </div>952              )}953            </For>954          </Show>955956          <p class="peach-settings-sub-label">PASSKEYS</p>957          <div class="peach-settings-row peach-settings-row--top">958            <div class="peach-settings-row-main">959              <span>Intercept passkey requests</span>960              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">961                Peach can handle passkey sign-in and creation on supported Chromium browsers.962              </span>963            </div>964            <button965              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}966              onClick={() => void handleTogglePasskeys()}967              aria-label="Toggle passkey interception"968              disabled={!passkeyState()?.supported || passkeySaving()}969            >970              <span class="peach-toggle-knob"></span>971            </button>972          </div>973          <div class="peach-settings-row peach-settings-row--meta">974            <span>Status</span>975            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>976          </div>977          <div class="peach-settings-row peach-settings-row--meta">978            <span>Browser</span>979            <span class="peach-settings-value">980              {passkeyState()?.browserLabel ?? "Unknown"}981              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}982            </span>983          </div>984          <div class="peach-info-notice" style="margin-top:8px">985            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.986          </div>987          <Show when={passkeyState()?.conflict}>988            <div class="peach-settings-row peach-settings-row--meta">989              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>990            </div>991          </Show>992        </div>993      </div>994    );995  }996997  function renderVault() {998    return (999      <div class="peach-settings-body">1000        <div class="peach-settings-group">1001          <p class="peach-settings-sub-label">ENTRIES</p>1002          <div class="peach-settings-row peach-settings-row--meta">1003            <span>Entries</span>1004            <span class="peach-settings-value">{props.entriesCount}</span>1005          </div>1006          <div class="peach-settings-row peach-settings-row--meta">1007            <span>Duplicates</span>1008            <Show when={dedupDone() !== null} fallback={1009              <button1010                class="peach-btn peach-btn--subtle peach-btn--sm"1011                onClick={() => void handleCountDuplicates()}1012                disabled={dedupRunning()}1013              >1014                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1015              </button>1016            }>1017              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1018                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1019              </span>1020            </Show>1021          </div>1022          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1023            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1024              <div class="peach-restore-warning">1025                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1026              </div>1027              <div style="display:flex;gap:6px">1028                <button1029                  class="peach-btn peach-btn--subtle peach-btn--sm"1030                  onClick={() => setDedupCount(null)}1031                >1032                  cancel1033                </button>1034                <button1035                  class="peach-btn peach-btn--accent peach-btn--sm"1036                  onClick={() => void handleDeduplicate()}1037                  disabled={dedupRunning()}1038                >1039                  {dedupRunning() ? "removing..." : "remove duplicates"}1040                </button>1041              </div>1042            </div>1043          </Show>10441045          <p class="peach-settings-sub-label">MAINTENANCE</p>1046          <div class="peach-settings-row peach-settings-row--meta">1047            <span>Trash auto-delete</span>1048            <select1049              class="peach-settings-select"1050              value={String(props.trashRetentionDays)}1051              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1052            >1053              <option value="7">7 days</option>1054              <option value="30">30 days</option>1055              <option value="90">90 days</option>1056              <option value="0">Never</option>1057            </select>1058          </div>1059          <div class="peach-settings-row peach-settings-row--meta">1060            <span>Vault Health</span>1061            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1062              open1063            </button>1064          </div>1065          <Show when={declinedCardCount() > 0}>1066            <div class="peach-settings-row peach-settings-row--meta">1067              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1068              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1069                Clear1070              </button>1071            </div>1072          </Show>10731074          <p class="peach-settings-sub-label">DATA</p>1075          <div class="peach-settings-row peach-settings-row--meta">1076            <span>Import passwords</span>1077            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1078              open1079            </button>1080          </div>1081          <div class="peach-settings-row peach-settings-row--meta">1082            <span>Export backup</span>1083            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1084              open1085            </button>1086          </div>1087          <div class="peach-settings-row peach-settings-row--meta">1088            <span>1089              Peach Codex1090              <Show when={!isPro()}>1091                <span class="peach-pro-badge">Pro</span>1092              </Show>1093            </span>1094            <button1095              class="peach-btn peach-btn--subtle peach-btn--sm"1096              onClick={() => props.onOpenCodexGenerate?.()}1097            >1098              open1099            </button>1100          </div>1101          {/* Restore from Codex: intentionally never gated — a printed1102              Codex must remain restorable regardless of license state. */}1103          <div class="peach-settings-row peach-settings-row--meta">1104            <span>Restore from Codex</span>1105            <button1106              class="peach-btn peach-btn--subtle peach-btn--sm"1107              onClick={() => props.onOpenCodexRestore?.()}1108            >1109              open1110            </button>1111          </div>11121113          <p class="peach-settings-sub-label">VAULT HISTORY</p>1114          <Show when={historyLoading()}>1115            <p class="peach-settings-hint">Loading…</p>1116          </Show>1117          <Show when={!historyLoading() && historySnapshots().length === 0}>1118            <p class="peach-settings-hint">1119              No snapshots yet. Peach saves automatically before every change —1120              saves, syncs, and imports.1121            </p>1122          </Show>1123          <Show when={!historyLoading() && historySnapshots().length > 0}>1124            <p class="peach-settings-hint">1125              Restoring locks your vault. Unlock with the password that1126              encrypted that snapshot to confirm.1127            </p>1128            <div class="peach-history-list">1129              <For each={historySnapshots()}>1130                {(snap) => (1131                  <div class="peach-history-row">1132                    <div class="peach-history-meta">1133                      <span class="peach-history-time">1134                        {formatSnapshotTime(snap.savedAt)}1135                      </span>1136                      <span class="peach-history-sub">1137                        {[1138                          snap.entryCount !== null1139                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1140                            : null,1141                          reasonLabel(snap.reason),1142                        ]1143                          .filter(Boolean)1144                          .join(" · ")}1145                      </span>1146                    </div>1147                    <button1148                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1149                        historyRestoring() === snap.id ? " is-busy" : ""1150                      }`}1151                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1152                      disabled={historyRestoring() !== null}1153                    >1154                      <Show when={historyRestoring() === snap.id} fallback="Restore">1155                        …1156                      </Show>1157                    </button>1158                  </div>1159                )}1160              </For>1161            </div>1162            <p class="peach-history-footer">1163              {historySnapshots().length} of 10 snapshots1164            </p>1165          </Show>11661167          <button1168            class="peach-settings-advanced-toggle"1169            onClick={() => setShowVaultAdvanced((v) => !v)}1170            aria-expanded={showVaultAdvanced()}1171          >1172            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1173            <span class="peach-settings-advanced-chevron">1174              {showVaultAdvanced() ? "▲" : "▼"}1175            </span>1176          </button>11771178          <Show when={showVaultAdvanced()}>1179            <div class="peach-danger-card">1180              <div class="peach-danger-card-head">1181                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1182                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1183              </div>1184              <p class="peach-danger-card-copy">1185                Wipes the current vault and restores from a backup file.1186                Recoverable only if your backup is good.1187              </p>1188              <button1189                class="peach-danger-card-button"1190                onClick={() => props.onOpenFileImport?.()}1191              >1192                Choose backup file1193              </button>1194            </div>11951196            <Show when={props.onOpenPanic}>1197              <div class="peach-danger-card peach-danger-card--hazard">1198                <div class="peach-danger-card-head">1199                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1200                  <h3 class="peach-danger-card-title">Destroy vault</h3>1201                </div>1202                <p class="peach-danger-card-copy">1203                  Permanently wipes every entry, setting, and license on this1204                  device. There is no recovery.1205                </p>1206                <button1207                  class="peach-danger-card-button"1208                  onClick={() => props.onOpenPanic?.()}1209                >1210                  Begin destruction1211                </button>1212              </div>1213            </Show>1214          </Show>1215        </div>1216      </div>1217    );1218  }12191220  function renderAppearance() {1221    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1222      { id: "small", label: "Small" },1223      { id: "medium", label: "Default" },1224      { id: "large", label: "Large" },1225    ];1226    return (1227      <div class="peach-settings-body">1228        <div class="peach-settings-group">1229          <p class="peach-settings-sub-label">THEME</p>1230          <div class="peach-settings-row peach-settings-row--meta">1231            <span>Theme</span>1232            <button1233              class="peach-btn peach-btn--subtle peach-btn--sm"1234              onClick={() =>1235                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1236              }1237            >1238              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1239            </button>1240          </div>12411242          <p class="peach-settings-sub-label">ACCENT</p>1243          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1244            <span>Accent</span>1245            <div class="peach-accent-picker">1246              <For each={ACCENT_COLORS}>1247                {(color) => (1248                  <button1249                    class="peach-accent-swatch"1250                    classList={{ "is-active": props.accentColor === color.name }}1251                    style={{ background: color.hex }}1252                    onClick={() => void props.onAccentChange(color.name)}1253                    aria-label={color.name}1254                    title={color.name}1255                  />1256                )}1257              </For>1258            </div>1259          </div>12601261          <p class="peach-settings-sub-label">TEXT SIZE</p>1262          <div class="peach-settings-row peach-settings-row--meta">1263            <span>Size</span>1264            <div class="peach-pill-group">1265              <For each={sizeOptions}>1266                {(opt) => (1267                  <button1268                    class="peach-btn peach-btn--ghost peach-btn--sm"1269                    classList={{ "is-active": props.textSize === opt.id }}1270                    onClick={() => void props.onTextSizeChange(opt.id)}1271                  >1272                    {opt.label}1273                  </button>1274                )}1275              </For>1276            </div>1277          </div>12781279          <p class="peach-settings-sub-label">MOTION</p>1280          <div class="peach-settings-row peach-settings-row--meta">1281            <span>Reduce motion</span>1282            <button1283              class="peach-btn peach-btn--subtle peach-btn--sm"1284              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1285            >1286              {props.reduceMotion ? "On" : "Off"}1287            </button>1288          </div>1289        </div>1290      </div>1291    );1292  }12931294  function renderShortcuts() {1295    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1296    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12971298    function startCapture(action: ShortcutAction) {1299      setConflictMsg(null);1300      setCapturingAction(action);1301    }1302    function cancelCapture() {1303      setCapturingAction(null);1304      setConflictMsg(null);1305    }1306    function commitBinding(action: ShortcutAction, binding: Keybinding) {1307      const conflict = findConflict(props.shortcuts, binding, action);1308      if (conflict) {1309        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1310        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1311        return;1312      }1313      const next = { ...props.shortcuts, [action]: binding };1314      setCapturingAction(null);1315      setConflictMsg(null);1316      void props.onShortcutsChange(next);1317    }1318    function resetAction(action: ShortcutAction) {1319      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1320      void props.onShortcutsChange(next);1321    }1322    function resetAll() {1323      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1324    }13251326    // Capture-mode key handler — wired via ref onto the chip element so it1327    // catches the next non-modifier keypress and ignores other bindings.1328    function handleCaptureKey(e: KeyboardEvent) {1329      e.preventDefault();1330      e.stopPropagation();1331      if (e.key === "Escape") { cancelCapture(); return; }1332      const binding = keybindingFromEvent(e);1333      if (!binding) return; // bare modifier — keep waiting1334      const action = capturingAction();1335      if (action) commitBinding(action, binding);1336    }13371338    return (1339      <div class="peach-settings-body">1340        <div class="peach-settings-group peach-shortcuts-group">1341          <p class="peach-settings-sub-label">ENTRY VIEW</p>1342          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1343            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1344          </For>1345        </div>13461347        <div class="peach-settings-group peach-shortcuts-group">1348          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1349          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1350            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1351          </For>1352        </div>13531354        <Show when={conflictMsg()}>1355          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1356        </Show>13571358        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1359          <div class="peach-settings-row peach-settings-row--meta">1360            <span>Inline autofill on page</span>1361            <a1362              class="peach-settings-link"1363              href="chrome://extensions/shortcuts"1364              onClick={(e) => {1365                e.preventDefault();1366                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1367              }}1368            >1369              ⌘⇧P — customize ↗1370            </a>1371          </div>1372          <button1373            class="peach-btn peach-btn--subtle peach-btn--sm"1374            style="align-self:flex-start"1375            onClick={resetAll}1376          >1377            Reset all to defaults1378          </button>1379        </div>1380      </div>1381    );13821383    function renderRow(1384      desc: typeof SHORTCUT_DESCRIPTORS[number],1385      capturing: () => ShortcutAction | null,1386      onStart: (a: ShortcutAction) => void,1387      onCancel: () => void,1388      onReset: (a: ShortcutAction) => void,1389      onKey: (e: KeyboardEvent) => void,1390    ) {1391      const isActive = () => capturing() === desc.action;1392      const binding = () => props.shortcuts[desc.action];1393      const isCustom = () => {1394        const b = binding();1395        const d = DEFAULT_BINDINGS[desc.action];1396        return b.key !== d.key1397          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1398          || Boolean(b.meta)  !== Boolean(d.meta)1399          || Boolean(b.shift) !== Boolean(d.shift)1400          || Boolean(b.alt)   !== Boolean(d.alt);1401      };1402      return (1403        <div class="peach-shortcut-row">1404          <span class="peach-shortcut-row-label">{desc.label}</span>1405          <button1406            class="peach-shortcut-chip"1407            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1408            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1409            onKeyDown={(e) => { if (isActive()) onKey(e); }}1410            ref={(el) => {1411              // Auto-focus when entering capture mode so the keydown listener catches the next press.1412              if (el) {1413                queueMicrotask(() => { if (isActive()) el.focus(); });1414              }1415            }}1416          >1417            {isActive() ? "Press a key…" : formatKeybinding(binding())}1418          </button>1419          <Show when={isCustom() && !isActive()}>1420            <button1421              class="peach-shortcut-reset"1422              title="Reset to default"1423              onClick={() => onReset(desc.action)}1424            >1425              ↺1426            </button>1427          </Show>1428        </div>1429      );1430    }1431  }14321433  const PRO_FEATURES: Array<{1434    feature: ProFeature;1435    name: string;1436    desc: string;1437  }> = [1438    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1439    { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1440    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1441    { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1442    { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1443    { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1444  ];14451446  const PRO_SECURITY_FEATURES: Array<{1447    feature: ProFeature;1448    name: string;1449    desc: string;1450  }> = [1451    { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1452    { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1453  ];14541455  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1456  const [showKey, setShowKey] = createSignal(false);14571458  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1459  const [deactivating, setDeactivating] = createSignal(false);14601461  async function handleDeactivateLicense() {1462    setDeactivating(true);1463    await sendMessage({ type: "DEACTIVATE_LICENSE" });1464    setLicenseInfo(null);1465    await props.onLicenseChange?.();1466    setDeactivating(false);1467  }14681469  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1470    setCanaryTestingId(entry.id);1471    setCanaryTestResult(null);1472    const res = await sendMessage({1473      type: "FIRE_CANARY_PING",1474      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1475    }) as { ok: boolean };1476    setCanaryTestResult({ id: entry.id, ok: res.ok });1477    setCanaryTestingId(null);1478    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1479  }14801481  function renderLicense() {1482    async function onActivate() {1483      await handleActivateLicense();1484      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1485        await props.onLicenseChange?.();1486      }1487    }14881489    return (1490      <div class="peach-settings-body">1491        <Show1492          when={isPro()}1493          fallback={1494                <div class="peach-pro-upgrade">1495                  <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>14961497                  <h2 class="peach-pro-headline">1498                    Every<em>thing,</em><br />every<em>where.</em>1499                  </h2>15001501                  <p class="peach-pro-subtitle">1502                    One key unlocks every Pro feature — for the lifetime of the app.1503                  </p>15041505                  <div class="peach-pro-grid">1506                    <For each={PRO_FEATURES}>1507                      {(item, i) => (1508                        <div1509                          class="peach-pro-grid-item"1510                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1511                        >1512                          <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1513                          <span class="peach-pro-grid-name">{item.name}</span>1514                          <span class="peach-pro-grid-desc">{item.desc}</span>1515                        </div>1516                      )}1517                    </For>1518                  </div>15191520                  <p class="peach-pro-section-label">ADVANCED SECURITY</p>1521                  <div class="peach-pro-grid">1522                    <For each={PRO_SECURITY_FEATURES}>1523                      {(item, i) => (1524                        <div1525                          class="peach-pro-grid-item"1526                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1527                        >1528                          <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1529                          <span class="peach-pro-grid-name">{item.name}</span>1530                          <span class="peach-pro-grid-desc">{item.desc}</span>1531                        </div>1532                      )}1533                    </For>1534                  </div>15351536                  <div class="peach-pro-upgrade-actions">1537                    <p class="peach-pro-pricing">1538                      $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1539                    </p>1540                    <a1541                      href="https://peachpasswords.com/#pricing"1542                      target="_blank"1543                      rel="noopener noreferrer"1544                      class="peach-pro-upgrade-cta"1545                    >1546                      Get Peach Pro1547                    </a>1548                    <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1549                    <div class="peach-pro-key-inline">1550                      <input1551                        class="peach-pro-key-inline-input"1552                        type="text"1553                        placeholder="PEACH-XXXX-XXXX-XXXX"1554                        value={licenseKeyInput()}1555                        onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1556                        onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1557                        autocomplete="off"1558                        spellcheck={false}1559                      />1560                      <button1561                        class="peach-pro-key-apply"1562                        onClick={() => void onActivate()}1563                        disabled={activatingLicense() || !licenseKeyInput().trim()}1564                      >1565                        {activatingLicense() ? "…" : "APPLY"}1566                      </button>1567                    </div>1568                    <Show when={licenseError()}>1569                      <p class="peach-pro-key-error">{licenseError()}</p>1570                    </Show>1571                  </div>1572                </div>1573          }1574        >1575          <div class="peach-pro-active">1576            <div class="peach-pro-active-hero">1577              <div class="peach-pro-active-mark-wrap">1578                <PeachMark class="peach-pro-active-mark" />1579              </div>1580              <h2 class="peach-pro-active-title">Peach Pro</h2>1581              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1582            </div>15831584            <Show when={licenseKey()}>1585              <div class="peach-settings-license">1586                <span class="peach-settings-label">LICENSE KEY</span>1587                <div class="peach-settings-license-row">1588                  <Show1589                    when={showKey()}1590                    fallback={1591                      <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1592                        ••••••••••••••••••••••••••••1593                      </span>1594                    }1595                  >1596                    <span class="peach-settings-license-key">{licenseKey()!}</span>1597                  </Show>1598                  <div class="peach-settings-license-actions">1599                    <button1600                      class="peach-settings-license-copy"1601                      title="Copy license key"1602                      onClick={() => copyLicenseKey(licenseKey()!)}1603                    >1604                      {copied() ? "✓" : "copy"}1605                    </button>1606                    <button1607                      class="peach-settings-license-toggle"1608                      aria-label={showKey() ? "Hide license key" : "Reveal license key"}1609                      title={showKey() ? "Hide" : "Reveal"}1610                      onClick={() => setShowKey(v => !v)}1611                    >1612                      <Show1613                        when={showKey()}1614                        fallback={1615                          <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1616                            <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1617                            <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1618                          </svg>1619                        }1620                      >1621                        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1622                          <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1623                          <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1624                          <line x1="2" y1="2" x2="12" y2="12" />1625                        </svg>1626                      </Show>1627                    </button>1628                  </div>1629                </div>1630              </div>1631            </Show>16321633            <div class="peach-pro-active-features">1634              <For each={PRO_FEATURES}>1635                {(item) => (1636                  <div class="peach-pro-active-feature">1637                    <span class="peach-pro-active-feature-mark">✦</span>1638                    <span class="peach-pro-active-feature-name">{item.name}</span>1639                  </div>1640                )}1641              </For>1642            </div>16431644            <button1645              class="peach-pro-deactivate"1646              onClick={() => void handleDeactivateLicense()}1647              disabled={deactivating()}1648            >1649              {deactivating() ? "Deactivating…" : "Deactivate"}1650            </button>1651          </div>1652        </Show>1653      </div>1654    );1655  }16561657  function renderFeedback() {1658    return (1659      <div class="peach-settings-body">1660        <div class="peach-settings-group peach-feedback-group">1661          <p class="peach-feedback-intro">1662            Help us improve Peach. Every report goes directly to our inbox.1663          </p>16641665          <p class="peach-settings-sub-label">TYPE</p>1666          <div class="peach-feedback-chips">1667            <For each={FEEDBACK_CATEGORIES}>1668              {(cat) => (1669                <button1670                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1671                  onClick={() => setFbCategory(cat)}1672                >1673                  {cat}1674                </button>1675              )}1676            </For>1677          </div>16781679          <p class="peach-settings-sub-label">MESSAGE</p>1680          <textarea1681            class="peach-feedback-textarea"1682            placeholder="Describe the issue or idea..."1683            value={fbMessage()}1684            onInput={(e) => setFbMessage(e.currentTarget.value)}1685          />16861687          <p class="peach-feedback-meta">1688            Version {version} · will be included automatically1689          </p>16901691          <Show when={fbResult() === "success"}>1692            <p class="peach-feedback-result peach-feedback-result--ok">1693              Sent — thank you1694            </p>1695          </Show>1696          <Show when={fbResult() === "error"}>1697            <p class="peach-feedback-result peach-feedback-result--err">1698              Failed to send — try again1699            </p>1700          </Show>17011702          <button1703            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1704            onClick={() => void handleSubmitFeedback()}1705            disabled={fbSubmitting() || !fbMessage().trim()}1706          >1707            {fbSubmitting() ? "Sending..." : "Send feedback"}1708          </button>17091710          <p class="peach-feedback-anon">1711            Sent anonymously · no account required1712          </p>1713        </div>1714      </div>1715    );1716  }17171718  return (1719    <Show1720      when={showNewRecoveryWords()}1721      fallback={1722        <div class="peach-overlay-view ov-enter-right" style="display:flex">1723          <div class="peach-overlay-header">1724            <button1725              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1726              onClick={handleHeaderBack}1727              aria-label="Back"1728            >1729              <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1730                <path d="M6 1L1 6L6 11" />1731              </svg>1732            </button>1733            <span class="peach-overlay-title">{currentTitle()}</span>1734            <div class="peach-overlay-spacer"></div>1735          </div>17361737          <Switch fallback={renderHome()}>1738            <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1739            <Match when={activeSection() === "vault"}>{renderVault()}</Match>1740            <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1741            <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1742            <Match when={activeSection() === "license"}>{renderLicense()}</Match>1743            <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1744          </Switch>1745        </div>1746      }1747    >1748      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1749    </Show>1750  );1751}