Settings.tsx

76.8 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 {8  AccentColorName,9  PasskeyProviderState,10  ProFeature,11  VaultSnapshot,12} from "../../shared/types";13import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";14import {15  DEFAULT_BINDINGS,16  findConflict,17  formatKeybinding,18  keybindingFromEvent,19  SHORTCUT_DESCRIPTORS,20  type Keybinding,21  type ShortcutAction,22} from "../../shared/shortcuts";2324interface LicenseInfo {25  valid: boolean;26  plan: "free" | "pro";27  issued?: string;28}2930interface SettingsProps {31  entriesCount: number;32  breachMonitoring: boolean;33  lockOnClose: boolean;34  autoLockMinutes: number;35  trashRetentionDays: number;36  theme: "dark" | "light";37  accentColor: AccentColorName;38  displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";39  textSize: "small" | "medium" | "large";40  reduceMotion: boolean;41  isPro?: boolean;42  initialSection?: string | null;43  upgradeHighlight?: ProFeature | null;44  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;45  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;46  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;47  onTrashRetentionChange: (days: number) => void | Promise<void>;48  onThemeChange: (theme: "dark" | "light") => void;49  onAccentChange: (name: AccentColorName) => void | Promise<void>;50  onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;51  onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;52  onReduceMotionChange: (enabled: boolean) => void | Promise<void>;53  shortcuts: Record<ShortcutAction, Keybinding>;54  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;55  onLicenseChange?: () => void | Promise<void>;56  onBack: () => void;57  onImport: () => void;58  onExport: () => void;59  onBreachCheckDone: () => void;60  onOpenHealth?: () => void;61  onOpenSyncPairing?: () => void;62  onOpenFileImport?: () => void;63  onOpenPanic?: () => void;64}6566type SectionName =67  | "security"68  | "vault"69  | "sync"70  | "appearance"71  | "shortcuts"72  | "license"73  | "feedback";7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7677function formatActivatedSince(issued: string | undefined): string {78  if (!issued) return "Active";79  try {80    const d = new Date(issued);81    if (Number.isNaN(d.getTime())) return "Active";82    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;83  } catch {84    return "Active";85  }86}8788function isSectionName(value: string): value is SectionName {89  return (90    value === "security" ||91    value === "vault" ||92    value === "sync" ||93    value === "appearance" ||94    value === "shortcuts" ||95    value === "license" ||96    value === "feedback"97  );98}99100export function SettingsView(props: SettingsProps) {101  const version = chrome.runtime.getManifest().version;102  const initial = props.initialSection;103  const [activeSection, setActiveSection] = createSignal<SectionName | null>(104    initial && isSectionName(initial) ? (initial as SectionName) : null105  );106107  const [checkingBreaches, setCheckingBreaches] = createSignal(false);108  const [breachResult, setBreachResult] = createSignal<string | null>(null);109  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);110  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");111  const [licenseError, setLicenseError] = createSignal<string | null>(null);112  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);113  const [activatingLicense, setActivatingLicense] = createSignal(false);114115  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);116  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);117  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");118  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);119  // Prefer the prop (cached at App boot), fall back to the sync-state probe.120  // The prop is authoritative once present so license activation reflects121  // immediately without waiting for a sync round-trip.122  const [syncProStatus, setSyncProStatus] = createSignal(false);123  const isPro = () => props.isPro ?? syncProStatus();124  const [syncingNow, setSyncingNow] = createSignal(false);125  const [syncFeedback, setSyncFeedback] = createSignal<126    { kind: "ok" | "err"; text: string } | null127  >(null);128  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);129  const [passkeySaving, setPasskeySaving] = createSignal(false);130131  const [dedupCount, setDedupCount] = createSignal<number | null>(null);132  const [dedupRunning, setDedupRunning] = createSignal(false);133  const [dedupDone, setDedupDone] = createSignal<number | null>(null);134135  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);136137  // Vault history (peach_vault_history) — last 10 snapshots, snapshotted138  // BEFORE every real-mode vault write so the user can roll back if a sync139  // or import overwrites the wrong state.140  const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);141  const [historyLoading, setHistoryLoading] = createSignal(false);142  const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);143144  async function loadVaultHistory() {145    setHistoryLoading(true);146    try {147      const res = await sendMessage({ type: "GET_VAULT_HISTORY" });148      const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];149      setHistorySnapshots(list);150    } finally {151      setHistoryLoading(false);152    }153  }154155  async function handleRestoreVaultSnapshot(id: string) {156    if (historyRestoring()) return;157    setHistoryRestoring(id);158    const res = await sendMessage({159      type: "RESTORE_VAULT_SNAPSHOT",160      payload: { snapshotId: id },161    });162    setHistoryRestoring(null);163    if ((res as { ok: boolean }).ok) {164      // Vault is now locked — let the parent close Settings; App.tsx's165      // VAULT_LOCKED listener will route back to the lock screen.166      props.onBack();167    }168  }169170  function formatSnapshotTime(savedAt: number): string {171    const diff = Date.now() - savedAt;172    if (diff < 60_000) return "Just now";173    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;174    const d = new Date(savedAt);175    const isToday = new Date().toDateString() === d.toDateString();176    const isYest =177      new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();178    const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });179    if (isToday) return `Today ${t}`;180    if (isYest) return `Yesterday ${t}`;181    return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;182  }183184  function reasonLabel(reason: VaultSnapshot["reason"]): string {185    return (186      {187        local_save: "saved",188        sync_received: "synced",189        import: "imported",190        restore: "restored",191      } as const192    )[reason] ?? reason;193  }194195  const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);196197  const [showChangePw, setShowChangePw] = createSignal(false);198  const [changePwCurrent, setChangePwCurrent] = createSignal("");199  const [changePwNew, setChangePwNew] = createSignal("");200  const [changePwConfirm, setChangePwConfirm] = createSignal("");201  const [changePwSaving, setChangePwSaving] = createSignal(false);202  const [changePwError, setChangePwError] = createSignal<string | null>(null);203  const [changePwSuccess, setChangePwSuccess] = createSignal(false);204205  const [duressEnabled, setDuressEnabled] = createSignal(false);206  const [showDuressSetup, setShowDuressSetup] = createSignal(false);207  const [duressPassword, setDuressPassword] = createSignal("");208  const [duressConfirm, setDuressConfirm] = createSignal("");209  const [duressMainPassword, setDuressMainPassword] = createSignal("");210  const [duressSaving, setDuressSaving] = createSignal(false);211  const [duressError, setDuressError] = createSignal<string | null>(null);212  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);213214  const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);215216  const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);217  const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);218219  const [fbCategory, setFbCategory] = createSignal("Bug report");220  const [fbMessage, setFbMessage] = createSignal("");221  const [fbSubmitting, setFbSubmitting] = createSignal(false);222  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);223224  onMount(async () => {225    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([226      sendMessage({ type: "GET_LICENSE" }),227      sendMessage({ type: "GET_SYNC_STATE" }),228      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),229      sendMessage({ type: "IS_DURESS_ENABLED" }),230      sendMessage({ type: "GET_SETTINGS" }),231      sendMessage({ type: "GET_LICENSE_KEY" }),232    ]);233234    // Vault history loads in the background — the Vault sub-view picks it up235    // when the user navigates in. Loading it lazily keeps Settings boot fast.236    void loadVaultHistory();237238    if ((licenseRes as { ok: boolean }).ok) {239      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);240    }241242    if ((syncRes as { ok: boolean }).ok) {243      const data = syncRes as { ok: boolean; data?: {244        pairedDevices?: PairedDevice[];245        trustedNetworks?: TrustedNetwork[];246        status?: SyncStatus;247        lastSyncAt?: number;248        isPro?: boolean;249      }};250      if (data.data) {251        setPairedDevices(data.data.pairedDevices ?? []);252        setTrustedNetworks(data.data.trustedNetworks ?? []);253        setSyncStatus(data.data.status ?? "offline");254        setLastSyncAt(data.data.lastSyncAt);255        setSyncProStatus(data.data.isPro ?? false);256      }257    }258259    if ((passkeyRes as { ok: boolean }).ok) {260      setPasskeyState(261        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null262      );263    }264265    if ((duressRes as { ok: boolean }).ok) {266      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);267    }268269    if ((settingsRes as { ok: boolean }).ok) {270      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;271      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);272    }273274    if ((keyRes as { ok: boolean }).ok) {275      setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);276    }277278    const entriesRes = await sendMessage({ type: "GET_ENTRIES" });279    if ((entriesRes as { ok: boolean }).ok) {280      const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];281      setCanaryEntries(282        entries283          .filter((e) => e.canary?.webhookUrl)284          .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))285      );286    }287288    chrome.runtime.onMessage.addListener((msg) => {289      if (msg.type === "SYNC_STATE_UPDATE") {290        setPairedDevices(msg.pairedDevices ?? []);291        setTrustedNetworks(msg.trustedNetworks ?? []);292        setSyncStatus(msg.status ?? "offline");293        setLastSyncAt(msg.lastSyncAt);294        setSyncProStatus(msg.isPro ?? false);295      }296    });297  });298299  async function handleChangePassword() {300    setChangePwError(null);301    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }302    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }303    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }304    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }305    setChangePwSaving(true);306    const res = await sendMessage({307      type: "CHANGE_PASSWORD",308      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },309    }) as { ok: boolean; error?: string };310    if (res.ok) {311      setChangePwSuccess(true);312      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");313      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);314    } else {315      setChangePwError(res.error ?? "failed to change password");316    }317    setChangePwSaving(false);318  }319320  const [copied, setCopied] = createSignal(false);321322  async function copyLicenseKey(key: string) {323    await navigator.clipboard.writeText(key);324    setCopied(true);325    setTimeout(() => setCopied(false), 2000);326  }327328  async function handleActivateLicense() {329    const key = licenseKeyInput().trim();330    if (!key) return;331    setActivatingLicense(true);332    setLicenseError(null);333    setLicenseSuccess(null);334    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });335    setActivatingLicense(false);336    if ((res as { ok: boolean }).ok) {337      const data = (res as { ok: boolean; data?: LicenseInfo }).data;338      if (data?.valid) {339        setLicenseInfo(data);340        setLicenseKey(key);341        setLicenseSuccess("License activated!");342        setLicenseKeyInput("");343      } else {344        setLicenseError("Invalid license key");345      }346    } else {347      setLicenseError("Activation failed");348    }349  }350351  async function handleTogglePasskeys() {352    const current = passkeyState();353    if (!current || !current.supported) return;354    setPasskeySaving(true);355    const res = await sendMessage({356      type: "SET_PASSKEY_PROVIDER_ENABLED",357      payload: { enabled: !current.enabled },358    });359    setPasskeySaving(false);360    if ((res as { ok: boolean }).ok) {361      setPasskeyState(362        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null363      );364    }365  }366367  async function handleCheckAllBreaches() {368    setCheckingBreaches(true);369    setBreachResult(null);370    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });371    setCheckingBreaches(false);372    if ((res as { ok: boolean }).ok) {373      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};374      const breached = Object.values(data).filter((count) => count > 0).length;375      setBreachResult(376        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"377      );378      props.onBreachCheckDone();379    }380  }381382  async function handleSyncNow() {383    if (syncStatus() === "not_trusted") return;384    setSyncingNow(true);385    setSyncFeedback(null);386    const res = await sendMessage({ type: "TRIGGER_SYNC" });387    const ok = (res as { ok: boolean }).ok;388    if (ok) {389      const data = (res as { data?: { syncedCount?: number; totalReachable?: number } }).data;390      const count = data?.syncedCount ?? 0;391      setSyncFeedback({ kind: "ok", text: count > 0 ? `Synced with ${count} ${count === 1 ? "device" : "devices"}` : "Up to date" });392      setLastSyncAt(Date.now());393      void refreshSyncState();394    } else {395      const error = (res as { error?: string }).error;396      const map: Record<string, string> = {397        no_paired_devices: "No paired devices",398        no_devices_reachable: "No devices found",399        vault_locked: "Vault is locked",400        pro_required: "Sync requires Pro",401      };402      setSyncFeedback({ kind: "err", text: map[error ?? ""] ?? "Sync failed" });403    }404    setTimeout(() => {405      setSyncingNow(false);406      setSyncFeedback(null);407    }, 3500);408  }409410  async function refreshSyncState() {411    const res = await sendMessage({ type: "GET_SYNC_STATE" });412    const data = (res as { ok: boolean; data?: {413      pairedDevices?: PairedDevice[];414      trustedNetworks?: TrustedNetwork[];415      status?: SyncStatus;416      lastSyncAt?: number;417    }}).data;418    if (!data) return;419    setPairedDevices(data.pairedDevices ?? []);420    setTrustedNetworks(data.trustedNetworks ?? []);421    setSyncStatus(data.status ?? "offline");422    setLastSyncAt(data.lastSyncAt);423  }424425  async function handleRemoveDevice(deviceId: string) {426    setPairedDevices((prev) => prev.filter((d) => d.deviceId !== deviceId));427    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });428    void refreshSyncState();429  }430431  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {432    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });433    void refreshSyncState();434  }435436  const [markingNetwork, setMarkingNetwork] = createSignal(false);437438  async function handleMarkNetworkTrusted() {439    if (markingNetwork()) return;440    setMarkingNetwork(true);441    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });442    await refreshSyncState();443    setMarkingNetwork(false);444  }445446  const [resetIdentityConfirm, setResetIdentityConfirm] = createSignal(false);447  const [resetIdentityRunning, setResetIdentityRunning] = createSignal(false);448449  async function handleResetSyncIdentity() {450    setResetIdentityRunning(true);451    const res = await sendMessage({ type: "RESET_SYNC_IDENTITY" });452    setResetIdentityRunning(false);453    if ((res as { ok: boolean }).ok) {454      setPairedDevices([]);455      setResetIdentityConfirm(false);456    }457  }458459  function formatRelativeTime(timestamp?: number): string {460    if (!timestamp) return "Never";461    const diff = Date.now() - timestamp;462    const minutes = Math.floor(diff / 60000);463    const hours = Math.floor(diff / 3600000);464    const days = Math.floor(diff / 86400000);465    if (minutes < 1) return "Just now";466    if (minutes < 60) return `${minutes}m ago`;467    if (hours < 24) return `${hours}h ago`;468    return `${days}d ago`;469  }470471  function getStatusColor(status: SyncStatus): string {472    switch (status) {473      case "synced": return "var(--peach-sync-ok)";474      case "syncing": return "var(--peach-sync-busy)";475      case "no_peers": return "var(--peach-sync-off)";476      case "not_trusted": return "var(--peach-sync-err)";477      case "offline": return "var(--peach-sync-off)";478      default: return "var(--peach-sync-off)";479    }480  }481482  function getStatusLabel(status: SyncStatus): string {483    switch (status) {484      case "synced": return "Synced";485      case "syncing": return "Syncing";486      case "no_peers": return "No peers visible";487      case "not_trusted": return "Not on a trusted network";488      case "offline": return "Offline";489      default: return "Unknown";490    }491  }492493  function isCurrentNetworkTrusted(): boolean {494    return trustedNetworks().length > 0;495  }496497  async function handleSetupDuress() {498    const mainPw = duressMainPassword().trim();499    const duressPw = duressPassword().trim();500    const confirmPw = duressConfirm().trim();501502    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }503    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }504    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }505    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }506507    setDuressSaving(true);508    setDuressError(null);509510    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };511    if (!verifyRes.ok || !verifyRes.data) {512      setDuressError("incorrect main password");513      setDuressSaving(false);514      return;515    }516517    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };518    setDuressSaving(false);519    if (res.ok) {520      setDuressEnabled(true);521      setShowDuressSetup(false);522      setDuressPassword("");523      setDuressConfirm("");524      setDuressMainPassword("");525      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");526      setTimeout(() => setDuressSuccess(null), 5000);527    } else {528      setDuressError(res.error ?? "failed to set duress password");529    }530  }531532  async function handleClearDeclinedCards() {533    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });534    setDeclinedCardCount(0);535  }536537  async function handleCountDuplicates() {538    setDedupRunning(true);539    setDedupDone(null);540    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };541    setDedupRunning(false);542    if (res.ok) setDedupCount(res.data ?? 0);543  }544545  async function handleDeduplicate() {546    setDedupRunning(true);547    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };548    setDedupRunning(false);549    if (res.ok) {550      setDedupDone(res.data ?? 0);551      setDedupCount(0);552    }553  }554555  async function handleDisableDuress() {556    const mainPw = duressMainPassword().trim();557    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }558559    setDuressSaving(true);560    setDuressError(null);561562    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };563    if (!verifyRes.ok || !verifyRes.data) {564      setDuressError("incorrect main password");565      setDuressSaving(false);566      return;567    }568569    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };570    setDuressSaving(false);571    if (res.ok) {572      setDuressEnabled(false);573      setShowDuressSetup(false);574      setDuressMainPassword("");575      setDuressSuccess("Duress password disabled.");576      setTimeout(() => setDuressSuccess(null), 4000);577    } else {578      setDuressError(res.error ?? "failed to disable duress password");579    }580  }581582  async function handleSubmitFeedback() {583    if (!fbMessage().trim() || fbSubmitting()) return;584    setFbSubmitting(true);585    setFbResult(null);586    const res = await sendMessage({587      type: "SUBMIT_FEEDBACK",588      payload: {589        category: fbCategory(),590        message: fbMessage().trim(),591        version,592        browser: navigator.userAgent,593      },594    }) as { ok: boolean; error?: string };595    setFbSubmitting(false);596    if (res.ok) {597      setFbResult("success");598      setFbMessage("");599      setTimeout(() => setFbResult(null), 4000);600    } else {601      setFbResult("error");602    }603  }604605  function handleHeaderBack() {606    if (activeSection() !== null) {607      setActiveSection(null);608    } else {609      props.onBack();610    }611  }612613  function currentTitle(): string {614    const s = activeSection();615    if (s === null) return "settings";616    if (s === "license") return "account";617    return s;618  }619620  function renderCatRow(opts: {621    section: SectionName;622    name: string;623    desc: string;624    icon: () => any;625    pro?: boolean;626  }) {627    return (628      <button629        class="peach-settings-cat-row"630        onClick={() => setActiveSection(opts.section)}631      >632        <div class="peach-settings-cat-icon">{opts.icon()}</div>633        <div class="peach-settings-cat-content">634          <span class="peach-settings-cat-name">635            {opts.name}636            <Show when={opts.pro && !isPro()}>637              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>638            </Show>639          </span>640          <span class="peach-settings-cat-desc">{opts.desc}</span>641        </div>642        <svg643          class="peach-settings-cat-chevron"644          width="6" height="10" viewBox="0 0 6 10" fill="none"645          stroke="currentColor" stroke-width="1.1"646          stroke-linecap="round" stroke-linejoin="round"647        >648          <path d="M1 1l4 4-4 4" />649        </svg>650      </button>651    );652  }653654  const iconShield = () => (655    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">656      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>657    </svg>658  );659  const iconKey = () => (660    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">661      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>662    </svg>663  );664  const iconLock = () => (665    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">666      <rect x="3" y="7" width="10" height="7" rx="1.5"/>667      <path d="M6 7V5a3 3 0 016 0v2"/>668    </svg>669  );670  const iconSync = () => (671    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">672      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>673      <path d="M14 6l-2 2-2-2"/>674      <path d="M2 10l2-2 2 2"/>675    </svg>676  );677  const iconSun = () => (678    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">679      <circle cx="8" cy="8" r="3"/>680      <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"/>681    </svg>682  );683  const iconKeys = () => (684    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">685      <rect x="2" y="5" width="5" height="4" rx="1"/>686      <rect x="9" y="7" width="5" height="4" rx="1"/>687      <path d="M7 7h2m-2 2h2"/>688    </svg>689  );690  const iconBubble = () => (691    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">692      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>693    </svg>694  );695696  function renderHome() {697    return (698      <div class="peach-settings-body">699        <p class="peach-settings-nav-label">PROTECTION</p>700        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}701        {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}702703        <p class="peach-settings-nav-label">VAULT</p>704        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}705        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}706707        <p class="peach-settings-nav-label">PREFERENCES</p>708        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}709        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}710711        <p class="peach-settings-nav-label">SUPPORT</p>712        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}713714        <div class="peach-settings-footer">715          <span>Peach {version}</span>716          <span class="peach-divider"></span>717          <span>Browser extension</span>718        </div>719      </div>720    );721  }722723  function renderSecurity() {724    return (725      <div class="peach-settings-body">726        <div class="peach-settings-group">727          <p class="peach-settings-sub-label">LOCKING</p>728          <div class="peach-settings-row">729            <span>Lock on close</span>730            <button731              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}732              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}733              aria-label="Toggle lock on close"734            >735              <span class="peach-toggle-knob"></span>736            </button>737          </div>738          <div class="peach-settings-row">739            <span>Auto-lock after</span>740            <select741              class="peach-settings-select"742              value={String(props.autoLockMinutes)}743              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}744            >745              <option value="1">1 minute</option>746              <option value="5">5 minutes</option>747              <option value="15">15 minutes</option>748              <option value="30">30 minutes</option>749              <option value="0">Never</option>750            </select>751          </div>752753          <p class="peach-settings-sub-label">BREACH PROTECTION</p>754          <div class="peach-settings-row">755            <span>Breach monitoring</span>756            <button757              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}758              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}759              aria-label="Toggle breach monitoring"760            >761              <span class="peach-toggle-knob"></span>762            </button>763          </div>764          <div class="peach-settings-row peach-settings-row--meta">765            <span>Check all passwords</span>766            <button767              class="peach-btn peach-btn--subtle peach-btn--sm"768              onClick={() => void handleCheckAllBreaches()}769              disabled={checkingBreaches()}770            >771              {checkingBreaches() ? "checking..." : "run"}772            </button>773          </div>774          <Show when={breachResult()}>775            <div class="peach-settings-row peach-settings-row--meta">776              <span>Last check</span>777              <span class="peach-settings-value">{breachResult()}</span>778            </div>779          </Show>780781          <p class="peach-settings-sub-label">MASTER PASSWORD</p>782          <div class="peach-settings-row peach-settings-row--meta">783            <span>Master password</span>784            <button785              class="peach-btn peach-btn--subtle peach-btn--sm"786              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}787            >788              {showChangePw() ? "cancel" : "change"}789            </button>790          </div>791          <Show when={showChangePw()}>792            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">793              <input794                class="peach-settings-input"795                type="password"796                placeholder="current password"797                value={changePwCurrent()}798                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}799                autocomplete="current-password"800              />801              <input802                class="peach-settings-input"803                type="password"804                placeholder="new password (min 12 chars)"805                value={changePwNew()}806                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}807                autocomplete="new-password"808              />809              <input810                class="peach-settings-input"811                type="password"812                placeholder="confirm new password"813                value={changePwConfirm()}814                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}815                autocomplete="new-password"816                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}817              />818              <Show when={changePwError()}>819                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>820              </Show>821              <Show when={changePwSuccess()}>822                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>823              </Show>824              <button825                class="peach-btn peach-btn--accent peach-btn--sm"826                style="align-self:flex-start"827                onClick={() => void handleChangePassword()}828                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}829              >830                {changePwSaving() ? "saving…" : "save new password"}831              </button>832            </div>833          </Show>834835          <p class="peach-settings-sub-label">DURESS PASSWORD</p>836          <div class="peach-settings-row peach-settings-row--top">837            <div class="peach-settings-row-main">838              <span>Duress password</span>839              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">840                If entered under coercion, shows a decoy empty vault instead of your real one.841              </span>842            </div>843            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>844              {duressEnabled() ? "on" : "off"}845            </div>846          </div>847848          <Show when={duressEnabled() && !showDuressSetup()}>849            <div class="peach-settings-row peach-settings-row--meta">850              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">851                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.852              </span>853            </div>854          </Show>855856          <Show when={duressSuccess()}>857            <div class="peach-settings-row peach-settings-row--meta">858              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>859            </div>860          </Show>861862          <Show when={!showDuressSetup()}>863            <div class="peach-settings-row peach-settings-row--meta">864              <button865                class="peach-btn peach-btn--subtle peach-btn--sm"866                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}867              >868                {duressEnabled() ? "Change / disable" : "Set up"}869              </button>870            </div>871          </Show>872873          <Show when={showDuressSetup()}>874            <div class="peach-duress-form">875              <label class="peach-label">main password (to confirm)</label>876              <input877                class="peach-settings-input"878                type="password"879                placeholder="your current master password"880                autocomplete="current-password"881                value={duressMainPassword()}882                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}883              />884              <Show when={!duressEnabled()}>885                <label class="peach-label">new duress password</label>886                <input887                  class="peach-settings-input"888                  type="password"889                  placeholder="duress password (min 8 chars)"890                  autocomplete="new-password"891                  value={duressPassword()}892                  onInput={(e) => setDuressPassword(e.currentTarget.value)}893                />894                <label class="peach-label">confirm duress password</label>895                <input896                  class="peach-settings-input"897                  type="password"898                  placeholder="confirm duress password"899                  autocomplete="new-password"900                  value={duressConfirm()}901                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}902                />903              </Show>904905              <Show when={duressError()}>906                <p class="peach-hint peach-hint-error">{duressError()}</p>907              </Show>908909              <div class="peach-duress-actions">910                <button911                  class="peach-btn peach-btn--subtle peach-btn--sm"912                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}913                >914                  cancel915                </button>916                <Show when={duressEnabled()}>917                  <button918                    class="peach-btn peach-btn--ghost peach-btn--sm"919                    style="color:var(--peach-breach)"920                    onClick={() => void handleDisableDuress()}921                    disabled={duressSaving()}922                  >923                    {duressSaving() ? "disabling..." : "disable duress"}924                  </button>925                </Show>926                <Show when={!duressEnabled()}>927                  <button928                    class="peach-btn peach-btn--accent peach-btn--sm"929                    onClick={() => void handleSetupDuress()}930                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}931                  >932                    {duressSaving() ? "saving..." : "save"}933                  </button>934                </Show>935              </div>936            </div>937          </Show>938939          <p class="peach-settings-sub-label">CANARY ENTRIES</p>940          <div class="peach-settings-row peach-settings-row--top">941            <div class="peach-settings-row-main">942              <span>Canary entries</span>943              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">944                Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.945              </span>946            </div>947            <span class="peach-settings-value" style="flex-shrink:0">948              {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}949            </span>950          </div>951          <Show when={canaryEntries().length > 0}>952            <For each={canaryEntries()}>953              {(entry) => (954                <div class="peach-settings-row peach-settings-row--meta">955                  <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>956                  <button957                    class="peach-btn peach-btn--subtle peach-btn--sm"958                    onClick={() => void handleTestCanary(entry)}959                    disabled={canaryTestingId() === entry.id}960                    style="flex-shrink:0"961                  >962                    {canaryTestingId() === entry.id963                      ? "sending…"964                      : canaryTestResult()?.id === entry.id965                        ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")966                        : "test"}967                  </button>968                </div>969              )}970            </For>971          </Show>972973          <p class="peach-settings-sub-label">PASSKEYS</p>974          <div class="peach-settings-row peach-settings-row--top">975            <div class="peach-settings-row-main">976              <span>Intercept passkey requests</span>977              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">978                Peach can handle passkey sign-in and creation on supported Chromium browsers.979              </span>980            </div>981            <button982              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}983              onClick={() => void handleTogglePasskeys()}984              aria-label="Toggle passkey interception"985              disabled={!passkeyState()?.supported || passkeySaving()}986            >987              <span class="peach-toggle-knob"></span>988            </button>989          </div>990          <div class="peach-settings-row peach-settings-row--meta">991            <span>Status</span>992            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>993          </div>994          <div class="peach-settings-row peach-settings-row--meta">995            <span>Browser</span>996            <span class="peach-settings-value">997              {passkeyState()?.browserLabel ?? "Unknown"}998              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}999            </span>1000          </div>1001          <div class="peach-info-notice" style="margin-top:8px">1002            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1003          </div>1004          <Show when={passkeyState()?.conflict}>1005            <div class="peach-settings-row peach-settings-row--meta">1006              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1007            </div>1008          </Show>1009        </div>1010      </div>1011    );1012  }10131014  function renderVault() {1015    return (1016      <div class="peach-settings-body">1017        <div class="peach-settings-group">1018          <p class="peach-settings-sub-label">ENTRIES</p>1019          <div class="peach-settings-row peach-settings-row--meta">1020            <span>Entries</span>1021            <span class="peach-settings-value">{props.entriesCount}</span>1022          </div>1023          <div class="peach-settings-row peach-settings-row--meta">1024            <span>Duplicates</span>1025            <Show when={dedupDone() !== null} fallback={1026              <button1027                class="peach-btn peach-btn--subtle peach-btn--sm"1028                onClick={() => void handleCountDuplicates()}1029                disabled={dedupRunning()}1030              >1031                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1032              </button>1033            }>1034              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1035                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1036              </span>1037            </Show>1038          </div>1039          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1040            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1041              <div class="peach-restore-warning">1042                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1043              </div>1044              <div style="display:flex;gap:6px">1045                <button1046                  class="peach-btn peach-btn--subtle peach-btn--sm"1047                  onClick={() => setDedupCount(null)}1048                >1049                  cancel1050                </button>1051                <button1052                  class="peach-btn peach-btn--accent peach-btn--sm"1053                  onClick={() => void handleDeduplicate()}1054                  disabled={dedupRunning()}1055                >1056                  {dedupRunning() ? "removing..." : "remove duplicates"}1057                </button>1058              </div>1059            </div>1060          </Show>10611062          <p class="peach-settings-sub-label">MAINTENANCE</p>1063          <div class="peach-settings-row peach-settings-row--meta">1064            <span>Trash auto-delete</span>1065            <select1066              class="peach-settings-select"1067              value={String(props.trashRetentionDays)}1068              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1069            >1070              <option value="7">7 days</option>1071              <option value="30">30 days</option>1072              <option value="90">90 days</option>1073              <option value="0">Never</option>1074            </select>1075          </div>1076          <div class="peach-settings-row peach-settings-row--meta">1077            <span>Vault Health</span>1078            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1079              open1080            </button>1081          </div>1082          <Show when={declinedCardCount() > 0}>1083            <div class="peach-settings-row peach-settings-row--meta">1084              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1085              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1086                Clear1087              </button>1088            </div>1089          </Show>10901091          <p class="peach-settings-sub-label">DATA</p>1092          <div class="peach-settings-row peach-settings-row--meta">1093            <span>Import passwords</span>1094            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1095              open1096            </button>1097          </div>1098          <div class="peach-settings-row peach-settings-row--meta">1099            <span>Export backup</span>1100            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1101              open1102            </button>1103          </div>11041105          <p class="peach-settings-sub-label">VAULT HISTORY</p>1106          <Show when={historyLoading()}>1107            <p class="peach-settings-hint">Loading…</p>1108          </Show>1109          <Show when={!historyLoading() && historySnapshots().length === 0}>1110            <p class="peach-settings-hint">1111              No snapshots yet. Peach saves automatically before every change —1112              saves, syncs, and imports.1113            </p>1114          </Show>1115          <Show when={!historyLoading() && historySnapshots().length > 0}>1116            <p class="peach-settings-hint">1117              Restoring locks your vault. Unlock with the password that1118              encrypted that snapshot to confirm.1119            </p>1120            <div class="peach-history-list">1121              <For each={historySnapshots()}>1122                {(snap) => (1123                  <div class="peach-history-row">1124                    <div class="peach-history-meta">1125                      <span class="peach-history-time">1126                        {formatSnapshotTime(snap.savedAt)}1127                      </span>1128                      <span class="peach-history-sub">1129                        {[1130                          snap.entryCount !== null1131                            ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1132                            : null,1133                          reasonLabel(snap.reason),1134                        ]1135                          .filter(Boolean)1136                          .join(" · ")}1137                      </span>1138                    </div>1139                    <button1140                      class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1141                        historyRestoring() === snap.id ? " is-busy" : ""1142                      }`}1143                      onClick={() => void handleRestoreVaultSnapshot(snap.id)}1144                      disabled={historyRestoring() !== null}1145                    >1146                      <Show when={historyRestoring() === snap.id} fallback="Restore">1147                        …1148                      </Show>1149                    </button>1150                  </div>1151                )}1152              </For>1153            </div>1154            <p class="peach-history-footer">1155              {historySnapshots().length} of 10 snapshots1156            </p>1157          </Show>11581159          <button1160            class="peach-settings-advanced-toggle"1161            onClick={() => setShowVaultAdvanced((v) => !v)}1162            aria-expanded={showVaultAdvanced()}1163          >1164            <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1165            <span class="peach-settings-advanced-chevron">1166              {showVaultAdvanced() ? "▲" : "▼"}1167            </span>1168          </button>11691170          <Show when={showVaultAdvanced()}>1171            <div class="peach-danger-card">1172              <div class="peach-danger-card-head">1173                <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1174                <h3 class="peach-danger-card-title">Replace vault from backup</h3>1175              </div>1176              <p class="peach-danger-card-copy">1177                Wipes the current vault and restores from a backup file.1178                Recoverable only if your backup is good.1179              </p>1180              <button1181                class="peach-danger-card-button"1182                onClick={() => props.onOpenFileImport?.()}1183              >1184                Choose backup file1185              </button>1186            </div>11871188            <Show when={props.onOpenPanic}>1189              <div class="peach-danger-card peach-danger-card--hazard">1190                <div class="peach-danger-card-head">1191                  <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1192                  <h3 class="peach-danger-card-title">Destroy vault</h3>1193                </div>1194                <p class="peach-danger-card-copy">1195                  Permanently wipes every entry, setting, and license on this1196                  device. There is no recovery.1197                </p>1198                <button1199                  class="peach-danger-card-button"1200                  onClick={() => props.onOpenPanic?.()}1201                >1202                  Begin destruction1203                </button>1204              </div>1205            </Show>1206          </Show>1207        </div>1208      </div>1209    );1210  }12111212  function renderSync() {1213    if (!isPro()) {1214      return (1215        <div class="peach-settings-body">1216          <div class="peach-pro-upgrade">1217            <div class="peach-pro-upgrade-hero">1218              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1219              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1220            </div>12211222            <div class="peach-pro-features">1223              <div class="peach-pro-feature">1224                <span class="peach-pro-feature-mark">✦</span>1225                <div class="peach-pro-feature-text">1226                  <span class="peach-pro-feature-name">Always in sync</span>1227                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1228                </div>1229              </div>1230              <div class="peach-pro-feature">1231                <span class="peach-pro-feature-mark">✦</span>1232                <div class="peach-pro-feature-text">1233                  <span class="peach-pro-feature-name">No cloud. No relay.</span>1234                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1235                </div>1236              </div>1237              <div class="peach-pro-feature">1238                <span class="peach-pro-feature-mark">✦</span>1239                <div class="peach-pro-feature-text">1240                  <span class="peach-pro-feature-name">Tailscale supported</span>1241                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1242                </div>1243              </div>1244              <div class="peach-pro-feature">1245                <span class="peach-pro-feature-mark">✦</span>1246                <div class="peach-pro-feature-text">1247                  <span class="peach-pro-feature-name">Zero friction after setup</span>1248                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1249                </div>1250              </div>1251            </div>12521253            <button1254              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1255              onClick={() => setActiveSection("license")}1256            >1257              Upgrade to Pro1258            </button>1259            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1260              Already have a key? Enter it →1261            </button>1262          </div>1263        </div>1264      );1265    }12661267    return (1268      <div class="peach-settings-body">1269        <div class="peach-settings-group">1270          <div class="peach-settings-row">1271            <span>Status</span>1272            <span class="peach-settings-value peach-settings-value--status">1273              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1274              {getStatusLabel(syncStatus())}1275            </span>1276          </div>1277          <div class="peach-settings-row peach-settings-row--meta">1278            <span>Last sync</span>1279            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1280          </div>1281          <div class="peach-settings-row peach-settings-row--meta">1282            <button1283              class="peach-btn peach-btn--subtle peach-btn--sm"1284              onClick={() => void handleSyncNow()}1285              disabled={syncStatus() === "not_trusted" || syncingNow()}1286            >1287              <Switch>1288                <Match when={syncingNow()}>Syncing…</Match>1289                <Match when={syncFeedback()?.kind === "ok"}>✓ {syncFeedback()!.text}</Match>1290                <Match when={syncFeedback()?.kind === "err"}>{syncFeedback()!.text}</Match>1291                <Match when={true}>Sync now</Match>1292              </Switch>1293            </button>1294          </div>1295          <p class="peach-settings-hint">1296            If you edit on multiple devices before syncing, the most recently1297            changed device wins. Use Vault History above to recover older1298            versions if needed.1299          </p>13001301          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1302            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1303            <Show when={pairedDevices().length === 0}>1304              <p class="peach-settings-hint">1305                No paired devices yet. Add a device to start syncing.1306              </p>1307            </Show>1308            <Show when={pairedDevices().length > 0}>1309              <div class="peach-history-list">1310                <For each={pairedDevices()}>1311                  {(device) => (1312                    <div class="peach-history-row">1313                      <div class="peach-history-meta">1314                        <span class="peach-history-time">{device.displayName}</span>1315                        <span class="peach-history-sub">1316                          {device.lastSyncAt1317                            ? `Last synced: ${formatSnapshotTime(device.lastSyncAt)}`1318                            : "Never synced"}1319                        </span>1320                      </div>1321                      <button1322                        class="peach-btn peach-btn--subtle peach-btn--sm peach-history-btn"1323                        onClick={() => void handleRemoveDevice(device.deviceId)}1324                      >1325                        Remove1326                      </button>1327                    </div>1328                  )}1329                </For>1330              </div>1331            </Show>1332            <div class="peach-settings-actions--row">1333              <button1334                class="peach-btn peach-btn--subtle peach-btn--sm"1335                onClick={() => props.onOpenSyncPairing?.()}1336              >1337                Add a device1338              </button>1339            </div>1340          </div>13411342          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1343            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1344            <For each={trustedNetworks()}>1345              {(network) => (1346                <div class="peach-sync-device-row">1347                  <span class="peach-sync-device-name">1348                    {network.isTailscale ? "Tailscale" : (network.ssid ?? "Unknown network")}1349                  </span>1350                  <Show1351                    when={!network.isTailscale}1352                    fallback={<span class="peach-sync-device-meta">Always trusted</span>}1353                  >1354                    <button1355                      class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1356                      onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1357                    >1358                      Remove1359                    </button>1360                  </Show>1361                </div>1362              )}1363            </For>1364            <div class="peach-settings-actions--row">1365              <Show when={!isCurrentNetworkTrusted()}>1366                <button1367                  class="peach-btn peach-btn--subtle peach-btn--sm"1368                  onClick={() => void handleMarkNetworkTrusted()}1369                  disabled={markingNetwork()}1370                >1371                  {markingNetwork() ? "Marking…" : "Mark current network as trusted"}1372                </button>1373              </Show>1374            </div>1375          </div>13761377          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1378            <span class="peach-sync-section-label">Advanced</span>1379            <Show1380              when={resetIdentityConfirm()}1381              fallback={1382                <div class="peach-sync-advanced-row">1383                  <div class="peach-sync-advanced-info">1384                    <span class="peach-sync-advanced-name">Reset sync identity</span>1385                    <span class="peach-sync-device-meta">1386                      Generates a new keypair and unpairs every device.1387                    </span>1388                  </div>1389                  <button1390                    class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1391                    onClick={() => setResetIdentityConfirm(true)}1392                  >1393                    Reset1394                  </button>1395                </div>1396              }1397            >1398              <div class="peach-sync-reset-confirm">1399                <p class="peach-sync-reset-confirm-headline">Reset sync identity?</p>1400                <p class="peach-sync-reset-confirm-body">1401                  This will unpair all devices. You will need to pair them again from scratch.1402                </p>1403                <div class="peach-settings-actions--row">1404                  <button1405                    class="peach-btn peach-btn--ghost peach-btn--sm"1406                    onClick={() => setResetIdentityConfirm(false)}1407                    disabled={resetIdentityRunning()}1408                  >1409                    Cancel1410                  </button>1411                  <button1412                    class="peach-btn peach-btn--danger peach-btn--sm"1413                    onClick={() => void handleResetSyncIdentity()}1414                    disabled={resetIdentityRunning()}1415                  >1416                    {resetIdentityRunning() ? "Resetting..." : "Reset"}1417                  </button>1418                </div>1419              </div>1420            </Show>1421          </div>1422        </div>1423      </div>1424    );1425  }14261427  function renderAppearance() {1428    const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1429      { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1430      { id: "cormorant", label: "Cormorant", preview: "Aa" },1431      { id: "inter", label: "Inter", preview: "Aa" },1432      { id: "system", label: "System", preview: "Aa" },1433    ];1434    const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1435      { id: "small", label: "Small" },1436      { id: "medium", label: "Default" },1437      { id: "large", label: "Large" },1438    ];1439    return (1440      <div class="peach-settings-body">1441        <div class="peach-settings-group">1442          <p class="peach-settings-sub-label">THEME</p>1443          <div class="peach-settings-row peach-settings-row--meta">1444            <span>Theme</span>1445            <button1446              class="peach-btn peach-btn--subtle peach-btn--sm"1447              onClick={() =>1448                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1449              }1450            >1451              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1452            </button>1453          </div>14541455          <p class="peach-settings-sub-label">ACCENT</p>1456          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1457            <span>Accent</span>1458            <div class="peach-accent-picker">1459              <For each={ACCENT_COLORS}>1460                {(color) => (1461                  <button1462                    class="peach-accent-swatch"1463                    classList={{ "is-active": props.accentColor === color.name }}1464                    style={{ background: color.hex }}1465                    onClick={() => void props.onAccentChange(color.name)}1466                    aria-label={color.name}1467                    title={color.name}1468                  />1469                )}1470              </For>1471            </div>1472          </div>14731474          <p class="peach-settings-sub-label">DISPLAY FONT</p>1475          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1476            <span>Font</span>1477            <div class="peach-font-picker">1478              <For each={fontOptions}>1479                {(opt) => (1480                  <button1481                    class="peach-font-swatch"1482                    classList={{ "is-active": props.displayFont === opt.id }}1483                    data-font-preview={opt.id}1484                    onClick={() => void props.onDisplayFontChange(opt.id)}1485                    aria-label={opt.label}1486                    title={opt.label}1487                  >1488                    <span class="peach-font-swatch-glyph">{opt.preview}</span>1489                    <span class="peach-font-swatch-name">{opt.label}</span>1490                  </button>1491                )}1492              </For>1493            </div>1494          </div>14951496          <p class="peach-settings-sub-label">TEXT SIZE</p>1497          <div class="peach-settings-row peach-settings-row--meta">1498            <span>Size</span>1499            <div class="peach-pill-group">1500              <For each={sizeOptions}>1501                {(opt) => (1502                  <button1503                    class="peach-btn peach-btn--ghost peach-btn--sm"1504                    classList={{ "is-active": props.textSize === opt.id }}1505                    onClick={() => void props.onTextSizeChange(opt.id)}1506                  >1507                    {opt.label}1508                  </button>1509                )}1510              </For>1511            </div>1512          </div>15131514          <p class="peach-settings-sub-label">MOTION</p>1515          <div class="peach-settings-row peach-settings-row--meta">1516            <span>Reduce motion</span>1517            <button1518              class="peach-btn peach-btn--subtle peach-btn--sm"1519              onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1520            >1521              {props.reduceMotion ? "On" : "Off"}1522            </button>1523          </div>1524        </div>1525      </div>1526    );1527  }15281529  function renderShortcuts() {1530    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1531    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);15321533    function startCapture(action: ShortcutAction) {1534      setConflictMsg(null);1535      setCapturingAction(action);1536    }1537    function cancelCapture() {1538      setCapturingAction(null);1539      setConflictMsg(null);1540    }1541    function commitBinding(action: ShortcutAction, binding: Keybinding) {1542      const conflict = findConflict(props.shortcuts, binding, action);1543      if (conflict) {1544        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1545        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1546        return;1547      }1548      const next = { ...props.shortcuts, [action]: binding };1549      setCapturingAction(null);1550      setConflictMsg(null);1551      void props.onShortcutsChange(next);1552    }1553    function resetAction(action: ShortcutAction) {1554      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1555      void props.onShortcutsChange(next);1556    }1557    function resetAll() {1558      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1559    }15601561    // Capture-mode key handler — wired via ref onto the chip element so it1562    // catches the next non-modifier keypress and ignores other bindings.1563    function handleCaptureKey(e: KeyboardEvent) {1564      e.preventDefault();1565      e.stopPropagation();1566      if (e.key === "Escape") { cancelCapture(); return; }1567      const binding = keybindingFromEvent(e);1568      if (!binding) return; // bare modifier — keep waiting1569      const action = capturingAction();1570      if (action) commitBinding(action, binding);1571    }15721573    return (1574      <div class="peach-settings-body">1575        <div class="peach-settings-group peach-shortcuts-group">1576          <p class="peach-settings-sub-label">ENTRY VIEW</p>1577          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1578            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1579          </For>1580        </div>15811582        <div class="peach-settings-group peach-shortcuts-group">1583          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1584          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1585            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1586          </For>1587        </div>15881589        <Show when={conflictMsg()}>1590          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1591        </Show>15921593        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1594          <div class="peach-settings-row peach-settings-row--meta">1595            <span>Inline autofill on page</span>1596            <a1597              class="peach-settings-link"1598              href="chrome://extensions/shortcuts"1599              onClick={(e) => {1600                e.preventDefault();1601                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1602              }}1603            >1604              ⌘⇧P — customize ↗1605            </a>1606          </div>1607          <button1608            class="peach-btn peach-btn--subtle peach-btn--sm"1609            style="align-self:flex-start"1610            onClick={resetAll}1611          >1612            Reset all to defaults1613          </button>1614        </div>1615      </div>1616    );16171618    function renderRow(1619      desc: typeof SHORTCUT_DESCRIPTORS[number],1620      capturing: () => ShortcutAction | null,1621      onStart: (a: ShortcutAction) => void,1622      onCancel: () => void,1623      onReset: (a: ShortcutAction) => void,1624      onKey: (e: KeyboardEvent) => void,1625    ) {1626      const isActive = () => capturing() === desc.action;1627      const binding = () => props.shortcuts[desc.action];1628      const isCustom = () => {1629        const b = binding();1630        const d = DEFAULT_BINDINGS[desc.action];1631        return b.key !== d.key1632          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1633          || Boolean(b.meta)  !== Boolean(d.meta)1634          || Boolean(b.shift) !== Boolean(d.shift)1635          || Boolean(b.alt)   !== Boolean(d.alt);1636      };1637      return (1638        <div class="peach-shortcut-row">1639          <span class="peach-shortcut-row-label">{desc.label}</span>1640          <button1641            class="peach-shortcut-chip"1642            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1643            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1644            onKeyDown={(e) => { if (isActive()) onKey(e); }}1645            ref={(el) => {1646              // Auto-focus when entering capture mode so the keydown listener catches the next press.1647              if (el) {1648                queueMicrotask(() => { if (isActive()) el.focus(); });1649              }1650            }}1651          >1652            {isActive() ? "Press a key…" : formatKeybinding(binding())}1653          </button>1654          <Show when={isCustom() && !isActive()}>1655            <button1656              class="peach-shortcut-reset"1657              title="Reset to default"1658              onClick={() => onReset(desc.action)}1659            >1660              ↺1661            </button>1662          </Show>1663        </div>1664      );1665    }1666  }16671668  const PRO_FEATURES: Array<{1669    feature: ProFeature;1670    name: string;1671    desc: string;1672  }> = [1673    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1674    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1675    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1676    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1677    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1678  ];16791680  const [licenseKey, setLicenseKey] = createSignal<string | null>(null);16811682  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1683  const [deactivating, setDeactivating] = createSignal(false);16841685  async function handleDeactivateLicense() {1686    setDeactivating(true);1687    await sendMessage({ type: "DEACTIVATE_LICENSE" });1688    setLicenseInfo(null);1689    await props.onLicenseChange?.();1690    setDeactivating(false);1691  }16921693  async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1694    setCanaryTestingId(entry.id);1695    setCanaryTestResult(null);1696    const res = await sendMessage({1697      type: "FIRE_CANARY_PING",1698      payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1699    }) as { ok: boolean };1700    setCanaryTestResult({ id: entry.id, ok: res.ok });1701    setCanaryTestingId(null);1702    setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1703  }17041705  function renderLicense() {1706    async function onActivate() {1707      await handleActivateLicense();1708      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1709        await props.onLicenseChange?.();1710      }1711    }17121713    return (1714      <div class="peach-settings-body">1715        <Show1716          when={isPro()}1717          fallback={1718            <Show1719              when={showKeyEntry()}1720              fallback={1721                <div class="peach-pro-upgrade">1722                  <div class="peach-pro-upgrade-hero">1723                    <div class="peach-pro-upgrade-mark-wrap">1724                      <PeachMark class="peach-pro-upgrade-mark" />1725                    </div>1726                    <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1727                    <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1728                  </div>17291730                  <div class="peach-pro-features">1731                    <For each={PRO_FEATURES}>1732                      {(item) => (1733                        <div1734                          class="peach-pro-feature"1735                          classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1736                        >1737                          <span class="peach-pro-feature-mark">✦</span>1738                          <div class="peach-pro-feature-text">1739                            <span class="peach-pro-feature-name">{item.name}</span>1740                            <span class="peach-pro-feature-desc">{item.desc}</span>1741                          </div>1742                        </div>1743                      )}1744                    </For>1745                  </div>17461747                  <div class="peach-pro-upgrade-actions">1748                    <p class="peach-pro-pricing">$39 · once · yours forever</p>1749                    <a1750                      href="https://peachpasswords.com/#pricing"1751                      target="_blank"1752                      rel="noopener noreferrer"1753                      class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1754                    >1755                      Get Peach Pro1756                    </a>1757                    <div class="peach-pro-key-divider"><span>or</span></div>1758                    <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1759                      Activate a license key1760                    </button>1761                  </div>1762                </div>1763              }1764            >1765              <div class="peach-pro-key-ceremony">1766                <LockRuneCanvas />1767                <p class="peach-pro-key-ceremony-label">Enter your license key</p>1768                <input1769                  class="peach-underline-input peach-pro-key-input"1770                  type="text"1771                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1772                  value={licenseKeyInput()}1773                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1774                  onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1775                  autocomplete="off"1776                  spellcheck={false}1777                />1778                <Show when={licenseError()}>1779                  <p class="peach-pro-key-error">That key doesn't look right.</p>1780                </Show>1781                <button1782                  class="peach-btn peach-btn--accent peach-pro-key-activate"1783                  onClick={() => void onActivate()}1784                  disabled={activatingLicense() || !licenseKeyInput().trim()}1785                >1786                  {activatingLicense() ? "activating…" : "Activate"}1787                </button>1788                <button1789                  class="peach-pro-key-cancel"1790                  onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1791                >1792                  cancel1793                </button>1794              </div>1795            </Show>1796          }1797        >1798          <div class="peach-pro-active">1799            <div class="peach-pro-active-hero">1800              <div class="peach-pro-active-mark-wrap">1801                <PeachMark class="peach-pro-active-mark" />1802              </div>1803              <h2 class="peach-pro-active-title">Peach Pro</h2>1804              <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1805            </div>18061807            <Show when={licenseKey()}>1808              <div class="peach-settings-license">1809                <span class="peach-settings-label">LICENSE KEY</span>1810                <div class="peach-settings-license-row">1811                  <span class="peach-settings-license-key">{licenseKey()!}</span>1812                  <button1813                    class="peach-settings-license-copy"1814                    onClick={() => copyLicenseKey(licenseKey()!)}1815                  >1816                    {copied() ? "✓" : "copy"}1817                  </button>1818                </div>1819              </div>1820            </Show>18211822            <div class="peach-pro-active-features">1823              <For each={PRO_FEATURES}>1824                {(item) => (1825                  <div class="peach-pro-active-feature">1826                    <span class="peach-pro-active-feature-mark">✦</span>1827                    <span class="peach-pro-active-feature-name">{item.name}</span>1828                  </div>1829                )}1830              </For>1831            </div>18321833            <button1834              class="peach-pro-deactivate"1835              onClick={() => void handleDeactivateLicense()}1836              disabled={deactivating()}1837            >1838              {deactivating() ? "Deactivating…" : "Deactivate"}1839            </button>1840          </div>1841        </Show>1842      </div>1843    );1844  }18451846  function renderFeedback() {1847    return (1848      <div class="peach-settings-body">1849        <div class="peach-settings-group peach-feedback-group">1850          <p class="peach-feedback-intro">1851            Help us improve Peach. Every report goes directly to our inbox.1852          </p>18531854          <p class="peach-settings-sub-label">TYPE</p>1855          <div class="peach-feedback-chips">1856            <For each={FEEDBACK_CATEGORIES}>1857              {(cat) => (1858                <button1859                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1860                  onClick={() => setFbCategory(cat)}1861                >1862                  {cat}1863                </button>1864              )}1865            </For>1866          </div>18671868          <p class="peach-settings-sub-label">MESSAGE</p>1869          <textarea1870            class="peach-feedback-textarea"1871            placeholder="Describe the issue or idea..."1872            value={fbMessage()}1873            onInput={(e) => setFbMessage(e.currentTarget.value)}1874          />18751876          <p class="peach-feedback-meta">1877            Version {version} · will be included automatically1878          </p>18791880          <Show when={fbResult() === "success"}>1881            <p class="peach-feedback-result peach-feedback-result--ok">1882              Sent — thank you1883            </p>1884          </Show>1885          <Show when={fbResult() === "error"}>1886            <p class="peach-feedback-result peach-feedback-result--err">1887              Failed to send — try again1888            </p>1889          </Show>18901891          <button1892            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1893            onClick={() => void handleSubmitFeedback()}1894            disabled={fbSubmitting() || !fbMessage().trim()}1895          >1896            {fbSubmitting() ? "Sending..." : "Send feedback"}1897          </button>18981899          <p class="peach-feedback-anon">1900            Sent anonymously · no account required1901          </p>1902        </div>1903      </div>1904    );1905  }19061907  return (1908    <div class="peach-overlay-view ov-enter-right" style="display:flex">1909      <div class="peach-overlay-header">1910        <button1911          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1912          onClick={handleHeaderBack}1913          aria-label="Back"1914        >1915          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1916            <path d="M6 1L1 6L6 11" />1917          </svg>1918        </button>1919        <span class="peach-overlay-title">{currentTitle()}</span>1920        <div class="peach-overlay-spacer"></div>1921      </div>19221923      <Switch fallback={renderHome()}>1924        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1925        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1926        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1927        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1928        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1929        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1930        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1931      </Switch>1932    </div>1933  );1934}