Settings.tsx

38.3 KB · raw

1import { createSignal, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import type { PasskeyProviderState } from "../../shared/types";4import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";56interface LicenseInfo {7  valid: boolean;8  plan: "free" | "pro";9  issued?: string;10}1112interface SettingsProps {13  entriesCount: number;14  breachMonitoring: boolean;15  lockOnClose: boolean;16  autoLockMinutes: number;17  trashRetentionDays: number;18  theme: "dark" | "light";19  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;20  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;21  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;22  onTrashRetentionChange: (days: number) => void | Promise<void>;23  onThemeChange: (theme: "dark" | "light") => void;24  onBack: () => void;25  onImport: () => void;26  onExport: () => void;27  onBreachCheckDone: () => void;28  onOpenHealth?: () => void;29  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;30  onOpenCJKImport?: () => void;31  onOpenFileImport?: () => void;32}3334export function SettingsView(props: SettingsProps) {35  const version = chrome.runtime.getManifest().version;36  const [checkingBreaches, setCheckingBreaches] = createSignal(false);37  const [breachResult, setBreachResult] = createSignal<string | null>(null);38  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);39  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");40  const [licenseError, setLicenseError] = createSignal<string | null>(null);41  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);42  const [activatingLicense, setActivatingLicense] = createSignal(false);4344  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);45  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);46  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");47  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);48  const [isPro, setIsPro] = createSignal(false);49  const [syncingNow, setSyncingNow] = createSignal(false);50  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);51  const [passkeySaving, setPasskeySaving] = createSignal(false);5253  // Deduplication state54  const [dedupCount, setDedupCount] = createSignal<number | null>(null);55  const [dedupRunning, setDedupRunning] = createSignal(false);56  const [dedupDone, setDedupDone] = createSignal<number | null>(null);5758  // Change master password state59  const [showChangePw, setShowChangePw] = createSignal(false);60  const [changePwCurrent, setChangePwCurrent] = createSignal("");61  const [changePwNew, setChangePwNew] = createSignal("");62  const [changePwConfirm, setChangePwConfirm] = createSignal("");63  const [changePwSaving, setChangePwSaving] = createSignal(false);64  const [changePwError, setChangePwError] = createSignal<string | null>(null);65  const [changePwSuccess, setChangePwSuccess] = createSignal(false);6667  async function handleChangePassword() {68    setChangePwError(null);69    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }70    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }71    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }72    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }73    setChangePwSaving(true);74    const res = await sendMessage({75      type: "CHANGE_PASSWORD",76      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },77    }) as { ok: boolean; error?: string };78    if (res.ok) {79      setChangePwSuccess(true);80      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");81      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);82    } else {83      setChangePwError(res.error ?? "failed to change password");84    }85    setChangePwSaving(false);86  }8788  // Duress password state89  const [duressEnabled, setDuressEnabled] = createSignal(false);90  const [showDuressSetup, setShowDuressSetup] = createSignal(false);91  const [duressPassword, setDuressPassword] = createSignal("");92  const [duressConfirm, setDuressConfirm] = createSignal("");93  const [duressMainPassword, setDuressMainPassword] = createSignal("");94  const [duressSaving, setDuressSaving] = createSignal(false);95  const [duressError, setDuressError] = createSignal<string | null>(null);96  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);9798  onMount(async () => {99    const [licenseRes, syncRes, passkeyRes, duressRes] = await Promise.all([100      sendMessage({ type: "GET_LICENSE" }),101      sendMessage({ type: "GET_SYNC_STATE" }),102      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),103      sendMessage({ type: "IS_DURESS_ENABLED" }),104    ]);105106    if ((licenseRes as { ok: boolean }).ok) {107      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);108    }109110    if ((syncRes as { ok: boolean }).ok) {111      const data = syncRes as { ok: boolean; data?: {112        pairedDevices?: PairedDevice[];113        trustedNetworks?: TrustedNetwork[];114        status?: SyncStatus;115        lastSyncAt?: number;116        isPro?: boolean;117      }};118      if (data.data) {119        setPairedDevices(data.data.pairedDevices ?? []);120        setTrustedNetworks(data.data.trustedNetworks ?? []);121        setSyncStatus(data.data.status ?? "offline");122        setLastSyncAt(data.data.lastSyncAt);123        setIsPro(data.data.isPro ?? false);124      }125    }126127    if ((passkeyRes as { ok: boolean }).ok) {128      setPasskeyState(129        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null130      );131    }132133    if ((duressRes as { ok: boolean }).ok) {134      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);135    }136137    chrome.runtime.onMessage.addListener((msg) => {138      if (msg.type === "SYNC_STATE_UPDATE") {139        setPairedDevices(msg.pairedDevices ?? []);140        setTrustedNetworks(msg.trustedNetworks ?? []);141        setSyncStatus(msg.status ?? "offline");142        setLastSyncAt(msg.lastSyncAt);143        setIsPro(msg.isPro ?? false);144      }145    });146  });147148  async function handleActivateLicense() {149    const key = licenseKeyInput().trim();150    if (!key) return;151    setActivatingLicense(true);152    setLicenseError(null);153    setLicenseSuccess(null);154    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });155    setActivatingLicense(false);156    if ((res as { ok: boolean }).ok) {157      const data = (res as { ok: boolean; data?: LicenseInfo }).data;158      if (data?.valid) {159        setLicenseInfo(data);160        setLicenseSuccess("License activated!");161        setLicenseKeyInput("");162      } else {163        setLicenseError("Invalid license key");164      }165    } else {166      setLicenseError("Activation failed");167    }168  }169170  async function handleTogglePasskeys() {171    const current = passkeyState();172    if (!current || !current.supported) return;173    setPasskeySaving(true);174    const res = await sendMessage({175      type: "SET_PASSKEY_PROVIDER_ENABLED",176      payload: { enabled: !current.enabled },177    });178    setPasskeySaving(false);179    if ((res as { ok: boolean }).ok) {180      setPasskeyState(181        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null182      );183    }184  }185186  function maskLicenseKey(key: string): string {187    if (key.length < 10) return key.slice(0, 4) + "****";188    return key.slice(0, 6) + "****" + key.slice(-4);189  }190191  async function handleCheckAllBreaches() {192    setCheckingBreaches(true);193    setBreachResult(null);194    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });195    setCheckingBreaches(false);196    if ((res as { ok: boolean }).ok) {197      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};198      const breached = Object.values(data).filter((count) => count > 0).length;199      setBreachResult(200        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"201      );202      props.onBreachCheckDone();203    }204  }205206  async function handleSyncNow() {207    if (syncStatus() === "not_trusted") return;208    setSyncingNow(true);209    await sendMessage({ type: "TRIGGER_SYNC" });210    setTimeout(() => setSyncingNow(false), 3000);211  }212213  async function handleRemoveDevice(deviceId: string) {214    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });215  }216217  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {218    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });219  }220221  async function handleMarkNetworkTrusted() {222    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });223  }224225  function formatRelativeTime(timestamp?: number): string {226    if (!timestamp) return "Never";227    const diff = Date.now() - timestamp;228    const minutes = Math.floor(diff / 60000);229    const hours = Math.floor(diff / 3600000);230    const days = Math.floor(diff / 86400000);231    if (minutes < 1) return "Just now";232    if (minutes < 60) return `${minutes}m ago`;233    if (hours < 24) return `${hours}h ago`;234    return `${days}d ago`;235  }236237  function getStatusColor(status: SyncStatus): string {238    switch (status) {239      case "synced": return "var(--peach-sync-ok)";240      case "syncing": return "var(--peach-sync-busy)";241      case "no_peers": return "var(--peach-sync-off)";242      case "not_trusted": return "var(--peach-sync-err)";243      case "offline": return "var(--peach-sync-off)";244      default: return "var(--peach-sync-off)";245    }246  }247248  function getStatusLabel(status: SyncStatus): string {249    switch (status) {250      case "synced": return "Synced";251      case "syncing": return "Syncing";252      case "no_peers": return "No peers visible";253      case "not_trusted": return "Not on a trusted network";254      case "offline": return "Offline";255      default: return "Unknown";256    }257  }258259  function isCurrentNetworkTrusted(): boolean {260    // Show the "mark trusted" button only if there are no trusted networks at all261    // (Tailscale is always trusted implicitly; any explicit networks count too)262    return trustedNetworks().length > 0;263  }264265  async function handleSetupDuress() {266    const mainPw = duressMainPassword().trim();267    const duressPw = duressPassword().trim();268    const confirmPw = duressConfirm().trim();269270    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }271    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }272    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }273    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }274275    setDuressSaving(true);276    setDuressError(null);277278    // Verify main password first279    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };280    if (!verifyRes.ok || !verifyRes.data) {281      setDuressError("incorrect main password");282      setDuressSaving(false);283      return;284    }285286    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };287    setDuressSaving(false);288    if (res.ok) {289      setDuressEnabled(true);290      setShowDuressSetup(false);291      setDuressPassword("");292      setDuressConfirm("");293      setDuressMainPassword("");294      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");295      setTimeout(() => setDuressSuccess(null), 5000);296    } else {297      setDuressError(res.error ?? "failed to set duress password");298    }299  }300301  async function handleCountDuplicates() {302    setDedupRunning(true);303    setDedupDone(null);304    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };305    setDedupRunning(false);306    if (res.ok) setDedupCount(res.data ?? 0);307  }308309  async function handleDeduplicate() {310    setDedupRunning(true);311    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };312    setDedupRunning(false);313    if (res.ok) {314      setDedupDone(res.data ?? 0);315      setDedupCount(0);316    }317  }318319  async function handleDisableDuress() {320    const mainPw = duressMainPassword().trim();321    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }322323    setDuressSaving(true);324    setDuressError(null);325326    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };327    if (!verifyRes.ok || !verifyRes.data) {328      setDuressError("incorrect main password");329      setDuressSaving(false);330      return;331    }332333    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };334    setDuressSaving(false);335    if (res.ok) {336      setDuressEnabled(false);337      setShowDuressSetup(false);338      setDuressMainPassword("");339      setDuressSuccess("Duress password disabled.");340      setTimeout(() => setDuressSuccess(null), 4000);341    } else {342      setDuressError(res.error ?? "failed to disable duress password");343    }344  }345346  function renderSyncSection() {347    if (!isPro()) {348      return (349        <div class="peach-settings-group" style="position:relative">350          <p class="peach-settings-label">sync</p>351          <div class="peach-sync-pro-gate">352            <div class="peach-sync-pro-gate-blur">353              <div class="peach-settings-row">354                <span>Status</span>355                <span class="peach-settings-value peach-settings-value--status">356                  <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>357                  {getStatusLabel(syncStatus())}358                </span>359              </div>360              <div class="peach-settings-row peach-settings-row--meta">361                <span>Last sync</span>362                <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>363              </div>364            </div>365            <div class="peach-sync-pro-gate-overlay">366              <span class="peach-sync-pro-badge">Pro</span>367              <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:#D96830;font-size:13px;font-weight:500">368                Upgrade to Pro →369              </a>370            </div>371          </div>372        </div>373      );374    }375376    return (377      <div class="peach-settings-group">378        <p class="peach-settings-label">sync</p>379        <div class="peach-settings-row">380          <span>Status</span>381          <span class="peach-settings-value peach-settings-value--status">382            <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>383            {getStatusLabel(syncStatus())}384          </span>385        </div>386        <div class="peach-settings-row peach-settings-row--meta">387          <span>Last sync</span>388          <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>389        </div>390        <div class="peach-settings-row peach-settings-row--meta">391          <button392            class="peach-btn peach-btn--subtle peach-btn--sm"393            onClick={() => void handleSyncNow()}394            disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}395          >396            {syncingNow() ? "Syncing..." : "Sync now"}397          </button>398        </div>399400        <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">401          <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>402          {pairedDevices().map((device) => (403            <div class="peach-sync-device-row">404              <div class="peach-sync-device-info">405                <span class="peach-sync-device-name">{device.displayName}</span>406                <span class="peach-sync-device-meta">407                  Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}408                </span>409              </div>410              <button411                class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"412                onClick={() => void handleRemoveDevice(device.deviceId)}413              >414                Remove415              </button>416            </div>417          ))}418          <div class="peach-settings-actions--row">419            <button420              class="peach-btn peach-btn--subtle peach-btn--sm"421              onClick={() => props.onOpenSyncPairing?.("initiate")}422            >423              Pair a new device424            </button>425            <button426              class="peach-btn peach-btn--subtle peach-btn--sm"427              onClick={() => props.onOpenSyncPairing?.("accept")}428            >429              Pair with another device430            </button>431          </div>432        </div>433434        <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">435          <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>436          {trustedNetworks().filter(n => !n.isTailscale).map((network) => (437            <div class="peach-sync-device-row">438              <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>439              <button440                class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"441                onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}442              >443                Remove444              </button>445            </div>446          ))}447          <div class="peach-settings-actions--row">448            {!isCurrentNetworkTrusted() && (449              <button450                class="peach-btn peach-btn--subtle peach-btn--sm"451                onClick={() => void handleMarkNetworkTrusted()}452              >453                Mark current network as trusted454              </button>455            )}456          </div>457          <span class="peach-sync-device-meta">458            Tailscale is always trusted459          </span>460        </div>461      </div>462    );463  }464465  return (466    <div class="peach-overlay-view ov-enter-right" style="display:flex">467      <div class="peach-overlay-header">468        <button469          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"470          onClick={props.onBack}471          aria-label="Back"472        >473          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">474            <path d="M6 1L1 6L6 11" />475          </svg>476        </button>477        <span class="peach-overlay-title">settings</span>478        <div class="peach-overlay-spacer"></div>479      </div>480481      <div class="peach-settings-body">482        <div class="peach-settings-group">483          <p class="peach-settings-label">security</p>484          <div class="peach-settings-row">485            <span>Lock on close</span>486            <button487              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}488              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}489              aria-label="Toggle lock on close"490            >491              <span class="peach-toggle-knob"></span>492            </button>493          </div>494          <div class="peach-settings-row">495            <span>Auto-lock after</span>496            <select497              class="peach-settings-select"498              value={String(props.autoLockMinutes)}499              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}500            >501              <option value="1">1 minute</option>502              <option value="5">5 minutes</option>503              <option value="15">15 minutes</option>504              <option value="30">30 minutes</option>505              <option value="0">Never</option>506            </select>507          </div>508          <div class="peach-settings-row">509            <span>Breach monitoring</span>510            <button511              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}512              onClick={() =>513                void props.onBreachMonitoringChange(!props.breachMonitoring)514              }515              aria-label="Toggle breach monitoring"516            >517              <span class="peach-toggle-knob"></span>518            </button>519          </div>520          <div class="peach-settings-row peach-settings-row--meta">521            <span>Check all passwords</span>522            <button523              class="peach-btn peach-btn--subtle peach-btn--sm"524              onClick={() => void handleCheckAllBreaches()}525              disabled={checkingBreaches()}526            >527              {checkingBreaches() ? "checking..." : "run"}528            </button>529          </div>530          {breachResult() && (531            <div class="peach-settings-row peach-settings-row--meta">532              <span>Last check</span>533              <span class="peach-settings-value">{breachResult()}</span>534            </div>535          )}536          {/* ── Change master password ──────────────────────────────────── */}537          <div class="peach-settings-row peach-settings-row--meta">538            <span>Master password</span>539            <button540              class="peach-btn peach-btn--subtle peach-btn--sm"541              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}542            >543              {showChangePw() ? "cancel" : "change"}544            </button>545          </div>546          <Show when={showChangePw()}>547            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">548              <input549                class="peach-settings-input"550                type="password"551                placeholder="current password"552                value={changePwCurrent()}553                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}554                autocomplete="current-password"555              />556              <input557                class="peach-settings-input"558                type="password"559                placeholder="new password (min 12 chars)"560                value={changePwNew()}561                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}562                autocomplete="new-password"563              />564              <input565                class="peach-settings-input"566                type="password"567                placeholder="confirm new password"568                value={changePwConfirm()}569                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}570                autocomplete="new-password"571                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}572              />573              <Show when={changePwError()}>574                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>575              </Show>576              <Show when={changePwSuccess()}>577                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>578              </Show>579              <button580                class="peach-btn peach-btn--accent peach-btn--sm"581                style="align-self:flex-start"582                onClick={() => void handleChangePassword()}583                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}584              >585                {changePwSaving() ? "saving…" : "save new password"}586              </button>587            </div>588          </Show>589        </div>590591        {/* ── Duress password ─────────────────────────────────────────────── */}592        <div class="peach-settings-group">593          <p class="peach-settings-label">duress password</p>594          <div class="peach-settings-row peach-settings-row--top">595            <div class="peach-settings-row-main">596              <span>Duress password</span>597              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">598                If entered under coercion, shows a decoy empty vault instead of your real one.599              </span>600            </div>601            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>602              {duressEnabled() ? "on" : "off"}603            </div>604          </div>605606          <Show when={duressEnabled() && !showDuressSetup()}>607            <div class="peach-settings-row peach-settings-row--meta">608              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">609                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.610              </span>611            </div>612          </Show>613614          <Show when={duressSuccess()}>615            <div class="peach-settings-row peach-settings-row--meta">616              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>617            </div>618          </Show>619620          <Show when={!showDuressSetup()}>621            <div class="peach-settings-row peach-settings-row--meta">622              <button623                class="peach-btn peach-btn--subtle peach-btn--sm"624                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}625              >626                {duressEnabled() ? "Change / disable" : "Set up"}627              </button>628            </div>629          </Show>630631          <Show when={showDuressSetup()}>632            <div class="peach-duress-form">633              <label class="peach-label">main password (to confirm)</label>634              <input635                class="peach-settings-input"636                type="password"637                placeholder="your current master password"638                autocomplete="current-password"639                value={duressMainPassword()}640                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}641              />642              <Show when={!duressEnabled()}>643                <label class="peach-label">new duress password</label>644                <input645                  class="peach-settings-input"646                  type="password"647                  placeholder="duress password (min 8 chars)"648                  autocomplete="new-password"649                  value={duressPassword()}650                  onInput={(e) => setDuressPassword(e.currentTarget.value)}651                />652                <label class="peach-label">confirm duress password</label>653                <input654                  class="peach-settings-input"655                  type="password"656                  placeholder="confirm duress password"657                  autocomplete="new-password"658                  value={duressConfirm()}659                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}660                />661              </Show>662663              <Show when={duressError()}>664                <p class="peach-hint peach-hint-error">{duressError()}</p>665              </Show>666667              <div class="peach-duress-actions">668                <button669                  class="peach-btn peach-btn--subtle peach-btn--sm"670                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}671                >672                  cancel673                </button>674                <Show when={duressEnabled()}>675                  <button676                    class="peach-btn peach-btn--ghost peach-btn--sm"677                    style="color:var(--peach-breach)"678                    onClick={() => void handleDisableDuress()}679                    disabled={duressSaving()}680                  >681                    {duressSaving() ? "disabling..." : "disable duress"}682                  </button>683                </Show>684                <Show when={!duressEnabled()}>685                  <button686                    class="peach-btn peach-btn--accent peach-btn--sm"687                    onClick={() => void handleSetupDuress()}688                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}689                  >690                    {duressSaving() ? "saving..." : "save"}691                  </button>692                </Show>693              </div>694            </div>695          </Show>696        </div>697698        <div class="peach-settings-group">699          <p class="peach-settings-label">passkeys</p>700          <div class="peach-settings-row peach-settings-row--top">701            <div class="peach-settings-row-main">702              <span>Intercept passkey requests</span>703              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">704                Peach can handle passkey sign-in and creation on supported Chromium browsers.705              </span>706            </div>707            <button708              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}709              onClick={() => void handleTogglePasskeys()}710              aria-label="Toggle passkey interception"711              disabled={!passkeyState()?.supported || passkeySaving()}712            >713              <span class="peach-toggle-knob"></span>714            </button>715          </div>716          <div class="peach-settings-row peach-settings-row--meta">717            <span>Status</span>718            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>719          </div>720          <div class="peach-settings-row peach-settings-row--meta">721            <span>Browser</span>722            <span class="peach-settings-value">723              {passkeyState()?.browserLabel ?? "Unknown"}724              {passkeyState()?.supported ? " \u2713" : " (interception unavailable)"}725            </span>726          </div>727          <div class="peach-info-notice" style="margin-top:8px">728            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.729          </div>730          {passkeyState()?.conflict && (731            <div class="peach-settings-row peach-settings-row--meta">732              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>733            </div>734          )}735        </div>736737        {renderSyncSection()}738739        <div class="peach-settings-group">740          <p class="peach-settings-label">vault</p>741          <div class="peach-settings-row peach-settings-row--meta">742            <span>Entries</span>743            <span class="peach-settings-value">{props.entriesCount}</span>744          </div>745          <div class="peach-settings-row peach-settings-row--meta">746            <span>Duplicates</span>747            <Show when={dedupDone() !== null} fallback={748              <button749                class="peach-btn peach-btn--subtle peach-btn--sm"750                onClick={() => void handleCountDuplicates()}751                disabled={dedupRunning()}752              >753                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}754              </button>755            }>756              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">757                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}758              </span>759            </Show>760          </div>761          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>762            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">763              <div class="peach-restore-warning">764                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.765              </div>766              <div style="display:flex;gap:6px">767                <button768                  class="peach-btn peach-btn--subtle peach-btn--sm"769                  onClick={() => setDedupCount(null)}770                >771                  cancel772                </button>773                <button774                  class="peach-btn peach-btn--accent peach-btn--sm"775                  onClick={() => void handleDeduplicate()}776                  disabled={dedupRunning()}777                >778                  {dedupRunning() ? "removing..." : "remove duplicates"}779                </button>780              </div>781            </div>782          </Show>783          <div class="peach-settings-row peach-settings-row--meta">784            <span>Theme</span>785            <button786              class="peach-btn peach-btn--subtle peach-btn--sm"787              onClick={() =>788                props.onThemeChange(props.theme === "dark" ? "light" : "dark")789              }790            >791              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}792            </button>793          </div>794          <div class="peach-settings-row peach-settings-row--meta">795            <span>Trash auto-delete</span>796            <select797              class="peach-settings-select"798              value={String(props.trashRetentionDays)}799              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}800            >801              <option value="7">7 days</option>802              <option value="30">30 days</option>803              <option value="90">90 days</option>804              <option value="0">Never</option>805            </select>806          </div>807          <div class="peach-settings-row peach-settings-row--meta">808            <span>Import passwords</span>809            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>810              open811            </button>812          </div>813          <div class="peach-settings-row peach-settings-row--meta">814            <span>Export backup</span>815            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>816              open817            </button>818          </div>819          <div class="peach-settings-row peach-settings-row--meta">820            <span>Vault Health</span>821            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>822              open823            </button>824          </div>825          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">826            <span>Replace vault from backup</span>827            <div class="peach-restore-warning" style="margin-top:2px">828              This will permanently replace your current vault with the backup.829            </div>830            <div style="display:flex;gap:6px">831              <button832                class="peach-btn peach-btn--subtle peach-btn--sm"833                onClick={() => props.onOpenCJKImport?.()}834              >835                CJK phrase836              </button>837              <button838                class="peach-btn peach-btn--subtle peach-btn--sm"839                onClick={() => props.onOpenFileImport?.()}840              >841                backup file842              </button>843            </div>844          </div>845        </div>846847        <div class="peach-settings-group">848          <p class="peach-settings-label">license</p>849          {licenseInfo()?.valid && licenseInfo()?.plan === "pro" ? (850            <div class="peach-settings-row peach-settings-row--meta">851              <span>Peach Pro</span>852              <span class="peach-settings-value">853                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}854              </span>855            </div>856          ) : (857            <>858              <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">859                <input860                  class="peach-underline-input"861                  type="text"862                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"863                  value={licenseKeyInput()}864                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}865                  style="width:100%;box-sizing:border-box"866                />867                <button868                  class="peach-btn peach-btn--subtle peach-btn--sm"869                  onClick={() => void handleActivateLicense()}870                  disabled={activatingLicense() || !licenseKeyInput().trim()}871                >872                  {activatingLicense() ? "activating..." : "Activate"}873                </button>874              </div>875              {licenseError() && (876                <div class="peach-settings-row peach-settings-row--meta">877                  <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>878                </div>879              )}880              {licenseSuccess() && (881                <div class="peach-settings-row peach-settings-row--meta">882                  <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>883                </div>884              )}885              <div class="peach-settings-row peach-settings-row--meta">886                <a887                  href="https://peachpasswords.com/#pricing"888                  target="_blank"889                  rel="noopener noreferrer"890                  class="peach-link"891                  style="color:#D96830"892                >893                  Purchase →894                </a>895              </div>896            </>897          )}898        </div>899900        {/* ── Keyboard shortcuts ────────────────────────────────────── */}901        <div class="peach-settings-group">902          <p class="peach-settings-label">keyboard shortcuts</p>903          <div class="peach-shortcuts-grid">904            <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>905            <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>906            <span class="peach-shortcut-key">/</span><span class="peach-shortcut-desc">search vault</span>907            <span class="peach-shortcut-key">↑ ↓</span><span class="peach-shortcut-desc">navigate entries</span>908            <span class="peach-shortcut-key">Esc</span><span class="peach-shortcut-desc">back / close</span>909            <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>910          </div>911        </div>912913        <div class="peach-settings-footer">914          <span>Peach {version}</span>915          <span class="peach-divider"></span>916          <span>Browser extension</span>917        </div>918      </div>919    </div>920  );921}