Settings.tsx

56.8 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { ACCENT_COLORS } from "../../shared/types";4import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";5import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";6import {7  DEFAULT_BINDINGS,8  findConflict,9  formatKeybinding,10  keybindingFromEvent,11  SHORTCUT_DESCRIPTORS,12  type Keybinding,13  type ShortcutAction,14} from "../../shared/shortcuts";1516interface LicenseInfo {17  valid: boolean;18  plan: "free" | "pro";19  issued?: string;20}2122interface SettingsProps {23  entriesCount: number;24  breachMonitoring: boolean;25  lockOnClose: boolean;26  autoLockMinutes: number;27  trashRetentionDays: number;28  theme: "dark" | "light";29  accentColor: AccentColorName;30  isPro?: boolean;31  initialSection?: string | null;32  upgradeHighlight?: ProFeature | null;33  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;34  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;35  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;36  onTrashRetentionChange: (days: number) => void | Promise<void>;37  onThemeChange: (theme: "dark" | "light") => void;38  onAccentChange: (name: AccentColorName) => void | Promise<void>;39  shortcuts: Record<ShortcutAction, Keybinding>;40  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;41  onLicenseChange?: () => void | Promise<void>;42  onBack: () => void;43  onImport: () => void;44  onExport: () => void;45  onBreachCheckDone: () => void;46  onOpenHealth?: () => void;47  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;48  onOpenCJKImport?: () => void;49  onOpenFileImport?: () => void;50}5152type SectionName =53  | "security"54  | "vault"55  | "sync"56  | "appearance"57  | "shortcuts"58  | "license"59  | "feedback";6061const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];6263function formatActivatedSince(issued: string | undefined): string {64  if (!issued) return "Active";65  try {66    const d = new Date(issued);67    if (Number.isNaN(d.getTime())) return "Active";68    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;69  } catch {70    return "Active";71  }72}7374function isSectionName(value: string): value is SectionName {75  return (76    value === "security" ||77    value === "vault" ||78    value === "sync" ||79    value === "appearance" ||80    value === "shortcuts" ||81    value === "license" ||82    value === "feedback"83  );84}8586export function SettingsView(props: SettingsProps) {87  const version = chrome.runtime.getManifest().version;88  const initial = props.initialSection;89  const [activeSection, setActiveSection] = createSignal<SectionName | null>(90    initial && isSectionName(initial) ? (initial as SectionName) : null91  );9293  const [checkingBreaches, setCheckingBreaches] = createSignal(false);94  const [breachResult, setBreachResult] = createSignal<string | null>(null);95  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);96  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");97  const [licenseError, setLicenseError] = createSignal<string | null>(null);98  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);99  const [activatingLicense, setActivatingLicense] = createSignal(false);100101  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);102  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);103  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");104  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);105  // Prefer the prop (cached at App boot), fall back to the sync-state probe.106  // The prop is authoritative once present so license activation reflects107  // immediately without waiting for a sync round-trip.108  const [syncProStatus, setSyncProStatus] = createSignal(false);109  const isPro = () => props.isPro ?? syncProStatus();110  const [syncingNow, setSyncingNow] = createSignal(false);111  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);112  const [passkeySaving, setPasskeySaving] = createSignal(false);113114  const [dedupCount, setDedupCount] = createSignal<number | null>(null);115  const [dedupRunning, setDedupRunning] = createSignal(false);116  const [dedupDone, setDedupDone] = createSignal<number | null>(null);117118  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);119120  const [showChangePw, setShowChangePw] = createSignal(false);121  const [changePwCurrent, setChangePwCurrent] = createSignal("");122  const [changePwNew, setChangePwNew] = createSignal("");123  const [changePwConfirm, setChangePwConfirm] = createSignal("");124  const [changePwSaving, setChangePwSaving] = createSignal(false);125  const [changePwError, setChangePwError] = createSignal<string | null>(null);126  const [changePwSuccess, setChangePwSuccess] = createSignal(false);127128  const [duressEnabled, setDuressEnabled] = createSignal(false);129  const [showDuressSetup, setShowDuressSetup] = createSignal(false);130  const [duressPassword, setDuressPassword] = createSignal("");131  const [duressConfirm, setDuressConfirm] = createSignal("");132  const [duressMainPassword, setDuressMainPassword] = createSignal("");133  const [duressSaving, setDuressSaving] = createSignal(false);134  const [duressError, setDuressError] = createSignal<string | null>(null);135  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);136137  const [fbCategory, setFbCategory] = createSignal("Bug report");138  const [fbMessage, setFbMessage] = createSignal("");139  const [fbSubmitting, setFbSubmitting] = createSignal(false);140  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);141142  onMount(async () => {143    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([144      sendMessage({ type: "GET_LICENSE" }),145      sendMessage({ type: "GET_SYNC_STATE" }),146      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),147      sendMessage({ type: "IS_DURESS_ENABLED" }),148      sendMessage({ type: "GET_SETTINGS" }),149    ]);150151    if ((licenseRes as { ok: boolean }).ok) {152      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);153    }154155    if ((syncRes as { ok: boolean }).ok) {156      const data = syncRes as { ok: boolean; data?: {157        pairedDevices?: PairedDevice[];158        trustedNetworks?: TrustedNetwork[];159        status?: SyncStatus;160        lastSyncAt?: number;161        isPro?: boolean;162      }};163      if (data.data) {164        setPairedDevices(data.data.pairedDevices ?? []);165        setTrustedNetworks(data.data.trustedNetworks ?? []);166        setSyncStatus(data.data.status ?? "offline");167        setLastSyncAt(data.data.lastSyncAt);168        setSyncProStatus(data.data.isPro ?? false);169      }170    }171172    if ((passkeyRes as { ok: boolean }).ok) {173      setPasskeyState(174        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null175      );176    }177178    if ((duressRes as { ok: boolean }).ok) {179      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);180    }181182    if ((settingsRes as { ok: boolean }).ok) {183      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;184      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);185    }186187    chrome.runtime.onMessage.addListener((msg) => {188      if (msg.type === "SYNC_STATE_UPDATE") {189        setPairedDevices(msg.pairedDevices ?? []);190        setTrustedNetworks(msg.trustedNetworks ?? []);191        setSyncStatus(msg.status ?? "offline");192        setLastSyncAt(msg.lastSyncAt);193        setSyncProStatus(msg.isPro ?? false);194      }195    });196  });197198  async function handleChangePassword() {199    setChangePwError(null);200    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }201    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }202    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }203    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }204    setChangePwSaving(true);205    const res = await sendMessage({206      type: "CHANGE_PASSWORD",207      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },208    }) as { ok: boolean; error?: string };209    if (res.ok) {210      setChangePwSuccess(true);211      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");212      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);213    } else {214      setChangePwError(res.error ?? "failed to change password");215    }216    setChangePwSaving(false);217  }218219  async function handleActivateLicense() {220    const key = licenseKeyInput().trim();221    if (!key) return;222    setActivatingLicense(true);223    setLicenseError(null);224    setLicenseSuccess(null);225    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });226    setActivatingLicense(false);227    if ((res as { ok: boolean }).ok) {228      const data = (res as { ok: boolean; data?: LicenseInfo }).data;229      if (data?.valid) {230        setLicenseInfo(data);231        setLicenseSuccess("License activated!");232        setLicenseKeyInput("");233      } else {234        setLicenseError("Invalid license key");235      }236    } else {237      setLicenseError("Activation failed");238    }239  }240241  async function handleTogglePasskeys() {242    const current = passkeyState();243    if (!current || !current.supported) return;244    setPasskeySaving(true);245    const res = await sendMessage({246      type: "SET_PASSKEY_PROVIDER_ENABLED",247      payload: { enabled: !current.enabled },248    });249    setPasskeySaving(false);250    if ((res as { ok: boolean }).ok) {251      setPasskeyState(252        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null253      );254    }255  }256257  async function handleCheckAllBreaches() {258    setCheckingBreaches(true);259    setBreachResult(null);260    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });261    setCheckingBreaches(false);262    if ((res as { ok: boolean }).ok) {263      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};264      const breached = Object.values(data).filter((count) => count > 0).length;265      setBreachResult(266        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"267      );268      props.onBreachCheckDone();269    }270  }271272  async function handleSyncNow() {273    if (syncStatus() === "not_trusted") return;274    setSyncingNow(true);275    await sendMessage({ type: "TRIGGER_SYNC" });276    setTimeout(() => setSyncingNow(false), 3000);277  }278279  async function handleRemoveDevice(deviceId: string) {280    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });281  }282283  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {284    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });285  }286287  async function handleMarkNetworkTrusted() {288    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });289  }290291  function formatRelativeTime(timestamp?: number): string {292    if (!timestamp) return "Never";293    const diff = Date.now() - timestamp;294    const minutes = Math.floor(diff / 60000);295    const hours = Math.floor(diff / 3600000);296    const days = Math.floor(diff / 86400000);297    if (minutes < 1) return "Just now";298    if (minutes < 60) return `${minutes}m ago`;299    if (hours < 24) return `${hours}h ago`;300    return `${days}d ago`;301  }302303  function getStatusColor(status: SyncStatus): string {304    switch (status) {305      case "synced": return "var(--peach-sync-ok)";306      case "syncing": return "var(--peach-sync-busy)";307      case "no_peers": return "var(--peach-sync-off)";308      case "not_trusted": return "var(--peach-sync-err)";309      case "offline": return "var(--peach-sync-off)";310      default: return "var(--peach-sync-off)";311    }312  }313314  function getStatusLabel(status: SyncStatus): string {315    switch (status) {316      case "synced": return "Synced";317      case "syncing": return "Syncing";318      case "no_peers": return "No peers visible";319      case "not_trusted": return "Not on a trusted network";320      case "offline": return "Offline";321      default: return "Unknown";322    }323  }324325  function isCurrentNetworkTrusted(): boolean {326    return trustedNetworks().length > 0;327  }328329  async function handleSetupDuress() {330    const mainPw = duressMainPassword().trim();331    const duressPw = duressPassword().trim();332    const confirmPw = duressConfirm().trim();333334    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }335    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }336    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }337    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }338339    setDuressSaving(true);340    setDuressError(null);341342    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };343    if (!verifyRes.ok || !verifyRes.data) {344      setDuressError("incorrect main password");345      setDuressSaving(false);346      return;347    }348349    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };350    setDuressSaving(false);351    if (res.ok) {352      setDuressEnabled(true);353      setShowDuressSetup(false);354      setDuressPassword("");355      setDuressConfirm("");356      setDuressMainPassword("");357      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");358      setTimeout(() => setDuressSuccess(null), 5000);359    } else {360      setDuressError(res.error ?? "failed to set duress password");361    }362  }363364  async function handleClearDeclinedCards() {365    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });366    setDeclinedCardCount(0);367  }368369  async function handleCountDuplicates() {370    setDedupRunning(true);371    setDedupDone(null);372    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };373    setDedupRunning(false);374    if (res.ok) setDedupCount(res.data ?? 0);375  }376377  async function handleDeduplicate() {378    setDedupRunning(true);379    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };380    setDedupRunning(false);381    if (res.ok) {382      setDedupDone(res.data ?? 0);383      setDedupCount(0);384    }385  }386387  async function handleDisableDuress() {388    const mainPw = duressMainPassword().trim();389    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }390391    setDuressSaving(true);392    setDuressError(null);393394    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };395    if (!verifyRes.ok || !verifyRes.data) {396      setDuressError("incorrect main password");397      setDuressSaving(false);398      return;399    }400401    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };402    setDuressSaving(false);403    if (res.ok) {404      setDuressEnabled(false);405      setShowDuressSetup(false);406      setDuressMainPassword("");407      setDuressSuccess("Duress password disabled.");408      setTimeout(() => setDuressSuccess(null), 4000);409    } else {410      setDuressError(res.error ?? "failed to disable duress password");411    }412  }413414  async function handleSubmitFeedback() {415    if (!fbMessage().trim() || fbSubmitting()) return;416    setFbSubmitting(true);417    setFbResult(null);418    const res = await sendMessage({419      type: "SUBMIT_FEEDBACK",420      payload: {421        category: fbCategory(),422        message: fbMessage().trim(),423        version,424        browser: navigator.userAgent,425      },426    }) as { ok: boolean; error?: string };427    setFbSubmitting(false);428    if (res.ok) {429      setFbResult("success");430      setFbMessage("");431      setTimeout(() => setFbResult(null), 4000);432    } else {433      setFbResult("error");434    }435  }436437  function handleHeaderBack() {438    if (activeSection() !== null) {439      setActiveSection(null);440    } else {441      props.onBack();442    }443  }444445  function currentTitle(): string {446    const s = activeSection();447    if (s === null) return "settings";448    if (s === "license") return "account";449    return s;450  }451452  function renderCatRow(opts: {453    section: SectionName;454    name: string;455    desc: string;456    icon: () => any;457    pro?: boolean;458  }) {459    return (460      <button461        class="peach-settings-cat-row"462        onClick={() => setActiveSection(opts.section)}463      >464        <div class="peach-settings-cat-icon">{opts.icon()}</div>465        <div class="peach-settings-cat-content">466          <span class="peach-settings-cat-name">467            {opts.name}468            <Show when={opts.pro && !isPro()}>469              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>470            </Show>471          </span>472          <span class="peach-settings-cat-desc">{opts.desc}</span>473        </div>474        <svg475          class="peach-settings-cat-chevron"476          width="6" height="10" viewBox="0 0 6 10" fill="none"477          stroke="currentColor" stroke-width="1.1"478          stroke-linecap="round" stroke-linejoin="round"479        >480          <path d="M1 1l4 4-4 4" />481        </svg>482      </button>483    );484  }485486  const iconShield = () => (487    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">488      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>489    </svg>490  );491  const iconKey = () => (492    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">493      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>494    </svg>495  );496  const iconLock = () => (497    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">498      <rect x="3" y="7" width="10" height="7" rx="1.5"/>499      <path d="M6 7V5a3 3 0 016 0v2"/>500    </svg>501  );502  const iconSync = () => (503    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">504      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>505      <path d="M14 6l-2 2-2-2"/>506      <path d="M2 10l2-2 2 2"/>507    </svg>508  );509  const iconSun = () => (510    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">511      <circle cx="8" cy="8" r="3"/>512      <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"/>513    </svg>514  );515  const iconKeys = () => (516    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">517      <rect x="2" y="5" width="5" height="4" rx="1"/>518      <rect x="9" y="7" width="5" height="4" rx="1"/>519      <path d="M7 7h2m-2 2h2"/>520    </svg>521  );522  const iconBubble = () => (523    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">524      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>525    </svg>526  );527528  function renderHome() {529    return (530      <div class="peach-settings-body">531        <p class="peach-settings-nav-label">PROTECTION</p>532        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}533        {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}534535        <p class="peach-settings-nav-label">VAULT</p>536        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}537        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}538539        <p class="peach-settings-nav-label">PREFERENCES</p>540        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}541        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}542543        <p class="peach-settings-nav-label">SUPPORT</p>544        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}545546        <div class="peach-settings-footer">547          <span>Peach {version}</span>548          <span class="peach-divider"></span>549          <span>Browser extension</span>550        </div>551      </div>552    );553  }554555  function renderSecurity() {556    return (557      <div class="peach-settings-body">558        <div class="peach-settings-group">559          <p class="peach-settings-sub-label">LOCKING</p>560          <div class="peach-settings-row">561            <span>Lock on close</span>562            <button563              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}564              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}565              aria-label="Toggle lock on close"566            >567              <span class="peach-toggle-knob"></span>568            </button>569          </div>570          <div class="peach-settings-row">571            <span>Auto-lock after</span>572            <select573              class="peach-settings-select"574              value={String(props.autoLockMinutes)}575              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}576            >577              <option value="1">1 minute</option>578              <option value="5">5 minutes</option>579              <option value="15">15 minutes</option>580              <option value="30">30 minutes</option>581              <option value="0">Never</option>582            </select>583          </div>584585          <p class="peach-settings-sub-label">BREACH PROTECTION</p>586          <div class="peach-settings-row">587            <span>Breach monitoring</span>588            <button589              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}590              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}591              aria-label="Toggle breach monitoring"592            >593              <span class="peach-toggle-knob"></span>594            </button>595          </div>596          <div class="peach-settings-row peach-settings-row--meta">597            <span>Check all passwords</span>598            <button599              class="peach-btn peach-btn--subtle peach-btn--sm"600              onClick={() => void handleCheckAllBreaches()}601              disabled={checkingBreaches()}602            >603              {checkingBreaches() ? "checking..." : "run"}604            </button>605          </div>606          <Show when={breachResult()}>607            <div class="peach-settings-row peach-settings-row--meta">608              <span>Last check</span>609              <span class="peach-settings-value">{breachResult()}</span>610            </div>611          </Show>612613          <p class="peach-settings-sub-label">MASTER PASSWORD</p>614          <div class="peach-settings-row peach-settings-row--meta">615            <span>Master password</span>616            <button617              class="peach-btn peach-btn--subtle peach-btn--sm"618              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}619            >620              {showChangePw() ? "cancel" : "change"}621            </button>622          </div>623          <Show when={showChangePw()}>624            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">625              <input626                class="peach-settings-input"627                type="password"628                placeholder="current password"629                value={changePwCurrent()}630                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}631                autocomplete="current-password"632              />633              <input634                class="peach-settings-input"635                type="password"636                placeholder="new password (min 12 chars)"637                value={changePwNew()}638                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}639                autocomplete="new-password"640              />641              <input642                class="peach-settings-input"643                type="password"644                placeholder="confirm new password"645                value={changePwConfirm()}646                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}647                autocomplete="new-password"648                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}649              />650              <Show when={changePwError()}>651                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>652              </Show>653              <Show when={changePwSuccess()}>654                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>655              </Show>656              <button657                class="peach-btn peach-btn--accent peach-btn--sm"658                style="align-self:flex-start"659                onClick={() => void handleChangePassword()}660                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}661              >662                {changePwSaving() ? "saving…" : "save new password"}663              </button>664            </div>665          </Show>666667          <p class="peach-settings-sub-label">DURESS PASSWORD</p>668          <div class="peach-settings-row peach-settings-row--top">669            <div class="peach-settings-row-main">670              <span>Duress password</span>671              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">672                If entered under coercion, shows a decoy empty vault instead of your real one.673              </span>674            </div>675            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>676              {duressEnabled() ? "on" : "off"}677            </div>678          </div>679680          <Show when={duressEnabled() && !showDuressSetup()}>681            <div class="peach-settings-row peach-settings-row--meta">682              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">683                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.684              </span>685            </div>686          </Show>687688          <Show when={duressSuccess()}>689            <div class="peach-settings-row peach-settings-row--meta">690              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>691            </div>692          </Show>693694          <Show when={!showDuressSetup()}>695            <div class="peach-settings-row peach-settings-row--meta">696              <button697                class="peach-btn peach-btn--subtle peach-btn--sm"698                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}699              >700                {duressEnabled() ? "Change / disable" : "Set up"}701              </button>702            </div>703          </Show>704705          <Show when={showDuressSetup()}>706            <div class="peach-duress-form">707              <label class="peach-label">main password (to confirm)</label>708              <input709                class="peach-settings-input"710                type="password"711                placeholder="your current master password"712                autocomplete="current-password"713                value={duressMainPassword()}714                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}715              />716              <Show when={!duressEnabled()}>717                <label class="peach-label">new duress password</label>718                <input719                  class="peach-settings-input"720                  type="password"721                  placeholder="duress password (min 8 chars)"722                  autocomplete="new-password"723                  value={duressPassword()}724                  onInput={(e) => setDuressPassword(e.currentTarget.value)}725                />726                <label class="peach-label">confirm duress password</label>727                <input728                  class="peach-settings-input"729                  type="password"730                  placeholder="confirm duress password"731                  autocomplete="new-password"732                  value={duressConfirm()}733                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}734                />735              </Show>736737              <Show when={duressError()}>738                <p class="peach-hint peach-hint-error">{duressError()}</p>739              </Show>740741              <div class="peach-duress-actions">742                <button743                  class="peach-btn peach-btn--subtle peach-btn--sm"744                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}745                >746                  cancel747                </button>748                <Show when={duressEnabled()}>749                  <button750                    class="peach-btn peach-btn--ghost peach-btn--sm"751                    style="color:var(--peach-breach)"752                    onClick={() => void handleDisableDuress()}753                    disabled={duressSaving()}754                  >755                    {duressSaving() ? "disabling..." : "disable duress"}756                  </button>757                </Show>758                <Show when={!duressEnabled()}>759                  <button760                    class="peach-btn peach-btn--accent peach-btn--sm"761                    onClick={() => void handleSetupDuress()}762                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}763                  >764                    {duressSaving() ? "saving..." : "save"}765                  </button>766                </Show>767              </div>768            </div>769          </Show>770771          <p class="peach-settings-sub-label">PASSKEYS</p>772          <div class="peach-settings-row peach-settings-row--top">773            <div class="peach-settings-row-main">774              <span>Intercept passkey requests</span>775              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">776                Peach can handle passkey sign-in and creation on supported Chromium browsers.777              </span>778            </div>779            <button780              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}781              onClick={() => void handleTogglePasskeys()}782              aria-label="Toggle passkey interception"783              disabled={!passkeyState()?.supported || passkeySaving()}784            >785              <span class="peach-toggle-knob"></span>786            </button>787          </div>788          <div class="peach-settings-row peach-settings-row--meta">789            <span>Status</span>790            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>791          </div>792          <div class="peach-settings-row peach-settings-row--meta">793            <span>Browser</span>794            <span class="peach-settings-value">795              {passkeyState()?.browserLabel ?? "Unknown"}796              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}797            </span>798          </div>799          <div class="peach-info-notice" style="margin-top:8px">800            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.801          </div>802          <Show when={passkeyState()?.conflict}>803            <div class="peach-settings-row peach-settings-row--meta">804              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>805            </div>806          </Show>807        </div>808      </div>809    );810  }811812  function renderVault() {813    return (814      <div class="peach-settings-body">815        <div class="peach-settings-group">816          <p class="peach-settings-sub-label">ENTRIES</p>817          <div class="peach-settings-row peach-settings-row--meta">818            <span>Entries</span>819            <span class="peach-settings-value">{props.entriesCount}</span>820          </div>821          <div class="peach-settings-row peach-settings-row--meta">822            <span>Duplicates</span>823            <Show when={dedupDone() !== null} fallback={824              <button825                class="peach-btn peach-btn--subtle peach-btn--sm"826                onClick={() => void handleCountDuplicates()}827                disabled={dedupRunning()}828              >829                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}830              </button>831            }>832              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">833                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}834              </span>835            </Show>836          </div>837          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>838            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">839              <div class="peach-restore-warning">840                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.841              </div>842              <div style="display:flex;gap:6px">843                <button844                  class="peach-btn peach-btn--subtle peach-btn--sm"845                  onClick={() => setDedupCount(null)}846                >847                  cancel848                </button>849                <button850                  class="peach-btn peach-btn--accent peach-btn--sm"851                  onClick={() => void handleDeduplicate()}852                  disabled={dedupRunning()}853                >854                  {dedupRunning() ? "removing..." : "remove duplicates"}855                </button>856              </div>857            </div>858          </Show>859860          <p class="peach-settings-sub-label">MAINTENANCE</p>861          <div class="peach-settings-row peach-settings-row--meta">862            <span>Trash auto-delete</span>863            <select864              class="peach-settings-select"865              value={String(props.trashRetentionDays)}866              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}867            >868              <option value="7">7 days</option>869              <option value="30">30 days</option>870              <option value="90">90 days</option>871              <option value="0">Never</option>872            </select>873          </div>874          <div class="peach-settings-row peach-settings-row--meta">875            <span>Vault Health</span>876            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>877              open878            </button>879          </div>880          <Show when={declinedCardCount() > 0}>881            <div class="peach-settings-row peach-settings-row--meta">882              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>883              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>884                Clear885              </button>886            </div>887          </Show>888889          <p class="peach-settings-sub-label">DATA</p>890          <div class="peach-settings-row peach-settings-row--meta">891            <span>Import passwords</span>892            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>893              open894            </button>895          </div>896          <div class="peach-settings-row peach-settings-row--meta">897            <span>Export backup</span>898            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>899              open900            </button>901          </div>902          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">903            <span>Replace vault from backup</span>904            <div class="peach-restore-warning" style="margin-top:2px">905              This will permanently replace your current vault with the backup.906            </div>907            <div style="display:flex;gap:6px">908              <button909                class="peach-btn peach-btn--subtle peach-btn--sm"910                onClick={() => props.onOpenCJKImport?.()}911              >912                CJK phrase913              </button>914              <button915                class="peach-btn peach-btn--subtle peach-btn--sm"916                onClick={() => props.onOpenFileImport?.()}917              >918                backup file919              </button>920            </div>921          </div>922        </div>923      </div>924    );925  }926927  function renderSync() {928    if (!isPro()) {929      return (930        <div class="peach-settings-body">931          <div class="peach-settings-group" style="position:relative">932            <div class="peach-sync-pro-gate">933              <div class="peach-sync-pro-gate-blur">934                <div class="peach-settings-row">935                  <span>Status</span>936                  <span class="peach-settings-value peach-settings-value--status">937                    <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>938                    {getStatusLabel(syncStatus())}939                  </span>940                </div>941                <div class="peach-settings-row peach-settings-row--meta">942                  <span>Last sync</span>943                  <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>944                </div>945              </div>946              <div class="peach-sync-pro-gate-overlay">947                <span class="peach-sync-pro-badge">Pro</span>948                <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:var(--peach-accent);font-size:13px;font-weight:500">949                  Upgrade to Pro →950                </a>951              </div>952            </div>953          </div>954        </div>955      );956    }957958    return (959      <div class="peach-settings-body">960        <div class="peach-settings-group">961          <div class="peach-settings-row">962            <span>Status</span>963            <span class="peach-settings-value peach-settings-value--status">964              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>965              {getStatusLabel(syncStatus())}966            </span>967          </div>968          <div class="peach-settings-row peach-settings-row--meta">969            <span>Last sync</span>970            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>971          </div>972          <div class="peach-settings-row peach-settings-row--meta">973            <button974              class="peach-btn peach-btn--subtle peach-btn--sm"975              onClick={() => void handleSyncNow()}976              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}977            >978              {syncingNow() ? "Syncing..." : "Sync now"}979            </button>980          </div>981982          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">983            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>984            {pairedDevices().map((device) => (985              <div class="peach-sync-device-row">986                <div class="peach-sync-device-info">987                  <span class="peach-sync-device-name">{device.displayName}</span>988                  <span class="peach-sync-device-meta">989                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}990                  </span>991                </div>992                <button993                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"994                  onClick={() => void handleRemoveDevice(device.deviceId)}995                >996                  Remove997                </button>998              </div>999            ))}1000            <div class="peach-settings-actions--row">1001              <button1002                class="peach-btn peach-btn--subtle peach-btn--sm"1003                onClick={() => props.onOpenSyncPairing?.("initiate")}1004              >1005                Pair a new device1006              </button>1007              <button1008                class="peach-btn peach-btn--subtle peach-btn--sm"1009                onClick={() => props.onOpenSyncPairing?.("accept")}1010              >1011                Pair with another device1012              </button>1013            </div>1014          </div>10151016          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1017            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1018            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1019              <div class="peach-sync-device-row">1020                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1021                <button1022                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1023                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1024                >1025                  Remove1026                </button>1027              </div>1028            ))}1029            <div class="peach-settings-actions--row">1030              <Show when={!isCurrentNetworkTrusted()}>1031                <button1032                  class="peach-btn peach-btn--subtle peach-btn--sm"1033                  onClick={() => void handleMarkNetworkTrusted()}1034                >1035                  Mark current network as trusted1036                </button>1037              </Show>1038            </div>1039            <span class="peach-sync-device-meta">1040              Tailscale is always trusted1041            </span>1042          </div>1043        </div>1044      </div>1045    );1046  }10471048  function renderAppearance() {1049    return (1050      <div class="peach-settings-body">1051        <div class="peach-settings-group">1052          <p class="peach-settings-sub-label">THEME</p>1053          <div class="peach-settings-row peach-settings-row--meta">1054            <span>Theme</span>1055            <button1056              class="peach-btn peach-btn--subtle peach-btn--sm"1057              onClick={() =>1058                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1059              }1060            >1061              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1062            </button>1063          </div>10641065          <p class="peach-settings-sub-label">ACCENT</p>1066          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1067            <span>Accent</span>1068            <div class="peach-accent-picker">1069              <For each={ACCENT_COLORS}>1070                {(color) => (1071                  <button1072                    class="peach-accent-swatch"1073                    classList={{ "is-active": props.accentColor === color.name }}1074                    style={{ background: color.hex }}1075                    onClick={() => void props.onAccentChange(color.name)}1076                    aria-label={color.name}1077                    title={color.name}1078                  />1079                )}1080              </For>1081            </div>1082          </div>1083        </div>1084      </div>1085    );1086  }10871088  function renderShortcuts() {1089    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1090    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);10911092    function startCapture(action: ShortcutAction) {1093      setConflictMsg(null);1094      setCapturingAction(action);1095    }1096    function cancelCapture() {1097      setCapturingAction(null);1098      setConflictMsg(null);1099    }1100    function commitBinding(action: ShortcutAction, binding: Keybinding) {1101      const conflict = findConflict(props.shortcuts, binding, action);1102      if (conflict) {1103        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1104        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1105        return;1106      }1107      const next = { ...props.shortcuts, [action]: binding };1108      setCapturingAction(null);1109      setConflictMsg(null);1110      void props.onShortcutsChange(next);1111    }1112    function resetAction(action: ShortcutAction) {1113      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1114      void props.onShortcutsChange(next);1115    }1116    function resetAll() {1117      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1118    }11191120    // Capture-mode key handler — wired via ref onto the chip element so it1121    // catches the next non-modifier keypress and ignores other bindings.1122    function handleCaptureKey(e: KeyboardEvent) {1123      e.preventDefault();1124      e.stopPropagation();1125      if (e.key === "Escape") { cancelCapture(); return; }1126      const binding = keybindingFromEvent(e);1127      if (!binding) return; // bare modifier — keep waiting1128      const action = capturingAction();1129      if (action) commitBinding(action, binding);1130    }11311132    return (1133      <div class="peach-settings-body">1134        <div class="peach-settings-group peach-shortcuts-group">1135          <p class="peach-settings-sub-label">ENTRY VIEW</p>1136          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1137            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1138          </For>1139        </div>11401141        <div class="peach-settings-group peach-shortcuts-group">1142          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1143          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1144            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1145          </For>1146        </div>11471148        <Show when={conflictMsg()}>1149          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1150        </Show>11511152        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1153          <div class="peach-settings-row peach-settings-row--meta">1154            <span>Inline autofill on page</span>1155            <a1156              class="peach-settings-link"1157              href="chrome://extensions/shortcuts"1158              onClick={(e) => {1159                e.preventDefault();1160                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1161              }}1162            >1163              ⌘⇧P — customize ↗1164            </a>1165          </div>1166          <button1167            class="peach-btn peach-btn--subtle peach-btn--sm"1168            style="align-self:flex-start"1169            onClick={resetAll}1170          >1171            Reset all to defaults1172          </button>1173        </div>1174      </div>1175    );11761177    function renderRow(1178      desc: typeof SHORTCUT_DESCRIPTORS[number],1179      capturing: () => ShortcutAction | null,1180      onStart: (a: ShortcutAction) => void,1181      onCancel: () => void,1182      onReset: (a: ShortcutAction) => void,1183      onKey: (e: KeyboardEvent) => void,1184    ) {1185      const isActive = () => capturing() === desc.action;1186      const binding = () => props.shortcuts[desc.action];1187      const isCustom = () => {1188        const b = binding();1189        const d = DEFAULT_BINDINGS[desc.action];1190        return b.key !== d.key1191          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1192          || Boolean(b.meta)  !== Boolean(d.meta)1193          || Boolean(b.shift) !== Boolean(d.shift)1194          || Boolean(b.alt)   !== Boolean(d.alt);1195      };1196      return (1197        <div class="peach-shortcut-row">1198          <span class="peach-shortcut-row-label">{desc.label}</span>1199          <button1200            class="peach-shortcut-chip"1201            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1202            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1203            onKeyDown={(e) => { if (isActive()) onKey(e); }}1204            ref={(el) => {1205              // Auto-focus when entering capture mode so the keydown listener catches the next press.1206              if (el) {1207                queueMicrotask(() => { if (isActive()) el.focus(); });1208              }1209            }}1210          >1211            {isActive() ? "Press a key…" : formatKeybinding(binding())}1212          </button>1213          <Show when={isCustom() && !isActive()}>1214            <button1215              class="peach-shortcut-reset"1216              title="Reset to default"1217              onClick={() => onReset(desc.action)}1218            >1219              ↺1220            </button>1221          </Show>1222        </div>1223      );1224    }1225  }12261227  const PRO_FEATURES: Array<{1228    feature: ProFeature;1229    name: string;1230    desc: string;1231  }> = [1232    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1233    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1234    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1235    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1236    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1237  ];12381239  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1240  const [deactivating, setDeactivating] = createSignal(false);12411242  async function handleDeactivateLicense() {1243    setDeactivating(true);1244    await sendMessage({ type: "DEACTIVATE_LICENSE" });1245    setLicenseInfo(null);1246    await props.onLicenseChange?.();1247    setDeactivating(false);1248  }12491250  function renderLicense() {1251    return (1252      <div class="peach-settings-body">1253        <Show1254          when={isPro()}1255          fallback={1256            <div class="peach-pro-upgrade">1257              <div class="peach-pro-upgrade-hero">1258                <span class="peach-pro-upgrade-mark" aria-hidden="true">🍑</span>1259                <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1260                <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1261              </div>12621263              <div class="peach-pro-features">1264                <For each={PRO_FEATURES}>1265                  {(item) => (1266                    <div1267                      class="peach-pro-feature"1268                      classList={{1269                        "is-highlighted": props.upgradeHighlight === item.feature,1270                      }}1271                    >1272                      <span class="peach-pro-feature-mark">✦</span>1273                      <div class="peach-pro-feature-text">1274                        <span class="peach-pro-feature-name">{item.name}</span>1275                        <span class="peach-pro-feature-desc">{item.desc}</span>1276                      </div>1277                    </div>1278                  )}1279                </For>1280              </div>12811282              <p class="peach-pro-pricing">$39 · once · yours forever</p>12831284              <a1285                href="https://peachpasswords.com/#pricing"1286                target="_blank"1287                rel="noopener noreferrer"1288                class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1289              >1290                Get Peach Pro1291              </a>12921293              <Show1294                when={showKeyEntry()}1295                fallback={1296                  <button1297                    class="peach-pro-key-link"1298                    onClick={() => setShowKeyEntry(true)}1299                  >1300                    Already have a key? Enter it →1301                  </button>1302                }1303              >1304                <div class="peach-pro-key-entry">1305                  <input1306                    class="peach-underline-input peach-pro-key-input"1307                    type="text"1308                    placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1309                    value={licenseKeyInput()}1310                    onInput={(e) =>1311                      setLicenseKeyInput(e.currentTarget.value.toUpperCase())1312                    }1313                    onKeyDown={(e) => {1314                      if (e.key === "Enter") void onActivate();1315                    }}1316                    autocomplete="off"1317                    spellcheck={false}1318                  />1319                  <button1320                    class="peach-btn peach-btn--accent peach-btn--sm"1321                    onClick={() => void onActivate()}1322                    disabled={activatingLicense() || !licenseKeyInput().trim()}1323                  >1324                    {activatingLicense() ? "activating…" : "Activate"}1325                  </button>1326                  <Show when={licenseError()}>1327                    <p class="peach-pro-key-error">That key doesn't look right.</p>1328                  </Show>1329                </div>1330              </Show>1331            </div>1332          }1333        >1334          <div class="peach-pro-active">1335            <div class="peach-pro-active-head">1336              <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1337              <h3 class="peach-pro-active-title">Peach Pro</h3>1338            </div>1339            <p class="peach-pro-active-since">1340              {formatActivatedSince(licenseInfo()?.issued)}1341            </p>1342            <div class="peach-pro-active-divider"></div>1343            <p class="peach-pro-active-thanks">Thank you.</p>1344            <button1345              class="peach-pro-deactivate"1346              onClick={() => void handleDeactivateLicense()}1347              disabled={deactivating()}1348            >1349              {deactivating() ? "Deactivating…" : "Deactivate →"}1350            </button>1351          </div>1352        </Show>1353      </div>1354    );13551356    async function onActivate() {1357      await handleActivateLicense();1358      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1359        await props.onLicenseChange?.();1360      }1361    }1362  }13631364  function renderFeedback() {1365    return (1366      <div class="peach-settings-body">1367        <div class="peach-settings-group peach-feedback-group">1368          <p class="peach-feedback-intro">1369            Help us improve Peach. Every report goes directly to our inbox.1370          </p>13711372          <p class="peach-settings-sub-label">TYPE</p>1373          <div class="peach-feedback-chips">1374            <For each={FEEDBACK_CATEGORIES}>1375              {(cat) => (1376                <button1377                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1378                  onClick={() => setFbCategory(cat)}1379                >1380                  {cat}1381                </button>1382              )}1383            </For>1384          </div>13851386          <p class="peach-settings-sub-label">MESSAGE</p>1387          <textarea1388            class="peach-feedback-textarea"1389            placeholder="Describe the issue or idea..."1390            value={fbMessage()}1391            onInput={(e) => setFbMessage(e.currentTarget.value)}1392          />13931394          <p class="peach-feedback-meta">1395            Version {version} · will be included automatically1396          </p>13971398          <Show when={fbResult() === "success"}>1399            <p class="peach-feedback-result peach-feedback-result--ok">1400              Sent — thank you1401            </p>1402          </Show>1403          <Show when={fbResult() === "error"}>1404            <p class="peach-feedback-result peach-feedback-result--err">1405              Failed to send — try again1406            </p>1407          </Show>14081409          <button1410            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1411            onClick={() => void handleSubmitFeedback()}1412            disabled={fbSubmitting() || !fbMessage().trim()}1413          >1414            {fbSubmitting() ? "Sending..." : "Send feedback"}1415          </button>14161417          <p class="peach-feedback-anon">1418            Sent anonymously · no account required1419          </p>1420        </div>1421      </div>1422    );1423  }14241425  return (1426    <div class="peach-overlay-view ov-enter-right" style="display:flex">1427      <div class="peach-overlay-header">1428        <button1429          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1430          onClick={handleHeaderBack}1431          aria-label="Back"1432        >1433          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1434            <path d="M6 1L1 6L6 11" />1435          </svg>1436        </button>1437        <span class="peach-overlay-title">{currentTitle()}</span>1438        <div class="peach-overlay-spacer"></div>1439      </div>14401441      <Switch fallback={renderHome()}>1442        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1443        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1444        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1445        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1446        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1447        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1448        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1449      </Switch>1450    </div>1451  );1452}