Settings.tsx

72.0 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { Capacitor, registerPlugin } from "@capacitor/core";3import { sendMessage } from "../App";4import { PeachMark } from "../components/PeachMark";5import { LockRuneCanvas } from "../components/LockRuneCanvas";6import { biometricAvailable } from "../../shared/biometric";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";10import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";11import {12  DEFAULT_BINDINGS,13  findConflict,14  formatKeybinding,15  keybindingFromEvent,16  SHORTCUT_DESCRIPTORS,17  type Keybinding,18  type ShortcutAction,19} from "../../shared/shortcuts";2021interface LicenseInfo {22  valid: boolean;23  plan: "free" | "pro";24  issued?: string;25}2627interface SettingsProps {28  entriesCount: number;29  breachMonitoring: boolean;30  lockOnClose: boolean;31  autoLockMinutes: number;32  trashRetentionDays: number;33  theme: "dark" | "light";34  accentColor: AccentColorName;35  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";36  textSize: "small" | "medium" | "large";37  reduceMotion: boolean;38  isPro?: boolean;39  initialSection?: string | null;40  upgradeHighlight?: ProFeature | null;41  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;42  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;43  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;44  onTrashRetentionChange: (days: number) => void | Promise<void>;45  onThemeChange: (theme: "dark" | "light") => void;46  onAccentChange: (name: AccentColorName) => void | Promise<void>;47  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;48  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;49  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;50  shortcuts: Record<ShortcutAction, Keybinding>;51  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;52  onLicenseChange?: () => void | Promise<void>;53  onBack: () => void;54  onImport: () => void;55  onExport: () => void;56  onBreachCheckDone: () => void;57  onOpenHealth?: () => void;58  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;59  onOpenFileImport?: () => void;60  onOpenPanic?: () => void;61}6263type SectionName =64  | "security"65  | "vault"66  | "sync"67  | "appearance"68  | "shortcuts"69  | "license"70  | "feedback"71  | "mobile";7273const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7475function formatActivatedSince(issued: string | undefined): string {76  if (!issued) return "Active";77  try {78    const d = new Date(issued);79    if (Number.isNaN(d.getTime())) return "Active";80    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;81  } catch {82    return "Active";83  }84}8586function isSectionName(value: string): value is SectionName {87  return (88    value === "security" ||89    value === "vault" ||90    value === "sync" ||91    value === "appearance" ||92    value === "shortcuts" ||93    value === "license" ||94    value === "feedback" ||95    value === "mobile"96  );97}9899export function SettingsView(props: SettingsProps) {100  const version = chrome.runtime.getManifest().version;101  const initial = props.initialSection;102  const [activeSection, setActiveSection] = createSignal<SectionName | null>(103    initial && isSectionName(initial) ? (initial as SectionName) : null104  );105106  const [checkingBreaches, setCheckingBreaches] = createSignal(false);107  const [breachResult, setBreachResult] = createSignal<string | null>(null);108  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);109  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");110  const [licenseError, setLicenseError] = createSignal<string | null>(null);111  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);112  const [activatingLicense, setActivatingLicense] = createSignal(false);113114  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);115  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);116  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");117  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);118  // Prefer the prop (cached at App boot), fall back to the sync-state probe.119  // The prop is authoritative once present so license activation reflects120  // immediately without waiting for a sync round-trip.121  const [syncProStatus, setSyncProStatus] = createSignal(false);122  const isPro = () => props.isPro ?? syncProStatus();123  const [syncingNow, setSyncingNow] = createSignal(false);124  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);125  const [passkeySaving, setPasskeySaving] = createSignal(false);126127  const [dedupCount, setDedupCount] = createSignal<number | null>(null);128  const [dedupRunning, setDedupRunning] = createSignal(false);129  const [dedupDone, setDedupDone] = createSignal<number | null>(null);130131  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);132133  const [showChangePw, setShowChangePw] = createSignal(false);134  const [changePwCurrent, setChangePwCurrent] = createSignal("");135  const [changePwNew, setChangePwNew] = createSignal("");136  const [changePwConfirm, setChangePwConfirm] = createSignal("");137  const [changePwSaving, setChangePwSaving] = createSignal(false);138  const [changePwError, setChangePwError] = createSignal<string | null>(null);139  const [changePwSuccess, setChangePwSuccess] = createSignal(false);140141  const [duressEnabled, setDuressEnabled] = createSignal(false);142  const [showDuressSetup, setShowDuressSetup] = createSignal(false);143  const [duressPassword, setDuressPassword] = createSignal("");144  const [duressConfirm, setDuressConfirm] = createSignal("");145  const [duressMainPassword, setDuressMainPassword] = createSignal("");146  const [duressSaving, setDuressSaving] = createSignal(false);147  const [duressError, setDuressError] = createSignal<string | null>(null);148  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);149150  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);151152  // Mobile-only state153  function getAutofillPlugin() {154    try {155      return registerPlugin<{156        isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;157        openAutofillSettings(): Promise<void>;158      }>("Autofill");159    } catch {160      return null;161    }162  }163  const AutofillPlugin = getAutofillPlugin();164  const [autofillEnabled, setAutofillEnabled] = createSignal(false);165  const [biometricEnabled, setBiometricEnabled] = createSignal(false);166  const isAndroid = Capacitor.getPlatform() === "android";167  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);168  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);169170  const [fbCategory, setFbCategory] = createSignal("Bug report");171  const [fbMessage, setFbMessage] = createSignal("");172  const [fbSubmitting, setFbSubmitting] = createSignal(false);173  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);174175  onMount(async () => {176    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([177      sendMessage({ type: "GET_LICENSE" }),178      sendMessage({ type: "GET_SYNC_STATE" }),179      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),180      sendMessage({ type: "IS_DURESS_ENABLED" }),181      sendMessage({ type: "GET_SETTINGS" }),182      sendMessage({ type: "GET_LICENSE_KEY" }),183    ]);184185    if ((licenseRes as { ok: boolean }).ok) {186      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);187    }188189    if ((syncRes as { ok: boolean }).ok) {190      const data = syncRes as { ok: boolean; data?: {191        pairedDevices?: PairedDevice[];192        trustedNetworks?: TrustedNetwork[];193        status?: SyncStatus;194        lastSyncAt?: number;195        isPro?: boolean;196      }};197      if (data.data) {198        setPairedDevices(data.data.pairedDevices ?? []);199        setTrustedNetworks(data.data.trustedNetworks ?? []);200        setSyncStatus(data.data.status ?? "offline");201        setLastSyncAt(data.data.lastSyncAt);202        setSyncProStatus(data.data.isPro ?? false);203      }204    }205206    if ((passkeyRes as { ok: boolean }).ok) {207      setPasskeyState(208        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null209      );210    }211212    if ((duressRes as { ok: boolean }).ok) {213      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);214    }215216    if ((settingsRes as { ok: boolean }).ok) {217      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;218      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);219    }220221    if ((keyRes as { ok: boolean }).ok) {222      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);223    }224225    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });226    if ((entriesRes as { ok: boolean }).ok) {227      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];228      setCanaryEntries(229        entries230          .filter((e) => e.canary?.webhookUrl)231          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))232      );233    }234235    // Android-only: check autofill and biometric status236    if (isAndroid) {237      if (AutofillPlugin) {238        const { enabled } = await AutofillPlugin.isEnabledAsAutofillService();239        setAutofillEnabled(enabled);240      }241      const bio = await biometricAvailable();242      setBiometricEnabled(bio);243    }244245    chrome.runtime.onMessage.addListener((msg) => {246      if (msg.type === "SYNC_STATE_UPDATE") {247        setPairedDevices(msg.pairedDevices ?? []);248        setTrustedNetworks(msg.trustedNetworks ?? []);249        setSyncStatus(msg.status ?? "offline");250        setLastSyncAt(msg.lastSyncAt);251        setSyncProStatus(msg.isPro ?? false);252      }253    });254  });255256  async function handleChangePassword() {257    setChangePwError(null);258    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }259    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }260    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }261    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }262    setChangePwSaving(true);263    const res = await sendMessage({264      type: "CHANGE_PASSWORD",265      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },266    }) as { ok: boolean; error?: string };267    if (res.ok) {268      setChangePwSuccess(true);269      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");270      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);271    } else {272      setChangePwError(res.error ?? "failed to change password");273    }274    setChangePwSaving(false);275  }276277  const [copied, setCopied] = createSignal(false);278279  async function copyLicenseKey(key: string) {280    await navigator.clipboard.writeText(key);281    setCopied(true);282    setTimeout(() => setCopied(false), 2000);283  }284285  async function handleActivateLicense() {286    const key = licenseKeyInput().trim();287    if (!key) return;288    setActivatingLicense(true);289    setLicenseError(null);290    setLicenseSuccess(null);291    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });292    setActivatingLicense(false);293    if ((res as { ok: boolean }).ok) {294      const data = (res as { ok: boolean; data?: LicenseInfo }).data;295      if (data?.valid) {296        setLicenseInfo(data);297        setLicenseKey(key);298        setLicenseSuccess("License activated!");299        setLicenseKeyInput("");300      } else {301        setLicenseError("Invalid license key");302      }303    } else {304      setLicenseError("Activation failed");305    }306  }307308  async function handleTogglePasskeys() {309    const current = passkeyState();310    if (!current || !current.supported) return;311    setPasskeySaving(true);312    const res = await sendMessage({313      type: "SET_PASSKEY_PROVIDER_ENABLED",314      payload: { enabled: !current.enabled },315    });316    setPasskeySaving(false);317    if ((res as { ok: boolean }).ok) {318      setPasskeyState(319        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null320      );321    }322  }323324  async function handleCheckAllBreaches() {325    setCheckingBreaches(true);326    setBreachResult(null);327    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });328    setCheckingBreaches(false);329    if ((res as { ok: boolean }).ok) {330      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};331      const breached = Object.values(data).filter((count) => count > 0).length;332      setBreachResult(333        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"334      );335      props.onBreachCheckDone();336    }337  }338339  async function handleSyncNow() {340    if (syncStatus() === "not_trusted") return;341    setSyncingNow(true);342    await sendMessage({ type: "TRIGGER_SYNC" });343    setTimeout(() => setSyncingNow(false), 3000);344  }345346  async function handleRemoveDevice(deviceId: string) {347    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });348  }349350  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {351    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });352  }353354  async function handleMarkNetworkTrusted() {355    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });356  }357358  const [resetIdentityConfirm, setResetIdentityConfirm] = createSignal(false);359  const [resetIdentityRunning, setResetIdentityRunning] = createSignal(false);360361  async function handleResetSyncIdentity() {362    setResetIdentityRunning(true);363    const res = await sendMessage({ type: "RESET_SYNC_IDENTITY" });364    setResetIdentityRunning(false);365    if ((res as { ok: boolean }).ok) {366      setPairedDevices([]);367      setResetIdentityConfirm(false);368    }369  }370371  function formatRelativeTime(timestamp?: number): string {372    if (!timestamp) return "Never";373    const diff = Date.now() - timestamp;374    const minutes = Math.floor(diff / 60000);375    const hours = Math.floor(diff / 3600000);376    const days = Math.floor(diff / 86400000);377    if (minutes < 1) return "Just now";378    if (minutes < 60) return `${minutes}m ago`;379    if (hours < 24) return `${hours}h ago`;380    return `${days}d ago`;381  }382383  function getStatusColor(status: SyncStatus): string {384    switch (status) {385      case "synced": return "var(--peach-sync-ok)";386      case "syncing": return "var(--peach-sync-busy)";387      case "no_peers": return "var(--peach-sync-off)";388      case "not_trusted": return "var(--peach-sync-err)";389      case "offline": return "var(--peach-sync-off)";390      default: return "var(--peach-sync-off)";391    }392  }393394  function getStatusLabel(status: SyncStatus): string {395    switch (status) {396      case "synced": return "Synced";397      case "syncing": return "Syncing";398      case "no_peers": return "No peers visible";399      case "not_trusted": return "Not on a trusted network";400      case "offline": return "Offline";401      default: return "Unknown";402    }403  }404405  function isCurrentNetworkTrusted(): boolean {406    return trustedNetworks().length > 0;407  }408409  async function handleSetupDuress() {410    const mainPw = duressMainPassword().trim();411    const duressPw = duressPassword().trim();412    const confirmPw = duressConfirm().trim();413414    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }415    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }416    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }417    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }418419    setDuressSaving(true);420    setDuressError(null);421422    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };423    if (!verifyRes.ok || !verifyRes.data) {424      setDuressError("incorrect main password");425      setDuressSaving(false);426      return;427    }428429    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };430    setDuressSaving(false);431    if (res.ok) {432      setDuressEnabled(true);433      setShowDuressSetup(false);434      setDuressPassword("");435      setDuressConfirm("");436      setDuressMainPassword("");437      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");438      setTimeout(() => setDuressSuccess(null), 5000);439    } else {440      setDuressError(res.error ?? "failed to set duress password");441    }442  }443444  async function handleClearDeclinedCards() {445    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });446    setDeclinedCardCount(0);447  }448449  async function handleCountDuplicates() {450    setDedupRunning(true);451    setDedupDone(null);452    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };453    setDedupRunning(false);454    if (res.ok) setDedupCount(res.data ?? 0);455  }456457  async function handleDeduplicate() {458    setDedupRunning(true);459    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };460    setDedupRunning(false);461    if (res.ok) {462      setDedupDone(res.data ?? 0);463      setDedupCount(0);464    }465  }466467  async function handleDisableDuress() {468    const mainPw = duressMainPassword().trim();469    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }470471    setDuressSaving(true);472    setDuressError(null);473474    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };475    if (!verifyRes.ok || !verifyRes.data) {476      setDuressError("incorrect main password");477      setDuressSaving(false);478      return;479    }480481    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };482    setDuressSaving(false);483    if (res.ok) {484      setDuressEnabled(false);485      setShowDuressSetup(false);486      setDuressMainPassword("");487      setDuressSuccess("Duress password disabled.");488      setTimeout(() => setDuressSuccess(null), 4000);489    } else {490      setDuressError(res.error ?? "failed to disable duress password");491    }492  }493494  async function handleSubmitFeedback() {495    if (!fbMessage().trim() || fbSubmitting()) return;496    setFbSubmitting(true);497    setFbResult(null);498    const res = await sendMessage({499      type: "SUBMIT_FEEDBACK",500      payload: {501        category: fbCategory(),502        message: fbMessage().trim(),503        version,504        browser: navigator.userAgent,505      },506    }) as { ok: boolean; error?: string };507    setFbSubmitting(false);508    if (res.ok) {509      setFbResult("success");510      setFbMessage("");511      setTimeout(() => setFbResult(null), 4000);512    } else {513      setFbResult("error");514    }515  }516517  function handleHeaderBack() {518    if (activeSection() !== null) {519      setActiveSection(null);520    } else {521      props.onBack();522    }523  }524525  function currentTitle(): string {526    const s = activeSection();527    if (s === null) return "settings";528    if (s === "license") return "account";529    return s;530  }531532  function renderCatRow(opts: {533    section: SectionName;534    name: string;535    desc: string;536    icon: () => any;537    pro?: boolean;538  }) {539    return (540      <button541        class="peach-settings-cat-row"542        onClick={() => setActiveSection(opts.section)}543      >544        <div class="peach-settings-cat-icon">{opts.icon()}</div>545        <div class="peach-settings-cat-content">546          <span class="peach-settings-cat-name">547            {opts.name}548            <Show when={opts.pro && !isPro()}>549              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>550            </Show>551          </span>552          <span class="peach-settings-cat-desc">{opts.desc}</span>553        </div>554        <svg555          class="peach-settings-cat-chevron"556          width="6" height="10" viewBox="0 0 6 10" fill="none"557          stroke="currentColor" stroke-width="1.1"558          stroke-linecap="round" stroke-linejoin="round"559        >560          <path d="M1 1l4 4-4 4" />561        </svg>562      </button>563    );564  }565566  const iconShield = () => (567    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">568      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>569    </svg>570  );571  const iconKey = () => (572    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">573      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>574    </svg>575  );576  const iconLock = () => (577    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">578      <rect x="3" y="7" width="10" height="7" rx="1.5"/>579      <path d="M6 7V5a3 3 0 016 0v2"/>580    </svg>581  );582  const iconSync = () => (583    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">584      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>585      <path d="M14 6l-2 2-2-2"/>586      <path d="M2 10l2-2 2 2"/>587    </svg>588  );589  const iconSun = () => (590    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">591      <circle cx="8" cy="8" r="3"/>592      <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"/>593    </svg>594  );595  const iconKeys = () => (596    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">597      <rect x="2" y="5" width="5" height="4" rx="1"/>598      <rect x="9" y="7" width="5" height="4" rx="1"/>599      <path d="M7 7h2m-2 2h2"/>600    </svg>601  );602  const iconBubble = () => (603    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">604      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>605    </svg>606  );607608  function renderHome() {609    return (610      <div class="peach-settings-body">611        <p class="peach-settings-nav-label">PROTECTION</p>612        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}613        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}614615        <p class="peach-settings-nav-label">VAULT</p>616        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}617        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}618619        <p class="peach-settings-nav-label">PREFERENCES</p>620        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}621        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}622        <Show when={isAndroid}>623          <p class="peach-settings-nav-label">MOBILE</p>624          {renderCatRow({ section: "mobile", name: "Mobile", desc: "Autofill & biometric settings", icon: iconSync })}625        </Show>626627        <p class="peach-settings-nav-label">SUPPORT</p>628        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}629630        <div class="peach-settings-footer">631          <span>Peach {version}</span>632          <span class="peach-divider"></span>633          <span>Browser extension</span>634        </div>635      </div>636    );637  }638639  function renderSecurity() {640    return (641      <div class="peach-settings-body">642        <div class="peach-settings-group">643          <p class="peach-settings-sub-label">LOCKING</p>644          <div class="peach-settings-row">645            <span>Lock on close</span>646            <button647              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}648              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}649              aria-label="Toggle lock on close"650            >651              <span class="peach-toggle-knob"></span>652            </button>653          </div>654          <div class="peach-settings-row">655            <span>Auto-lock after</span>656            <select657              class="peach-settings-select"658              value={String(props.autoLockMinutes)}659              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}660            >661              <option value="1">1 minute</option>662              <option value="5">5 minutes</option>663              <option value="15">15 minutes</option>664              <option value="30">30 minutes</option>665              <option value="0">Never</option>666            </select>667          </div>668669          <p class="peach-settings-sub-label">BREACH PROTECTION</p>670          <div class="peach-settings-row">671            <span>Breach monitoring</span>672            <button673              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}674              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}675              aria-label="Toggle breach monitoring"676            >677              <span class="peach-toggle-knob"></span>678            </button>679          </div>680          <div class="peach-settings-row peach-settings-row--meta">681            <span>Check all passwords</span>682            <button683              class="peach-btn peach-btn--subtle peach-btn--sm"684              onClick={() => void handleCheckAllBreaches()}685              disabled={checkingBreaches()}686            >687              {checkingBreaches() ? "checking..." : "run"}688            </button>689          </div>690          <Show when={breachResult()}>691            <div class="peach-settings-row peach-settings-row--meta">692              <span>Last check</span>693              <span class="peach-settings-value">{breachResult()}</span>694            </div>695          </Show>696697          <p class="peach-settings-sub-label">MASTER PASSWORD</p>698          <div class="peach-settings-row peach-settings-row--meta">699            <span>Master password</span>700            <button701              class="peach-btn peach-btn--subtle peach-btn--sm"702              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}703            >704              {showChangePw() ? "cancel" : "change"}705            </button>706          </div>707          <Show when={showChangePw()}>708            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">709              <input710                class="peach-settings-input"711                type="password"712                placeholder="current password"713                value={changePwCurrent()}714                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}715                autocomplete="current-password"716              />717              <input718                class="peach-settings-input"719                type="password"720                placeholder="new password (min 12 chars)"721                value={changePwNew()}722                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}723                autocomplete="new-password"724              />725              <input726                class="peach-settings-input"727                type="password"728                placeholder="confirm new password"729                value={changePwConfirm()}730                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}731                autocomplete="new-password"732                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}733              />734              <Show when={changePwError()}>735                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>736              </Show>737              <Show when={changePwSuccess()}>738                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>739              </Show>740              <button741                class="peach-btn peach-btn--accent peach-btn--sm"742                style="align-self:flex-start"743                onClick={() => void handleChangePassword()}744                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}745              >746                {changePwSaving() ? "saving…" : "save new password"}747              </button>748            </div>749          </Show>750751          <p class="peach-settings-sub-label">DURESS PASSWORD</p>752          <div class="peach-settings-row peach-settings-row--top">753            <div class="peach-settings-row-main">754              <span>Duress password</span>755              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">756                If entered under coercion, shows a decoy empty vault instead of your real one.757              </span>758            </div>759            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>760              {duressEnabled() ? "on" : "off"}761            </div>762          </div>763764          <Show when={duressEnabled() && !showDuressSetup()}>765            <div class="peach-settings-row peach-settings-row--meta">766              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">767                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.768              </span>769            </div>770          </Show>771772          <Show when={duressSuccess()}>773            <div class="peach-settings-row peach-settings-row--meta">774              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>775            </div>776          </Show>777778          <Show when={!showDuressSetup()}>779            <div class="peach-settings-row peach-settings-row--meta">780              <button781                class="peach-btn peach-btn--subtle peach-btn--sm"782                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}783              >784                {duressEnabled() ? "Change / disable" : "Set up"}785              </button>786            </div>787          </Show>788789          <Show when={showDuressSetup()}>790            <div class="peach-duress-form">791              <label class="peach-label">main password (to confirm)</label>792              <input793                class="peach-settings-input"794                type="password"795                placeholder="your current master password"796                autocomplete="current-password"797                value={duressMainPassword()}798                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}799              />800              <Show when={!duressEnabled()}>801                <label class="peach-label">new duress password</label>802                <input803                  class="peach-settings-input"804                  type="password"805                  placeholder="duress password (min 8 chars)"806                  autocomplete="new-password"807                  value={duressPassword()}808                  onInput={(e) => setDuressPassword(e.currentTarget.value)}809                />810                <label class="peach-label">confirm duress password</label>811                <input812                  class="peach-settings-input"813                  type="password"814                  placeholder="confirm duress password"815                  autocomplete="new-password"816                  value={duressConfirm()}817                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}818                />819              </Show>820821              <Show when={duressError()}>822                <p class="peach-hint peach-hint-error">{duressError()}</p>823              </Show>824825              <div class="peach-duress-actions">826                <button827                  class="peach-btn peach-btn--subtle peach-btn--sm"828                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}829                >830                  cancel831                </button>832                <Show when={duressEnabled()}>833                  <button834                    class="peach-btn peach-btn--ghost peach-btn--sm"835                    style="color:var(--peach-breach)"836                    onClick={() => void handleDisableDuress()}837                    disabled={duressSaving()}838                  >839                    {duressSaving() ? "disabling..." : "disable duress"}840                  </button>841                </Show>842                <Show when={!duressEnabled()}>843                  <button844                    class="peach-btn peach-btn--accent peach-btn--sm"845                    onClick={() => void handleSetupDuress()}846                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}847                  >848                    {duressSaving() ? "saving..." : "save"}849                  </button>850                </Show>851              </div>852            </div>853          </Show>854855          <p class="peach-settings-sub-label">CANARY ENTRIES</p>856          <div class="peach-settings-row peach-settings-row--top">857            <div class="peach-settings-row-main">858              <span>Canary entries</span>859              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">860                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.861              </span>862            </div>863            <span class="peach-settings-value" style="flex-shrink:0">864              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}865            </span>866          </div>867          <Show when={canaryEntries().length > 0}>868            <For each={canaryEntries()}>869              {(entry) => (870                <div class="peach-settings-row peach-settings-row--meta">871                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>872                  <button873                    class="peach-btn peach-btn--subtle peach-btn--sm"874                    onClick={() => void handleTestCanary(entry)}875                    disabled={canaryTestingId() === entry.id}876                    style="flex-shrink:0"877                  >878                    {canaryTestingId() === entry.id879                      ? "sending…"880                      : canaryTestResult()?.id === entry.id881                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")882                        : "test"}883                  </button>884                </div>885              )}886            </For>887          </Show>888889          <p class="peach-settings-sub-label">PASSKEYS</p>890          <div class="peach-settings-row peach-settings-row--top">891            <div class="peach-settings-row-main">892              <span>Intercept passkey requests</span>893              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">894                Peach can handle passkey sign-in and creation on supported Chromium browsers.895              </span>896            </div>897            <button898              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}899              onClick={() => void handleTogglePasskeys()}900              aria-label="Toggle passkey interception"901              disabled={!passkeyState()?.supported || passkeySaving()}902            >903              <span class="peach-toggle-knob"></span>904            </button>905          </div>906          <div class="peach-settings-row peach-settings-row--meta">907            <span>Status</span>908            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>909          </div>910          <div class="peach-settings-row peach-settings-row--meta">911            <span>Browser</span>912            <span class="peach-settings-value">913              {passkeyState()?.browserLabel ?? "Unknown"}914              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}915            </span>916          </div>917          <div class="peach-info-notice" style="margin-top:8px">918            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.919          </div>920          <Show when={passkeyState()?.conflict}>921            <div class="peach-settings-row peach-settings-row--meta">922              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>923            </div>924          </Show>925        </div>926      </div>927    );928  }929930  function renderVault() {931    return (932      <div class="peach-settings-body">933        <div class="peach-settings-group">934          <p class="peach-settings-sub-label">ENTRIES</p>935          <div class="peach-settings-row peach-settings-row--meta">936            <span>Entries</span>937            <span class="peach-settings-value">{props.entriesCount}</span>938          </div>939          <div class="peach-settings-row peach-settings-row--meta">940            <span>Duplicates</span>941            <Show when={dedupDone() !== null} fallback={942              <button943                class="peach-btn peach-btn--subtle peach-btn--sm"944                onClick={() => void handleCountDuplicates()}945                disabled={dedupRunning()}946              >947                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}948              </button>949            }>950              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">951                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}952              </span>953            </Show>954          </div>955          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>956            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">957              <div class="peach-restore-warning">958                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.959              </div>960              <div style="display:flex;gap:6px">961                <button962                  class="peach-btn peach-btn--subtle peach-btn--sm"963                  onClick={() => setDedupCount(null)}964                >965                  cancel966                </button>967                <button968                  class="peach-btn peach-btn--accent peach-btn--sm"969                  onClick={() => void handleDeduplicate()}970                  disabled={dedupRunning()}971                >972                  {dedupRunning() ? "removing..." : "remove duplicates"}973                </button>974              </div>975            </div>976          </Show>977978          <p class="peach-settings-sub-label">MAINTENANCE</p>979          <div class="peach-settings-row peach-settings-row--meta">980            <span>Trash auto-delete</span>981            <select982              class="peach-settings-select"983              value={String(props.trashRetentionDays)}984              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}985            >986              <option value="7">7 days</option>987              <option value="30">30 days</option>988              <option value="90">90 days</option>989              <option value="0">Never</option>990            </select>991          </div>992          <div class="peach-settings-row peach-settings-row--meta">993            <span>Vault Health</span>994            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>995              open996            </button>997          </div>998          <Show when={declinedCardCount() > 0}>999            <div class="peach-settings-row peach-settings-row--meta">1000              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1001              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1002                Clear1003              </button>1004            </div>1005          </Show>10061007          <p class="peach-settings-sub-label">DATA</p>1008          <div class="peach-settings-row peach-settings-row--meta">1009            <span>Import passwords</span>1010            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1011              open1012            </button>1013          </div>1014          <div class="peach-settings-row peach-settings-row--meta">1015            <span>Export backup</span>1016            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1017              open1018            </button>1019          </div>1020          <div class="peach-danger-card">1021            <div class="peach-danger-card-head">1022              <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1023              <h3 class="peach-danger-card-title">Replace vault from backup</h3>1024            </div>1025            <p class="peach-danger-card-copy">1026              Wipes the current vault and restores from a backup file.1027              Recoverable only if your backup is good.1028            </p>1029            <button1030              class="peach-danger-card-button"1031              onClick={() => props.onOpenFileImport?.()}1032            >1033              Choose backup file1034            </button>1035          </div>10361037          <Show when={props.onOpenPanic}>1038            <div class="peach-danger-card peach-danger-card--hazard">1039              <div class="peach-danger-card-head">1040                <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1041                <h3 class="peach-danger-card-title">Destroy vault</h3>1042              </div>1043              <p class="peach-danger-card-copy">1044                Permanently wipes every entry, setting, and license on this1045                device. There is no recovery.1046              </p>1047              <button1048                class="peach-danger-card-button"1049                onClick={() => props.onOpenPanic?.()}1050              >1051                Begin destruction1052              </button>1053            </div>1054          </Show>1055        </div>1056      </div>1057    );1058  }10591060  function renderSync() {1061    if (!isPro()) {1062      return (1063        <div class="peach-settings-body">1064          <div class="peach-pro-upgrade">1065            <div class="peach-pro-upgrade-hero">1066              <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">1067                <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1068                <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1069                <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"/>1070                <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"/>1071                <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>1072                <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1073                <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1074              </svg>1075              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1076              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1077            </div>10781079            <div class="peach-pro-features">1080              <div class="peach-pro-feature">1081                <span class="peach-pro-feature-mark">✦</span>1082                <div class="peach-pro-feature-text">1083                  <span class="peach-pro-feature-name">Always in sync</span>1084                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1085                </div>1086              </div>1087              <div class="peach-pro-feature">1088                <span class="peach-pro-feature-mark">✦</span>1089                <div class="peach-pro-feature-text">1090                  <span class="peach-pro-feature-name">No cloud. No relay.</span>1091                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1092                </div>1093              </div>1094              <div class="peach-pro-feature">1095                <span class="peach-pro-feature-mark">✦</span>1096                <div class="peach-pro-feature-text">1097                  <span class="peach-pro-feature-name">Tailscale supported</span>1098                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1099                </div>1100              </div>1101              <div class="peach-pro-feature">1102                <span class="peach-pro-feature-mark">✦</span>1103                <div class="peach-pro-feature-text">1104                  <span class="peach-pro-feature-name">Zero friction after setup</span>1105                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1106                </div>1107              </div>1108            </div>11091110            <button1111              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1112              onClick={() => setActiveSection("license")}1113            >1114              Upgrade to Pro1115            </button>1116            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1117              Already have a key? Enter it →1118            </button>1119          </div>1120        </div>1121      );1122    }11231124    return (1125      <div class="peach-settings-body">1126        <div class="peach-settings-group">1127          <div class="peach-settings-row">1128            <span>Status</span>1129            <span class="peach-settings-value peach-settings-value--status">1130              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1131              {getStatusLabel(syncStatus())}1132            </span>1133          </div>1134          <div class="peach-settings-row peach-settings-row--meta">1135            <span>Last sync</span>1136            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1137          </div>1138          <div class="peach-settings-row peach-settings-row--meta">1139            <button1140              class="peach-btn peach-btn--subtle peach-btn--sm"1141              onClick={() => void handleSyncNow()}1142              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1143            >1144              {syncingNow() ? "Syncing..." : "Sync now"}1145            </button>1146          </div>11471148          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1149            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1150            {pairedDevices().map((device) => (1151              <div class="peach-sync-device-row">1152                <div class="peach-sync-device-info">1153                  <span class="peach-sync-device-name">{device.displayName}</span>1154                  <span class="peach-sync-device-meta">1155                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1156                  </span>1157                </div>1158                <button1159                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1160                  onClick={() => void handleRemoveDevice(device.deviceId)}1161                >1162                  Remove1163                </button>1164              </div>1165            ))}1166            <div class="peach-settings-actions--row">1167              <button1168                class="peach-btn peach-btn--subtle peach-btn--sm"1169                onClick={() => props.onOpenSyncPairing?.("initiate")}1170              >1171                Pair a new device1172              </button>1173              <button1174                class="peach-btn peach-btn--subtle peach-btn--sm"1175                onClick={() => props.onOpenSyncPairing?.("accept")}1176              >1177                Pair with another device1178              </button>1179            </div>1180          </div>11811182          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1183            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1184            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1185              <div class="peach-sync-device-row">1186                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1187                <button1188                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1189                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1190                >1191                  Remove1192                </button>1193              </div>1194            ))}1195            <div class="peach-settings-actions--row">1196              <Show when={!isCurrentNetworkTrusted()}>1197                <button1198                  class="peach-btn peach-btn--subtle peach-btn--sm"1199                  onClick={() => void handleMarkNetworkTrusted()}1200                >1201                  Mark current network as trusted1202                </button>1203              </Show>1204            </div>1205            <span class="peach-sync-device-meta">1206              Tailscale is always trusted1207            </span>1208          </div>12091210          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1211            <span class="peach-sync-section-label">Advanced</span>1212            <Show1213              when={resetIdentityConfirm()}1214              fallback={1215                <div class="peach-sync-advanced-row">1216                  <div class="peach-sync-advanced-info">1217                    <span class="peach-sync-advanced-name">Reset sync identity</span>1218                    <span class="peach-sync-device-meta">1219                      Generates a new keypair and unpairs every device.1220                    </span>1221                  </div>1222                  <button1223                    class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1224                    onClick={() => setResetIdentityConfirm(true)}1225                  >1226                    Reset1227                  </button>1228                </div>1229              }1230            >1231              <div class="peach-sync-reset-confirm">1232                <p class="peach-sync-reset-confirm-headline">Reset sync identity?</p>1233                <p class="peach-sync-reset-confirm-body">1234                  This will unpair all devices. You will need to pair them again from scratch.1235                </p>1236                <div class="peach-settings-actions--row">1237                  <button1238                    class="peach-btn peach-btn--ghost peach-btn--sm"1239                    onClick={() => setResetIdentityConfirm(false)}1240                    disabled={resetIdentityRunning()}1241                  >1242                    Cancel1243                  </button>1244                  <button1245                    class="peach-btn peach-btn--danger peach-btn--sm"1246                    onClick={() => void handleResetSyncIdentity()}1247                    disabled={resetIdentityRunning()}1248                  >1249                    {resetIdentityRunning() ? "Resetting..." : "Reset"}1250                  </button>1251                </div>1252              </div>1253            </Show>1254          </div>1255        </div>1256      </div>1257    );1258  }12591260  function renderAppearance() {1261    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1262      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1263      { id: "cormorant", label: "Cormorant", preview: "Aa" },1264      { id: "inter", label: "Inter", preview: "Aa" },1265      { id: "system", label: "System", preview: "Aa" },1266    ];1267    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1268      { id: "small", label: "Small" },1269      { id: "medium", label: "Default" },1270      { id: "large", label: "Large" },1271    ];1272    return (1273      <div class="peach-settings-body">1274        <div class="peach-settings-group">1275          <p class="peach-settings-sub-label">THEME</p>1276          <div class="peach-settings-row peach-settings-row--meta">1277            <span>Theme</span>1278            <button1279              class="peach-btn peach-btn--subtle peach-btn--sm"1280              onClick={() =>1281                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1282              }1283            >1284              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1285            </button>1286          </div>12871288          <p class="peach-settings-sub-label">ACCENT</p>1289          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1290            <span>Accent</span>1291            <div class="peach-accent-picker">1292              <For each={ACCENT_COLORS}>1293                {(color) => (1294                  <button1295                    class="peach-accent-swatch"1296                    classList={{ "is-active": props.accentColor === color.name }}1297                    style={{ background: color.hex }}1298                    onClick={() => void props.onAccentChange(color.name)}1299                    aria-label={color.name}1300                    title={color.name}1301                  />1302                )}1303              </For>1304            </div>1305          </div>13061307          <p class="peach-settings-sub-label">DISPLAY FONT</p>1308          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1309            <span>Font</span>1310            <div class="peach-font-picker">1311              <For each={fontOptions}>1312                {(opt) => (1313                  <button1314                    class="peach-font-swatch"1315                    classList={{ "is-active": props.displayFont === opt.id }}1316                    data-font-preview={opt.id}1317                    onClick={() => void props.onDisplayFontChange(opt.id)}1318                    aria-label={opt.label}1319                    title={opt.label}1320                  >1321                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1322                    <span class="peach-font-swatch-name">{opt.label}</span>1323                  </button>1324                )}1325              </For>1326            </div>1327          </div>13281329          <p class="peach-settings-sub-label">TEXT SIZE</p>1330          <div class="peach-settings-row peach-settings-row--meta">1331            <span>Size</span>1332            <div class="peach-pill-group">1333              <For each={sizeOptions}>1334                {(opt) => (1335                  <button1336                    class="peach-btn peach-btn--ghost peach-btn--sm"1337                    classList={{ "is-active": props.textSize === opt.id }}1338                    onClick={() => void props.onTextSizeChange(opt.id)}1339                  >1340                    {opt.label}1341                  </button>1342                )}1343              </For>1344            </div>1345          </div>13461347          <p class="peach-settings-sub-label">MOTION</p>1348          <div class="peach-settings-row peach-settings-row--meta">1349            <span>Reduce motion</span>1350            <button1351              class="peach-btn peach-btn--subtle peach-btn--sm"1352              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1353            >1354              {props.reduceMotion ? "On" : "Off"}1355            </button>1356          </div>1357        </div>1358      </div>1359    );1360  }13611362  function renderShortcuts() {1363    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1364    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13651366    function startCapture(action: ShortcutAction) {1367      setConflictMsg(null);1368      setCapturingAction(action);1369    }1370    function cancelCapture() {1371      setCapturingAction(null);1372      setConflictMsg(null);1373    }1374    function commitBinding(action: ShortcutAction, binding: Keybinding) {1375      const conflict = findConflict(props.shortcuts, binding, action);1376      if (conflict) {1377        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1378        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1379        return;1380      }1381      const next = { ...props.shortcuts, [action]: binding };1382      setCapturingAction(null);1383      setConflictMsg(null);1384      void props.onShortcutsChange(next);1385    }1386    function resetAction(action: ShortcutAction) {1387      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1388      void props.onShortcutsChange(next);1389    }1390    function resetAll() {1391      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1392    }13931394    // Capture-mode key handler — wired via ref onto the chip element so it1395    // catches the next non-modifier keypress and ignores other bindings.1396    function handleCaptureKey(e: KeyboardEvent) {1397      e.preventDefault();1398      e.stopPropagation();1399      if (e.key === "Escape") { cancelCapture(); return; }1400      const binding = keybindingFromEvent(e);1401      if (!binding) return; // bare modifier — keep waiting1402      const action = capturingAction();1403      if (action) commitBinding(action, binding);1404    }14051406    return (1407      <div class="peach-settings-body">1408        <div class="peach-settings-group peach-shortcuts-group">1409          <p class="peach-settings-sub-label">ENTRY VIEW</p>1410          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1411            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1412          </For>1413        </div>14141415        <div class="peach-settings-group peach-shortcuts-group">1416          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1417          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1418            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1419          </For>1420        </div>14211422        <Show when={conflictMsg()}>1423          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1424        </Show>14251426        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1427          <div class="peach-settings-row peach-settings-row--meta">1428            <span>Inline autofill on page</span>1429            <a1430              class="peach-settings-link"1431              href="chrome://extensions/shortcuts"1432              onClick={(e) => {1433                e.preventDefault();1434                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1435              }}1436            >1437              ⌘⇧P — customize ↗1438            </a>1439          </div>1440          <button1441            class="peach-btn peach-btn--subtle peach-btn--sm"1442            style="align-self:flex-start"1443            onClick={resetAll}1444          >1445            Reset all to defaults1446          </button>1447        </div>1448      </div>1449    );14501451    function renderRow(1452      desc: typeof SHORTCUT_DESCRIPTORS[number],1453      capturing: () => ShortcutAction | null,1454      onStart: (a: ShortcutAction) => void,1455      onCancel: () => void,1456      onReset: (a: ShortcutAction) => void,1457      onKey: (e: KeyboardEvent) => void,1458    ) {1459      const isActive = () => capturing() === desc.action;1460      const binding = () => props.shortcuts[desc.action];1461      const isCustom = () => {1462        const b = binding();1463        const d = DEFAULT_BINDINGS[desc.action];1464        return b.key !== d.key1465          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1466          || Boolean(b.meta)  !== Boolean(d.meta)1467          || Boolean(b.shift) !== Boolean(d.shift)1468          || Boolean(b.alt)   !== Boolean(d.alt);1469      };1470      return (1471        <div class="peach-shortcut-row">1472          <span class="peach-shortcut-row-label">{desc.label}</span>1473          <button1474            class="peach-shortcut-chip"1475            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1476            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1477            onKeyDown={(e) => { if (isActive()) onKey(e); }}1478            ref={(el) => {1479              // Auto-focus when entering capture mode so the keydown listener catches the next press.1480              if (el) {1481                queueMicrotask(() => { if (isActive()) el.focus(); });1482              }1483            }}1484          >1485            {isActive() ? "Press a key…" : formatKeybinding(binding())}1486          </button>1487          <Show when={isCustom() && !isActive()}>1488            <button1489              class="peach-shortcut-reset"1490              title="Reset to default"1491              onClick={() => onReset(desc.action)}1492            >1493              ↺1494            </button>1495          </Show>1496        </div>1497      );1498    }1499  }15001501  const PRO_FEATURES: Array<{1502    feature: ProFeature;1503    name: string;1504    desc: string;1505  }> = [1506    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1507    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1508    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1509    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1510    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1511  ];15121513  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);15141515  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1516  const [deactivating, setDeactivating] = createSignal(false);15171518  async function handleDeactivateLicense() {1519    setDeactivating(true);1520    await sendMessage({ type: "DEACTIVATE_LICENSE" });1521    setLicenseInfo(null);1522    await props.onLicenseChange?.();1523    setDeactivating(false);1524  }15251526  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1527    setCanaryTestingId(entry.id);1528    setCanaryTestResult(null);1529    const res = await sendMessage({1530      type: "FIRE_CANARY_PING",1531      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1532    }) as { ok: boolean };1533    setCanaryTestResult({ id: entry.id, ok: res.ok });1534    setCanaryTestingId(null);1535    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1536  }15371538  function renderLicense() {1539    async function onActivate() {1540      await handleActivateLicense();1541      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1542        await props.onLicenseChange?.();1543      }1544    }15451546    return (1547      <div class="peach-settings-body">1548        <Show1549          when={isPro()}1550          fallback={1551            <Show1552              when={showKeyEntry()}1553              fallback={1554                <div class="peach-pro-upgrade">1555                  <div class="peach-pro-upgrade-hero">1556                    <div class="peach-pro-upgrade-mark-wrap">1557                      <PeachMark class="peach-pro-upgrade-mark" />1558                    </div>1559                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1560                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1561                  </div>15621563                  <div class="peach-pro-features">1564                    <For each={PRO_FEATURES}>1565                      {(item) => (1566                        <div1567                          class="peach-pro-feature"1568                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1569                        >1570                          <span class="peach-pro-feature-mark">✦</span>1571                          <div class="peach-pro-feature-text">1572                            <span class="peach-pro-feature-name">{item.name}</span>1573                            <span class="peach-pro-feature-desc">{item.desc}</span>1574                          </div>1575                        </div>1576                      )}1577                    </For>1578                  </div>15791580                  <div class="peach-pro-upgrade-actions">1581                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1582                    <a1583                      href="https://peachpasswords.com/#pricing"1584                      target="_blank"1585                      rel="noopener noreferrer"1586                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1587                    >1588                      Get Peach Pro1589                    </a>1590                    <div class="peach-pro-key-divider"><span>or</span></div>1591                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1592                      Activate a license key1593                    </button>1594                  </div>1595                </div>1596              }1597            >1598              <div class="peach-pro-key-ceremony">1599                <LockRuneCanvas />1600                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1601                <input1602                  class="peach-underline-input peach-pro-key-input"1603                  type="text"1604                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1605                  value={licenseKeyInput()}1606                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1607                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1608                  autocomplete="off"1609                  spellcheck={false}1610                />1611                <Show when={licenseError()}>1612                  <p class="peach-pro-key-error">That key doesn't look right.</p>1613                </Show>1614                <button1615                  class="peach-btn peach-btn--accent peach-pro-key-activate"1616                  onClick={() => void onActivate()}1617                  disabled={activatingLicense() || !licenseKeyInput().trim()}1618                >1619                  {activatingLicense() ? "activating…" : "Activate"}1620                </button>1621                <button1622                  class="peach-pro-key-cancel"1623                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1624                >1625                  cancel1626                </button>1627              </div>1628            </Show>1629          }1630        >1631          <div class="peach-pro-active">1632            <div class="peach-pro-active-hero">1633              <div class="peach-pro-active-mark-wrap">1634                <PeachMark class="peach-pro-active-mark" />1635              </div>1636              <h2 class="peach-pro-active-title">Peach Pro</h2>1637              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1638            </div>16391640            <Show when={licenseKey()}>1641              <div class="peach-settings-license">1642                <span class="peach-settings-label">LICENSE KEY</span>1643                <div class="peach-settings-license-row">1644                  <span class="peach-settings-license-key">{licenseKey()!}</span>1645                  <button1646                    class="peach-settings-license-copy"1647                    onClick={() => copyLicenseKey(licenseKey()!)}1648                  >1649                    {copied() ? "✓" : "copy"}1650                  </button>1651                </div>1652              </div>1653            </Show>16541655            <div class="peach-pro-active-features">1656              <For each={PRO_FEATURES}>1657                {(item) => (1658                  <div class="peach-pro-active-feature">1659                    <span class="peach-pro-active-feature-mark">✦</span>1660                    <span class="peach-pro-active-feature-name">{item.name}</span>1661                  </div>1662                )}1663              </For>1664            </div>16651666            <button1667              class="peach-pro-deactivate"1668              onClick={() => void handleDeactivateLicense()}1669              disabled={deactivating()}1670            >1671              {deactivating() ? "Deactivating…" : "Deactivate"}1672            </button>1673          </div>1674        </Show>1675      </div>1676    );1677  }16781679  function renderFeedback() {1680    return (1681      <div class="peach-settings-body">1682        <div class="peach-settings-group peach-feedback-group">1683          <p class="peach-feedback-intro">1684            Help us improve Peach. Every report goes directly to our inbox.1685          </p>16861687          <p class="peach-settings-sub-label">TYPE</p>1688          <div class="peach-feedback-chips">1689            <For each={FEEDBACK_CATEGORIES}>1690              {(cat) => (1691                <button1692                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1693                  onClick={() => setFbCategory(cat)}1694                >1695                  {cat}1696                </button>1697              )}1698            </For>1699          </div>17001701          <p class="peach-settings-sub-label">MESSAGE</p>1702          <textarea1703            class="peach-feedback-textarea"1704            placeholder="Describe the issue or idea..."1705            value={fbMessage()}1706            onInput={(e) => setFbMessage(e.currentTarget.value)}1707          />17081709          <p class="peach-feedback-meta">1710            Version {version} · will be included automatically1711          </p>17121713          <Show when={fbResult() === "success"}>1714            <p class="peach-feedback-result peach-feedback-result--ok">1715              Sent — thank you1716            </p>1717          </Show>1718          <Show when={fbResult() === "error"}>1719            <p class="peach-feedback-result peach-feedback-result--err">1720              Failed to send — try again1721            </p>1722          </Show>17231724          <button1725            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1726            onClick={() => void handleSubmitFeedback()}1727            disabled={fbSubmitting() || !fbMessage().trim()}1728          >1729            {fbSubmitting() ? "Sending..." : "Send feedback"}1730          </button>17311732          <p class="peach-feedback-anon">1733            Sent anonymously · no account required1734          </p>1735        </div>1736      </div>1737    );1738  }17391740  function renderMobile() {1741    return (1742      <div class="peach-settings-body">1743        <div class="peach-settings-group">1744          <p class="peach-settings-label">Autofill service</p>1745          <div class="peach-mobile-row">1746            <span class="peach-mobile-status">1747              {autofillEnabled() ? "Enabled" : "Disabled"}1748            </span>1749            <button1750              class="peach-btn peach-btn--subtle peach-btn--sm"1751              onClick={() => void AutofillPlugin?.openAutofillSettings()}1752            >1753              {autofillEnabled() ? "Change" : "Enable"}1754            </button>1755          </div>1756        </div>17571758        <div class="peach-settings-group">1759          <p class="peach-settings-label">Biometric unlock</p>1760          <div class="peach-mobile-row">1761            <span class="peach-mobile-status">1762              {biometricEnabled() ? "Available" : "Not available"}1763            </span>1764          </div>1765        </div>1766      </div>1767    );1768  }17691770  return (1771    <div class="peach-overlay-view ov-enter-right" style="display:flex">1772      <div class="peach-overlay-header">1773        <button1774          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1775          onClick={handleHeaderBack}1776          aria-label="Back"1777        >1778          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1779            <path d="M6 1L1 6L6 11" />1780          </svg>1781        </button>1782        <span class="peach-overlay-title">{currentTitle()}</span>1783        <div class="peach-overlay-spacer"></div>1784      </div>17851786      <Switch fallback={renderHome()}>1787        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1788        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1789        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1790        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1791        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1792        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1793        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1794        <Match when={activeSection() === "mobile"}>{renderMobile()}</Match>1795      </Switch>1796    </div>1797  );1798}