Settings.tsx

59.0 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { ACCENT_COLORS } from "../../shared/types";5import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";6import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";7import {8  DEFAULT_BINDINGS,9  findConflict,10  formatKeybinding,11  keybindingFromEvent,12  SHORTCUT_DESCRIPTORS,13  type Keybinding,14  type ShortcutAction,15} from "../../shared/shortcuts";1617interface LicenseInfo {18  valid: boolean;19  plan: "free" | "pro";20  issued?: string;21}2223interface SettingsProps {24  entriesCount: number;25  breachMonitoring: boolean;26  lockOnClose: boolean;27  autoLockMinutes: number;28  trashRetentionDays: number;29  theme: "dark" | "light";30  accentColor: AccentColorName;31  isPro?: boolean;32  initialSection?: string | null;33  upgradeHighlight?: ProFeature | null;34  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;35  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;36  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;37  onTrashRetentionChange: (days: number) => void | Promise<void>;38  onThemeChange: (theme: "dark" | "light") => void;39  onAccentChange: (name: AccentColorName) => void | Promise<void>;40  shortcuts: Record<ShortcutAction, Keybinding>;41  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;42  onLicenseChange?: () => void | Promise<void>;43  onBack: () => void;44  onImport: () => void;45  onExport: () => void;46  onBreachCheckDone: () => void;47  onOpenHealth?: () => void;48  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;49  onOpenCJKImport?: () => void;50  onOpenFileImport?: () => void;51}5253type SectionName =54  | "security"55  | "vault"56  | "sync"57  | "appearance"58  | "shortcuts"59  | "license"60  | "feedback";6162const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];6364function formatActivatedSince(issued: string | undefined): string {65  if (!issued) return "Active";66  try {67    const d = new Date(issued);68    if (Number.isNaN(d.getTime())) return "Active";69    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;70  } catch {71    return "Active";72  }73}7475function isSectionName(value: string): value is SectionName {76  return (77    value === "security" ||78    value === "vault" ||79    value === "sync" ||80    value === "appearance" ||81    value === "shortcuts" ||82    value === "license" ||83    value === "feedback"84  );85}8687export function SettingsView(props: SettingsProps) {88  const version = chrome.runtime.getManifest().version;89  const initial = props.initialSection;90  const [activeSection, setActiveSection] = createSignal<SectionName | null>(91    initial && isSectionName(initial) ? (initial as SectionName) : null92  );9394  const [checkingBreaches, setCheckingBreaches] = createSignal(false);95  const [breachResult, setBreachResult] = createSignal<string | null>(null);96  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);97  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");98  const [licenseError, setLicenseError] = createSignal<string | null>(null);99  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);100  const [activatingLicense, setActivatingLicense] = createSignal(false);101102  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);103  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);104  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");105  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);106  // Prefer the prop (cached at App boot), fall back to the sync-state probe.107  // The prop is authoritative once present so license activation reflects108  // immediately without waiting for a sync round-trip.109  const [syncProStatus, setSyncProStatus] = createSignal(false);110  const isPro = () => props.isPro ?? syncProStatus();111  const [syncingNow, setSyncingNow] = createSignal(false);112  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);113  const [passkeySaving, setPasskeySaving] = createSignal(false);114115  const [dedupCount, setDedupCount] = createSignal<number | null>(null);116  const [dedupRunning, setDedupRunning] = createSignal(false);117  const [dedupDone, setDedupDone] = createSignal<number | null>(null);118119  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);120121  const [showChangePw, setShowChangePw] = createSignal(false);122  const [changePwCurrent, setChangePwCurrent] = createSignal("");123  const [changePwNew, setChangePwNew] = createSignal("");124  const [changePwConfirm, setChangePwConfirm] = createSignal("");125  const [changePwSaving, setChangePwSaving] = createSignal(false);126  const [changePwError, setChangePwError] = createSignal<string | null>(null);127  const [changePwSuccess, setChangePwSuccess] = createSignal(false);128129  const [duressEnabled, setDuressEnabled] = createSignal(false);130  const [showDuressSetup, setShowDuressSetup] = createSignal(false);131  const [duressPassword, setDuressPassword] = createSignal("");132  const [duressConfirm, setDuressConfirm] = createSignal("");133  const [duressMainPassword, setDuressMainPassword] = createSignal("");134  const [duressSaving, setDuressSaving] = createSignal(false);135  const [duressError, setDuressError] = createSignal<string | null>(null);136  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);137138  const [fbCategory, setFbCategory] = createSignal("Bug report");139  const [fbMessage, setFbMessage] = createSignal("");140  const [fbSubmitting, setFbSubmitting] = createSignal(false);141  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);142143  onMount(async () => {144    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([145      sendMessage({ type: "GET_LICENSE" }),146      sendMessage({ type: "GET_SYNC_STATE" }),147      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),148      sendMessage({ type: "IS_DURESS_ENABLED" }),149      sendMessage({ type: "GET_SETTINGS" }),150    ]);151152    if ((licenseRes as { ok: boolean }).ok) {153      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);154    }155156    if ((syncRes as { ok: boolean }).ok) {157      const data = syncRes as { ok: boolean; data?: {158        pairedDevices?: PairedDevice[];159        trustedNetworks?: TrustedNetwork[];160        status?: SyncStatus;161        lastSyncAt?: number;162        isPro?: boolean;163      }};164      if (data.data) {165        setPairedDevices(data.data.pairedDevices ?? []);166        setTrustedNetworks(data.data.trustedNetworks ?? []);167        setSyncStatus(data.data.status ?? "offline");168        setLastSyncAt(data.data.lastSyncAt);169        setSyncProStatus(data.data.isPro ?? false);170      }171    }172173    if ((passkeyRes as { ok: boolean }).ok) {174      setPasskeyState(175        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null176      );177    }178179    if ((duressRes as { ok: boolean }).ok) {180      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);181    }182183    if ((settingsRes as { ok: boolean }).ok) {184      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;185      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);186    }187188    chrome.runtime.onMessage.addListener((msg) => {189      if (msg.type === "SYNC_STATE_UPDATE") {190        setPairedDevices(msg.pairedDevices ?? []);191        setTrustedNetworks(msg.trustedNetworks ?? []);192        setSyncStatus(msg.status ?? "offline");193        setLastSyncAt(msg.lastSyncAt);194        setSyncProStatus(msg.isPro ?? false);195      }196    });197  });198199  async function handleChangePassword() {200    setChangePwError(null);201    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }202    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }203    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }204    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }205    setChangePwSaving(true);206    const res = await sendMessage({207      type: "CHANGE_PASSWORD",208      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },209    }) as { ok: boolean; error?: string };210    if (res.ok) {211      setChangePwSuccess(true);212      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");213      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);214    } else {215      setChangePwError(res.error ?? "failed to change password");216    }217    setChangePwSaving(false);218  }219220  async function handleActivateLicense() {221    const key = licenseKeyInput().trim();222    if (!key) return;223    setActivatingLicense(true);224    setLicenseError(null);225    setLicenseSuccess(null);226    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });227    setActivatingLicense(false);228    if ((res as { ok: boolean }).ok) {229      const data = (res as { ok: boolean; data?: LicenseInfo }).data;230      if (data?.valid) {231        setLicenseInfo(data);232        setLicenseSuccess("License activated!");233        setLicenseKeyInput("");234      } else {235        setLicenseError("Invalid license key");236      }237    } else {238      setLicenseError("Activation failed");239    }240  }241242  async function handleTogglePasskeys() {243    const current = passkeyState();244    if (!current || !current.supported) return;245    setPasskeySaving(true);246    const res = await sendMessage({247      type: "SET_PASSKEY_PROVIDER_ENABLED",248      payload: { enabled: !current.enabled },249    });250    setPasskeySaving(false);251    if ((res as { ok: boolean }).ok) {252      setPasskeyState(253        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null254      );255    }256  }257258  async function handleCheckAllBreaches() {259    setCheckingBreaches(true);260    setBreachResult(null);261    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });262    setCheckingBreaches(false);263    if ((res as { ok: boolean }).ok) {264      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};265      const breached = Object.values(data).filter((count) => count > 0).length;266      setBreachResult(267        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"268      );269      props.onBreachCheckDone();270    }271  }272273  async function handleSyncNow() {274    if (syncStatus() === "not_trusted") return;275    setSyncingNow(true);276    await sendMessage({ type: "TRIGGER_SYNC" });277    setTimeout(() => setSyncingNow(false), 3000);278  }279280  async function handleRemoveDevice(deviceId: string) {281    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });282  }283284  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {285    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });286  }287288  async function handleMarkNetworkTrusted() {289    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });290  }291292  function formatRelativeTime(timestamp?: number): string {293    if (!timestamp) return "Never";294    const diff = Date.now() - timestamp;295    const minutes = Math.floor(diff / 60000);296    const hours = Math.floor(diff / 3600000);297    const days = Math.floor(diff / 86400000);298    if (minutes < 1) return "Just now";299    if (minutes < 60) return `${minutes}m ago`;300    if (hours < 24) return `${hours}h ago`;301    return `${days}d ago`;302  }303304  function getStatusColor(status: SyncStatus): string {305    switch (status) {306      case "synced": return "var(--peach-sync-ok)";307      case "syncing": return "var(--peach-sync-busy)";308      case "no_peers": return "var(--peach-sync-off)";309      case "not_trusted": return "var(--peach-sync-err)";310      case "offline": return "var(--peach-sync-off)";311      default: return "var(--peach-sync-off)";312    }313  }314315  function getStatusLabel(status: SyncStatus): string {316    switch (status) {317      case "synced": return "Synced";318      case "syncing": return "Syncing";319      case "no_peers": return "No peers visible";320      case "not_trusted": return "Not on a trusted network";321      case "offline": return "Offline";322      default: return "Unknown";323    }324  }325326  function isCurrentNetworkTrusted(): boolean {327    return trustedNetworks().length > 0;328  }329330  async function handleSetupDuress() {331    const mainPw = duressMainPassword().trim();332    const duressPw = duressPassword().trim();333    const confirmPw = duressConfirm().trim();334335    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }336    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }337    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }338    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }339340    setDuressSaving(true);341    setDuressError(null);342343    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };344    if (!verifyRes.ok || !verifyRes.data) {345      setDuressError("incorrect main password");346      setDuressSaving(false);347      return;348    }349350    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };351    setDuressSaving(false);352    if (res.ok) {353      setDuressEnabled(true);354      setShowDuressSetup(false);355      setDuressPassword("");356      setDuressConfirm("");357      setDuressMainPassword("");358      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");359      setTimeout(() => setDuressSuccess(null), 5000);360    } else {361      setDuressError(res.error ?? "failed to set duress password");362    }363  }364365  async function handleClearDeclinedCards() {366    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });367    setDeclinedCardCount(0);368  }369370  async function handleCountDuplicates() {371    setDedupRunning(true);372    setDedupDone(null);373    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };374    setDedupRunning(false);375    if (res.ok) setDedupCount(res.data ?? 0);376  }377378  async function handleDeduplicate() {379    setDedupRunning(true);380    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };381    setDedupRunning(false);382    if (res.ok) {383      setDedupDone(res.data ?? 0);384      setDedupCount(0);385    }386  }387388  async function handleDisableDuress() {389    const mainPw = duressMainPassword().trim();390    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }391392    setDuressSaving(true);393    setDuressError(null);394395    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };396    if (!verifyRes.ok || !verifyRes.data) {397      setDuressError("incorrect main password");398      setDuressSaving(false);399      return;400    }401402    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };403    setDuressSaving(false);404    if (res.ok) {405      setDuressEnabled(false);406      setShowDuressSetup(false);407      setDuressMainPassword("");408      setDuressSuccess("Duress password disabled.");409      setTimeout(() => setDuressSuccess(null), 4000);410    } else {411      setDuressError(res.error ?? "failed to disable duress password");412    }413  }414415  async function handleSubmitFeedback() {416    if (!fbMessage().trim() || fbSubmitting()) return;417    setFbSubmitting(true);418    setFbResult(null);419    const res = await sendMessage({420      type: "SUBMIT_FEEDBACK",421      payload: {422        category: fbCategory(),423        message: fbMessage().trim(),424        version,425        browser: navigator.userAgent,426      },427    }) as { ok: boolean; error?: string };428    setFbSubmitting(false);429    if (res.ok) {430      setFbResult("success");431      setFbMessage("");432      setTimeout(() => setFbResult(null), 4000);433    } else {434      setFbResult("error");435    }436  }437438  function handleHeaderBack() {439    if (activeSection() !== null) {440      setActiveSection(null);441    } else {442      props.onBack();443    }444  }445446  function currentTitle(): string {447    const s = activeSection();448    if (s === null) return "settings";449    if (s === "license") return "account";450    return s;451  }452453  function renderCatRow(opts: {454    section: SectionName;455    name: string;456    desc: string;457    icon: () => any;458    pro?: boolean;459  }) {460    return (461      <button462        class="peach-settings-cat-row"463        onClick={() => setActiveSection(opts.section)}464      >465        <div class="peach-settings-cat-icon">{opts.icon()}</div>466        <div class="peach-settings-cat-content">467          <span class="peach-settings-cat-name">468            {opts.name}469            <Show when={opts.pro && !isPro()}>470              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>471            </Show>472          </span>473          <span class="peach-settings-cat-desc">{opts.desc}</span>474        </div>475        <svg476          class="peach-settings-cat-chevron"477          width="6" height="10" viewBox="0 0 6 10" fill="none"478          stroke="currentColor" stroke-width="1.1"479          stroke-linecap="round" stroke-linejoin="round"480        >481          <path d="M1 1l4 4-4 4" />482        </svg>483      </button>484    );485  }486487  const iconShield = () => (488    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">489      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>490    </svg>491  );492  const iconKey = () => (493    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">494      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>495    </svg>496  );497  const iconLock = () => (498    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">499      <rect x="3" y="7" width="10" height="7" rx="1.5"/>500      <path d="M6 7V5a3 3 0 016 0v2"/>501    </svg>502  );503  const iconSync = () => (504    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">505      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>506      <path d="M14 6l-2 2-2-2"/>507      <path d="M2 10l2-2 2 2"/>508    </svg>509  );510  const iconSun = () => (511    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">512      <circle cx="8" cy="8" r="3"/>513      <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"/>514    </svg>515  );516  const iconKeys = () => (517    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">518      <rect x="2" y="5" width="5" height="4" rx="1"/>519      <rect x="9" y="7" width="5" height="4" rx="1"/>520      <path d="M7 7h2m-2 2h2"/>521    </svg>522  );523  const iconBubble = () => (524    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">525      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>526    </svg>527  );528529  function renderHome() {530    return (531      <div class="peach-settings-body">532        <p class="peach-settings-nav-label">PROTECTION</p>533        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}534        {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}535536        <p class="peach-settings-nav-label">VAULT</p>537        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}538        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}539540        <p class="peach-settings-nav-label">PREFERENCES</p>541        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}542        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}543544        <p class="peach-settings-nav-label">SUPPORT</p>545        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}546547        <div class="peach-settings-footer">548          <span>Peach {version}</span>549          <span class="peach-divider"></span>550          <span>Browser extension</span>551        </div>552      </div>553    );554  }555556  function renderSecurity() {557    return (558      <div class="peach-settings-body">559        <div class="peach-settings-group">560          <p class="peach-settings-sub-label">LOCKING</p>561          <div class="peach-settings-row">562            <span>Lock on close</span>563            <button564              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}565              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}566              aria-label="Toggle lock on close"567            >568              <span class="peach-toggle-knob"></span>569            </button>570          </div>571          <div class="peach-settings-row">572            <span>Auto-lock after</span>573            <select574              class="peach-settings-select"575              value={String(props.autoLockMinutes)}576              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}577            >578              <option value="1">1 minute</option>579              <option value="5">5 minutes</option>580              <option value="15">15 minutes</option>581              <option value="30">30 minutes</option>582              <option value="0">Never</option>583            </select>584          </div>585586          <p class="peach-settings-sub-label">BREACH PROTECTION</p>587          <div class="peach-settings-row">588            <span>Breach monitoring</span>589            <button590              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}591              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}592              aria-label="Toggle breach monitoring"593            >594              <span class="peach-toggle-knob"></span>595            </button>596          </div>597          <div class="peach-settings-row peach-settings-row--meta">598            <span>Check all passwords</span>599            <button600              class="peach-btn peach-btn--subtle peach-btn--sm"601              onClick={() => void handleCheckAllBreaches()}602              disabled={checkingBreaches()}603            >604              {checkingBreaches() ? "checking..." : "run"}605            </button>606          </div>607          <Show when={breachResult()}>608            <div class="peach-settings-row peach-settings-row--meta">609              <span>Last check</span>610              <span class="peach-settings-value">{breachResult()}</span>611            </div>612          </Show>613614          <p class="peach-settings-sub-label">MASTER PASSWORD</p>615          <div class="peach-settings-row peach-settings-row--meta">616            <span>Master password</span>617            <button618              class="peach-btn peach-btn--subtle peach-btn--sm"619              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}620            >621              {showChangePw() ? "cancel" : "change"}622            </button>623          </div>624          <Show when={showChangePw()}>625            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">626              <input627                class="peach-settings-input"628                type="password"629                placeholder="current password"630                value={changePwCurrent()}631                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}632                autocomplete="current-password"633              />634              <input635                class="peach-settings-input"636                type="password"637                placeholder="new password (min 12 chars)"638                value={changePwNew()}639                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}640                autocomplete="new-password"641              />642              <input643                class="peach-settings-input"644                type="password"645                placeholder="confirm new password"646                value={changePwConfirm()}647                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}648                autocomplete="new-password"649                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}650              />651              <Show when={changePwError()}>652                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>653              </Show>654              <Show when={changePwSuccess()}>655                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>656              </Show>657              <button658                class="peach-btn peach-btn--accent peach-btn--sm"659                style="align-self:flex-start"660                onClick={() => void handleChangePassword()}661                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}662              >663                {changePwSaving() ? "saving…" : "save new password"}664              </button>665            </div>666          </Show>667668          <p class="peach-settings-sub-label">DURESS PASSWORD</p>669          <div class="peach-settings-row peach-settings-row--top">670            <div class="peach-settings-row-main">671              <span>Duress password</span>672              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">673                If entered under coercion, shows a decoy empty vault instead of your real one.674              </span>675            </div>676            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>677              {duressEnabled() ? "on" : "off"}678            </div>679          </div>680681          <Show when={duressEnabled() && !showDuressSetup()}>682            <div class="peach-settings-row peach-settings-row--meta">683              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">684                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.685              </span>686            </div>687          </Show>688689          <Show when={duressSuccess()}>690            <div class="peach-settings-row peach-settings-row--meta">691              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>692            </div>693          </Show>694695          <Show when={!showDuressSetup()}>696            <div class="peach-settings-row peach-settings-row--meta">697              <button698                class="peach-btn peach-btn--subtle peach-btn--sm"699                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}700              >701                {duressEnabled() ? "Change / disable" : "Set up"}702              </button>703            </div>704          </Show>705706          <Show when={showDuressSetup()}>707            <div class="peach-duress-form">708              <label class="peach-label">main password (to confirm)</label>709              <input710                class="peach-settings-input"711                type="password"712                placeholder="your current master password"713                autocomplete="current-password"714                value={duressMainPassword()}715                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}716              />717              <Show when={!duressEnabled()}>718                <label class="peach-label">new duress password</label>719                <input720                  class="peach-settings-input"721                  type="password"722                  placeholder="duress password (min 8 chars)"723                  autocomplete="new-password"724                  value={duressPassword()}725                  onInput={(e) => setDuressPassword(e.currentTarget.value)}726                />727                <label class="peach-label">confirm duress password</label>728                <input729                  class="peach-settings-input"730                  type="password"731                  placeholder="confirm duress password"732                  autocomplete="new-password"733                  value={duressConfirm()}734                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}735                />736              </Show>737738              <Show when={duressError()}>739                <p class="peach-hint peach-hint-error">{duressError()}</p>740              </Show>741742              <div class="peach-duress-actions">743                <button744                  class="peach-btn peach-btn--subtle peach-btn--sm"745                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}746                >747                  cancel748                </button>749                <Show when={duressEnabled()}>750                  <button751                    class="peach-btn peach-btn--ghost peach-btn--sm"752                    style="color:var(--peach-breach)"753                    onClick={() => void handleDisableDuress()}754                    disabled={duressSaving()}755                  >756                    {duressSaving() ? "disabling..." : "disable duress"}757                  </button>758                </Show>759                <Show when={!duressEnabled()}>760                  <button761                    class="peach-btn peach-btn--accent peach-btn--sm"762                    onClick={() => void handleSetupDuress()}763                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}764                  >765                    {duressSaving() ? "saving..." : "save"}766                  </button>767                </Show>768              </div>769            </div>770          </Show>771772          <p class="peach-settings-sub-label">PASSKEYS</p>773          <div class="peach-settings-row peach-settings-row--top">774            <div class="peach-settings-row-main">775              <span>Intercept passkey requests</span>776              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">777                Peach can handle passkey sign-in and creation on supported Chromium browsers.778              </span>779            </div>780            <button781              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}782              onClick={() => void handleTogglePasskeys()}783              aria-label="Toggle passkey interception"784              disabled={!passkeyState()?.supported || passkeySaving()}785            >786              <span class="peach-toggle-knob"></span>787            </button>788          </div>789          <div class="peach-settings-row peach-settings-row--meta">790            <span>Status</span>791            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>792          </div>793          <div class="peach-settings-row peach-settings-row--meta">794            <span>Browser</span>795            <span class="peach-settings-value">796              {passkeyState()?.browserLabel ?? "Unknown"}797              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}798            </span>799          </div>800          <div class="peach-info-notice" style="margin-top:8px">801            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.802          </div>803          <Show when={passkeyState()?.conflict}>804            <div class="peach-settings-row peach-settings-row--meta">805              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>806            </div>807          </Show>808        </div>809      </div>810    );811  }812813  function renderVault() {814    return (815      <div class="peach-settings-body">816        <div class="peach-settings-group">817          <p class="peach-settings-sub-label">ENTRIES</p>818          <div class="peach-settings-row peach-settings-row--meta">819            <span>Entries</span>820            <span class="peach-settings-value">{props.entriesCount}</span>821          </div>822          <div class="peach-settings-row peach-settings-row--meta">823            <span>Duplicates</span>824            <Show when={dedupDone() !== null} fallback={825              <button826                class="peach-btn peach-btn--subtle peach-btn--sm"827                onClick={() => void handleCountDuplicates()}828                disabled={dedupRunning()}829              >830                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}831              </button>832            }>833              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">834                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}835              </span>836            </Show>837          </div>838          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>839            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">840              <div class="peach-restore-warning">841                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.842              </div>843              <div style="display:flex;gap:6px">844                <button845                  class="peach-btn peach-btn--subtle peach-btn--sm"846                  onClick={() => setDedupCount(null)}847                >848                  cancel849                </button>850                <button851                  class="peach-btn peach-btn--accent peach-btn--sm"852                  onClick={() => void handleDeduplicate()}853                  disabled={dedupRunning()}854                >855                  {dedupRunning() ? "removing..." : "remove duplicates"}856                </button>857              </div>858            </div>859          </Show>860861          <p class="peach-settings-sub-label">MAINTENANCE</p>862          <div class="peach-settings-row peach-settings-row--meta">863            <span>Trash auto-delete</span>864            <select865              class="peach-settings-select"866              value={String(props.trashRetentionDays)}867              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}868            >869              <option value="7">7 days</option>870              <option value="30">30 days</option>871              <option value="90">90 days</option>872              <option value="0">Never</option>873            </select>874          </div>875          <div class="peach-settings-row peach-settings-row--meta">876            <span>Vault Health</span>877            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>878              open879            </button>880          </div>881          <Show when={declinedCardCount() > 0}>882            <div class="peach-settings-row peach-settings-row--meta">883              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>884              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>885                Clear886              </button>887            </div>888          </Show>889890          <p class="peach-settings-sub-label">DATA</p>891          <div class="peach-settings-row peach-settings-row--meta">892            <span>Import passwords</span>893            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>894              open895            </button>896          </div>897          <div class="peach-settings-row peach-settings-row--meta">898            <span>Export backup</span>899            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>900              open901            </button>902          </div>903          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">904            <span>Replace vault from backup</span>905            <div class="peach-restore-warning" style="margin-top:2px">906              This will permanently replace your current vault with the backup.907            </div>908            <div style="display:flex;gap:6px">909              <button910                class="peach-btn peach-btn--subtle peach-btn--sm"911                onClick={() => props.onOpenCJKImport?.()}912              >913                CJK phrase914              </button>915              <button916                class="peach-btn peach-btn--subtle peach-btn--sm"917                onClick={() => props.onOpenFileImport?.()}918              >919                backup file920              </button>921            </div>922          </div>923        </div>924      </div>925    );926  }927928  function renderSync() {929    if (!isPro()) {930      return (931        <div class="peach-settings-body">932          <div class="peach-pro-upgrade">933            <div class="peach-pro-upgrade-hero">934              <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">935                <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>936                <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>937                <path d="M13.5 15.5C15 12 17 10 18 10C19 10 21 12 22.5 15.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>938                <path d="M13.5 20.5C15 24 17 26 18 26C19 26 21 24 22.5 20.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>939                <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>940                <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>941                <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>942              </svg>943              <h2 class="peach-pro-upgrade-title">Peach Sync</h2>944              <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>945            </div>946947            <div class="peach-pro-features">948              <div class="peach-pro-feature">949                <span class="peach-pro-feature-mark">✦</span>950                <div class="peach-pro-feature-text">951                  <span class="peach-pro-feature-name">Always in sync</span>952                  <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>953                </div>954              </div>955              <div class="peach-pro-feature">956                <span class="peach-pro-feature-mark">✦</span>957                <div class="peach-pro-feature-text">958                  <span class="peach-pro-feature-name">No cloud. No relay.</span>959                  <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>960                </div>961              </div>962              <div class="peach-pro-feature">963                <span class="peach-pro-feature-mark">✦</span>964                <div class="peach-pro-feature-text">965                  <span class="peach-pro-feature-name">Tailscale supported</span>966                  <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>967                </div>968              </div>969              <div class="peach-pro-feature">970                <span class="peach-pro-feature-mark">✦</span>971                <div class="peach-pro-feature-text">972                  <span class="peach-pro-feature-name">Zero friction after setup</span>973                  <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>974                </div>975              </div>976            </div>977978            <button979              class="peach-btn peach-btn--accent peach-pro-upgrade-cta"980              onClick={() => setActiveSection("license")}981            >982              Upgrade to Pro983            </button>984            <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>985              Already have a key? Enter it →986            </button>987          </div>988        </div>989      );990    }991992    return (993      <div class="peach-settings-body">994        <div class="peach-settings-group">995          <div class="peach-settings-row">996            <span>Status</span>997            <span class="peach-settings-value peach-settings-value--status">998              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>999              {getStatusLabel(syncStatus())}1000            </span>1001          </div>1002          <div class="peach-settings-row peach-settings-row--meta">1003            <span>Last sync</span>1004            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1005          </div>1006          <div class="peach-settings-row peach-settings-row--meta">1007            <button1008              class="peach-btn peach-btn--subtle peach-btn--sm"1009              onClick={() => void handleSyncNow()}1010              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1011            >1012              {syncingNow() ? "Syncing..." : "Sync now"}1013            </button>1014          </div>10151016          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1017            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1018            {pairedDevices().map((device) => (1019              <div class="peach-sync-device-row">1020                <div class="peach-sync-device-info">1021                  <span class="peach-sync-device-name">{device.displayName}</span>1022                  <span class="peach-sync-device-meta">1023                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1024                  </span>1025                </div>1026                <button1027                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1028                  onClick={() => void handleRemoveDevice(device.deviceId)}1029                >1030                  Remove1031                </button>1032              </div>1033            ))}1034            <div class="peach-settings-actions--row">1035              <button1036                class="peach-btn peach-btn--subtle peach-btn--sm"1037                onClick={() => props.onOpenSyncPairing?.("initiate")}1038              >1039                Pair a new device1040              </button>1041              <button1042                class="peach-btn peach-btn--subtle peach-btn--sm"1043                onClick={() => props.onOpenSyncPairing?.("accept")}1044              >1045                Pair with another device1046              </button>1047            </div>1048          </div>10491050          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1051            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1052            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1053              <div class="peach-sync-device-row">1054                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1055                <button1056                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1057                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1058                >1059                  Remove1060                </button>1061              </div>1062            ))}1063            <div class="peach-settings-actions--row">1064              <Show when={!isCurrentNetworkTrusted()}>1065                <button1066                  class="peach-btn peach-btn--subtle peach-btn--sm"1067                  onClick={() => void handleMarkNetworkTrusted()}1068                >1069                  Mark current network as trusted1070                </button>1071              </Show>1072            </div>1073            <span class="peach-sync-device-meta">1074              Tailscale is always trusted1075            </span>1076          </div>1077        </div>1078      </div>1079    );1080  }10811082  function renderAppearance() {1083    return (1084      <div class="peach-settings-body">1085        <div class="peach-settings-group">1086          <p class="peach-settings-sub-label">THEME</p>1087          <div class="peach-settings-row peach-settings-row--meta">1088            <span>Theme</span>1089            <button1090              class="peach-btn peach-btn--subtle peach-btn--sm"1091              onClick={() =>1092                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1093              }1094            >1095              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1096            </button>1097          </div>10981099          <p class="peach-settings-sub-label">ACCENT</p>1100          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1101            <span>Accent</span>1102            <div class="peach-accent-picker">1103              <For each={ACCENT_COLORS}>1104                {(color) => (1105                  <button1106                    class="peach-accent-swatch"1107                    classList={{ "is-active": props.accentColor === color.name }}1108                    style={{ background: color.hex }}1109                    onClick={() => void props.onAccentChange(color.name)}1110                    aria-label={color.name}1111                    title={color.name}1112                  />1113                )}1114              </For>1115            </div>1116          </div>1117        </div>1118      </div>1119    );1120  }11211122  function renderShortcuts() {1123    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1124    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);11251126    function startCapture(action: ShortcutAction) {1127      setConflictMsg(null);1128      setCapturingAction(action);1129    }1130    function cancelCapture() {1131      setCapturingAction(null);1132      setConflictMsg(null);1133    }1134    function commitBinding(action: ShortcutAction, binding: Keybinding) {1135      const conflict = findConflict(props.shortcuts, binding, action);1136      if (conflict) {1137        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1138        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1139        return;1140      }1141      const next = { ...props.shortcuts, [action]: binding };1142      setCapturingAction(null);1143      setConflictMsg(null);1144      void props.onShortcutsChange(next);1145    }1146    function resetAction(action: ShortcutAction) {1147      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1148      void props.onShortcutsChange(next);1149    }1150    function resetAll() {1151      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1152    }11531154    // Capture-mode key handler — wired via ref onto the chip element so it1155    // catches the next non-modifier keypress and ignores other bindings.1156    function handleCaptureKey(e: KeyboardEvent) {1157      e.preventDefault();1158      e.stopPropagation();1159      if (e.key === "Escape") { cancelCapture(); return; }1160      const binding = keybindingFromEvent(e);1161      if (!binding) return; // bare modifier — keep waiting1162      const action = capturingAction();1163      if (action) commitBinding(action, binding);1164    }11651166    return (1167      <div class="peach-settings-body">1168        <div class="peach-settings-group peach-shortcuts-group">1169          <p class="peach-settings-sub-label">ENTRY VIEW</p>1170          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1171            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1172          </For>1173        </div>11741175        <div class="peach-settings-group peach-shortcuts-group">1176          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1177          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1178            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1179          </For>1180        </div>11811182        <Show when={conflictMsg()}>1183          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1184        </Show>11851186        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1187          <div class="peach-settings-row peach-settings-row--meta">1188            <span>Inline autofill on page</span>1189            <a1190              class="peach-settings-link"1191              href="chrome://extensions/shortcuts"1192              onClick={(e) => {1193                e.preventDefault();1194                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1195              }}1196            >1197              ⌘⇧P — customize ↗1198            </a>1199          </div>1200          <button1201            class="peach-btn peach-btn--subtle peach-btn--sm"1202            style="align-self:flex-start"1203            onClick={resetAll}1204          >1205            Reset all to defaults1206          </button>1207        </div>1208      </div>1209    );12101211    function renderRow(1212      desc: typeof SHORTCUT_DESCRIPTORS[number],1213      capturing: () => ShortcutAction | null,1214      onStart: (a: ShortcutAction) => void,1215      onCancel: () => void,1216      onReset: (a: ShortcutAction) => void,1217      onKey: (e: KeyboardEvent) => void,1218    ) {1219      const isActive = () => capturing() === desc.action;1220      const binding = () => props.shortcuts[desc.action];1221      const isCustom = () => {1222        const b = binding();1223        const d = DEFAULT_BINDINGS[desc.action];1224        return b.key !== d.key1225          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1226          || Boolean(b.meta)  !== Boolean(d.meta)1227          || Boolean(b.shift) !== Boolean(d.shift)1228          || Boolean(b.alt)   !== Boolean(d.alt);1229      };1230      return (1231        <div class="peach-shortcut-row">1232          <span class="peach-shortcut-row-label">{desc.label}</span>1233          <button1234            class="peach-shortcut-chip"1235            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1236            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1237            onKeyDown={(e) => { if (isActive()) onKey(e); }}1238            ref={(el) => {1239              // Auto-focus when entering capture mode so the keydown listener catches the next press.1240              if (el) {1241                queueMicrotask(() => { if (isActive()) el.focus(); });1242              }1243            }}1244          >1245            {isActive() ? "Press a key…" : formatKeybinding(binding())}1246          </button>1247          <Show when={isCustom() && !isActive()}>1248            <button1249              class="peach-shortcut-reset"1250              title="Reset to default"1251              onClick={() => onReset(desc.action)}1252            >1253              ↺1254            </button>1255          </Show>1256        </div>1257      );1258    }1259  }12601261  const PRO_FEATURES: Array<{1262    feature: ProFeature;1263    name: string;1264    desc: string;1265  }> = [1266    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1267    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1268    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1269    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1270    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1271  ];12721273  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1274  const [deactivating, setDeactivating] = createSignal(false);12751276  async function handleDeactivateLicense() {1277    setDeactivating(true);1278    await sendMessage({ type: "DEACTIVATE_LICENSE" });1279    setLicenseInfo(null);1280    await props.onLicenseChange?.();1281    setDeactivating(false);1282  }12831284  function renderLicense() {1285    return (1286      <div class="peach-settings-body">1287        <Show1288          when={isPro()}1289          fallback={1290            <div class="peach-pro-upgrade">1291              <div class="peach-pro-upgrade-hero">1292                <PeachMark class="peach-pro-upgrade-mark" />1293                <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1294                <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1295              </div>12961297              <div class="peach-pro-features">1298                <For each={PRO_FEATURES}>1299                  {(item) => (1300                    <div1301                      class="peach-pro-feature"1302                      classList={{1303                        "is-highlighted": props.upgradeHighlight === item.feature,1304                      }}1305                    >1306                      <span class="peach-pro-feature-mark">✦</span>1307                      <div class="peach-pro-feature-text">1308                        <span class="peach-pro-feature-name">{item.name}</span>1309                        <span class="peach-pro-feature-desc">{item.desc}</span>1310                      </div>1311                    </div>1312                  )}1313                </For>1314              </div>13151316              <p class="peach-pro-pricing">$39 · once · yours forever</p>13171318              <a1319                href="https://peachpasswords.com/#pricing"1320                target="_blank"1321                rel="noopener noreferrer"1322                class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1323              >1324                Get Peach Pro1325              </a>13261327              <Show1328                when={showKeyEntry()}1329                fallback={1330                  <button1331                    class="peach-pro-key-link"1332                    onClick={() => setShowKeyEntry(true)}1333                  >1334                    Already have a key? Enter it →1335                  </button>1336                }1337              >1338                <div class="peach-pro-key-entry">1339                  <input1340                    class="peach-underline-input peach-pro-key-input"1341                    type="text"1342                    placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1343                    value={licenseKeyInput()}1344                    onInput={(e) =>1345                      setLicenseKeyInput(e.currentTarget.value.toUpperCase())1346                    }1347                    onKeyDown={(e) => {1348                      if (e.key === "Enter") void onActivate();1349                    }}1350                    autocomplete="off"1351                    spellcheck={false}1352                  />1353                  <button1354                    class="peach-btn peach-btn--accent peach-btn--sm"1355                    onClick={() => void onActivate()}1356                    disabled={activatingLicense() || !licenseKeyInput().trim()}1357                  >1358                    {activatingLicense() ? "activating…" : "Activate"}1359                  </button>1360                  <Show when={licenseError()}>1361                    <p class="peach-pro-key-error">That key doesn't look right.</p>1362                  </Show>1363                </div>1364              </Show>1365            </div>1366          }1367        >1368          <div class="peach-pro-active">1369            <div class="peach-pro-active-head">1370              <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1371              <h3 class="peach-pro-active-title">Peach Pro</h3>1372            </div>1373            <p class="peach-pro-active-since">1374              {formatActivatedSince(licenseInfo()?.issued)}1375            </p>1376            <div class="peach-pro-active-divider"></div>1377            <p class="peach-pro-active-thanks">Thank you.</p>1378            <button1379              class="peach-pro-deactivate"1380              onClick={() => void handleDeactivateLicense()}1381              disabled={deactivating()}1382            >1383              {deactivating() ? "Deactivating…" : "Deactivate →"}1384            </button>1385          </div>1386        </Show>1387      </div>1388    );13891390    async function onActivate() {1391      await handleActivateLicense();1392      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1393        await props.onLicenseChange?.();1394      }1395    }1396  }13971398  function renderFeedback() {1399    return (1400      <div class="peach-settings-body">1401        <div class="peach-settings-group peach-feedback-group">1402          <p class="peach-feedback-intro">1403            Help us improve Peach. Every report goes directly to our inbox.1404          </p>14051406          <p class="peach-settings-sub-label">TYPE</p>1407          <div class="peach-feedback-chips">1408            <For each={FEEDBACK_CATEGORIES}>1409              {(cat) => (1410                <button1411                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1412                  onClick={() => setFbCategory(cat)}1413                >1414                  {cat}1415                </button>1416              )}1417            </For>1418          </div>14191420          <p class="peach-settings-sub-label">MESSAGE</p>1421          <textarea1422            class="peach-feedback-textarea"1423            placeholder="Describe the issue or idea..."1424            value={fbMessage()}1425            onInput={(e) => setFbMessage(e.currentTarget.value)}1426          />14271428          <p class="peach-feedback-meta">1429            Version {version} · will be included automatically1430          </p>14311432          <Show when={fbResult() === "success"}>1433            <p class="peach-feedback-result peach-feedback-result--ok">1434              Sent — thank you1435            </p>1436          </Show>1437          <Show when={fbResult() === "error"}>1438            <p class="peach-feedback-result peach-feedback-result--err">1439              Failed to send — try again1440            </p>1441          </Show>14421443          <button1444            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1445            onClick={() => void handleSubmitFeedback()}1446            disabled={fbSubmitting() || !fbMessage().trim()}1447          >1448            {fbSubmitting() ? "Sending..." : "Send feedback"}1449          </button>14501451          <p class="peach-feedback-anon">1452            Sent anonymously · no account required1453          </p>1454        </div>1455      </div>1456    );1457  }14581459  return (1460    <div class="peach-overlay-view ov-enter-right" style="display:flex">1461      <div class="peach-overlay-header">1462        <button1463          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1464          onClick={handleHeaderBack}1465          aria-label="Back"1466        >1467          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1468            <path d="M6 1L1 6L6 11" />1469          </svg>1470        </button>1471        <span class="peach-overlay-title">{currentTitle()}</span>1472        <div class="peach-overlay-spacer"></div>1473      </div>14741475      <Switch fallback={renderHome()}>1476        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1477        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1478        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1479        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1480        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1481        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1482        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1483      </Switch>1484    </div>1485  );1486}