Settings.tsx

66.4 KB · raw

1import { createEffect, 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  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";40  textSize: "small" | "medium" | "large";41  reduceMotion: boolean;42  isPro?: boolean;43  initialSection?: string | null;44  upgradeHighlight?: ProFeature | null;45  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;46  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;47  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;48  onTrashRetentionChange: (days: number) => void | Promise<void>;49  onThemeChange: (theme: "dark" | "light") => void;50  onAccentChange: (name: AccentColorName) => void | Promise<void>;51  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;52  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54  shortcuts: Record<ShortcutAction, Keybinding>;55  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56  onLicenseChange?: () => void | Promise<void>;57  onBack: () => void;58  onImport: () => void;59  onExport: () => void;60  onBreachCheckDone: () => void;61  onOpenHealth?: () => void;62  onOpenSyncPairing?: () => void;63  onOpenFileImport?: () => void;64  onOpenPanic?: () => void;65  onOpenCodexGenerate?: () => void;66}6768type SectionName =69  | "security"70  | "vault"71  | "appearance"72  | "shortcuts"73  | "license"74  | "feedback";7576const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7778function formatActivatedSince(issued: string | undefined): string {79  if (!issued) return "Active";80  try {81    const d = new Date(issued);82    if (Number.isNaN(d.getTime())) return "Active";83    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;84  } catch {85    return "Active";86  }87}8889function isSectionName(value: string): value is SectionName {90  return (91    value === "security" ||92    value === "vault" ||93    value === "appearance" ||94    value === "shortcuts" ||95    value === "license" ||96    value === "feedback"97  );98}99100export function SettingsView(props: SettingsProps) {101  const manifest = chrome.runtime.getManifest();102  const version = manifest.version;103  const isAndroid = (manifest as any).version_name === 'mobile';104  const initial = props.initialSection;105  const [activeSection, setActiveSection] = createSignal<SectionName | null>(106    initial && isSectionName(initial) ? (initial as SectionName) : null107  );108109  const [checkingBreaches, setCheckingBreaches] = createSignal(false);110  const [breachResult, setBreachResult] = createSignal<string | null>(null);111  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);112  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");113  const [licenseError, setLicenseError] = createSignal<string | null>(null);114  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);115  const [activatingLicense, setActivatingLicense] = createSignal(false);116117  const isPro = () => props.isPro ?? false;118  const [biometricSupported, setBiometricSupported] = createSignal(false);119  const [biometricStored, setBiometricStored] = createSignal(false);120  const [biometricBusy, setBiometricBusy] = createSignal(false);121  const [biometricError, setBiometricError] = createSignal<string | null>(null);122  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);123  const [passkeySaving, setPasskeySaving] = createSignal(false);124125  const [dedupCount, setDedupCount] = createSignal<number | null>(null);126  const [dedupRunning, setDedupRunning] = createSignal(false);127  const [dedupDone, setDedupDone] = createSignal<number | null>(null);128129  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);130131  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted132  // BEFORE every real-mode vault write so the user can roll back if a sync133  // or import overwrites the wrong state.134  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);135  const [historyLoading, setHistoryLoading] = createSignal(false);136  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);137138  async function loadVaultHistory() {139    setHistoryLoading(true);140    try {141      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });142      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];143      setHistorySnapshots(list);144    } finally {145      setHistoryLoading(false);146    }147  }148149  async function handleRestoreVaultSnapshot(id: string) {150    if (historyRestoring()) return;151    setHistoryRestoring(id);152    const res = await sendMessage({153      type: "RESTORE_VAULT_SNAPSHOT",154      payload: { snapshotId: id },155    });156    setHistoryRestoring(null);157    if ((res as { ok: boolean }).ok) {158      // Vault is now locked — let the parent close Settings; App.tsx's159      // VAULT_LOCKED listener will route back to the lock screen.160      props.onBack();161    }162  }163164  function formatSnapshotTime(savedAt: number): string {165    const diff = Date.now() - savedAt;166    if (diff < 60_000) return "Just now";167    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;168    const d = new Date(savedAt);169    const isToday = new Date().toDateString() === d.toDateString();170    const isYest =171      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();172    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });173    if (isToday) return `Today ${t}`;174    if (isYest) return `Yesterday ${t}`;175    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;176  }177178  function reasonLabel(reason: VaultSnapshot["reason"]): string {179    return (180      {181        local_save: "saved",182        sync_received: "synced",183        import: "imported",184        restore: "restored",185      } as const186    )[reason] ?? reason;187  }188189  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);190191  const [showChangePw, setShowChangePw] = createSignal(false);192  const [changePwCurrent, setChangePwCurrent] = createSignal("");193  const [changePwNew, setChangePwNew] = createSignal("");194  const [changePwConfirm, setChangePwConfirm] = createSignal("");195  const [changePwSaving, setChangePwSaving] = createSignal(false);196  const [changePwError, setChangePwError] = createSignal<string | null>(null);197  const [changePwSuccess, setChangePwSuccess] = createSignal(false);198  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);199200  const [duressEnabled, setDuressEnabled] = createSignal(false);201  const [showDuressSetup, setShowDuressSetup] = createSignal(false);202  const [duressPassword, setDuressPassword] = createSignal("");203  const [duressConfirm, setDuressConfirm] = createSignal("");204  const [duressMainPassword, setDuressMainPassword] = createSignal("");205  const [duressSaving, setDuressSaving] = createSignal(false);206  const [duressError, setDuressError] = createSignal<string | null>(null);207  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);208209  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);210211  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);212  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);213214  const [fbCategory, setFbCategory] = createSignal("Bug report");215  const [fbMessage, setFbMessage] = createSignal("");216  const [fbSubmitting, setFbSubmitting] = createSignal(false);217  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);218219  onMount(async () => {220    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([221      sendMessage({ type: "GET_LICENSE" }),222      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),223      sendMessage({ type: "IS_DURESS_ENABLED" }),224      sendMessage({ type: "GET_SETTINGS" }),225      sendMessage({ type: "GET_LICENSE_KEY" }),226    ]);227228    // Vault history loads in the background — the Vault sub-view picks it up229    // when the user navigates in. Loading it lazily keeps Settings boot fast.230    void loadVaultHistory();231232    if ((licenseRes as { ok: boolean }).ok) {233      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);234    }235236    if ((passkeyRes as { ok: boolean }).ok) {237      setPasskeyState(238        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null239      );240    }241242    if ((duressRes as { ok: boolean }).ok) {243      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);244    }245246    if ((settingsRes as { ok: boolean }).ok) {247      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;248      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);249    }250251    if ((keyRes as { ok: boolean }).ok) {252      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);253    }254255    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });256    if ((entriesRes as { ok: boolean }).ok) {257      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];258      setCanaryEntries(259        entries260          .filter((e) => e.canary?.webhookUrl)261          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))262      );263    }264265266    if (isNativePlatform()) {267      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");268      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");269      try {270        if (biometric) {271          const a = await biometric.isAvailable();272          setBiometricSupported(a.available);273        }274        if (keystore) {275          const k = await keystore.hasMasterKey();276          setBiometricStored(k.exists);277        }278      } catch { /* missing plugins — leave defaults */ }279    }280  });281282  async function handleEnableBiometrics() {283    setBiometricBusy(true);284    setBiometricError(null);285    try {286      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {287        ok: boolean;288        data?: { masterKeyHex: string };289        error?: string;290      };291      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {292        setBiometricError(keyRes.error === "vault_locked"293          ? "unlock the vault first"294          : "couldn't read derived key");295        return;296      }297      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");298      if (!keystore) {299        setBiometricError("biometric plugin unavailable");300        return;301      }302      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });303      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });304      setBiometricStored(true);305    } catch (e) {306      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");307    } finally {308      setBiometricBusy(false);309    }310  }311312  async function handleDisableBiometrics() {313    setBiometricBusy(true);314    setBiometricError(null);315    try {316      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");317      if (keystore) await keystore.clearMasterKey();318      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });319      setBiometricStored(false);320    } catch (e) {321      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");322    } finally {323      setBiometricBusy(false);324    }325  }326327  async function handleChangePassword() {328    setChangePwError(null);329    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }330    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }331    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }332    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }333    setChangePwSaving(true);334    const res = await sendMessage({335      type: "CHANGE_PASSWORD",336      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },337    }) as { ok: boolean; error?: string };338    if (res.ok) {339      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");340      setShowChangePw(false);341      setShowNewRecoveryWords(true);342    } else {343      setChangePwError(res.error ?? "failed to change password");344    }345    setChangePwSaving(false);346  }347348  const [copied, setCopied] = createSignal(false);349350  async function copyLicenseKey(key: string) {351    await navigator.clipboard.writeText(key);352    setCopied(true);353    setTimeout(() => setCopied(false), 2000);354  }355356  async function handleActivateLicense() {357    const key = licenseKeyInput().trim();358    if (!key) return;359    setActivatingLicense(true);360    setLicenseError(null);361    setLicenseSuccess(null);362    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });363    setActivatingLicense(false);364    if ((res as { ok: boolean }).ok) {365      const data = (res as { ok: boolean; data?: LicenseInfo }).data;366      if (data?.valid) {367        setLicenseInfo(data);368        setLicenseKey(key);369        setLicenseSuccess("License activated!");370        setLicenseKeyInput("");371      } else {372        setLicenseError("Invalid license key");373      }374    } else {375      setLicenseError("Activation failed");376    }377  }378379  async function handleTogglePasskeys() {380    const current = passkeyState();381    if (!current || !current.supported) return;382    setPasskeySaving(true);383    const res = await sendMessage({384      type: "SET_PASSKEY_PROVIDER_ENABLED",385      payload: { enabled: !current.enabled },386    });387    setPasskeySaving(false);388    if ((res as { ok: boolean }).ok) {389      setPasskeyState(390        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null391      );392    }393  }394395  async function handleCheckAllBreaches() {396    setCheckingBreaches(true);397    setBreachResult(null);398    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });399    setCheckingBreaches(false);400    if ((res as { ok: boolean }).ok) {401      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};402      const breached = Object.values(data).filter((count) => count > 0).length;403      setBreachResult(404        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"405      );406      props.onBreachCheckDone();407    }408  }409410411  async function handleSetupDuress() {412    const mainPw = duressMainPassword().trim();413    const duressPw = duressPassword().trim();414    const confirmPw = duressConfirm().trim();415416    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }417    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }418    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }419    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }420421    setDuressSaving(true);422    setDuressError(null);423424    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };425    if (!verifyRes.ok || !verifyRes.data) {426      setDuressError("incorrect main password");427      setDuressSaving(false);428      return;429    }430431    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };432    setDuressSaving(false);433    if (res.ok) {434      setDuressEnabled(true);435      setShowDuressSetup(false);436      setDuressPassword("");437      setDuressConfirm("");438      setDuressMainPassword("");439      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");440      setTimeout(() => setDuressSuccess(null), 5000);441    } else {442      setDuressError(res.error ?? "failed to set duress password");443    }444  }445446  async function handleClearDeclinedCards() {447    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });448    setDeclinedCardCount(0);449  }450451  async function handleCountDuplicates() {452    setDedupRunning(true);453    setDedupDone(null);454    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };455    setDedupRunning(false);456    if (res.ok) setDedupCount(res.data ?? 0);457  }458459  async function handleDeduplicate() {460    setDedupRunning(true);461    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };462    setDedupRunning(false);463    if (res.ok) {464      setDedupDone(res.data ?? 0);465      setDedupCount(0);466    }467  }468469  async function handleDisableDuress() {470    const mainPw = duressMainPassword().trim();471    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }472473    setDuressSaving(true);474    setDuressError(null);475476    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };477    if (!verifyRes.ok || !verifyRes.data) {478      setDuressError("incorrect main password");479      setDuressSaving(false);480      return;481    }482483    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };484    setDuressSaving(false);485    if (res.ok) {486      setDuressEnabled(false);487      setShowDuressSetup(false);488      setDuressMainPassword("");489      setDuressSuccess("Duress password disabled.");490      setTimeout(() => setDuressSuccess(null), 4000);491    } else {492      setDuressError(res.error ?? "failed to disable duress password");493    }494  }495496  async function handleSubmitFeedback() {497    if (!fbMessage().trim() || fbSubmitting()) return;498    setFbSubmitting(true);499    setFbResult(null);500    const res = await sendMessage({501      type: "SUBMIT_FEEDBACK",502      payload: {503        category: fbCategory(),504        message: fbMessage().trim(),505        version,506        browser: navigator.userAgent,507      },508    }) as { ok: boolean; error?: string };509    setFbSubmitting(false);510    if (res.ok) {511      setFbResult("success");512      setFbMessage("");513      setTimeout(() => setFbResult(null), 4000);514    } else {515      setFbResult("error");516    }517  }518519  function handleHeaderBack() {520    if (activeSection() !== null) {521      setActiveSection(null);522    } else {523      props.onBack();524    }525  }526527  function currentTitle(): string {528    const s = activeSection();529    if (s === null) return "settings";530    if (s === "license") return "account";531    return s;532  }533534  function renderCatRow(opts: {535    section: SectionName;536    name: string;537    desc: string;538    icon: () => any;539    pro?: boolean;540  }) {541    return (542      <button543        class="peach-settings-cat-row"544        onClick={() => setActiveSection(opts.section)}545      >546        <div class="peach-settings-cat-icon">{opts.icon()}</div>547        <div class="peach-settings-cat-content">548          <span class="peach-settings-cat-name">549            {opts.name}550            <Show when={opts.pro && !isPro()}>551              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>552            </Show>553          </span>554          <span class="peach-settings-cat-desc">{opts.desc}</span>555        </div>556        <svg557          class="peach-settings-cat-chevron"558          width="6" height="10" viewBox="0 0 6 10" fill="none"559          stroke="currentColor" stroke-width="1.1"560          stroke-linecap="round" stroke-linejoin="round"561        >562          <path d="M1 1l4 4-4 4" />563        </svg>564      </button>565    );566  }567568  const iconShield = () => (569    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">570      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>571    </svg>572  );573  const iconKey = () => (574    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">575      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>576    </svg>577  );578  const iconLock = () => (579    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">580      <rect x="3" y="7" width="10" height="7" rx="1.5"/>581      <path d="M6 7V5a3 3 0 016 0v2"/>582    </svg>583  );584  const iconSync = () => (585    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">586      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>587      <path d="M14 6l-2 2-2-2"/>588      <path d="M2 10l2-2 2 2"/>589    </svg>590  );591  const iconSun = () => (592    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">593      <circle cx="8" cy="8" r="3"/>594      <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"/>595    </svg>596  );597  const iconKeys = () => (598    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">599      <rect x="2" y="5" width="5" height="4" rx="1"/>600      <rect x="9" y="7" width="5" height="4" rx="1"/>601      <path d="M7 7h2m-2 2h2"/>602    </svg>603  );604  const iconBubble = () => (605    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">606      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>607    </svg>608  );609610  function renderHome() {611    return (612      <div class="peach-settings-body">613        <p class="peach-settings-nav-label">PROTECTION</p>614        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}615        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}616617        <p class="peach-settings-nav-label">VAULT</p>618        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}619        <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>620          <div class="peach-settings-cat-icon">{iconSync()}</div>621          <div class="peach-settings-cat-content">622            <span class="peach-settings-cat-name">Sync</span>623            <span class="peach-settings-cat-desc">Pair and sync across devices</span>624          </div>625          <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">626            <path d="M1 1l4 4-4 4" />627          </svg>628        </button>629630        <p class="peach-settings-nav-label">PREFERENCES</p>631        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}632        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}633634        <p class="peach-settings-nav-label">SUPPORT</p>635        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}636637        <div class="peach-settings-footer">638          <span>Peach {version}</span>639          <span class="peach-divider"></span>640          <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>641        </div>642      </div>643    );644  }645646  function renderSecurity() {647    return (648      <div class="peach-settings-body">649        <div class="peach-settings-group">650          <p class="peach-settings-sub-label">LOCKING</p>651          <div class="peach-settings-row">652            <span>Lock on close</span>653            <button654              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}655              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}656              aria-label="Toggle lock on close"657            >658              <span class="peach-toggle-knob"></span>659            </button>660          </div>661          <div class="peach-settings-row">662            <span>Auto-lock after</span>663            <select664              class="peach-settings-select"665              value={String(props.autoLockMinutes)}666              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}667            >668              <option value="1">1 minute</option>669              <option value="5">5 minutes</option>670              <option value="15">15 minutes</option>671              <option value="30">30 minutes</option>672              <option value="0">Never</option>673            </select>674          </div>675676          <Show when={isNativePlatform() && biometricSupported()}>677            <p class="peach-settings-sub-label">BIOMETRICS</p>678            <div class="peach-settings-row">679              <span>Unlock with biometrics</span>680              <button681                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}682                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}683                disabled={biometricBusy()}684                aria-label="Toggle biometric unlock"685              >686                <span class="peach-toggle-knob"></span>687              </button>688            </div>689            <Show when={biometricError()}>690              <div class="peach-settings-row peach-settings-row--meta">691                <span class="peach-hint peach-hint-error">{biometricError()}</span>692              </div>693            </Show>694          </Show>695696          <p class="peach-settings-sub-label">BREACH PROTECTION</p>697          <div class="peach-settings-row">698            <span>Breach monitoring</span>699            <button700              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}701              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}702              aria-label="Toggle breach monitoring"703            >704              <span class="peach-toggle-knob"></span>705            </button>706          </div>707          <div class="peach-settings-row peach-settings-row--meta">708            <span>Check all passwords</span>709            <button710              class="peach-btn peach-btn--subtle peach-btn--sm"711              onClick={() => void handleCheckAllBreaches()}712              disabled={checkingBreaches()}713            >714              {checkingBreaches() ? "checking..." : "run"}715            </button>716          </div>717          <Show when={breachResult()}>718            <div class="peach-settings-row peach-settings-row--meta">719              <span>Last check</span>720              <span class="peach-settings-value">{breachResult()}</span>721            </div>722          </Show>723724          <p class="peach-settings-sub-label">MASTER PASSWORD</p>725          <div class="peach-settings-row peach-settings-row--meta">726            <span>Master password</span>727            <button728              class="peach-btn peach-btn--subtle peach-btn--sm"729              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}730            >731              {showChangePw() ? "cancel" : "change"}732            </button>733          </div>734          <Show when={showChangePw()}>735            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">736              <input737                class="peach-settings-input"738                type="password"739                placeholder="current password"740                value={changePwCurrent()}741                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}742                autocomplete="current-password"743              />744              <input745                class="peach-settings-input"746                type="password"747                placeholder="new password (min 12 chars)"748                value={changePwNew()}749                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}750                autocomplete="new-password"751              />752              <input753                class="peach-settings-input"754                type="password"755                placeholder="confirm new password"756                value={changePwConfirm()}757                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}758                autocomplete="new-password"759                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}760              />761              <Show when={changePwError()}>762                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>763              </Show>764              <Show when={changePwSuccess()}>765                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>766              </Show>767              <button768                class="peach-btn peach-btn--accent peach-btn--sm"769                style="align-self:flex-start"770                onClick={() => void handleChangePassword()}771                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}772              >773                {changePwSaving() ? "saving…" : "save new password"}774              </button>775            </div>776          </Show>777778          <p class="peach-settings-sub-label">DURESS PASSWORD</p>779          <div class="peach-settings-row peach-settings-row--top">780            <div class="peach-settings-row-main">781              <span>Duress password</span>782              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">783                If entered under coercion, shows a decoy empty vault instead of your real one.784              </span>785            </div>786            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>787              {duressEnabled() ? "on" : "off"}788            </div>789          </div>790791          <Show when={duressEnabled() && !showDuressSetup()}>792            <div class="peach-settings-row peach-settings-row--meta">793              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">794                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.795              </span>796            </div>797          </Show>798799          <Show when={duressSuccess()}>800            <div class="peach-settings-row peach-settings-row--meta">801              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>802            </div>803          </Show>804805          <Show when={!showDuressSetup()}>806            <div class="peach-settings-row peach-settings-row--meta">807              <button808                class="peach-btn peach-btn--subtle peach-btn--sm"809                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}810              >811                {duressEnabled() ? "Change / disable" : "Set up"}812              </button>813            </div>814          </Show>815816          <Show when={showDuressSetup()}>817            <div class="peach-duress-form">818              <label class="peach-label">main password (to confirm)</label>819              <input820                class="peach-settings-input"821                type="password"822                placeholder="your current master password"823                autocomplete="current-password"824                value={duressMainPassword()}825                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}826              />827              <Show when={!duressEnabled()}>828                <label class="peach-label">new duress password</label>829                <input830                  class="peach-settings-input"831                  type="password"832                  placeholder="duress password (min 8 chars)"833                  autocomplete="new-password"834                  value={duressPassword()}835                  onInput={(e) => setDuressPassword(e.currentTarget.value)}836                />837                <label class="peach-label">confirm duress password</label>838                <input839                  class="peach-settings-input"840                  type="password"841                  placeholder="confirm duress password"842                  autocomplete="new-password"843                  value={duressConfirm()}844                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}845                />846              </Show>847848              <Show when={duressError()}>849                <p class="peach-hint peach-hint-error">{duressError()}</p>850              </Show>851852              <div class="peach-duress-actions">853                <button854                  class="peach-btn peach-btn--subtle peach-btn--sm"855                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}856                >857                  cancel858                </button>859                <Show when={duressEnabled()}>860                  <button861                    class="peach-btn peach-btn--ghost peach-btn--sm"862                    style="color:var(--peach-breach)"863                    onClick={() => void handleDisableDuress()}864                    disabled={duressSaving()}865                  >866                    {duressSaving() ? "disabling..." : "disable duress"}867                  </button>868                </Show>869                <Show when={!duressEnabled()}>870                  <button871                    class="peach-btn peach-btn--accent peach-btn--sm"872                    onClick={() => void handleSetupDuress()}873                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}874                  >875                    {duressSaving() ? "saving..." : "save"}876                  </button>877                </Show>878              </div>879            </div>880          </Show>881882          <p class="peach-settings-sub-label">CANARY ENTRIES</p>883          <div class="peach-settings-row peach-settings-row--top">884            <div class="peach-settings-row-main">885              <span>Canary entries</span>886              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">887                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.888              </span>889            </div>890            <span class="peach-settings-value" style="flex-shrink:0">891              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}892            </span>893          </div>894          <Show when={canaryEntries().length > 0}>895            <For each={canaryEntries()}>896              {(entry) => (897                <div class="peach-settings-row peach-settings-row--meta">898                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>899                  <button900                    class="peach-btn peach-btn--subtle peach-btn--sm"901                    onClick={() => void handleTestCanary(entry)}902                    disabled={canaryTestingId() === entry.id}903                    style="flex-shrink:0"904                  >905                    {canaryTestingId() === entry.id906                      ? "sending…"907                      : canaryTestResult()?.id === entry.id908                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")909                        : "test"}910                  </button>911                </div>912              )}913            </For>914          </Show>915916          <p class="peach-settings-sub-label">PASSKEYS</p>917          <div class="peach-settings-row peach-settings-row--top">918            <div class="peach-settings-row-main">919              <span>Intercept passkey requests</span>920              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">921                Peach can handle passkey sign-in and creation on supported Chromium browsers.922              </span>923            </div>924            <button925              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}926              onClick={() => void handleTogglePasskeys()}927              aria-label="Toggle passkey interception"928              disabled={!passkeyState()?.supported || passkeySaving()}929            >930              <span class="peach-toggle-knob"></span>931            </button>932          </div>933          <div class="peach-settings-row peach-settings-row--meta">934            <span>Status</span>935            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>936          </div>937          <div class="peach-settings-row peach-settings-row--meta">938            <span>Browser</span>939            <span class="peach-settings-value">940              {passkeyState()?.browserLabel ?? "Unknown"}941              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}942            </span>943          </div>944          <div class="peach-info-notice" style="margin-top:8px">945            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.946          </div>947          <Show when={passkeyState()?.conflict}>948            <div class="peach-settings-row peach-settings-row--meta">949              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>950            </div>951          </Show>952        </div>953      </div>954    );955  }956957  function renderVault() {958    return (959      <div class="peach-settings-body">960        <div class="peach-settings-group">961          <p class="peach-settings-sub-label">ENTRIES</p>962          <div class="peach-settings-row peach-settings-row--meta">963            <span>Entries</span>964            <span class="peach-settings-value">{props.entriesCount}</span>965          </div>966          <div class="peach-settings-row peach-settings-row--meta">967            <span>Duplicates</span>968            <Show when={dedupDone() !== null} fallback={969              <button970                class="peach-btn peach-btn--subtle peach-btn--sm"971                onClick={() => void handleCountDuplicates()}972                disabled={dedupRunning()}973              >974                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}975              </button>976            }>977              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">978                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}979              </span>980            </Show>981          </div>982          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>983            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">984              <div class="peach-restore-warning">985                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.986              </div>987              <div style="display:flex;gap:6px">988                <button989                  class="peach-btn peach-btn--subtle peach-btn--sm"990                  onClick={() => setDedupCount(null)}991                >992                  cancel993                </button>994                <button995                  class="peach-btn peach-btn--accent peach-btn--sm"996                  onClick={() => void handleDeduplicate()}997                  disabled={dedupRunning()}998                >999                  {dedupRunning() ? "removing..." : "remove duplicates"}1000                </button>1001              </div>1002            </div>1003          </Show>10041005          <p class="peach-settings-sub-label">MAINTENANCE</p>1006          <div class="peach-settings-row peach-settings-row--meta">1007            <span>Trash auto-delete</span>1008            <select1009              class="peach-settings-select"1010              value={String(props.trashRetentionDays)}1011              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1012            >1013              <option value="7">7 days</option>1014              <option value="30">30 days</option>1015              <option value="90">90 days</option>1016              <option value="0">Never</option>1017            </select>1018          </div>1019          <div class="peach-settings-row peach-settings-row--meta">1020            <span>Vault Health</span>1021            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1022              open1023            </button>1024          </div>1025          <Show when={declinedCardCount() > 0}>1026            <div class="peach-settings-row peach-settings-row--meta">1027              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1028              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1029                Clear1030              </button>1031            </div>1032          </Show>10331034          <p class="peach-settings-sub-label">DATA</p>1035          <div class="peach-settings-row peach-settings-row--meta">1036            <span>Import passwords</span>1037            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1038              open1039            </button>1040          </div>1041          <div class="peach-settings-row peach-settings-row--meta">1042            <span>Export backup</span>1043            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1044              open1045            </button>1046          </div>1047          <div class="peach-settings-row peach-settings-row--meta">1048            <span>1049              Peach Codex1050              <Show when={!isPro()}>1051                <span class="peach-pro-badge">Pro</span>1052              </Show>1053            </span>1054            <button1055              class="peach-btn peach-btn--subtle peach-btn--sm"1056              onClick={() => props.onOpenCodexGenerate?.()}1057            >1058              open1059            </button>1060          </div>10611062          <p class="peach-settings-sub-label">VAULT HISTORY</p>1063          <Show when={historyLoading()}>1064            <p class="peach-settings-hint">Loading…</p>1065          </Show>1066          <Show when={!historyLoading() && historySnapshots().length === 0}>1067            <p class="peach-settings-hint">1068              No snapshots yet. Peach saves automatically before every change —1069              saves, syncs, and imports.1070            </p>1071          </Show>1072          <Show when={!historyLoading() && historySnapshots().length > 0}>1073            <p class="peach-settings-hint">1074              Restoring locks your vault. Unlock with the password that1075              encrypted that snapshot to confirm.1076            </p>1077            <div class="peach-history-list">1078              <For each={historySnapshots()}>1079                {(snap) => (1080                  <div class="peach-history-row">1081                    <div class="peach-history-meta">1082                      <span class="peach-history-time">1083                        {formatSnapshotTime(snap.savedAt)}1084                      </span>1085                      <span class="peach-history-sub">1086                        {[1087                          snap.entryCount !== null1088                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1089                            : null,1090                          reasonLabel(snap.reason),1091                        ]1092                          .filter(Boolean)1093                          .join(" · ")}1094                      </span>1095                    </div>1096                    <button1097                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1098                        historyRestoring() === snap.id ? " is-busy" : ""1099                      }`}1100                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1101                      disabled={historyRestoring() !== null}1102                    >1103                      <Show when={historyRestoring() === snap.id} fallback="Restore">1104                        …1105                      </Show>1106                    </button>1107                  </div>1108                )}1109              </For>1110            </div>1111            <p class="peach-history-footer">1112              {historySnapshots().length} of 10 snapshots1113            </p>1114          </Show>11151116          <button1117            class="peach-settings-advanced-toggle"1118            onClick={() => setShowVaultAdvanced((v) => !v)}1119            aria-expanded={showVaultAdvanced()}1120          >1121            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1122            <span class="peach-settings-advanced-chevron">1123              {showVaultAdvanced() ? "▲" : "▼"}1124            </span>1125          </button>11261127          <Show when={showVaultAdvanced()}>1128            <div class="peach-danger-card">1129              <div class="peach-danger-card-head">1130                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1131                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1132              </div>1133              <p class="peach-danger-card-copy">1134                Wipes the current vault and restores from a backup file.1135                Recoverable only if your backup is good.1136              </p>1137              <button1138                class="peach-danger-card-button"1139                onClick={() => props.onOpenFileImport?.()}1140              >1141                Choose backup file1142              </button>1143            </div>11441145            <Show when={props.onOpenPanic}>1146              <div class="peach-danger-card peach-danger-card--hazard">1147                <div class="peach-danger-card-head">1148                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1149                  <h3 class="peach-danger-card-title">Destroy vault</h3>1150                </div>1151                <p class="peach-danger-card-copy">1152                  Permanently wipes every entry, setting, and license on this1153                  device. There is no recovery.1154                </p>1155                <button1156                  class="peach-danger-card-button"1157                  onClick={() => props.onOpenPanic?.()}1158                >1159                  Begin destruction1160                </button>1161              </div>1162            </Show>1163          </Show>1164        </div>1165      </div>1166    );1167  }11681169  function renderAppearance() {1170    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1171      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1172      { id: "cormorant", label: "Cormorant", preview: "Aa" },1173      { id: "inter", label: "Inter", preview: "Aa" },1174      { id: "system", label: "System", preview: "Aa" },1175    ];1176    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1177      { id: "small", label: "Small" },1178      { id: "medium", label: "Default" },1179      { id: "large", label: "Large" },1180    ];1181    return (1182      <div class="peach-settings-body">1183        <div class="peach-settings-group">1184          <p class="peach-settings-sub-label">THEME</p>1185          <div class="peach-settings-row peach-settings-row--meta">1186            <span>Theme</span>1187            <button1188              class="peach-btn peach-btn--subtle peach-btn--sm"1189              onClick={() =>1190                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1191              }1192            >1193              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1194            </button>1195          </div>11961197          <p class="peach-settings-sub-label">ACCENT</p>1198          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1199            <span>Accent</span>1200            <div class="peach-accent-picker">1201              <For each={ACCENT_COLORS}>1202                {(color) => (1203                  <button1204                    class="peach-accent-swatch"1205                    classList={{ "is-active": props.accentColor === color.name }}1206                    style={{ background: color.hex }}1207                    onClick={() => void props.onAccentChange(color.name)}1208                    aria-label={color.name}1209                    title={color.name}1210                  />1211                )}1212              </For>1213            </div>1214          </div>12151216          <p class="peach-settings-sub-label">DISPLAY FONT</p>1217          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1218            <span>Font</span>1219            <div class="peach-font-picker">1220              <For each={fontOptions}>1221                {(opt) => (1222                  <button1223                    class="peach-font-swatch"1224                    classList={{ "is-active": props.displayFont === opt.id }}1225                    data-font-preview={opt.id}1226                    onClick={() => void props.onDisplayFontChange(opt.id)}1227                    aria-label={opt.label}1228                    title={opt.label}1229                  >1230                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1231                    <span class="peach-font-swatch-name">{opt.label}</span>1232                  </button>1233                )}1234              </For>1235            </div>1236          </div>12371238          <p class="peach-settings-sub-label">TEXT SIZE</p>1239          <div class="peach-settings-row peach-settings-row--meta">1240            <span>Size</span>1241            <div class="peach-pill-group">1242              <For each={sizeOptions}>1243                {(opt) => (1244                  <button1245                    class="peach-btn peach-btn--ghost peach-btn--sm"1246                    classList={{ "is-active": props.textSize === opt.id }}1247                    onClick={() => void props.onTextSizeChange(opt.id)}1248                  >1249                    {opt.label}1250                  </button>1251                )}1252              </For>1253            </div>1254          </div>12551256          <p class="peach-settings-sub-label">MOTION</p>1257          <div class="peach-settings-row peach-settings-row--meta">1258            <span>Reduce motion</span>1259            <button1260              class="peach-btn peach-btn--subtle peach-btn--sm"1261              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1262            >1263              {props.reduceMotion ? "On" : "Off"}1264            </button>1265          </div>1266        </div>1267      </div>1268    );1269  }12701271  function renderShortcuts() {1272    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1273    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12741275    function startCapture(action: ShortcutAction) {1276      setConflictMsg(null);1277      setCapturingAction(action);1278    }1279    function cancelCapture() {1280      setCapturingAction(null);1281      setConflictMsg(null);1282    }1283    function commitBinding(action: ShortcutAction, binding: Keybinding) {1284      const conflict = findConflict(props.shortcuts, binding, action);1285      if (conflict) {1286        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1287        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1288        return;1289      }1290      const next = { ...props.shortcuts, [action]: binding };1291      setCapturingAction(null);1292      setConflictMsg(null);1293      void props.onShortcutsChange(next);1294    }1295    function resetAction(action: ShortcutAction) {1296      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1297      void props.onShortcutsChange(next);1298    }1299    function resetAll() {1300      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1301    }13021303    // Capture-mode key handler — wired via ref onto the chip element so it1304    // catches the next non-modifier keypress and ignores other bindings.1305    function handleCaptureKey(e: KeyboardEvent) {1306      e.preventDefault();1307      e.stopPropagation();1308      if (e.key === "Escape") { cancelCapture(); return; }1309      const binding = keybindingFromEvent(e);1310      if (!binding) return; // bare modifier — keep waiting1311      const action = capturingAction();1312      if (action) commitBinding(action, binding);1313    }13141315    return (1316      <div class="peach-settings-body">1317        <div class="peach-settings-group peach-shortcuts-group">1318          <p class="peach-settings-sub-label">ENTRY VIEW</p>1319          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1320            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1321          </For>1322        </div>13231324        <div class="peach-settings-group peach-shortcuts-group">1325          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1326          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1327            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1328          </For>1329        </div>13301331        <Show when={conflictMsg()}>1332          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1333        </Show>13341335        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1336          <div class="peach-settings-row peach-settings-row--meta">1337            <span>Inline autofill on page</span>1338            <a1339              class="peach-settings-link"1340              href="chrome://extensions/shortcuts"1341              onClick={(e) => {1342                e.preventDefault();1343                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1344              }}1345            >1346              ⌘⇧P — customize ↗1347            </a>1348          </div>1349          <button1350            class="peach-btn peach-btn--subtle peach-btn--sm"1351            style="align-self:flex-start"1352            onClick={resetAll}1353          >1354            Reset all to defaults1355          </button>1356        </div>1357      </div>1358    );13591360    function renderRow(1361      desc: typeof SHORTCUT_DESCRIPTORS[number],1362      capturing: () => ShortcutAction | null,1363      onStart: (a: ShortcutAction) => void,1364      onCancel: () => void,1365      onReset: (a: ShortcutAction) => void,1366      onKey: (e: KeyboardEvent) => void,1367    ) {1368      const isActive = () => capturing() === desc.action;1369      const binding = () => props.shortcuts[desc.action];1370      const isCustom = () => {1371        const b = binding();1372        const d = DEFAULT_BINDINGS[desc.action];1373        return b.key !== d.key1374          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1375          || Boolean(b.meta)  !== Boolean(d.meta)1376          || Boolean(b.shift) !== Boolean(d.shift)1377          || Boolean(b.alt)   !== Boolean(d.alt);1378      };1379      return (1380        <div class="peach-shortcut-row">1381          <span class="peach-shortcut-row-label">{desc.label}</span>1382          <button1383            class="peach-shortcut-chip"1384            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1385            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1386            onKeyDown={(e) => { if (isActive()) onKey(e); }}1387            ref={(el) => {1388              // Auto-focus when entering capture mode so the keydown listener catches the next press.1389              if (el) {1390                queueMicrotask(() => { if (isActive()) el.focus(); });1391              }1392            }}1393          >1394            {isActive() ? "Press a key…" : formatKeybinding(binding())}1395          </button>1396          <Show when={isCustom() && !isActive()}>1397            <button1398              class="peach-shortcut-reset"1399              title="Reset to default"1400              onClick={() => onReset(desc.action)}1401            >1402              ↺1403            </button>1404          </Show>1405        </div>1406      );1407    }1408  }14091410  const PRO_FEATURES: Array<{1411    feature: ProFeature;1412    name: string;1413    desc: string;1414  }> = [1415    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1416    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1417    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1418    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1419    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1420  ];14211422  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14231424  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1425  const [deactivating, setDeactivating] = createSignal(false);14261427  async function handleDeactivateLicense() {1428    setDeactivating(true);1429    await sendMessage({ type: "DEACTIVATE_LICENSE" });1430    setLicenseInfo(null);1431    await props.onLicenseChange?.();1432    setDeactivating(false);1433  }14341435  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1436    setCanaryTestingId(entry.id);1437    setCanaryTestResult(null);1438    const res = await sendMessage({1439      type: "FIRE_CANARY_PING",1440      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1441    }) as { ok: boolean };1442    setCanaryTestResult({ id: entry.id, ok: res.ok });1443    setCanaryTestingId(null);1444    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1445  }14461447  function renderLicense() {1448    async function onActivate() {1449      await handleActivateLicense();1450      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1451        await props.onLicenseChange?.();1452      }1453    }14541455    return (1456      <div class="peach-settings-body">1457        <Show1458          when={isPro()}1459          fallback={1460            <Show1461              when={showKeyEntry()}1462              fallback={1463                <div class="peach-pro-upgrade">1464                  <div class="peach-pro-upgrade-hero">1465                    <div class="peach-pro-upgrade-mark-wrap">1466                      <PeachMark class="peach-pro-upgrade-mark" />1467                    </div>1468                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1469                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1470                  </div>14711472                  <div class="peach-pro-features">1473                    <For each={PRO_FEATURES}>1474                      {(item) => (1475                        <div1476                          class="peach-pro-feature"1477                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1478                        >1479                          <span class="peach-pro-feature-mark">✦</span>1480                          <div class="peach-pro-feature-text">1481                            <span class="peach-pro-feature-name">{item.name}</span>1482                            <span class="peach-pro-feature-desc">{item.desc}</span>1483                          </div>1484                        </div>1485                      )}1486                    </For>1487                  </div>14881489                  <div class="peach-pro-upgrade-actions">1490                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1491                    <a1492                      href="https://peachpasswords.com/#pricing"1493                      target="_blank"1494                      rel="noopener noreferrer"1495                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1496                    >1497                      Get Peach Pro1498                    </a>1499                    <div class="peach-pro-key-divider"><span>or</span></div>1500                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1501                      Activate a license key1502                    </button>1503                  </div>1504                </div>1505              }1506            >1507              <div class="peach-pro-key-ceremony">1508                <LockRuneCanvas />1509                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1510                <input1511                  class="peach-underline-input peach-pro-key-input"1512                  type="text"1513                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1514                  value={licenseKeyInput()}1515                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1516                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1517                  autocomplete="off"1518                  spellcheck={false}1519                />1520                <Show when={licenseError()}>1521                  <p class="peach-pro-key-error">That key doesn't look right.</p>1522                </Show>1523                <button1524                  class="peach-btn peach-btn--accent peach-pro-key-activate"1525                  onClick={() => void onActivate()}1526                  disabled={activatingLicense() || !licenseKeyInput().trim()}1527                >1528                  {activatingLicense() ? "activating…" : "Activate"}1529                </button>1530                <button1531                  class="peach-pro-key-cancel"1532                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1533                >1534                  cancel1535                </button>1536              </div>1537            </Show>1538          }1539        >1540          <div class="peach-pro-active">1541            <div class="peach-pro-active-hero">1542              <div class="peach-pro-active-mark-wrap">1543                <PeachMark class="peach-pro-active-mark" />1544              </div>1545              <h2 class="peach-pro-active-title">Peach Pro</h2>1546              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1547            </div>15481549            <Show when={licenseKey()}>1550              <div class="peach-settings-license">1551                <span class="peach-settings-label">LICENSE KEY</span>1552                <div class="peach-settings-license-row">1553                  <span class="peach-settings-license-key">{licenseKey()!}</span>1554                  <button1555                    class="peach-settings-license-copy"1556                    onClick={() => copyLicenseKey(licenseKey()!)}1557                  >1558                    {copied() ? "✓" : "copy"}1559                  </button>1560                </div>1561              </div>1562            </Show>15631564            <div class="peach-pro-active-features">1565              <For each={PRO_FEATURES}>1566                {(item) => (1567                  <div class="peach-pro-active-feature">1568                    <span class="peach-pro-active-feature-mark">✦</span>1569                    <span class="peach-pro-active-feature-name">{item.name}</span>1570                  </div>1571                )}1572              </For>1573            </div>15741575            <button1576              class="peach-pro-deactivate"1577              onClick={() => void handleDeactivateLicense()}1578              disabled={deactivating()}1579            >1580              {deactivating() ? "Deactivating…" : "Deactivate"}1581            </button>1582          </div>1583        </Show>1584      </div>1585    );1586  }15871588  function renderFeedback() {1589    return (1590      <div class="peach-settings-body">1591        <div class="peach-settings-group peach-feedback-group">1592          <p class="peach-feedback-intro">1593            Help us improve Peach. Every report goes directly to our inbox.1594          </p>15951596          <p class="peach-settings-sub-label">TYPE</p>1597          <div class="peach-feedback-chips">1598            <For each={FEEDBACK_CATEGORIES}>1599              {(cat) => (1600                <button1601                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1602                  onClick={() => setFbCategory(cat)}1603                >1604                  {cat}1605                </button>1606              )}1607            </For>1608          </div>16091610          <p class="peach-settings-sub-label">MESSAGE</p>1611          <textarea1612            class="peach-feedback-textarea"1613            placeholder="Describe the issue or idea..."1614            value={fbMessage()}1615            onInput={(e) => setFbMessage(e.currentTarget.value)}1616          />16171618          <p class="peach-feedback-meta">1619            Version {version} · will be included automatically1620          </p>16211622          <Show when={fbResult() === "success"}>1623            <p class="peach-feedback-result peach-feedback-result--ok">1624              Sent — thank you1625            </p>1626          </Show>1627          <Show when={fbResult() === "error"}>1628            <p class="peach-feedback-result peach-feedback-result--err">1629              Failed to send — try again1630            </p>1631          </Show>16321633          <button1634            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1635            onClick={() => void handleSubmitFeedback()}1636            disabled={fbSubmitting() || !fbMessage().trim()}1637          >1638            {fbSubmitting() ? "Sending..." : "Send feedback"}1639          </button>16401641          <p class="peach-feedback-anon">1642            Sent anonymously · no account required1643          </p>1644        </div>1645      </div>1646    );1647  }16481649  return (1650    <Show1651      when={showNewRecoveryWords()}1652      fallback={1653        <div class="peach-overlay-view ov-enter-right" style="display:flex">1654          <div class="peach-overlay-header">1655            <button1656              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1657              onClick={handleHeaderBack}1658              aria-label="Back"1659            >1660              <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1661                <path d="M6 1L1 6L6 11" />1662              </svg>1663            </button>1664            <span class="peach-overlay-title">{currentTitle()}</span>1665            <div class="peach-overlay-spacer"></div>1666          </div>16671668          <Switch fallback={renderHome()}>1669            <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1670            <Match when={activeSection() === "vault"}>{renderVault()}</Match>1671            <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1672            <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1673            <Match when={activeSection() === "license"}>{renderLicense()}</Match>1674            <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1675          </Switch>1676        </div>1677      }1678    >1679      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1680    </Show>1681  );1682}