Settings.tsx

65.3 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 type { Canary } from "../../shared/types";6import { ACCENT_COLORS } from "../../shared/types";7import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";8import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";9import {10  DEFAULT_BINDINGS,11  findConflict,12  formatKeybinding,13  keybindingFromEvent,14  SHORTCUT_DESCRIPTORS,15  type Keybinding,16  type ShortcutAction,17} from "../../shared/shortcuts";1819interface LicenseInfo {20  valid: boolean;21  plan: "free" | "pro";22  issued?: string;23}2425interface SettingsProps {26  entriesCount: number;27  breachMonitoring: boolean;28  lockOnClose: boolean;29  autoLockMinutes: number;30  trashRetentionDays: number;31  theme: "dark" | "light";32  accentColor: AccentColorName;33  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";34  textSize: "small" | "medium" | "large";35  reduceMotion: boolean;36  isPro?: boolean;37  initialSection?: string | null;38  upgradeHighlight?: ProFeature | null;39  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;40  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;41  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;42  onTrashRetentionChange: (days: number) => void | Promise<void>;43  onThemeChange: (theme: "dark" | "light") => void;44  onAccentChange: (name: AccentColorName) => void | Promise<void>;45  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;46  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;47  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;48  shortcuts: Record<ShortcutAction, Keybinding>;49  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;50  onLicenseChange?: () => void | Promise<void>;51  onBack: () => void;52  onImport: () => void;53  onExport: () => void;54  onBreachCheckDone: () => void;55  onOpenHealth?: () => void;56  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;57  onOpenFileImport?: () => void;58}5960type SectionName =61  | "security"62  | "vault"63  | "sync"64  | "appearance"65  | "shortcuts"66  | "license"67  | "feedback";6869const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7071function formatActivatedSince(issued: string | undefined): string {72  if (!issued) return "Active";73  try {74    const d = new Date(issued);75    if (Number.isNaN(d.getTime())) return "Active";76    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;77  } catch {78    return "Active";79  }80}8182function isSectionName(value: string): value is SectionName {83  return (84    value === "security" ||85    value === "vault" ||86    value === "sync" ||87    value === "appearance" ||88    value === "shortcuts" ||89    value === "license" ||90    value === "feedback"91  );92}9394export function SettingsView(props: SettingsProps) {95  const version = chrome.runtime.getManifest().version;96  const initial = props.initialSection;97  const [activeSection, setActiveSection] = createSignal<SectionName | null>(98    initial && isSectionName(initial) ? (initial as SectionName) : null99  );100101  const [checkingBreaches, setCheckingBreaches] = createSignal(false);102  const [breachResult, setBreachResult] = createSignal<string | null>(null);103  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);104  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");105  const [licenseError, setLicenseError] = createSignal<string | null>(null);106  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);107  const [activatingLicense, setActivatingLicense] = createSignal(false);108109  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);110  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);111  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");112  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);113  // Prefer the prop (cached at App boot), fall back to the sync-state probe.114  // The prop is authoritative once present so license activation reflects115  // immediately without waiting for a sync round-trip.116  const [syncProStatus, setSyncProStatus] = createSignal(false);117  const isPro = () => props.isPro ?? syncProStatus();118  const [syncingNow, setSyncingNow] = createSignal(false);119  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);120  const [passkeySaving, setPasskeySaving] = createSignal(false);121122  const [dedupCount, setDedupCount] = createSignal<number | null>(null);123  const [dedupRunning, setDedupRunning] = createSignal(false);124  const [dedupDone, setDedupDone] = createSignal<number | null>(null);125126  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);127128  const [showChangePw, setShowChangePw] = createSignal(false);129  const [changePwCurrent, setChangePwCurrent] = createSignal("");130  const [changePwNew, setChangePwNew] = createSignal("");131  const [changePwConfirm, setChangePwConfirm] = createSignal("");132  const [changePwSaving, setChangePwSaving] = createSignal(false);133  const [changePwError, setChangePwError] = createSignal<string | null>(null);134  const [changePwSuccess, setChangePwSuccess] = createSignal(false);135136  const [duressEnabled, setDuressEnabled] = createSignal(false);137  const [showDuressSetup, setShowDuressSetup] = createSignal(false);138  const [duressPassword, setDuressPassword] = createSignal("");139  const [duressConfirm, setDuressConfirm] = createSignal("");140  const [duressMainPassword, setDuressMainPassword] = createSignal("");141  const [duressSaving, setDuressSaving] = createSignal(false);142  const [duressError, setDuressError] = createSignal<string | null>(null);143  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);144145  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);146  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);147  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);148149  const [fbCategory, setFbCategory] = createSignal("Bug report");150  const [fbMessage, setFbMessage] = createSignal("");151  const [fbSubmitting, setFbSubmitting] = createSignal(false);152  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);153154  onMount(async () => {155    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([156      sendMessage({ type: "GET_LICENSE" }),157      sendMessage({ type: "GET_SYNC_STATE" }),158      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),159      sendMessage({ type: "IS_DURESS_ENABLED" }),160      sendMessage({ type: "GET_SETTINGS" }),161    ]);162163    if ((licenseRes as { ok: boolean }).ok) {164      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);165    }166167    if ((syncRes as { ok: boolean }).ok) {168      const data = syncRes as { ok: boolean; data?: {169        pairedDevices?: PairedDevice[];170        trustedNetworks?: TrustedNetwork[];171        status?: SyncStatus;172        lastSyncAt?: number;173        isPro?: boolean;174      }};175      if (data.data) {176        setPairedDevices(data.data.pairedDevices ?? []);177        setTrustedNetworks(data.data.trustedNetworks ?? []);178        setSyncStatus(data.data.status ?? "offline");179        setLastSyncAt(data.data.lastSyncAt);180        setSyncProStatus(data.data.isPro ?? false);181      }182    }183184    if ((passkeyRes as { ok: boolean }).ok) {185      setPasskeyState(186        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null187      );188    }189190    if ((duressRes as { ok: boolean }).ok) {191      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);192    }193194    if ((settingsRes as { ok: boolean }).ok) {195      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;196      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);197    }198199    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });200    if ((entriesRes as { ok: boolean }).ok) {201      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];202      setCanaryEntries(203        entries204          .filter((e) => e.canary?.webhookUrl)205          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))206      );207    }208209    chrome.runtime.onMessage.addListener((msg) => {210      if (msg.type === "SYNC_STATE_UPDATE") {211        setPairedDevices(msg.pairedDevices ?? []);212        setTrustedNetworks(msg.trustedNetworks ?? []);213        setSyncStatus(msg.status ?? "offline");214        setLastSyncAt(msg.lastSyncAt);215        setSyncProStatus(msg.isPro ?? false);216      }217    });218  });219220  async function handleChangePassword() {221    setChangePwError(null);222    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }223    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }224    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }225    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }226    setChangePwSaving(true);227    const res = await sendMessage({228      type: "CHANGE_PASSWORD",229      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },230    }) as { ok: boolean; error?: string };231    if (res.ok) {232      setChangePwSuccess(true);233      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");234      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);235    } else {236      setChangePwError(res.error ?? "failed to change password");237    }238    setChangePwSaving(false);239  }240241  async function handleActivateLicense() {242    const key = licenseKeyInput().trim();243    if (!key) return;244    setActivatingLicense(true);245    setLicenseError(null);246    setLicenseSuccess(null);247    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });248    setActivatingLicense(false);249    if ((res as { ok: boolean }).ok) {250      const data = (res as { ok: boolean; data?: LicenseInfo }).data;251      if (data?.valid) {252        setLicenseInfo(data);253        setLicenseSuccess("License activated!");254        setLicenseKeyInput("");255      } else {256        setLicenseError("Invalid license key");257      }258    } else {259      setLicenseError("Activation failed");260    }261  }262263  async function handleTogglePasskeys() {264    const current = passkeyState();265    if (!current || !current.supported) return;266    setPasskeySaving(true);267    const res = await sendMessage({268      type: "SET_PASSKEY_PROVIDER_ENABLED",269      payload: { enabled: !current.enabled },270    });271    setPasskeySaving(false);272    if ((res as { ok: boolean }).ok) {273      setPasskeyState(274        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null275      );276    }277  }278279  async function handleCheckAllBreaches() {280    setCheckingBreaches(true);281    setBreachResult(null);282    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });283    setCheckingBreaches(false);284    if ((res as { ok: boolean }).ok) {285      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};286      const breached = Object.values(data).filter((count) => count > 0).length;287      setBreachResult(288        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"289      );290      props.onBreachCheckDone();291    }292  }293294  async function handleSyncNow() {295    if (syncStatus() === "not_trusted") return;296    setSyncingNow(true);297    await sendMessage({ type: "TRIGGER_SYNC" });298    setTimeout(() => setSyncingNow(false), 3000);299  }300301  async function handleRemoveDevice(deviceId: string) {302    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });303  }304305  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {306    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });307  }308309  async function handleMarkNetworkTrusted() {310    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });311  }312313  function formatRelativeTime(timestamp?: number): string {314    if (!timestamp) return "Never";315    const diff = Date.now() - timestamp;316    const minutes = Math.floor(diff / 60000);317    const hours = Math.floor(diff / 3600000);318    const days = Math.floor(diff / 86400000);319    if (minutes < 1) return "Just now";320    if (minutes < 60) return `${minutes}m ago`;321    if (hours < 24) return `${hours}h ago`;322    return `${days}d ago`;323  }324325  function getStatusColor(status: SyncStatus): string {326    switch (status) {327      case "synced": return "var(--peach-sync-ok)";328      case "syncing": return "var(--peach-sync-busy)";329      case "no_peers": return "var(--peach-sync-off)";330      case "not_trusted": return "var(--peach-sync-err)";331      case "offline": return "var(--peach-sync-off)";332      default: return "var(--peach-sync-off)";333    }334  }335336  function getStatusLabel(status: SyncStatus): string {337    switch (status) {338      case "synced": return "Synced";339      case "syncing": return "Syncing";340      case "no_peers": return "No peers visible";341      case "not_trusted": return "Not on a trusted network";342      case "offline": return "Offline";343      default: return "Unknown";344    }345  }346347  function isCurrentNetworkTrusted(): boolean {348    return trustedNetworks().length > 0;349  }350351  async function handleSetupDuress() {352    const mainPw = duressMainPassword().trim();353    const duressPw = duressPassword().trim();354    const confirmPw = duressConfirm().trim();355356    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }357    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }358    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }359    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }360361    setDuressSaving(true);362    setDuressError(null);363364    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };365    if (!verifyRes.ok || !verifyRes.data) {366      setDuressError("incorrect main password");367      setDuressSaving(false);368      return;369    }370371    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };372    setDuressSaving(false);373    if (res.ok) {374      setDuressEnabled(true);375      setShowDuressSetup(false);376      setDuressPassword("");377      setDuressConfirm("");378      setDuressMainPassword("");379      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");380      setTimeout(() => setDuressSuccess(null), 5000);381    } else {382      setDuressError(res.error ?? "failed to set duress password");383    }384  }385386  async function handleClearDeclinedCards() {387    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });388    setDeclinedCardCount(0);389  }390391  async function handleCountDuplicates() {392    setDedupRunning(true);393    setDedupDone(null);394    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };395    setDedupRunning(false);396    if (res.ok) setDedupCount(res.data ?? 0);397  }398399  async function handleDeduplicate() {400    setDedupRunning(true);401    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };402    setDedupRunning(false);403    if (res.ok) {404      setDedupDone(res.data ?? 0);405      setDedupCount(0);406    }407  }408409  async function handleDisableDuress() {410    const mainPw = duressMainPassword().trim();411    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }412413    setDuressSaving(true);414    setDuressError(null);415416    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };417    if (!verifyRes.ok || !verifyRes.data) {418      setDuressError("incorrect main password");419      setDuressSaving(false);420      return;421    }422423    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };424    setDuressSaving(false);425    if (res.ok) {426      setDuressEnabled(false);427      setShowDuressSetup(false);428      setDuressMainPassword("");429      setDuressSuccess("Duress password disabled.");430      setTimeout(() => setDuressSuccess(null), 4000);431    } else {432      setDuressError(res.error ?? "failed to disable duress password");433    }434  }435436  async function handleSubmitFeedback() {437    if (!fbMessage().trim() || fbSubmitting()) return;438    setFbSubmitting(true);439    setFbResult(null);440    const res = await sendMessage({441      type: "SUBMIT_FEEDBACK",442      payload: {443        category: fbCategory(),444        message: fbMessage().trim(),445        version,446        browser: navigator.userAgent,447      },448    }) as { ok: boolean; error?: string };449    setFbSubmitting(false);450    if (res.ok) {451      setFbResult("success");452      setFbMessage("");453      setTimeout(() => setFbResult(null), 4000);454    } else {455      setFbResult("error");456    }457  }458459  function handleHeaderBack() {460    if (activeSection() !== null) {461      setActiveSection(null);462    } else {463      props.onBack();464    }465  }466467  function currentTitle(): string {468    const s = activeSection();469    if (s === null) return "settings";470    if (s === "license") return "account";471    return s;472  }473474  function renderCatRow(opts: {475    section: SectionName;476    name: string;477    desc: string;478    icon: () => any;479    pro?: boolean;480  }) {481    return (482      <button483        class="peach-settings-cat-row"484        onClick={() => setActiveSection(opts.section)}485      >486        <div class="peach-settings-cat-icon">{opts.icon()}</div>487        <div class="peach-settings-cat-content">488          <span class="peach-settings-cat-name">489            {opts.name}490            <Show when={opts.pro && !isPro()}>491              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>492            </Show>493          </span>494          <span class="peach-settings-cat-desc">{opts.desc}</span>495        </div>496        <svg497          class="peach-settings-cat-chevron"498          width="6" height="10" viewBox="0 0 6 10" fill="none"499          stroke="currentColor" stroke-width="1.1"500          stroke-linecap="round" stroke-linejoin="round"501        >502          <path d="M1 1l4 4-4 4" />503        </svg>504      </button>505    );506  }507508  const iconShield = () => (509    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">510      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>511    </svg>512  );513  const iconKey = () => (514    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">515      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>516    </svg>517  );518  const iconLock = () => (519    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">520      <rect x="3" y="7" width="10" height="7" rx="1.5"/>521      <path d="M6 7V5a3 3 0 016 0v2"/>522    </svg>523  );524  const iconSync = () => (525    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">526      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>527      <path d="M14 6l-2 2-2-2"/>528      <path d="M2 10l2-2 2 2"/>529    </svg>530  );531  const iconSun = () => (532    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">533      <circle cx="8" cy="8" r="3"/>534      <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"/>535    </svg>536  );537  const iconKeys = () => (538    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">539      <rect x="2" y="5" width="5" height="4" rx="1"/>540      <rect x="9" y="7" width="5" height="4" rx="1"/>541      <path d="M7 7h2m-2 2h2"/>542    </svg>543  );544  const iconBubble = () => (545    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">546      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>547    </svg>548  );549550  function renderHome() {551    return (552      <div class="peach-settings-body">553        <p class="peach-settings-nav-label">PROTECTION</p>554        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}555        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}556557        <p class="peach-settings-nav-label">VAULT</p>558        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}559        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}560561        <p class="peach-settings-nav-label">PREFERENCES</p>562        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}563        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}564565        <p class="peach-settings-nav-label">SUPPORT</p>566        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}567568        <div class="peach-settings-footer">569          <span>Peach {version}</span>570          <span class="peach-divider"></span>571          <span>Browser extension</span>572        </div>573      </div>574    );575  }576577  function renderSecurity() {578    return (579      <div class="peach-settings-body">580        <div class="peach-settings-group">581          <p class="peach-settings-sub-label">LOCKING</p>582          <div class="peach-settings-row">583            <span>Lock on close</span>584            <button585              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}586              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}587              aria-label="Toggle lock on close"588            >589              <span class="peach-toggle-knob"></span>590            </button>591          </div>592          <div class="peach-settings-row">593            <span>Auto-lock after</span>594            <select595              class="peach-settings-select"596              value={String(props.autoLockMinutes)}597              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}598            >599              <option value="1">1 minute</option>600              <option value="5">5 minutes</option>601              <option value="15">15 minutes</option>602              <option value="30">30 minutes</option>603              <option value="0">Never</option>604            </select>605          </div>606607          <p class="peach-settings-sub-label">BREACH PROTECTION</p>608          <div class="peach-settings-row">609            <span>Breach monitoring</span>610            <button611              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}612              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}613              aria-label="Toggle breach monitoring"614            >615              <span class="peach-toggle-knob"></span>616            </button>617          </div>618          <div class="peach-settings-row peach-settings-row--meta">619            <span>Check all passwords</span>620            <button621              class="peach-btn peach-btn--subtle peach-btn--sm"622              onClick={() => void handleCheckAllBreaches()}623              disabled={checkingBreaches()}624            >625              {checkingBreaches() ? "checking..." : "run"}626            </button>627          </div>628          <Show when={breachResult()}>629            <div class="peach-settings-row peach-settings-row--meta">630              <span>Last check</span>631              <span class="peach-settings-value">{breachResult()}</span>632            </div>633          </Show>634635          <p class="peach-settings-sub-label">MASTER PASSWORD</p>636          <div class="peach-settings-row peach-settings-row--meta">637            <span>Master password</span>638            <button639              class="peach-btn peach-btn--subtle peach-btn--sm"640              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}641            >642              {showChangePw() ? "cancel" : "change"}643            </button>644          </div>645          <Show when={showChangePw()}>646            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">647              <input648                class="peach-settings-input"649                type="password"650                placeholder="current password"651                value={changePwCurrent()}652                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}653                autocomplete="current-password"654              />655              <input656                class="peach-settings-input"657                type="password"658                placeholder="new password (min 12 chars)"659                value={changePwNew()}660                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}661                autocomplete="new-password"662              />663              <input664                class="peach-settings-input"665                type="password"666                placeholder="confirm new password"667                value={changePwConfirm()}668                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}669                autocomplete="new-password"670                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}671              />672              <Show when={changePwError()}>673                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>674              </Show>675              <Show when={changePwSuccess()}>676                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>677              </Show>678              <button679                class="peach-btn peach-btn--accent peach-btn--sm"680                style="align-self:flex-start"681                onClick={() => void handleChangePassword()}682                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}683              >684                {changePwSaving() ? "saving…" : "save new password"}685              </button>686            </div>687          </Show>688689          <p class="peach-settings-sub-label">DURESS PASSWORD</p>690          <div class="peach-settings-row peach-settings-row--top">691            <div class="peach-settings-row-main">692              <span>Duress password</span>693              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">694                If entered under coercion, shows a decoy empty vault instead of your real one.695              </span>696            </div>697            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>698              {duressEnabled() ? "on" : "off"}699            </div>700          </div>701702          <Show when={duressEnabled() && !showDuressSetup()}>703            <div class="peach-settings-row peach-settings-row--meta">704              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">705                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.706              </span>707            </div>708          </Show>709710          <Show when={duressSuccess()}>711            <div class="peach-settings-row peach-settings-row--meta">712              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>713            </div>714          </Show>715716          <Show when={!showDuressSetup()}>717            <div class="peach-settings-row peach-settings-row--meta">718              <button719                class="peach-btn peach-btn--subtle peach-btn--sm"720                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}721              >722                {duressEnabled() ? "Change / disable" : "Set up"}723              </button>724            </div>725          </Show>726727          <Show when={showDuressSetup()}>728            <div class="peach-duress-form">729              <label class="peach-label">main password (to confirm)</label>730              <input731                class="peach-settings-input"732                type="password"733                placeholder="your current master password"734                autocomplete="current-password"735                value={duressMainPassword()}736                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}737              />738              <Show when={!duressEnabled()}>739                <label class="peach-label">new duress password</label>740                <input741                  class="peach-settings-input"742                  type="password"743                  placeholder="duress password (min 8 chars)"744                  autocomplete="new-password"745                  value={duressPassword()}746                  onInput={(e) => setDuressPassword(e.currentTarget.value)}747                />748                <label class="peach-label">confirm duress password</label>749                <input750                  class="peach-settings-input"751                  type="password"752                  placeholder="confirm duress password"753                  autocomplete="new-password"754                  value={duressConfirm()}755                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}756                />757              </Show>758759              <Show when={duressError()}>760                <p class="peach-hint peach-hint-error">{duressError()}</p>761              </Show>762763              <div class="peach-duress-actions">764                <button765                  class="peach-btn peach-btn--subtle peach-btn--sm"766                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}767                >768                  cancel769                </button>770                <Show when={duressEnabled()}>771                  <button772                    class="peach-btn peach-btn--ghost peach-btn--sm"773                    style="color:var(--peach-breach)"774                    onClick={() => void handleDisableDuress()}775                    disabled={duressSaving()}776                  >777                    {duressSaving() ? "disabling..." : "disable duress"}778                  </button>779                </Show>780                <Show when={!duressEnabled()}>781                  <button782                    class="peach-btn peach-btn--accent peach-btn--sm"783                    onClick={() => void handleSetupDuress()}784                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}785                  >786                    {duressSaving() ? "saving..." : "save"}787                  </button>788                </Show>789              </div>790            </div>791          </Show>792793          <p class="peach-settings-sub-label">CANARY ENTRIES</p>794          <div class="peach-settings-row peach-settings-row--top">795            <div class="peach-settings-row-main">796              <span>Canary entries</span>797              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">798                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.799              </span>800            </div>801            <span class="peach-settings-value" style="flex-shrink:0">802              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}803            </span>804          </div>805          <Show when={canaryEntries().length > 0}>806            <For each={canaryEntries()}>807              {(entry) => (808                <div class="peach-settings-row peach-settings-row--meta">809                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>810                  <button811                    class="peach-btn peach-btn--subtle peach-btn--sm"812                    onClick={() => void handleTestCanary(entry)}813                    disabled={canaryTestingId() === entry.id}814                    style="flex-shrink:0"815                  >816                    {canaryTestingId() === entry.id817                      ? "sending…"818                      : canaryTestResult()?.id === entry.id819                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")820                        : "test"}821                  </button>822                </div>823              )}824            </For>825          </Show>826827          <p class="peach-settings-sub-label">PASSKEYS</p>828          <div class="peach-settings-row peach-settings-row--top">829            <div class="peach-settings-row-main">830              <span>Intercept passkey requests</span>831              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">832                Peach can handle passkey sign-in and creation on supported Chromium browsers.833              </span>834            </div>835            <button836              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}837              onClick={() => void handleTogglePasskeys()}838              aria-label="Toggle passkey interception"839              disabled={!passkeyState()?.supported || passkeySaving()}840            >841              <span class="peach-toggle-knob"></span>842            </button>843          </div>844          <div class="peach-settings-row peach-settings-row--meta">845            <span>Status</span>846            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>847          </div>848          <div class="peach-settings-row peach-settings-row--meta">849            <span>Browser</span>850            <span class="peach-settings-value">851              {passkeyState()?.browserLabel ?? "Unknown"}852              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}853            </span>854          </div>855          <div class="peach-info-notice" style="margin-top:8px">856            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.857          </div>858          <Show when={passkeyState()?.conflict}>859            <div class="peach-settings-row peach-settings-row--meta">860              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>861            </div>862          </Show>863        </div>864      </div>865    );866  }867868  function renderVault() {869    return (870      <div class="peach-settings-body">871        <div class="peach-settings-group">872          <p class="peach-settings-sub-label">ENTRIES</p>873          <div class="peach-settings-row peach-settings-row--meta">874            <span>Entries</span>875            <span class="peach-settings-value">{props.entriesCount}</span>876          </div>877          <div class="peach-settings-row peach-settings-row--meta">878            <span>Duplicates</span>879            <Show when={dedupDone() !== null} fallback={880              <button881                class="peach-btn peach-btn--subtle peach-btn--sm"882                onClick={() => void handleCountDuplicates()}883                disabled={dedupRunning()}884              >885                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}886              </button>887            }>888              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">889                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}890              </span>891            </Show>892          </div>893          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>894            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">895              <div class="peach-restore-warning">896                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.897              </div>898              <div style="display:flex;gap:6px">899                <button900                  class="peach-btn peach-btn--subtle peach-btn--sm"901                  onClick={() => setDedupCount(null)}902                >903                  cancel904                </button>905                <button906                  class="peach-btn peach-btn--accent peach-btn--sm"907                  onClick={() => void handleDeduplicate()}908                  disabled={dedupRunning()}909                >910                  {dedupRunning() ? "removing..." : "remove duplicates"}911                </button>912              </div>913            </div>914          </Show>915916          <p class="peach-settings-sub-label">MAINTENANCE</p>917          <div class="peach-settings-row peach-settings-row--meta">918            <span>Trash auto-delete</span>919            <select920              class="peach-settings-select"921              value={String(props.trashRetentionDays)}922              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}923            >924              <option value="7">7 days</option>925              <option value="30">30 days</option>926              <option value="90">90 days</option>927              <option value="0">Never</option>928            </select>929          </div>930          <div class="peach-settings-row peach-settings-row--meta">931            <span>Vault Health</span>932            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>933              open934            </button>935          </div>936          <Show when={declinedCardCount() > 0}>937            <div class="peach-settings-row peach-settings-row--meta">938              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>939              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>940                Clear941              </button>942            </div>943          </Show>944945          <p class="peach-settings-sub-label">DATA</p>946          <div class="peach-settings-row peach-settings-row--meta">947            <span>Import passwords</span>948            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>949              open950            </button>951          </div>952          <div class="peach-settings-row peach-settings-row--meta">953            <span>Export backup</span>954            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>955              open956            </button>957          </div>958          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">959            <span>Replace vault from backup</span>960            <div class="peach-restore-warning" style="margin-top:2px">961              This will permanently replace your current vault with the backup.962            </div>963            <div style="display:flex;gap:6px">964              <button965                class="peach-btn peach-btn--subtle peach-btn--sm"966                onClick={() => props.onOpenFileImport?.()}967              >968                backup file969              </button>970            </div>971          </div>972        </div>973      </div>974    );975  }976977  function renderSync() {978    if (!isPro()) {979      return (980        <div class="peach-settings-body">981          <div class="peach-pro-upgrade">982            <div class="peach-pro-upgrade-hero">983              <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">984                <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>985                <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>986                <path d="M13.5 15.5C15 12 17 10 18 10C19 10 21 12 22.5 15.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>987                <path d="M13.5 20.5C15 24 17 26 18 26C19 26 21 24 22.5 20.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>988                <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>989                <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>990                <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>991              </svg>992              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>993              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>994            </div>995996            <div class="peach-pro-features">997              <div class="peach-pro-feature">998                <span class="peach-pro-feature-mark">✦</span>999                <div class="peach-pro-feature-text">1000                  <span class="peach-pro-feature-name">Always in sync</span>1001                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1002                </div>1003              </div>1004              <div class="peach-pro-feature">1005                <span class="peach-pro-feature-mark">✦</span>1006                <div class="peach-pro-feature-text">1007                  <span class="peach-pro-feature-name">No cloud. No relay.</span>1008                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1009                </div>1010              </div>1011              <div class="peach-pro-feature">1012                <span class="peach-pro-feature-mark">✦</span>1013                <div class="peach-pro-feature-text">1014                  <span class="peach-pro-feature-name">Tailscale supported</span>1015                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1016                </div>1017              </div>1018              <div class="peach-pro-feature">1019                <span class="peach-pro-feature-mark">✦</span>1020                <div class="peach-pro-feature-text">1021                  <span class="peach-pro-feature-name">Zero friction after setup</span>1022                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1023                </div>1024              </div>1025            </div>10261027            <button1028              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1029              onClick={() => setActiveSection("license")}1030            >1031              Upgrade to Pro1032            </button>1033            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1034              Already have a key? Enter it →1035            </button>1036          </div>1037        </div>1038      );1039    }10401041    return (1042      <div class="peach-settings-body">1043        <div class="peach-settings-group">1044          <div class="peach-settings-row">1045            <span>Status</span>1046            <span class="peach-settings-value peach-settings-value--status">1047              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1048              {getStatusLabel(syncStatus())}1049            </span>1050          </div>1051          <div class="peach-settings-row peach-settings-row--meta">1052            <span>Last sync</span>1053            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1054          </div>1055          <div class="peach-settings-row peach-settings-row--meta">1056            <button1057              class="peach-btn peach-btn--subtle peach-btn--sm"1058              onClick={() => void handleSyncNow()}1059              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1060            >1061              {syncingNow() ? "Syncing..." : "Sync now"}1062            </button>1063          </div>10641065          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1066            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1067            {pairedDevices().map((device) => (1068              <div class="peach-sync-device-row">1069                <div class="peach-sync-device-info">1070                  <span class="peach-sync-device-name">{device.displayName}</span>1071                  <span class="peach-sync-device-meta">1072                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1073                  </span>1074                </div>1075                <button1076                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1077                  onClick={() => void handleRemoveDevice(device.deviceId)}1078                >1079                  Remove1080                </button>1081              </div>1082            ))}1083            <div class="peach-settings-actions--row">1084              <button1085                class="peach-btn peach-btn--subtle peach-btn--sm"1086                onClick={() => props.onOpenSyncPairing?.("initiate")}1087              >1088                Pair a new device1089              </button>1090              <button1091                class="peach-btn peach-btn--subtle peach-btn--sm"1092                onClick={() => props.onOpenSyncPairing?.("accept")}1093              >1094                Pair with another device1095              </button>1096            </div>1097          </div>10981099          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1100            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1101            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1102              <div class="peach-sync-device-row">1103                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1104                <button1105                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1106                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1107                >1108                  Remove1109                </button>1110              </div>1111            ))}1112            <div class="peach-settings-actions--row">1113              <Show when={!isCurrentNetworkTrusted()}>1114                <button1115                  class="peach-btn peach-btn--subtle peach-btn--sm"1116                  onClick={() => void handleMarkNetworkTrusted()}1117                >1118                  Mark current network as trusted1119                </button>1120              </Show>1121            </div>1122            <span class="peach-sync-device-meta">1123              Tailscale is always trusted1124            </span>1125          </div>1126        </div>1127      </div>1128    );1129  }11301131  function renderAppearance() {1132    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1133      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1134      { id: "cormorant", label: "Cormorant", preview: "Aa" },1135      { id: "inter", label: "Inter", preview: "Aa" },1136      { id: "system", label: "System", preview: "Aa" },1137    ];1138    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1139      { id: "small", label: "Small" },1140      { id: "medium", label: "Default" },1141      { id: "large", label: "Large" },1142    ];1143    return (1144      <div class="peach-settings-body">1145        <div class="peach-settings-group">1146          <p class="peach-settings-sub-label">THEME</p>1147          <div class="peach-settings-row peach-settings-row--meta">1148            <span>Theme</span>1149            <button1150              class="peach-btn peach-btn--subtle peach-btn--sm"1151              onClick={() =>1152                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1153              }1154            >1155              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1156            </button>1157          </div>11581159          <p class="peach-settings-sub-label">ACCENT</p>1160          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1161            <span>Accent</span>1162            <div class="peach-accent-picker">1163              <For each={ACCENT_COLORS}>1164                {(color) => (1165                  <button1166                    class="peach-accent-swatch"1167                    classList={{ "is-active": props.accentColor === color.name }}1168                    style={{ background: color.hex }}1169                    onClick={() => void props.onAccentChange(color.name)}1170                    aria-label={color.name}1171                    title={color.name}1172                  />1173                )}1174              </For>1175            </div>1176          </div>11771178          <p class="peach-settings-sub-label">DISPLAY FONT</p>1179          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1180            <span>Font</span>1181            <div class="peach-font-picker">1182              <For each={fontOptions}>1183                {(opt) => (1184                  <button1185                    class="peach-font-swatch"1186                    classList={{ "is-active": props.displayFont === opt.id }}1187                    data-font-preview={opt.id}1188                    onClick={() => void props.onDisplayFontChange(opt.id)}1189                    aria-label={opt.label}1190                    title={opt.label}1191                  >1192                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1193                    <span class="peach-font-swatch-name">{opt.label}</span>1194                  </button>1195                )}1196              </For>1197            </div>1198          </div>11991200          <p class="peach-settings-sub-label">TEXT SIZE</p>1201          <div class="peach-settings-row peach-settings-row--meta">1202            <span>Size</span>1203            <div class="peach-pill-group">1204              <For each={sizeOptions}>1205                {(opt) => (1206                  <button1207                    class="peach-btn peach-btn--ghost peach-btn--sm"1208                    classList={{ "is-active": props.textSize === opt.id }}1209                    onClick={() => void props.onTextSizeChange(opt.id)}1210                  >1211                    {opt.label}1212                  </button>1213                )}1214              </For>1215            </div>1216          </div>12171218          <p class="peach-settings-sub-label">MOTION</p>1219          <div class="peach-settings-row peach-settings-row--meta">1220            <span>Reduce motion</span>1221            <button1222              class="peach-btn peach-btn--subtle peach-btn--sm"1223              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1224            >1225              {props.reduceMotion ? "On" : "Off"}1226            </button>1227          </div>1228        </div>1229      </div>1230    );1231  }12321233  function renderShortcuts() {1234    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1235    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12361237    function startCapture(action: ShortcutAction) {1238      setConflictMsg(null);1239      setCapturingAction(action);1240    }1241    function cancelCapture() {1242      setCapturingAction(null);1243      setConflictMsg(null);1244    }1245    function commitBinding(action: ShortcutAction, binding: Keybinding) {1246      const conflict = findConflict(props.shortcuts, binding, action);1247      if (conflict) {1248        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1249        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1250        return;1251      }1252      const next = { ...props.shortcuts, [action]: binding };1253      setCapturingAction(null);1254      setConflictMsg(null);1255      void props.onShortcutsChange(next);1256    }1257    function resetAction(action: ShortcutAction) {1258      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1259      void props.onShortcutsChange(next);1260    }1261    function resetAll() {1262      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1263    }12641265    // Capture-mode key handler — wired via ref onto the chip element so it1266    // catches the next non-modifier keypress and ignores other bindings.1267    function handleCaptureKey(e: KeyboardEvent) {1268      e.preventDefault();1269      e.stopPropagation();1270      if (e.key === "Escape") { cancelCapture(); return; }1271      const binding = keybindingFromEvent(e);1272      if (!binding) return; // bare modifier — keep waiting1273      const action = capturingAction();1274      if (action) commitBinding(action, binding);1275    }12761277    return (1278      <div class="peach-settings-body">1279        <div class="peach-settings-group peach-shortcuts-group">1280          <p class="peach-settings-sub-label">ENTRY VIEW</p>1281          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1282            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1283          </For>1284        </div>12851286        <div class="peach-settings-group peach-shortcuts-group">1287          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1288          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1289            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1290          </For>1291        </div>12921293        <Show when={conflictMsg()}>1294          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1295        </Show>12961297        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1298          <div class="peach-settings-row peach-settings-row--meta">1299            <span>Inline autofill on page</span>1300            <a1301              class="peach-settings-link"1302              href="chrome://extensions/shortcuts"1303              onClick={(e) => {1304                e.preventDefault();1305                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1306              }}1307            >1308              ⌘⇧P — customize ↗1309            </a>1310          </div>1311          <button1312            class="peach-btn peach-btn--subtle peach-btn--sm"1313            style="align-self:flex-start"1314            onClick={resetAll}1315          >1316            Reset all to defaults1317          </button>1318        </div>1319      </div>1320    );13211322    function renderRow(1323      desc: typeof SHORTCUT_DESCRIPTORS[number],1324      capturing: () => ShortcutAction | null,1325      onStart: (a: ShortcutAction) => void,1326      onCancel: () => void,1327      onReset: (a: ShortcutAction) => void,1328      onKey: (e: KeyboardEvent) => void,1329    ) {1330      const isActive = () => capturing() === desc.action;1331      const binding = () => props.shortcuts[desc.action];1332      const isCustom = () => {1333        const b = binding();1334        const d = DEFAULT_BINDINGS[desc.action];1335        return b.key !== d.key1336          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1337          || Boolean(b.meta)  !== Boolean(d.meta)1338          || Boolean(b.shift) !== Boolean(d.shift)1339          || Boolean(b.alt)   !== Boolean(d.alt);1340      };1341      return (1342        <div class="peach-shortcut-row">1343          <span class="peach-shortcut-row-label">{desc.label}</span>1344          <button1345            class="peach-shortcut-chip"1346            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1347            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1348            onKeyDown={(e) => { if (isActive()) onKey(e); }}1349            ref={(el) => {1350              // Auto-focus when entering capture mode so the keydown listener catches the next press.1351              if (el) {1352                queueMicrotask(() => { if (isActive()) el.focus(); });1353              }1354            }}1355          >1356            {isActive() ? "Press a key…" : formatKeybinding(binding())}1357          </button>1358          <Show when={isCustom() && !isActive()}>1359            <button1360              class="peach-shortcut-reset"1361              title="Reset to default"1362              onClick={() => onReset(desc.action)}1363            >1364              ↺1365            </button>1366          </Show>1367        </div>1368      );1369    }1370  }13711372  const PRO_FEATURES: Array<{1373    feature: ProFeature;1374    name: string;1375    desc: string;1376  }> = [1377    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1378    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1379    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1380    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1381    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1382  ];13831384  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1385  const [deactivating, setDeactivating] = createSignal(false);13861387  async function handleDeactivateLicense() {1388    setDeactivating(true);1389    await sendMessage({ type: "DEACTIVATE_LICENSE" });1390    setLicenseInfo(null);1391    await props.onLicenseChange?.();1392    setDeactivating(false);1393  }13941395  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1396    setCanaryTestingId(entry.id);1397    setCanaryTestResult(null);1398    const res = await sendMessage({1399      type: "FIRE_CANARY_PING",1400      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1401    }) as { ok: boolean };1402    setCanaryTestResult({ id: entry.id, ok: res.ok });1403    setCanaryTestingId(null);1404    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1405  }14061407  function renderLicense() {1408    async function onActivate() {1409      await handleActivateLicense();1410      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1411        await props.onLicenseChange?.();1412      }1413    }14141415    return (1416      <div class="peach-settings-body">1417        <Show1418          when={isPro()}1419          fallback={1420            <Show1421              when={showKeyEntry()}1422              fallback={1423                <div class="peach-pro-upgrade">1424                  <div class="peach-pro-upgrade-hero">1425                    <div class="peach-pro-upgrade-mark-wrap">1426                      <PeachMark class="peach-pro-upgrade-mark" />1427                    </div>1428                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1429                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1430                  </div>14311432                  <div class="peach-pro-features">1433                    <For each={PRO_FEATURES}>1434                      {(item) => (1435                        <div1436                          class="peach-pro-feature"1437                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1438                        >1439                          <span class="peach-pro-feature-mark">✦</span>1440                          <div class="peach-pro-feature-text">1441                            <span class="peach-pro-feature-name">{item.name}</span>1442                            <span class="peach-pro-feature-desc">{item.desc}</span>1443                          </div>1444                        </div>1445                      )}1446                    </For>1447                  </div>14481449                  <p class="peach-pro-pricing">$39 · once · yours forever</p>14501451                  <a1452                    href="https://peachpasswords.com/#pricing"1453                    target="_blank"1454                    rel="noopener noreferrer"1455                    class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1456                  >1457                    Get Peach Pro1458                  </a>14591460                  <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1461                    Already have a key? Enter it →1462                  </button>1463                </div>1464              }1465            >1466              <div class="peach-pro-key-ceremony">1467                <LockRuneCanvas />1468                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1469                <input1470                  class="peach-underline-input peach-pro-key-input"1471                  type="text"1472                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1473                  value={licenseKeyInput()}1474                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1475                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1476                  autocomplete="off"1477                  spellcheck={false}1478                />1479                <Show when={licenseError()}>1480                  <p class="peach-pro-key-error">That key doesn't look right.</p>1481                </Show>1482                <button1483                  class="peach-btn peach-btn--accent peach-pro-key-activate"1484                  onClick={() => void onActivate()}1485                  disabled={activatingLicense() || !licenseKeyInput().trim()}1486                >1487                  {activatingLicense() ? "activating…" : "Activate"}1488                </button>1489                <button1490                  class="peach-pro-key-cancel"1491                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1492                >1493                  cancel1494                </button>1495              </div>1496            </Show>1497          }1498        >1499          <div class="peach-pro-active">1500            <div class="peach-pro-active-hero">1501              <div class="peach-pro-active-mark-wrap">1502                <PeachMark class="peach-pro-active-mark" />1503              </div>1504              <h2 class="peach-pro-active-title">Peach Pro</h2>1505              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1506            </div>15071508            <div class="peach-pro-active-features">1509              <For each={PRO_FEATURES}>1510                {(item) => (1511                  <div class="peach-pro-active-feature">1512                    <span class="peach-pro-active-feature-mark">✦</span>1513                    <span class="peach-pro-active-feature-name">{item.name}</span>1514                  </div>1515                )}1516              </For>1517            </div>15181519            <button1520              class="peach-pro-deactivate"1521              onClick={() => void handleDeactivateLicense()}1522              disabled={deactivating()}1523            >1524              {deactivating() ? "Deactivating…" : "Deactivate"}1525            </button>1526          </div>1527        </Show>1528      </div>1529    );1530  }15311532  function renderFeedback() {1533    return (1534      <div class="peach-settings-body">1535        <div class="peach-settings-group peach-feedback-group">1536          <p class="peach-feedback-intro">1537            Help us improve Peach. Every report goes directly to our inbox.1538          </p>15391540          <p class="peach-settings-sub-label">TYPE</p>1541          <div class="peach-feedback-chips">1542            <For each={FEEDBACK_CATEGORIES}>1543              {(cat) => (1544                <button1545                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1546                  onClick={() => setFbCategory(cat)}1547                >1548                  {cat}1549                </button>1550              )}1551            </For>1552          </div>15531554          <p class="peach-settings-sub-label">MESSAGE</p>1555          <textarea1556            class="peach-feedback-textarea"1557            placeholder="Describe the issue or idea..."1558            value={fbMessage()}1559            onInput={(e) => setFbMessage(e.currentTarget.value)}1560          />15611562          <p class="peach-feedback-meta">1563            Version {version} · will be included automatically1564          </p>15651566          <Show when={fbResult() === "success"}>1567            <p class="peach-feedback-result peach-feedback-result--ok">1568              Sent — thank you1569            </p>1570          </Show>1571          <Show when={fbResult() === "error"}>1572            <p class="peach-feedback-result peach-feedback-result--err">1573              Failed to send — try again1574            </p>1575          </Show>15761577          <button1578            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1579            onClick={() => void handleSubmitFeedback()}1580            disabled={fbSubmitting() || !fbMessage().trim()}1581          >1582            {fbSubmitting() ? "Sending..." : "Send feedback"}1583          </button>15841585          <p class="peach-feedback-anon">1586            Sent anonymously · no account required1587          </p>1588        </div>1589      </div>1590    );1591  }15921593  return (1594    <div class="peach-overlay-view ov-enter-right" style="display:flex">1595      <div class="peach-overlay-header">1596        <button1597          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1598          onClick={handleHeaderBack}1599          aria-label="Back"1600        >1601          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1602            <path d="M6 1L1 6L6 11" />1603          </svg>1604        </button>1605        <span class="peach-overlay-title">{currentTitle()}</span>1606        <div class="peach-overlay-spacer"></div>1607      </div>16081609      <Switch fallback={renderHome()}>1610        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1611        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1612        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1613        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1614        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1615        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1616        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1617      </Switch>1618    </div>1619  );1620}