Settings.tsx

67.1 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  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  onOpenCodexRestore?: () => void;67}6869type SectionName =70  | "security"71  | "vault"72  | "appearance"73  | "shortcuts"74  | "license"75  | "feedback";7677const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7879function formatActivatedSince(issued: string | undefined): string {80  if (!issued) return "Active";81  try {82    const d = new Date(issued);83    if (Number.isNaN(d.getTime())) return "Active";84    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;85  } catch {86    return "Active";87  }88}8990function isSectionName(value: string): value is SectionName {91  return (92    value === "security" ||93    value === "vault" ||94    value === "appearance" ||95    value === "shortcuts" ||96    value === "license" ||97    value === "feedback"98  );99}100101export function SettingsView(props: SettingsProps) {102  const manifest = chrome.runtime.getManifest();103  const version = manifest.version;104  const isAndroid = (manifest as any).version_name === 'mobile';105  const initial = props.initialSection;106  const [activeSection, setActiveSection] = createSignal<SectionName | null>(107    initial && isSectionName(initial) ? (initial as SectionName) : null108  );109110  const [checkingBreaches, setCheckingBreaches] = createSignal(false);111  const [breachResult, setBreachResult] = createSignal<string | null>(null);112  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);113  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");114  const [licenseError, setLicenseError] = createSignal<string | null>(null);115  const [, setLicenseSuccess] = createSignal<string | null>(null);116  const [activatingLicense, setActivatingLicense] = createSignal(false);117118  const isPro = () => props.isPro ?? false;119  const [biometricSupported, setBiometricSupported] = createSignal(false);120  const [biometricStored, setBiometricStored] = createSignal(false);121  const [biometricBusy, setBiometricBusy] = createSignal(false);122  const [biometricError, setBiometricError] = createSignal<string | null>(null);123  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);124  const [passkeySaving, setPasskeySaving] = createSignal(false);125126  const [dedupCount, setDedupCount] = createSignal<number | null>(null);127  const [dedupRunning, setDedupRunning] = createSignal(false);128  const [dedupDone, setDedupDone] = createSignal<number | null>(null);129130  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);131132  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted133  // BEFORE every real-mode vault write so the user can roll back if a sync134  // or import overwrites the wrong state.135  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);136  const [historyLoading, setHistoryLoading] = createSignal(false);137  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);138139  async function loadVaultHistory() {140    setHistoryLoading(true);141    try {142      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });143      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];144      setHistorySnapshots(list);145    } finally {146      setHistoryLoading(false);147    }148  }149150  async function handleRestoreVaultSnapshot(id: string) {151    if (historyRestoring()) return;152    setHistoryRestoring(id);153    const res = await sendMessage({154      type: "RESTORE_VAULT_SNAPSHOT",155      payload: { snapshotId: id },156    });157    setHistoryRestoring(null);158    if ((res as { ok: boolean }).ok) {159      // Vault is now locked — let the parent close Settings; App.tsx's160      // VAULT_LOCKED listener will route back to the lock screen.161      props.onBack();162    }163  }164165  function formatSnapshotTime(savedAt: number): string {166    const diff = Date.now() - savedAt;167    if (diff < 60_000) return "Just now";168    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;169    const d = new Date(savedAt);170    const isToday = new Date().toDateString() === d.toDateString();171    const isYest =172      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();173    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });174    if (isToday) return `Today ${t}`;175    if (isYest) return `Yesterday ${t}`;176    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;177  }178179  function reasonLabel(reason: VaultSnapshot["reason"]): string {180    return (181      {182        local_save: "saved",183        sync_received: "synced",184        import: "imported",185        restore: "restored",186      } as const187    )[reason] ?? reason;188  }189190  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);191192  const [showChangePw, setShowChangePw] = createSignal(false);193  const [changePwCurrent, setChangePwCurrent] = createSignal("");194  const [changePwNew, setChangePwNew] = createSignal("");195  const [changePwConfirm, setChangePwConfirm] = createSignal("");196  const [changePwSaving, setChangePwSaving] = createSignal(false);197  const [changePwError, setChangePwError] = createSignal<string | null>(null);198  const [changePwSuccess] = createSignal(false);199  const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);200201  const [duressEnabled, setDuressEnabled] = createSignal(false);202  const [showDuressSetup, setShowDuressSetup] = createSignal(false);203  const [duressPassword, setDuressPassword] = createSignal("");204  const [duressConfirm, setDuressConfirm] = createSignal("");205  const [duressMainPassword, setDuressMainPassword] = createSignal("");206  const [duressSaving, setDuressSaving] = createSignal(false);207  const [duressError, setDuressError] = createSignal<string | null>(null);208  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);209210  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);211212  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);213  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);214215  const [fbCategory, setFbCategory] = createSignal("Bug report");216  const [fbMessage, setFbMessage] = createSignal("");217  const [fbSubmitting, setFbSubmitting] = createSignal(false);218  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);219220  onMount(async () => {221    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([222      sendMessage({ type: "GET_LICENSE" }),223      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),224      sendMessage({ type: "IS_DURESS_ENABLED" }),225      sendMessage({ type: "GET_SETTINGS" }),226      sendMessage({ type: "GET_LICENSE_KEY" }),227    ]);228229    // Vault history loads in the background — the Vault sub-view picks it up230    // when the user navigates in. Loading it lazily keeps Settings boot fast.231    void loadVaultHistory();232233    if ((licenseRes as { ok: boolean }).ok) {234      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);235    }236237    if ((passkeyRes as { ok: boolean }).ok) {238      setPasskeyState(239        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null240      );241    }242243    if ((duressRes as { ok: boolean }).ok) {244      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);245    }246247    if ((settingsRes as { ok: boolean }).ok) {248      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;249      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);250    }251252    if ((keyRes as { ok: boolean }).ok) {253      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);254    }255256    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });257    if ((entriesRes as { ok: boolean }).ok) {258      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];259      setCanaryEntries(260        entries261          .filter((e) => e.canary?.webhookUrl)262          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))263      );264    }265266267    if (isNativePlatform()) {268      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");269      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");270      try {271        if (biometric) {272          const a = await biometric.isAvailable();273          setBiometricSupported(a.available);274        }275        if (keystore) {276          const k = await keystore.hasMasterKey();277          setBiometricStored(k.exists);278        }279      } catch { /* missing plugins — leave defaults */ }280    }281  });282283  async function handleEnableBiometrics() {284    setBiometricBusy(true);285    setBiometricError(null);286    try {287      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {288        ok: boolean;289        data?: { masterKeyHex: string };290        error?: string;291      };292      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {293        setBiometricError(keyRes.error === "vault_locked"294          ? "unlock the vault first"295          : "couldn't read derived key");296        return;297      }298      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");299      if (!keystore) {300        setBiometricError("biometric plugin unavailable");301        return;302      }303      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });304      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });305      setBiometricStored(true);306    } catch (e) {307      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");308    } finally {309      setBiometricBusy(false);310    }311  }312313  async function handleDisableBiometrics() {314    setBiometricBusy(true);315    setBiometricError(null);316    try {317      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");318      if (keystore) await keystore.clearMasterKey();319      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });320      setBiometricStored(false);321    } catch (e) {322      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");323    } finally {324      setBiometricBusy(false);325    }326  }327328  async function handleChangePassword() {329    setChangePwError(null);330    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }331    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }332    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }333    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }334    setChangePwSaving(true);335    const res = await sendMessage({336      type: "CHANGE_PASSWORD",337      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },338    }) as { ok: boolean; error?: string };339    if (res.ok) {340      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");341      setShowChangePw(false);342      setShowNewRecoveryWords(true);343    } else {344      setChangePwError(res.error ?? "failed to change password");345    }346    setChangePwSaving(false);347  }348349  const [copied, setCopied] = createSignal(false);350351  async function copyLicenseKey(key: string) {352    await navigator.clipboard.writeText(key);353    setCopied(true);354    setTimeout(() => setCopied(false), 2000);355  }356357  async function handleActivateLicense() {358    const key = licenseKeyInput().trim();359    if (!key) return;360    setActivatingLicense(true);361    setLicenseError(null);362    setLicenseSuccess(null);363    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });364    setActivatingLicense(false);365    if ((res as { ok: boolean }).ok) {366      const data = (res as { ok: boolean; data?: LicenseInfo }).data;367      if (data?.valid) {368        setLicenseInfo(data);369        setLicenseKey(key);370        setLicenseSuccess("License activated!");371        setLicenseKeyInput("");372      } else {373        setLicenseError("Invalid license key");374      }375    } else {376      setLicenseError("Activation failed");377    }378  }379380  async function handleTogglePasskeys() {381    const current = passkeyState();382    if (!current || !current.supported) return;383    setPasskeySaving(true);384    const res = await sendMessage({385      type: "SET_PASSKEY_PROVIDER_ENABLED",386      payload: { enabled: !current.enabled },387    });388    setPasskeySaving(false);389    if ((res as { ok: boolean }).ok) {390      setPasskeyState(391        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null392      );393    }394  }395396  async function handleCheckAllBreaches() {397    setCheckingBreaches(true);398    setBreachResult(null);399    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });400    setCheckingBreaches(false);401    if ((res as { ok: boolean }).ok) {402      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};403      const breached = Object.values(data).filter((count) => count > 0).length;404      setBreachResult(405        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"406      );407      props.onBreachCheckDone();408    }409  }410411412  async function handleSetupDuress() {413    const mainPw = duressMainPassword().trim();414    const duressPw = duressPassword().trim();415    const confirmPw = duressConfirm().trim();416417    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }418    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }419    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }420    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }421422    setDuressSaving(true);423    setDuressError(null);424425    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };426    if (!verifyRes.ok || !verifyRes.data) {427      setDuressError("incorrect main password");428      setDuressSaving(false);429      return;430    }431432    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };433    setDuressSaving(false);434    if (res.ok) {435      setDuressEnabled(true);436      setShowDuressSetup(false);437      setDuressPassword("");438      setDuressConfirm("");439      setDuressMainPassword("");440      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");441      setTimeout(() => setDuressSuccess(null), 5000);442    } else {443      setDuressError(res.error ?? "failed to set duress password");444    }445  }446447  async function handleClearDeclinedCards() {448    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });449    setDeclinedCardCount(0);450  }451452  async function handleCountDuplicates() {453    setDedupRunning(true);454    setDedupDone(null);455    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };456    setDedupRunning(false);457    if (res.ok) setDedupCount(res.data ?? 0);458  }459460  async function handleDeduplicate() {461    setDedupRunning(true);462    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };463    setDedupRunning(false);464    if (res.ok) {465      setDedupDone(res.data ?? 0);466      setDedupCount(0);467    }468  }469470  async function handleDisableDuress() {471    const mainPw = duressMainPassword().trim();472    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }473474    setDuressSaving(true);475    setDuressError(null);476477    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };478    if (!verifyRes.ok || !verifyRes.data) {479      setDuressError("incorrect main password");480      setDuressSaving(false);481      return;482    }483484    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };485    setDuressSaving(false);486    if (res.ok) {487      setDuressEnabled(false);488      setShowDuressSetup(false);489      setDuressMainPassword("");490      setDuressSuccess("Duress password disabled.");491      setTimeout(() => setDuressSuccess(null), 4000);492    } else {493      setDuressError(res.error ?? "failed to disable duress password");494    }495  }496497  async function handleSubmitFeedback() {498    if (!fbMessage().trim() || fbSubmitting()) return;499    setFbSubmitting(true);500    setFbResult(null);501    const res = await sendMessage({502      type: "SUBMIT_FEEDBACK",503      payload: {504        category: fbCategory(),505        message: fbMessage().trim(),506        version,507        browser: navigator.userAgent,508      },509    }) as { ok: boolean; error?: string };510    setFbSubmitting(false);511    if (res.ok) {512      setFbResult("success");513      setFbMessage("");514      setTimeout(() => setFbResult(null), 4000);515    } else {516      setFbResult("error");517    }518  }519520  function handleHeaderBack() {521    if (activeSection() !== null) {522      setActiveSection(null);523    } else {524      props.onBack();525    }526  }527528  function currentTitle(): string {529    const s = activeSection();530    if (s === null) return "settings";531    if (s === "license") return "account";532    return s;533  }534535  function renderCatRow(opts: {536    section: SectionName;537    name: string;538    desc: string;539    icon: () => any;540    pro?: boolean;541  }) {542    return (543      <button544        class="peach-settings-cat-row"545        onClick={() => setActiveSection(opts.section)}546      >547        <div class="peach-settings-cat-icon">{opts.icon()}</div>548        <div class="peach-settings-cat-content">549          <span class="peach-settings-cat-name">550            {opts.name}551            <Show when={opts.pro && !isPro()}>552              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>553            </Show>554          </span>555          <span class="peach-settings-cat-desc">{opts.desc}</span>556        </div>557        <svg558          class="peach-settings-cat-chevron"559          width="6" height="10" viewBox="0 0 6 10" fill="none"560          stroke="currentColor" stroke-width="1.1"561          stroke-linecap="round" stroke-linejoin="round"562        >563          <path d="M1 1l4 4-4 4" />564        </svg>565      </button>566    );567  }568569  const iconShield = () => (570    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">571      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>572    </svg>573  );574  const iconKey = () => (575    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">576      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>577    </svg>578  );579  const iconLock = () => (580    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">581      <rect x="3" y="7" width="10" height="7" rx="1.5"/>582      <path d="M6 7V5a3 3 0 016 0v2"/>583    </svg>584  );585  const iconSync = () => (586    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">587      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>588      <path d="M14 6l-2 2-2-2"/>589      <path d="M2 10l2-2 2 2"/>590    </svg>591  );592  const iconSun = () => (593    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">594      <circle cx="8" cy="8" r="3"/>595      <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"/>596    </svg>597  );598  const iconKeys = () => (599    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">600      <rect x="2" y="5" width="5" height="4" rx="1"/>601      <rect x="9" y="7" width="5" height="4" rx="1"/>602      <path d="M7 7h2m-2 2h2"/>603    </svg>604  );605  const iconBubble = () => (606    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">607      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>608    </svg>609  );610611  function renderHome() {612    return (613      <div class="peach-settings-body">614        <p class="peach-settings-nav-label">PROTECTION</p>615        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}616        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}617618        <p class="peach-settings-nav-label">VAULT</p>619        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}620        <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>621          <div class="peach-settings-cat-icon">{iconSync()}</div>622          <div class="peach-settings-cat-content">623            <span class="peach-settings-cat-name">Sync</span>624            <span class="peach-settings-cat-desc">Pair and sync across devices</span>625          </div>626          <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">627            <path d="M1 1l4 4-4 4" />628          </svg>629        </button>630631        <p class="peach-settings-nav-label">PREFERENCES</p>632        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}633        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}634635        <p class="peach-settings-nav-label">SUPPORT</p>636        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}637638        <div class="peach-settings-footer">639          <span>Peach {version}</span>640          <span class="peach-divider"></span>641          <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>642        </div>643      </div>644    );645  }646647  function renderSecurity() {648    return (649      <div class="peach-settings-body">650        <div class="peach-settings-group">651          <p class="peach-settings-sub-label">LOCKING</p>652          <div class="peach-settings-row">653            <span>Lock on close</span>654            <button655              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}656              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}657              aria-label="Toggle lock on close"658            >659              <span class="peach-toggle-knob"></span>660            </button>661          </div>662          <div class="peach-settings-row">663            <span>Auto-lock after</span>664            <select665              class="peach-settings-select"666              value={String(props.autoLockMinutes)}667              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}668            >669              <option value="1">1 minute</option>670              <option value="5">5 minutes</option>671              <option value="15">15 minutes</option>672              <option value="30">30 minutes</option>673              <option value="0">Never</option>674            </select>675          </div>676677          <Show when={isNativePlatform() && biometricSupported()}>678            <p class="peach-settings-sub-label">BIOMETRICS</p>679            <div class="peach-settings-row">680              <span>Unlock with biometrics</span>681              <button682                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}683                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}684                disabled={biometricBusy()}685                aria-label="Toggle biometric unlock"686              >687                <span class="peach-toggle-knob"></span>688              </button>689            </div>690            <Show when={biometricError()}>691              <div class="peach-settings-row peach-settings-row--meta">692                <span class="peach-hint peach-hint-error">{biometricError()}</span>693              </div>694            </Show>695          </Show>696697          <p class="peach-settings-sub-label">BREACH PROTECTION</p>698          <div class="peach-settings-row">699            <span>Breach monitoring</span>700            <button701              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}702              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}703              aria-label="Toggle breach monitoring"704            >705              <span class="peach-toggle-knob"></span>706            </button>707          </div>708          <div class="peach-settings-row peach-settings-row--meta">709            <span>Check all passwords</span>710            <button711              class="peach-btn peach-btn--subtle peach-btn--sm"712              onClick={() => void handleCheckAllBreaches()}713              disabled={checkingBreaches()}714            >715              {checkingBreaches() ? "checking..." : "run"}716            </button>717          </div>718          <Show when={breachResult()}>719            <div class="peach-settings-row peach-settings-row--meta">720              <span>Last check</span>721              <span class="peach-settings-value">{breachResult()}</span>722            </div>723          </Show>724725          <p class="peach-settings-sub-label">MASTER PASSWORD</p>726          <div class="peach-settings-row peach-settings-row--meta">727            <span>Master password</span>728            <button729              class="peach-btn peach-btn--subtle peach-btn--sm"730              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}731            >732              {showChangePw() ? "cancel" : "change"}733            </button>734          </div>735          <Show when={showChangePw()}>736            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">737              <input738                class="peach-settings-input"739                type="password"740                placeholder="current password"741                value={changePwCurrent()}742                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}743                autocomplete="current-password"744              />745              <input746                class="peach-settings-input"747                type="password"748                placeholder="new password (min 12 chars)"749                value={changePwNew()}750                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}751                autocomplete="new-password"752              />753              <input754                class="peach-settings-input"755                type="password"756                placeholder="confirm new password"757                value={changePwConfirm()}758                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}759                autocomplete="new-password"760                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}761              />762              <Show when={changePwError()}>763                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>764              </Show>765              <Show when={changePwSuccess()}>766                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>767              </Show>768              <button769                class="peach-btn peach-btn--accent peach-btn--sm"770                style="align-self:flex-start"771                onClick={() => void handleChangePassword()}772                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}773              >774                {changePwSaving() ? "saving…" : "save new password"}775              </button>776            </div>777          </Show>778779          <p class="peach-settings-sub-label">DURESS PASSWORD</p>780          <div class="peach-settings-row peach-settings-row--top">781            <div class="peach-settings-row-main">782              <span>Duress password</span>783              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">784                If entered under coercion, shows a decoy empty vault instead of your real one.785              </span>786            </div>787            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>788              {duressEnabled() ? "on" : "off"}789            </div>790          </div>791792          <Show when={duressEnabled() && !showDuressSetup()}>793            <div class="peach-settings-row peach-settings-row--meta">794              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">795                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.796              </span>797            </div>798          </Show>799800          <Show when={duressSuccess()}>801            <div class="peach-settings-row peach-settings-row--meta">802              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>803            </div>804          </Show>805806          <Show when={!showDuressSetup()}>807            <div class="peach-settings-row peach-settings-row--meta">808              <button809                class="peach-btn peach-btn--subtle peach-btn--sm"810                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}811              >812                {duressEnabled() ? "Change / disable" : "Set up"}813              </button>814            </div>815          </Show>816817          <Show when={showDuressSetup()}>818            <div class="peach-duress-form">819              <label class="peach-label">main password (to confirm)</label>820              <input821                class="peach-settings-input"822                type="password"823                placeholder="your current master password"824                autocomplete="current-password"825                value={duressMainPassword()}826                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}827              />828              <Show when={!duressEnabled()}>829                <label class="peach-label">new duress password</label>830                <input831                  class="peach-settings-input"832                  type="password"833                  placeholder="duress password (min 8 chars)"834                  autocomplete="new-password"835                  value={duressPassword()}836                  onInput={(e) => setDuressPassword(e.currentTarget.value)}837                />838                <label class="peach-label">confirm duress password</label>839                <input840                  class="peach-settings-input"841                  type="password"842                  placeholder="confirm duress password"843                  autocomplete="new-password"844                  value={duressConfirm()}845                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}846                />847              </Show>848849              <Show when={duressError()}>850                <p class="peach-hint peach-hint-error">{duressError()}</p>851              </Show>852853              <div class="peach-duress-actions">854                <button855                  class="peach-btn peach-btn--subtle peach-btn--sm"856                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}857                >858                  cancel859                </button>860                <Show when={duressEnabled()}>861                  <button862                    class="peach-btn peach-btn--ghost peach-btn--sm"863                    style="color:var(--peach-breach)"864                    onClick={() => void handleDisableDuress()}865                    disabled={duressSaving()}866                  >867                    {duressSaving() ? "disabling..." : "disable duress"}868                  </button>869                </Show>870                <Show when={!duressEnabled()}>871                  <button872                    class="peach-btn peach-btn--accent peach-btn--sm"873                    onClick={() => void handleSetupDuress()}874                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}875                  >876                    {duressSaving() ? "saving..." : "save"}877                  </button>878                </Show>879              </div>880            </div>881          </Show>882883          <p class="peach-settings-sub-label">CANARY ENTRIES</p>884          <div class="peach-settings-row peach-settings-row--top">885            <div class="peach-settings-row-main">886              <span>Canary entries</span>887              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">888                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.889              </span>890            </div>891            <span class="peach-settings-value" style="flex-shrink:0">892              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}893            </span>894          </div>895          <Show when={canaryEntries().length > 0}>896            <For each={canaryEntries()}>897              {(entry) => (898                <div class="peach-settings-row peach-settings-row--meta">899                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>900                  <button901                    class="peach-btn peach-btn--subtle peach-btn--sm"902                    onClick={() => void handleTestCanary(entry)}903                    disabled={canaryTestingId() === entry.id}904                    style="flex-shrink:0"905                  >906                    {canaryTestingId() === entry.id907                      ? "sending…"908                      : canaryTestResult()?.id === entry.id909                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")910                        : "test"}911                  </button>912                </div>913              )}914            </For>915          </Show>916917          <p class="peach-settings-sub-label">PASSKEYS</p>918          <div class="peach-settings-row peach-settings-row--top">919            <div class="peach-settings-row-main">920              <span>Intercept passkey requests</span>921              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">922                Peach can handle passkey sign-in and creation on supported Chromium browsers.923              </span>924            </div>925            <button926              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}927              onClick={() => void handleTogglePasskeys()}928              aria-label="Toggle passkey interception"929              disabled={!passkeyState()?.supported || passkeySaving()}930            >931              <span class="peach-toggle-knob"></span>932            </button>933          </div>934          <div class="peach-settings-row peach-settings-row--meta">935            <span>Status</span>936            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>937          </div>938          <div class="peach-settings-row peach-settings-row--meta">939            <span>Browser</span>940            <span class="peach-settings-value">941              {passkeyState()?.browserLabel ?? "Unknown"}942              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}943            </span>944          </div>945          <div class="peach-info-notice" style="margin-top:8px">946            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.947          </div>948          <Show when={passkeyState()?.conflict}>949            <div class="peach-settings-row peach-settings-row--meta">950              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>951            </div>952          </Show>953        </div>954      </div>955    );956  }957958  function renderVault() {959    return (960      <div class="peach-settings-body">961        <div class="peach-settings-group">962          <p class="peach-settings-sub-label">ENTRIES</p>963          <div class="peach-settings-row peach-settings-row--meta">964            <span>Entries</span>965            <span class="peach-settings-value">{props.entriesCount}</span>966          </div>967          <div class="peach-settings-row peach-settings-row--meta">968            <span>Duplicates</span>969            <Show when={dedupDone() !== null} fallback={970              <button971                class="peach-btn peach-btn--subtle peach-btn--sm"972                onClick={() => void handleCountDuplicates()}973                disabled={dedupRunning()}974              >975                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}976              </button>977            }>978              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">979                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}980              </span>981            </Show>982          </div>983          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>984            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">985              <div class="peach-restore-warning">986                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.987              </div>988              <div style="display:flex;gap:6px">989                <button990                  class="peach-btn peach-btn--subtle peach-btn--sm"991                  onClick={() => setDedupCount(null)}992                >993                  cancel994                </button>995                <button996                  class="peach-btn peach-btn--accent peach-btn--sm"997                  onClick={() => void handleDeduplicate()}998                  disabled={dedupRunning()}999                >1000                  {dedupRunning() ? "removing..." : "remove duplicates"}1001                </button>1002              </div>1003            </div>1004          </Show>10051006          <p class="peach-settings-sub-label">MAINTENANCE</p>1007          <div class="peach-settings-row peach-settings-row--meta">1008            <span>Trash auto-delete</span>1009            <select1010              class="peach-settings-select"1011              value={String(props.trashRetentionDays)}1012              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1013            >1014              <option value="7">7 days</option>1015              <option value="30">30 days</option>1016              <option value="90">90 days</option>1017              <option value="0">Never</option>1018            </select>1019          </div>1020          <div class="peach-settings-row peach-settings-row--meta">1021            <span>Vault Health</span>1022            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1023              open1024            </button>1025          </div>1026          <Show when={declinedCardCount() > 0}>1027            <div class="peach-settings-row peach-settings-row--meta">1028              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1029              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1030                Clear1031              </button>1032            </div>1033          </Show>10341035          <p class="peach-settings-sub-label">DATA</p>1036          <div class="peach-settings-row peach-settings-row--meta">1037            <span>Import passwords</span>1038            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1039              open1040            </button>1041          </div>1042          <div class="peach-settings-row peach-settings-row--meta">1043            <span>Export backup</span>1044            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1045              open1046            </button>1047          </div>1048          <div class="peach-settings-row peach-settings-row--meta">1049            <span>1050              Peach Codex1051              <Show when={!isPro()}>1052                <span class="peach-pro-badge">Pro</span>1053              </Show>1054            </span>1055            <button1056              class="peach-btn peach-btn--subtle peach-btn--sm"1057              onClick={() => props.onOpenCodexGenerate?.()}1058            >1059              open1060            </button>1061          </div>1062          {/* Restore from Codex: intentionally never gated — a printed1063              Codex must remain restorable regardless of license state. */}1064          <div class="peach-settings-row peach-settings-row--meta">1065            <span>Restore from Codex</span>1066            <button1067              class="peach-btn peach-btn--subtle peach-btn--sm"1068              onClick={() => props.onOpenCodexRestore?.()}1069            >1070              open1071            </button>1072          </div>10731074          <p class="peach-settings-sub-label">VAULT HISTORY</p>1075          <Show when={historyLoading()}>1076            <p class="peach-settings-hint">Loading…</p>1077          </Show>1078          <Show when={!historyLoading() && historySnapshots().length === 0}>1079            <p class="peach-settings-hint">1080              No snapshots yet. Peach saves automatically before every change —1081              saves, syncs, and imports.1082            </p>1083          </Show>1084          <Show when={!historyLoading() && historySnapshots().length > 0}>1085            <p class="peach-settings-hint">1086              Restoring locks your vault. Unlock with the password that1087              encrypted that snapshot to confirm.1088            </p>1089            <div class="peach-history-list">1090              <For each={historySnapshots()}>1091                {(snap) => (1092                  <div class="peach-history-row">1093                    <div class="peach-history-meta">1094                      <span class="peach-history-time">1095                        {formatSnapshotTime(snap.savedAt)}1096                      </span>1097                      <span class="peach-history-sub">1098                        {[1099                          snap.entryCount !== null1100                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1101                            : null,1102                          reasonLabel(snap.reason),1103                        ]1104                          .filter(Boolean)1105                          .join(" · ")}1106                      </span>1107                    </div>1108                    <button1109                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1110                        historyRestoring() === snap.id ? " is-busy" : ""1111                      }`}1112                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1113                      disabled={historyRestoring() !== null}1114                    >1115                      <Show when={historyRestoring() === snap.id} fallback="Restore">1116                        …1117                      </Show>1118                    </button>1119                  </div>1120                )}1121              </For>1122            </div>1123            <p class="peach-history-footer">1124              {historySnapshots().length} of 10 snapshots1125            </p>1126          </Show>11271128          <button1129            class="peach-settings-advanced-toggle"1130            onClick={() => setShowVaultAdvanced((v) => !v)}1131            aria-expanded={showVaultAdvanced()}1132          >1133            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1134            <span class="peach-settings-advanced-chevron">1135              {showVaultAdvanced() ? "▲" : "▼"}1136            </span>1137          </button>11381139          <Show when={showVaultAdvanced()}>1140            <div class="peach-danger-card">1141              <div class="peach-danger-card-head">1142                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1143                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1144              </div>1145              <p class="peach-danger-card-copy">1146                Wipes the current vault and restores from a backup file.1147                Recoverable only if your backup is good.1148              </p>1149              <button1150                class="peach-danger-card-button"1151                onClick={() => props.onOpenFileImport?.()}1152              >1153                Choose backup file1154              </button>1155            </div>11561157            <Show when={props.onOpenPanic}>1158              <div class="peach-danger-card peach-danger-card--hazard">1159                <div class="peach-danger-card-head">1160                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1161                  <h3 class="peach-danger-card-title">Destroy vault</h3>1162                </div>1163                <p class="peach-danger-card-copy">1164                  Permanently wipes every entry, setting, and license on this1165                  device. There is no recovery.1166                </p>1167                <button1168                  class="peach-danger-card-button"1169                  onClick={() => props.onOpenPanic?.()}1170                >1171                  Begin destruction1172                </button>1173              </div>1174            </Show>1175          </Show>1176        </div>1177      </div>1178    );1179  }11801181  function renderAppearance() {1182    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1183      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1184      { id: "cormorant", label: "Cormorant", preview: "Aa" },1185      { id: "inter", label: "Inter", preview: "Aa" },1186      { id: "system", label: "System", preview: "Aa" },1187    ];1188    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1189      { id: "small", label: "Small" },1190      { id: "medium", label: "Default" },1191      { id: "large", label: "Large" },1192    ];1193    return (1194      <div class="peach-settings-body">1195        <div class="peach-settings-group">1196          <p class="peach-settings-sub-label">THEME</p>1197          <div class="peach-settings-row peach-settings-row--meta">1198            <span>Theme</span>1199            <button1200              class="peach-btn peach-btn--subtle peach-btn--sm"1201              onClick={() =>1202                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1203              }1204            >1205              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1206            </button>1207          </div>12081209          <p class="peach-settings-sub-label">ACCENT</p>1210          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1211            <span>Accent</span>1212            <div class="peach-accent-picker">1213              <For each={ACCENT_COLORS}>1214                {(color) => (1215                  <button1216                    class="peach-accent-swatch"1217                    classList={{ "is-active": props.accentColor === color.name }}1218                    style={{ background: color.hex }}1219                    onClick={() => void props.onAccentChange(color.name)}1220                    aria-label={color.name}1221                    title={color.name}1222                  />1223                )}1224              </For>1225            </div>1226          </div>12271228          <p class="peach-settings-sub-label">DISPLAY FONT</p>1229          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1230            <span>Font</span>1231            <div class="peach-font-picker">1232              <For each={fontOptions}>1233                {(opt) => (1234                  <button1235                    class="peach-font-swatch"1236                    classList={{ "is-active": props.displayFont === opt.id }}1237                    data-font-preview={opt.id}1238                    onClick={() => void props.onDisplayFontChange(opt.id)}1239                    aria-label={opt.label}1240                    title={opt.label}1241                  >1242                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1243                    <span class="peach-font-swatch-name">{opt.label}</span>1244                  </button>1245                )}1246              </For>1247            </div>1248          </div>12491250          <p class="peach-settings-sub-label">TEXT SIZE</p>1251          <div class="peach-settings-row peach-settings-row--meta">1252            <span>Size</span>1253            <div class="peach-pill-group">1254              <For each={sizeOptions}>1255                {(opt) => (1256                  <button1257                    class="peach-btn peach-btn--ghost peach-btn--sm"1258                    classList={{ "is-active": props.textSize === opt.id }}1259                    onClick={() => void props.onTextSizeChange(opt.id)}1260                  >1261                    {opt.label}1262                  </button>1263                )}1264              </For>1265            </div>1266          </div>12671268          <p class="peach-settings-sub-label">MOTION</p>1269          <div class="peach-settings-row peach-settings-row--meta">1270            <span>Reduce motion</span>1271            <button1272              class="peach-btn peach-btn--subtle peach-btn--sm"1273              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1274            >1275              {props.reduceMotion ? "On" : "Off"}1276            </button>1277          </div>1278        </div>1279      </div>1280    );1281  }12821283  function renderShortcuts() {1284    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1285    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12861287    function startCapture(action: ShortcutAction) {1288      setConflictMsg(null);1289      setCapturingAction(action);1290    }1291    function cancelCapture() {1292      setCapturingAction(null);1293      setConflictMsg(null);1294    }1295    function commitBinding(action: ShortcutAction, binding: Keybinding) {1296      const conflict = findConflict(props.shortcuts, binding, action);1297      if (conflict) {1298        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1299        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1300        return;1301      }1302      const next = { ...props.shortcuts, [action]: binding };1303      setCapturingAction(null);1304      setConflictMsg(null);1305      void props.onShortcutsChange(next);1306    }1307    function resetAction(action: ShortcutAction) {1308      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1309      void props.onShortcutsChange(next);1310    }1311    function resetAll() {1312      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1313    }13141315    // Capture-mode key handler — wired via ref onto the chip element so it1316    // catches the next non-modifier keypress and ignores other bindings.1317    function handleCaptureKey(e: KeyboardEvent) {1318      e.preventDefault();1319      e.stopPropagation();1320      if (e.key === "Escape") { cancelCapture(); return; }1321      const binding = keybindingFromEvent(e);1322      if (!binding) return; // bare modifier — keep waiting1323      const action = capturingAction();1324      if (action) commitBinding(action, binding);1325    }13261327    return (1328      <div class="peach-settings-body">1329        <div class="peach-settings-group peach-shortcuts-group">1330          <p class="peach-settings-sub-label">ENTRY VIEW</p>1331          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1332            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1333          </For>1334        </div>13351336        <div class="peach-settings-group peach-shortcuts-group">1337          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1338          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1339            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1340          </For>1341        </div>13421343        <Show when={conflictMsg()}>1344          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1345        </Show>13461347        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1348          <div class="peach-settings-row peach-settings-row--meta">1349            <span>Inline autofill on page</span>1350            <a1351              class="peach-settings-link"1352              href="chrome://extensions/shortcuts"1353              onClick={(e) => {1354                e.preventDefault();1355                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1356              }}1357            >1358              ⌘⇧P — customize ↗1359            </a>1360          </div>1361          <button1362            class="peach-btn peach-btn--subtle peach-btn--sm"1363            style="align-self:flex-start"1364            onClick={resetAll}1365          >1366            Reset all to defaults1367          </button>1368        </div>1369      </div>1370    );13711372    function renderRow(1373      desc: typeof SHORTCUT_DESCRIPTORS[number],1374      capturing: () => ShortcutAction | null,1375      onStart: (a: ShortcutAction) => void,1376      onCancel: () => void,1377      onReset: (a: ShortcutAction) => void,1378      onKey: (e: KeyboardEvent) => void,1379    ) {1380      const isActive = () => capturing() === desc.action;1381      const binding = () => props.shortcuts[desc.action];1382      const isCustom = () => {1383        const b = binding();1384        const d = DEFAULT_BINDINGS[desc.action];1385        return b.key !== d.key1386          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1387          || Boolean(b.meta)  !== Boolean(d.meta)1388          || Boolean(b.shift) !== Boolean(d.shift)1389          || Boolean(b.alt)   !== Boolean(d.alt);1390      };1391      return (1392        <div class="peach-shortcut-row">1393          <span class="peach-shortcut-row-label">{desc.label}</span>1394          <button1395            class="peach-shortcut-chip"1396            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1397            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1398            onKeyDown={(e) => { if (isActive()) onKey(e); }}1399            ref={(el) => {1400              // Auto-focus when entering capture mode so the keydown listener catches the next press.1401              if (el) {1402                queueMicrotask(() => { if (isActive()) el.focus(); });1403              }1404            }}1405          >1406            {isActive() ? "Press a key…" : formatKeybinding(binding())}1407          </button>1408          <Show when={isCustom() && !isActive()}>1409            <button1410              class="peach-shortcut-reset"1411              title="Reset to default"1412              onClick={() => onReset(desc.action)}1413            >1414              ↺1415            </button>1416          </Show>1417        </div>1418      );1419    }1420  }14211422  const PRO_FEATURES: Array<{1423    feature: ProFeature;1424    name: string;1425    desc: string;1426  }> = [1427    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1428    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1429    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1430    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1431    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1432  ];14331434  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14351436  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1437  const [deactivating, setDeactivating] = createSignal(false);14381439  async function handleDeactivateLicense() {1440    setDeactivating(true);1441    await sendMessage({ type: "DEACTIVATE_LICENSE" });1442    setLicenseInfo(null);1443    await props.onLicenseChange?.();1444    setDeactivating(false);1445  }14461447  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1448    setCanaryTestingId(entry.id);1449    setCanaryTestResult(null);1450    const res = await sendMessage({1451      type: "FIRE_CANARY_PING",1452      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1453    }) as { ok: boolean };1454    setCanaryTestResult({ id: entry.id, ok: res.ok });1455    setCanaryTestingId(null);1456    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1457  }14581459  function renderLicense() {1460    async function onActivate() {1461      await handleActivateLicense();1462      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1463        await props.onLicenseChange?.();1464      }1465    }14661467    return (1468      <div class="peach-settings-body">1469        <Show1470          when={isPro()}1471          fallback={1472            <Show1473              when={showKeyEntry()}1474              fallback={1475                <div class="peach-pro-upgrade">1476                  <div class="peach-pro-upgrade-hero">1477                    <div class="peach-pro-upgrade-mark-wrap">1478                      <PeachMark class="peach-pro-upgrade-mark" />1479                    </div>1480                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1481                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1482                  </div>14831484                  <div class="peach-pro-features">1485                    <For each={PRO_FEATURES}>1486                      {(item) => (1487                        <div1488                          class="peach-pro-feature"1489                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1490                        >1491                          <span class="peach-pro-feature-mark">✦</span>1492                          <div class="peach-pro-feature-text">1493                            <span class="peach-pro-feature-name">{item.name}</span>1494                            <span class="peach-pro-feature-desc">{item.desc}</span>1495                          </div>1496                        </div>1497                      )}1498                    </For>1499                  </div>15001501                  <div class="peach-pro-upgrade-actions">1502                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1503                    <a1504                      href="https://peachpasswords.com/#pricing"1505                      target="_blank"1506                      rel="noopener noreferrer"1507                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1508                    >1509                      Get Peach Pro1510                    </a>1511                    <div class="peach-pro-key-divider"><span>or</span></div>1512                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1513                      Activate a license key1514                    </button>1515                  </div>1516                </div>1517              }1518            >1519              <div class="peach-pro-key-ceremony">1520                <LockRuneCanvas />1521                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1522                <input1523                  class="peach-underline-input peach-pro-key-input"1524                  type="text"1525                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1526                  value={licenseKeyInput()}1527                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1528                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1529                  autocomplete="off"1530                  spellcheck={false}1531                />1532                <Show when={licenseError()}>1533                  <p class="peach-pro-key-error">That key doesn't look right.</p>1534                </Show>1535                <button1536                  class="peach-btn peach-btn--accent peach-pro-key-activate"1537                  onClick={() => void onActivate()}1538                  disabled={activatingLicense() || !licenseKeyInput().trim()}1539                >1540                  {activatingLicense() ? "activating…" : "Activate"}1541                </button>1542                <button1543                  class="peach-pro-key-cancel"1544                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1545                >1546                  cancel1547                </button>1548              </div>1549            </Show>1550          }1551        >1552          <div class="peach-pro-active">1553            <div class="peach-pro-active-hero">1554              <div class="peach-pro-active-mark-wrap">1555                <PeachMark class="peach-pro-active-mark" />1556              </div>1557              <h2 class="peach-pro-active-title">Peach Pro</h2>1558              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1559            </div>15601561            <Show when={licenseKey()}>1562              <div class="peach-settings-license">1563                <span class="peach-settings-label">LICENSE KEY</span>1564                <div class="peach-settings-license-row">1565                  <span class="peach-settings-license-key">{licenseKey()!}</span>1566                  <button1567                    class="peach-settings-license-copy"1568                    onClick={() => copyLicenseKey(licenseKey()!)}1569                  >1570                    {copied() ? "✓" : "copy"}1571                  </button>1572                </div>1573              </div>1574            </Show>15751576            <div class="peach-pro-active-features">1577              <For each={PRO_FEATURES}>1578                {(item) => (1579                  <div class="peach-pro-active-feature">1580                    <span class="peach-pro-active-feature-mark">✦</span>1581                    <span class="peach-pro-active-feature-name">{item.name}</span>1582                  </div>1583                )}1584              </For>1585            </div>15861587            <button1588              class="peach-pro-deactivate"1589              onClick={() => void handleDeactivateLicense()}1590              disabled={deactivating()}1591            >1592              {deactivating() ? "Deactivating…" : "Deactivate"}1593            </button>1594          </div>1595        </Show>1596      </div>1597    );1598  }15991600  function renderFeedback() {1601    return (1602      <div class="peach-settings-body">1603        <div class="peach-settings-group peach-feedback-group">1604          <p class="peach-feedback-intro">1605            Help us improve Peach. Every report goes directly to our inbox.1606          </p>16071608          <p class="peach-settings-sub-label">TYPE</p>1609          <div class="peach-feedback-chips">1610            <For each={FEEDBACK_CATEGORIES}>1611              {(cat) => (1612                <button1613                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1614                  onClick={() => setFbCategory(cat)}1615                >1616                  {cat}1617                </button>1618              )}1619            </For>1620          </div>16211622          <p class="peach-settings-sub-label">MESSAGE</p>1623          <textarea1624            class="peach-feedback-textarea"1625            placeholder="Describe the issue or idea..."1626            value={fbMessage()}1627            onInput={(e) => setFbMessage(e.currentTarget.value)}1628          />16291630          <p class="peach-feedback-meta">1631            Version {version} · will be included automatically1632          </p>16331634          <Show when={fbResult() === "success"}>1635            <p class="peach-feedback-result peach-feedback-result--ok">1636              Sent — thank you1637            </p>1638          </Show>1639          <Show when={fbResult() === "error"}>1640            <p class="peach-feedback-result peach-feedback-result--err">1641              Failed to send — try again1642            </p>1643          </Show>16441645          <button1646            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1647            onClick={() => void handleSubmitFeedback()}1648            disabled={fbSubmitting() || !fbMessage().trim()}1649          >1650            {fbSubmitting() ? "Sending..." : "Send feedback"}1651          </button>16521653          <p class="peach-feedback-anon">1654            Sent anonymously · no account required1655          </p>1656        </div>1657      </div>1658    );1659  }16601661  return (1662    <Show1663      when={showNewRecoveryWords()}1664      fallback={1665        <div class="peach-overlay-view ov-enter-right" style="display:flex">1666          <div class="peach-overlay-header">1667            <button1668              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1669              onClick={handleHeaderBack}1670              aria-label="Back"1671            >1672              <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1673                <path d="M6 1L1 6L6 11" />1674              </svg>1675            </button>1676            <span class="peach-overlay-title">{currentTitle()}</span>1677            <div class="peach-overlay-spacer"></div>1678          </div>16791680          <Switch fallback={renderHome()}>1681            <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1682            <Match when={activeSection() === "vault"}>{renderVault()}</Match>1683            <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1684            <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1685            <Match when={activeSection() === "license"}>{renderLicense()}</Match>1686            <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1687          </Switch>1688        </div>1689      }1690    >1691      <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1692    </Show>1693  );1694}