Settings.tsx

40.0 KB · raw

1import { createSignal, For, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import { ACCENT_COLORS } from "../../shared/types";4import type { AccentColorName, PasskeyProviderState } from "../../shared/types";5import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";67interface LicenseInfo {8  valid: boolean;9  plan: "free" | "pro";10  issued?: string;11}1213interface SettingsProps {14  entriesCount: number;15  breachMonitoring: boolean;16  lockOnClose: boolean;17  autoLockMinutes: number;18  trashRetentionDays: number;19  theme: "dark" | "light";20  accentColor: AccentColorName;21  onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;22  onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;23  onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;24  onTrashRetentionChange: (days: number) => void | Promise<void>;25  onThemeChange: (theme: "dark" | "light") => void;26  onAccentChange: (name: AccentColorName) => void | Promise<void>;27  onBack: () => void;28  onImport: () => void;29  onExport: () => void;30  onBreachCheckDone: () => void;31  onOpenHealth?: () => void;32  onOpenSyncPairing?: (mode: "initiate" | "accept") => void;33  onOpenCJKImport?: () => void;34  onOpenFileImport?: () => void;35}3637export function SettingsView(props: SettingsProps) {38  const version = chrome.runtime.getManifest().version;39  const [checkingBreaches, setCheckingBreaches] = createSignal(false);40  const [breachResult, setBreachResult] = createSignal<string | null>(null);41  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);42  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");43  const [licenseError, setLicenseError] = createSignal<string | null>(null);44  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);45  const [activatingLicense, setActivatingLicense] = createSignal(false);4647  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);48  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);49  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");50  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);51  const [isPro, setIsPro] = createSignal(false);52  const [syncingNow, setSyncingNow] = createSignal(false);53  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);54  const [passkeySaving, setPasskeySaving] = createSignal(false);5556  // Deduplication state57  const [dedupCount, setDedupCount] = createSignal<number | null>(null);58  const [dedupRunning, setDedupRunning] = createSignal(false);59  const [dedupDone, setDedupDone] = createSignal<number | null>(null);6061  // Change master password state62  const [showChangePw, setShowChangePw] = createSignal(false);63  const [changePwCurrent, setChangePwCurrent] = createSignal("");64  const [changePwNew, setChangePwNew] = createSignal("");65  const [changePwConfirm, setChangePwConfirm] = createSignal("");66  const [changePwSaving, setChangePwSaving] = createSignal(false);67  const [changePwError, setChangePwError] = createSignal<string | null>(null);68  const [changePwSuccess, setChangePwSuccess] = createSignal(false);6970  async function handleChangePassword() {71    setChangePwError(null);72    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }73    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }74    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }75    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }76    setChangePwSaving(true);77    const res = await sendMessage({78      type: "CHANGE_PASSWORD",79      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },80    }) as { ok: boolean; error?: string };81    if (res.ok) {82      setChangePwSuccess(true);83      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");84      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);85    } else {86      setChangePwError(res.error ?? "failed to change password");87    }88    setChangePwSaving(false);89  }9091  // Duress password state92  const [duressEnabled, setDuressEnabled] = createSignal(false);93  const [showDuressSetup, setShowDuressSetup] = createSignal(false);94  const [duressPassword, setDuressPassword] = createSignal("");95  const [duressConfirm, setDuressConfirm] = createSignal("");96  const [duressMainPassword, setDuressMainPassword] = createSignal("");97  const [duressSaving, setDuressSaving] = createSignal(false);98  const [duressError, setDuressError] = createSignal<string | null>(null);99  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);100101  onMount(async () => {102    const [licenseRes, syncRes, passkeyRes, duressRes] = await Promise.all([103      sendMessage({ type: "GET_LICENSE" }),104      sendMessage({ type: "GET_SYNC_STATE" }),105      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),106      sendMessage({ type: "IS_DURESS_ENABLED" }),107    ]);108109    if ((licenseRes as { ok: boolean }).ok) {110      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);111    }112113    if ((syncRes as { ok: boolean }).ok) {114      const data = syncRes as { ok: boolean; data?: {115        pairedDevices?: PairedDevice[];116        trustedNetworks?: TrustedNetwork[];117        status?: SyncStatus;118        lastSyncAt?: number;119        isPro?: boolean;120      }};121      if (data.data) {122        setPairedDevices(data.data.pairedDevices ?? []);123        setTrustedNetworks(data.data.trustedNetworks ?? []);124        setSyncStatus(data.data.status ?? "offline");125        setLastSyncAt(data.data.lastSyncAt);126        setIsPro(data.data.isPro ?? false);127      }128    }129130    if ((passkeyRes as { ok: boolean }).ok) {131      setPasskeyState(132        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null133      );134    }135136    if ((duressRes as { ok: boolean }).ok) {137      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);138    }139140    chrome.runtime.onMessage.addListener((msg) => {141      if (msg.type === "SYNC_STATE_UPDATE") {142        setPairedDevices(msg.pairedDevices ?? []);143        setTrustedNetworks(msg.trustedNetworks ?? []);144        setSyncStatus(msg.status ?? "offline");145        setLastSyncAt(msg.lastSyncAt);146        setIsPro(msg.isPro ?? false);147      }148    });149  });150151  async function handleActivateLicense() {152    const key = licenseKeyInput().trim();153    if (!key) return;154    setActivatingLicense(true);155    setLicenseError(null);156    setLicenseSuccess(null);157    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });158    setActivatingLicense(false);159    if ((res as { ok: boolean }).ok) {160      const data = (res as { ok: boolean; data?: LicenseInfo }).data;161      if (data?.valid) {162        setLicenseInfo(data);163        setLicenseSuccess("License activated!");164        setLicenseKeyInput("");165      } else {166        setLicenseError("Invalid license key");167      }168    } else {169      setLicenseError("Activation failed");170    }171  }172173  async function handleTogglePasskeys() {174    const current = passkeyState();175    if (!current || !current.supported) return;176    setPasskeySaving(true);177    const res = await sendMessage({178      type: "SET_PASSKEY_PROVIDER_ENABLED",179      payload: { enabled: !current.enabled },180    });181    setPasskeySaving(false);182    if ((res as { ok: boolean }).ok) {183      setPasskeyState(184        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null185      );186    }187  }188189  function maskLicenseKey(key: string): string {190    if (key.length < 10) return key.slice(0, 4) + "****";191    return key.slice(0, 6) + "****" + key.slice(-4);192  }193194  async function handleCheckAllBreaches() {195    setCheckingBreaches(true);196    setBreachResult(null);197    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });198    setCheckingBreaches(false);199    if ((res as { ok: boolean }).ok) {200      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};201      const breached = Object.values(data).filter((count) => count > 0).length;202      setBreachResult(203        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"204      );205      props.onBreachCheckDone();206    }207  }208209  async function handleSyncNow() {210    if (syncStatus() === "not_trusted") return;211    setSyncingNow(true);212    await sendMessage({ type: "TRIGGER_SYNC" });213    setTimeout(() => setSyncingNow(false), 3000);214  }215216  async function handleRemoveDevice(deviceId: string) {217    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });218  }219220  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {221    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });222  }223224  async function handleMarkNetworkTrusted() {225    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });226  }227228  function formatRelativeTime(timestamp?: number): string {229    if (!timestamp) return "Never";230    const diff = Date.now() - timestamp;231    const minutes = Math.floor(diff / 60000);232    const hours = Math.floor(diff / 3600000);233    const days = Math.floor(diff / 86400000);234    if (minutes < 1) return "Just now";235    if (minutes < 60) return `${minutes}m ago`;236    if (hours < 24) return `${hours}h ago`;237    return `${days}d ago`;238  }239240  function getStatusColor(status: SyncStatus): string {241    switch (status) {242      case "synced": return "var(--peach-sync-ok)";243      case "syncing": return "var(--peach-sync-busy)";244      case "no_peers": return "var(--peach-sync-off)";245      case "not_trusted": return "var(--peach-sync-err)";246      case "offline": return "var(--peach-sync-off)";247      default: return "var(--peach-sync-off)";248    }249  }250251  function getStatusLabel(status: SyncStatus): string {252    switch (status) {253      case "synced": return "Synced";254      case "syncing": return "Syncing";255      case "no_peers": return "No peers visible";256      case "not_trusted": return "Not on a trusted network";257      case "offline": return "Offline";258      default: return "Unknown";259    }260  }261262  function isCurrentNetworkTrusted(): boolean {263    // Show the "mark trusted" button only if there are no trusted networks at all264    // (Tailscale is always trusted implicitly; any explicit networks count too)265    return trustedNetworks().length > 0;266  }267268  async function handleSetupDuress() {269    const mainPw = duressMainPassword().trim();270    const duressPw = duressPassword().trim();271    const confirmPw = duressConfirm().trim();272273    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }274    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }275    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }276    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }277278    setDuressSaving(true);279    setDuressError(null);280281    // Verify main password first282    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };283    if (!verifyRes.ok || !verifyRes.data) {284      setDuressError("incorrect main password");285      setDuressSaving(false);286      return;287    }288289    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };290    setDuressSaving(false);291    if (res.ok) {292      setDuressEnabled(true);293      setShowDuressSetup(false);294      setDuressPassword("");295      setDuressConfirm("");296      setDuressMainPassword("");297      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");298      setTimeout(() => setDuressSuccess(null), 5000);299    } else {300      setDuressError(res.error ?? "failed to set duress password");301    }302  }303304  async function handleCountDuplicates() {305    setDedupRunning(true);306    setDedupDone(null);307    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };308    setDedupRunning(false);309    if (res.ok) setDedupCount(res.data ?? 0);310  }311312  async function handleDeduplicate() {313    setDedupRunning(true);314    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };315    setDedupRunning(false);316    if (res.ok) {317      setDedupDone(res.data ?? 0);318      setDedupCount(0);319    }320  }321322  async function handleDisableDuress() {323    const mainPw = duressMainPassword().trim();324    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }325326    setDuressSaving(true);327    setDuressError(null);328329    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };330    if (!verifyRes.ok || !verifyRes.data) {331      setDuressError("incorrect main password");332      setDuressSaving(false);333      return;334    }335336    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };337    setDuressSaving(false);338    if (res.ok) {339      setDuressEnabled(false);340      setShowDuressSetup(false);341      setDuressMainPassword("");342      setDuressSuccess("Duress password disabled.");343      setTimeout(() => setDuressSuccess(null), 4000);344    } else {345      setDuressError(res.error ?? "failed to disable duress password");346    }347  }348349  function renderSyncSection() {350    if (!isPro()) {351      return (352        <div class="peach-settings-group" style="position:relative">353          <p class="peach-settings-label">sync</p>354          <div class="peach-sync-pro-gate">355            <div class="peach-sync-pro-gate-blur">356              <div class="peach-settings-row">357                <span>Status</span>358                <span class="peach-settings-value peach-settings-value--status">359                  <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>360                  {getStatusLabel(syncStatus())}361                </span>362              </div>363              <div class="peach-settings-row peach-settings-row--meta">364                <span>Last sync</span>365                <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>366              </div>367            </div>368            <div class="peach-sync-pro-gate-overlay">369              <span class="peach-sync-pro-badge">Pro</span>370              <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:var(--peach-accent);font-size:13px;font-weight:500">371                Upgrade to Pro →372              </a>373            </div>374          </div>375        </div>376      );377    }378379    return (380      <div class="peach-settings-group">381        <p class="peach-settings-label">sync</p>382        <div class="peach-settings-row">383          <span>Status</span>384          <span class="peach-settings-value peach-settings-value--status">385            <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>386            {getStatusLabel(syncStatus())}387          </span>388        </div>389        <div class="peach-settings-row peach-settings-row--meta">390          <span>Last sync</span>391          <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>392        </div>393        <div class="peach-settings-row peach-settings-row--meta">394          <button395            class="peach-btn peach-btn--subtle peach-btn--sm"396            onClick={() => void handleSyncNow()}397            disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}398          >399            {syncingNow() ? "Syncing..." : "Sync now"}400          </button>401        </div>402403        <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">404          <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>405          {pairedDevices().map((device) => (406            <div class="peach-sync-device-row">407              <div class="peach-sync-device-info">408                <span class="peach-sync-device-name">{device.displayName}</span>409                <span class="peach-sync-device-meta">410                  Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}411                </span>412              </div>413              <button414                class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"415                onClick={() => void handleRemoveDevice(device.deviceId)}416              >417                Remove418              </button>419            </div>420          ))}421          <div class="peach-settings-actions--row">422            <button423              class="peach-btn peach-btn--subtle peach-btn--sm"424              onClick={() => props.onOpenSyncPairing?.("initiate")}425            >426              Pair a new device427            </button>428            <button429              class="peach-btn peach-btn--subtle peach-btn--sm"430              onClick={() => props.onOpenSyncPairing?.("accept")}431            >432              Pair with another device433            </button>434          </div>435        </div>436437        <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">438          <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>439          {trustedNetworks().filter(n => !n.isTailscale).map((network) => (440            <div class="peach-sync-device-row">441              <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>442              <button443                class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"444                onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}445              >446                Remove447              </button>448            </div>449          ))}450          <div class="peach-settings-actions--row">451            {!isCurrentNetworkTrusted() && (452              <button453                class="peach-btn peach-btn--subtle peach-btn--sm"454                onClick={() => void handleMarkNetworkTrusted()}455              >456                Mark current network as trusted457              </button>458            )}459          </div>460          <span class="peach-sync-device-meta">461            Tailscale is always trusted462          </span>463        </div>464      </div>465    );466  }467468  return (469    <div class="peach-overlay-view ov-enter-right" style="display:flex">470      <div class="peach-overlay-header">471        <button472          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"473          onClick={props.onBack}474          aria-label="Back"475        >476          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">477            <path d="M6 1L1 6L6 11" />478          </svg>479        </button>480        <span class="peach-overlay-title">settings</span>481        <div class="peach-overlay-spacer"></div>482      </div>483484      <div class="peach-settings-body">485        <div class="peach-settings-group">486          <p class="peach-settings-label">security</p>487          <div class="peach-settings-row">488            <span>Lock on close</span>489            <button490              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}491              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}492              aria-label="Toggle lock on close"493            >494              <span class="peach-toggle-knob"></span>495            </button>496          </div>497          <div class="peach-settings-row">498            <span>Auto-lock after</span>499            <select500              class="peach-settings-select"501              value={String(props.autoLockMinutes)}502              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}503            >504              <option value="1">1 minute</option>505              <option value="5">5 minutes</option>506              <option value="15">15 minutes</option>507              <option value="30">30 minutes</option>508              <option value="0">Never</option>509            </select>510          </div>511          <div class="peach-settings-row">512            <span>Breach monitoring</span>513            <button514              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}515              onClick={() =>516                void props.onBreachMonitoringChange(!props.breachMonitoring)517              }518              aria-label="Toggle breach monitoring"519            >520              <span class="peach-toggle-knob"></span>521            </button>522          </div>523          <div class="peach-settings-row peach-settings-row--meta">524            <span>Check all passwords</span>525            <button526              class="peach-btn peach-btn--subtle peach-btn--sm"527              onClick={() => void handleCheckAllBreaches()}528              disabled={checkingBreaches()}529            >530              {checkingBreaches() ? "checking..." : "run"}531            </button>532          </div>533          {breachResult() && (534            <div class="peach-settings-row peach-settings-row--meta">535              <span>Last check</span>536              <span class="peach-settings-value">{breachResult()}</span>537            </div>538          )}539          {/* ── Change master password ──────────────────────────────────── */}540          <div class="peach-settings-row peach-settings-row--meta">541            <span>Master password</span>542            <button543              class="peach-btn peach-btn--subtle peach-btn--sm"544              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}545            >546              {showChangePw() ? "cancel" : "change"}547            </button>548          </div>549          <Show when={showChangePw()}>550            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">551              <input552                class="peach-settings-input"553                type="password"554                placeholder="current password"555                value={changePwCurrent()}556                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}557                autocomplete="current-password"558              />559              <input560                class="peach-settings-input"561                type="password"562                placeholder="new password (min 12 chars)"563                value={changePwNew()}564                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}565                autocomplete="new-password"566              />567              <input568                class="peach-settings-input"569                type="password"570                placeholder="confirm new password"571                value={changePwConfirm()}572                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}573                autocomplete="new-password"574                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}575              />576              <Show when={changePwError()}>577                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>578              </Show>579              <Show when={changePwSuccess()}>580                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>581              </Show>582              <button583                class="peach-btn peach-btn--accent peach-btn--sm"584                style="align-self:flex-start"585                onClick={() => void handleChangePassword()}586                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}587              >588                {changePwSaving() ? "saving…" : "save new password"}589              </button>590            </div>591          </Show>592        </div>593594        {/* ── Keyboard shortcuts ──────────────────────────────────────────── */}595        <div class="peach-settings-group">596          <p class="peach-settings-label">keyboard shortcuts</p>597          <div class="peach-settings-row peach-settings-row--meta">598            <span>Inline popup shortcut</span>599            <a600              class="peach-settings-link"601              href="chrome://extensions/shortcuts"602              onClick={(e) => {603                e.preventDefault();604                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });605              }}606            >607              Ctrl+Shift+Space — customize ↗608            </a>609          </div>610        </div>611612        {/* ── Duress password ─────────────────────────────────────────────── */}613        <div class="peach-settings-group">614          <p class="peach-settings-label">duress password</p>615          <div class="peach-settings-row peach-settings-row--top">616            <div class="peach-settings-row-main">617              <span>Duress password</span>618              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">619                If entered under coercion, shows a decoy empty vault instead of your real one.620              </span>621            </div>622            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>623              {duressEnabled() ? "on" : "off"}624            </div>625          </div>626627          <Show when={duressEnabled() && !showDuressSetup()}>628            <div class="peach-settings-row peach-settings-row--meta">629              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">630                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.631              </span>632            </div>633          </Show>634635          <Show when={duressSuccess()}>636            <div class="peach-settings-row peach-settings-row--meta">637              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>638            </div>639          </Show>640641          <Show when={!showDuressSetup()}>642            <div class="peach-settings-row peach-settings-row--meta">643              <button644                class="peach-btn peach-btn--subtle peach-btn--sm"645                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}646              >647                {duressEnabled() ? "Change / disable" : "Set up"}648              </button>649            </div>650          </Show>651652          <Show when={showDuressSetup()}>653            <div class="peach-duress-form">654              <label class="peach-label">main password (to confirm)</label>655              <input656                class="peach-settings-input"657                type="password"658                placeholder="your current master password"659                autocomplete="current-password"660                value={duressMainPassword()}661                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}662              />663              <Show when={!duressEnabled()}>664                <label class="peach-label">new duress password</label>665                <input666                  class="peach-settings-input"667                  type="password"668                  placeholder="duress password (min 8 chars)"669                  autocomplete="new-password"670                  value={duressPassword()}671                  onInput={(e) => setDuressPassword(e.currentTarget.value)}672                />673                <label class="peach-label">confirm duress password</label>674                <input675                  class="peach-settings-input"676                  type="password"677                  placeholder="confirm duress password"678                  autocomplete="new-password"679                  value={duressConfirm()}680                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}681                />682              </Show>683684              <Show when={duressError()}>685                <p class="peach-hint peach-hint-error">{duressError()}</p>686              </Show>687688              <div class="peach-duress-actions">689                <button690                  class="peach-btn peach-btn--subtle peach-btn--sm"691                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}692                >693                  cancel694                </button>695                <Show when={duressEnabled()}>696                  <button697                    class="peach-btn peach-btn--ghost peach-btn--sm"698                    style="color:var(--peach-breach)"699                    onClick={() => void handleDisableDuress()}700                    disabled={duressSaving()}701                  >702                    {duressSaving() ? "disabling..." : "disable duress"}703                  </button>704                </Show>705                <Show when={!duressEnabled()}>706                  <button707                    class="peach-btn peach-btn--accent peach-btn--sm"708                    onClick={() => void handleSetupDuress()}709                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}710                  >711                    {duressSaving() ? "saving..." : "save"}712                  </button>713                </Show>714              </div>715            </div>716          </Show>717        </div>718719        <div class="peach-settings-group">720          <p class="peach-settings-label">passkeys</p>721          <div class="peach-settings-row peach-settings-row--top">722            <div class="peach-settings-row-main">723              <span>Intercept passkey requests</span>724              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">725                Peach can handle passkey sign-in and creation on supported Chromium browsers.726              </span>727            </div>728            <button729              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}730              onClick={() => void handleTogglePasskeys()}731              aria-label="Toggle passkey interception"732              disabled={!passkeyState()?.supported || passkeySaving()}733            >734              <span class="peach-toggle-knob"></span>735            </button>736          </div>737          <div class="peach-settings-row peach-settings-row--meta">738            <span>Status</span>739            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>740          </div>741          <div class="peach-settings-row peach-settings-row--meta">742            <span>Browser</span>743            <span class="peach-settings-value">744              {passkeyState()?.browserLabel ?? "Unknown"}745              {passkeyState()?.supported ? " \u2713" : " (interception unavailable)"}746            </span>747          </div>748          <div class="peach-info-notice" style="margin-top:8px">749            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.750          </div>751          {passkeyState()?.conflict && (752            <div class="peach-settings-row peach-settings-row--meta">753              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>754            </div>755          )}756        </div>757758        {renderSyncSection()}759760        <div class="peach-settings-group">761          <p class="peach-settings-label">vault</p>762          <div class="peach-settings-row peach-settings-row--meta">763            <span>Entries</span>764            <span class="peach-settings-value">{props.entriesCount}</span>765          </div>766          <div class="peach-settings-row peach-settings-row--meta">767            <span>Duplicates</span>768            <Show when={dedupDone() !== null} fallback={769              <button770                class="peach-btn peach-btn--subtle peach-btn--sm"771                onClick={() => void handleCountDuplicates()}772                disabled={dedupRunning()}773              >774                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}775              </button>776            }>777              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">778                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}779              </span>780            </Show>781          </div>782          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>783            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">784              <div class="peach-restore-warning">785                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.786              </div>787              <div style="display:flex;gap:6px">788                <button789                  class="peach-btn peach-btn--subtle peach-btn--sm"790                  onClick={() => setDedupCount(null)}791                >792                  cancel793                </button>794                <button795                  class="peach-btn peach-btn--accent peach-btn--sm"796                  onClick={() => void handleDeduplicate()}797                  disabled={dedupRunning()}798                >799                  {dedupRunning() ? "removing..." : "remove duplicates"}800                </button>801              </div>802            </div>803          </Show>804          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">805            <span>Accent</span>806            <div class="peach-accent-picker">807              <For each={ACCENT_COLORS}>808                {(color) => (809                  <button810                    class="peach-accent-swatch"811                    classList={{ "is-active": props.accentColor === color.name }}812                    style={{ background: color.hex }}813                    onClick={() => void props.onAccentChange(color.name)}814                    aria-label={color.name}815                    title={color.name}816                  />817                )}818              </For>819            </div>820          </div>821          <div class="peach-settings-row peach-settings-row--meta">822            <span>Theme</span>823            <button824              class="peach-btn peach-btn--subtle peach-btn--sm"825              onClick={() =>826                props.onThemeChange(props.theme === "dark" ? "light" : "dark")827              }828            >829              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}830            </button>831          </div>832          <div class="peach-settings-row peach-settings-row--meta">833            <span>Trash auto-delete</span>834            <select835              class="peach-settings-select"836              value={String(props.trashRetentionDays)}837              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}838            >839              <option value="7">7 days</option>840              <option value="30">30 days</option>841              <option value="90">90 days</option>842              <option value="0">Never</option>843            </select>844          </div>845          <div class="peach-settings-row peach-settings-row--meta">846            <span>Import passwords</span>847            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>848              open849            </button>850          </div>851          <div class="peach-settings-row peach-settings-row--meta">852            <span>Export backup</span>853            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>854              open855            </button>856          </div>857          <div class="peach-settings-row peach-settings-row--meta">858            <span>Vault Health</span>859            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>860              open861            </button>862          </div>863          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">864            <span>Replace vault from backup</span>865            <div class="peach-restore-warning" style="margin-top:2px">866              This will permanently replace your current vault with the backup.867            </div>868            <div style="display:flex;gap:6px">869              <button870                class="peach-btn peach-btn--subtle peach-btn--sm"871                onClick={() => props.onOpenCJKImport?.()}872              >873                CJK phrase874              </button>875              <button876                class="peach-btn peach-btn--subtle peach-btn--sm"877                onClick={() => props.onOpenFileImport?.()}878              >879                backup file880              </button>881            </div>882          </div>883        </div>884885        <div class="peach-settings-group">886          <p class="peach-settings-label">license</p>887          {licenseInfo()?.valid && licenseInfo()?.plan === "pro" ? (888            <div class="peach-settings-row peach-settings-row--meta">889              <span>Peach Pro</span>890              <span class="peach-settings-value">891                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}892              </span>893            </div>894          ) : (895            <>896              <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">897                <input898                  class="peach-underline-input"899                  type="text"900                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"901                  value={licenseKeyInput()}902                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}903                  style="width:100%;box-sizing:border-box"904                />905                <button906                  class="peach-btn peach-btn--subtle peach-btn--sm"907                  onClick={() => void handleActivateLicense()}908                  disabled={activatingLicense() || !licenseKeyInput().trim()}909                >910                  {activatingLicense() ? "activating..." : "Activate"}911                </button>912              </div>913              {licenseError() && (914                <div class="peach-settings-row peach-settings-row--meta">915                  <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>916                </div>917              )}918              {licenseSuccess() && (919                <div class="peach-settings-row peach-settings-row--meta">920                  <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>921                </div>922              )}923              <div class="peach-settings-row peach-settings-row--meta">924                <a925                  href="https://peachpasswords.com/#pricing"926                  target="_blank"927                  rel="noopener noreferrer"928                  class="peach-link"929                  style="color:var(--peach-accent)"930                >931                  Purchase →932                </a>933              </div>934            </>935          )}936        </div>937938        {/* ── Keyboard shortcuts ────────────────────────────────────── */}939        <div class="peach-settings-group">940          <p class="peach-settings-label">keyboard shortcuts</p>941          <div class="peach-shortcuts-grid">942            <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>943            <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>944            <span class="peach-shortcut-key">/</span><span class="peach-shortcut-desc">search vault</span>945            <span class="peach-shortcut-key">↑ ↓</span><span class="peach-shortcut-desc">navigate entries</span>946            <span class="peach-shortcut-key">Esc</span><span class="peach-shortcut-desc">back / close</span>947            <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>948          </div>949        </div>950951        <div class="peach-settings-footer">952          <span>Peach {version}</span>953          <span class="peach-divider"></span>954          <span>Browser extension</span>955        </div>956      </div>957    </div>958  );959}