Settings.tsx

39.2 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        {/* ── Duress password ─────────────────────────────────────────────── */}595        <div class="peach-settings-group">596          <p class="peach-settings-label">duress password</p>597          <div class="peach-settings-row peach-settings-row--top">598            <div class="peach-settings-row-main">599              <span>Duress password</span>600              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">601                If entered under coercion, shows a decoy empty vault instead of your real one.602              </span>603            </div>604            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>605              {duressEnabled() ? "on" : "off"}606            </div>607          </div>608609          <Show when={duressEnabled() && !showDuressSetup()}>610            <div class="peach-settings-row peach-settings-row--meta">611              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">612                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.613              </span>614            </div>615          </Show>616617          <Show when={duressSuccess()}>618            <div class="peach-settings-row peach-settings-row--meta">619              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>620            </div>621          </Show>622623          <Show when={!showDuressSetup()}>624            <div class="peach-settings-row peach-settings-row--meta">625              <button626                class="peach-btn peach-btn--subtle peach-btn--sm"627                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}628              >629                {duressEnabled() ? "Change / disable" : "Set up"}630              </button>631            </div>632          </Show>633634          <Show when={showDuressSetup()}>635            <div class="peach-duress-form">636              <label class="peach-label">main password (to confirm)</label>637              <input638                class="peach-settings-input"639                type="password"640                placeholder="your current master password"641                autocomplete="current-password"642                value={duressMainPassword()}643                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}644              />645              <Show when={!duressEnabled()}>646                <label class="peach-label">new duress password</label>647                <input648                  class="peach-settings-input"649                  type="password"650                  placeholder="duress password (min 8 chars)"651                  autocomplete="new-password"652                  value={duressPassword()}653                  onInput={(e) => setDuressPassword(e.currentTarget.value)}654                />655                <label class="peach-label">confirm duress password</label>656                <input657                  class="peach-settings-input"658                  type="password"659                  placeholder="confirm duress password"660                  autocomplete="new-password"661                  value={duressConfirm()}662                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}663                />664              </Show>665666              <Show when={duressError()}>667                <p class="peach-hint peach-hint-error">{duressError()}</p>668              </Show>669670              <div class="peach-duress-actions">671                <button672                  class="peach-btn peach-btn--subtle peach-btn--sm"673                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}674                >675                  cancel676                </button>677                <Show when={duressEnabled()}>678                  <button679                    class="peach-btn peach-btn--ghost peach-btn--sm"680                    style="color:var(--peach-breach)"681                    onClick={() => void handleDisableDuress()}682                    disabled={duressSaving()}683                  >684                    {duressSaving() ? "disabling..." : "disable duress"}685                  </button>686                </Show>687                <Show when={!duressEnabled()}>688                  <button689                    class="peach-btn peach-btn--accent peach-btn--sm"690                    onClick={() => void handleSetupDuress()}691                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}692                  >693                    {duressSaving() ? "saving..." : "save"}694                  </button>695                </Show>696              </div>697            </div>698          </Show>699        </div>700701        <div class="peach-settings-group">702          <p class="peach-settings-label">passkeys</p>703          <div class="peach-settings-row peach-settings-row--top">704            <div class="peach-settings-row-main">705              <span>Intercept passkey requests</span>706              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">707                Peach can handle passkey sign-in and creation on supported Chromium browsers.708              </span>709            </div>710            <button711              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}712              onClick={() => void handleTogglePasskeys()}713              aria-label="Toggle passkey interception"714              disabled={!passkeyState()?.supported || passkeySaving()}715            >716              <span class="peach-toggle-knob"></span>717            </button>718          </div>719          <div class="peach-settings-row peach-settings-row--meta">720            <span>Status</span>721            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>722          </div>723          <div class="peach-settings-row peach-settings-row--meta">724            <span>Browser</span>725            <span class="peach-settings-value">726              {passkeyState()?.browserLabel ?? "Unknown"}727              {passkeyState()?.supported ? " \u2713" : " (interception unavailable)"}728            </span>729          </div>730          <div class="peach-info-notice" style="margin-top:8px">731            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.732          </div>733          {passkeyState()?.conflict && (734            <div class="peach-settings-row peach-settings-row--meta">735              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>736            </div>737          )}738        </div>739740        {renderSyncSection()}741742        <div class="peach-settings-group">743          <p class="peach-settings-label">vault</p>744          <div class="peach-settings-row peach-settings-row--meta">745            <span>Entries</span>746            <span class="peach-settings-value">{props.entriesCount}</span>747          </div>748          <div class="peach-settings-row peach-settings-row--meta">749            <span>Duplicates</span>750            <Show when={dedupDone() !== null} fallback={751              <button752                class="peach-btn peach-btn--subtle peach-btn--sm"753                onClick={() => void handleCountDuplicates()}754                disabled={dedupRunning()}755              >756                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}757              </button>758            }>759              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">760                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}761              </span>762            </Show>763          </div>764          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>765            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">766              <div class="peach-restore-warning">767                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.768              </div>769              <div style="display:flex;gap:6px">770                <button771                  class="peach-btn peach-btn--subtle peach-btn--sm"772                  onClick={() => setDedupCount(null)}773                >774                  cancel775                </button>776                <button777                  class="peach-btn peach-btn--accent peach-btn--sm"778                  onClick={() => void handleDeduplicate()}779                  disabled={dedupRunning()}780                >781                  {dedupRunning() ? "removing..." : "remove duplicates"}782                </button>783              </div>784            </div>785          </Show>786          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">787            <span>Accent</span>788            <div class="peach-accent-picker">789              <For each={ACCENT_COLORS}>790                {(color) => (791                  <button792                    class="peach-accent-swatch"793                    classList={{ "is-active": props.accentColor === color.name }}794                    style={{ background: color.hex }}795                    onClick={() => void props.onAccentChange(color.name)}796                    aria-label={color.name}797                    title={color.name}798                  />799                )}800              </For>801            </div>802          </div>803          <div class="peach-settings-row peach-settings-row--meta">804            <span>Theme</span>805            <button806              class="peach-btn peach-btn--subtle peach-btn--sm"807              onClick={() =>808                props.onThemeChange(props.theme === "dark" ? "light" : "dark")809              }810            >811              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}812            </button>813          </div>814          <div class="peach-settings-row peach-settings-row--meta">815            <span>Trash auto-delete</span>816            <select817              class="peach-settings-select"818              value={String(props.trashRetentionDays)}819              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}820            >821              <option value="7">7 days</option>822              <option value="30">30 days</option>823              <option value="90">90 days</option>824              <option value="0">Never</option>825            </select>826          </div>827          <div class="peach-settings-row peach-settings-row--meta">828            <span>Import passwords</span>829            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>830              open831            </button>832          </div>833          <div class="peach-settings-row peach-settings-row--meta">834            <span>Export backup</span>835            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>836              open837            </button>838          </div>839          <div class="peach-settings-row peach-settings-row--meta">840            <span>Vault Health</span>841            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>842              open843            </button>844          </div>845          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">846            <span>Replace vault from backup</span>847            <div class="peach-restore-warning" style="margin-top:2px">848              This will permanently replace your current vault with the backup.849            </div>850            <div style="display:flex;gap:6px">851              <button852                class="peach-btn peach-btn--subtle peach-btn--sm"853                onClick={() => props.onOpenCJKImport?.()}854              >855                CJK phrase856              </button>857              <button858                class="peach-btn peach-btn--subtle peach-btn--sm"859                onClick={() => props.onOpenFileImport?.()}860              >861                backup file862              </button>863            </div>864          </div>865        </div>866867        <div class="peach-settings-group">868          <p class="peach-settings-label">license</p>869          {licenseInfo()?.valid && licenseInfo()?.plan === "pro" ? (870            <div class="peach-settings-row peach-settings-row--meta">871              <span>Peach Pro</span>872              <span class="peach-settings-value">873                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}874              </span>875            </div>876          ) : (877            <>878              <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">879                <input880                  class="peach-underline-input"881                  type="text"882                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"883                  value={licenseKeyInput()}884                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}885                  style="width:100%;box-sizing:border-box"886                />887                <button888                  class="peach-btn peach-btn--subtle peach-btn--sm"889                  onClick={() => void handleActivateLicense()}890                  disabled={activatingLicense() || !licenseKeyInput().trim()}891                >892                  {activatingLicense() ? "activating..." : "Activate"}893                </button>894              </div>895              {licenseError() && (896                <div class="peach-settings-row peach-settings-row--meta">897                  <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>898                </div>899              )}900              {licenseSuccess() && (901                <div class="peach-settings-row peach-settings-row--meta">902                  <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>903                </div>904              )}905              <div class="peach-settings-row peach-settings-row--meta">906                <a907                  href="https://peachpasswords.com/#pricing"908                  target="_blank"909                  rel="noopener noreferrer"910                  class="peach-link"911                  style="color:var(--peach-accent)"912                >913                  Purchase →914                </a>915              </div>916            </>917          )}918        </div>919920        {/* ── Keyboard shortcuts ────────────────────────────────────── */}921        <div class="peach-settings-group">922          <p class="peach-settings-label">keyboard shortcuts</p>923          <div class="peach-shortcuts-grid">924            <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>925            <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>926            <span class="peach-shortcut-key">/</span><span class="peach-shortcut-desc">search vault</span>927            <span class="peach-shortcut-key">↑ ↓</span><span class="peach-shortcut-desc">navigate entries</span>928            <span class="peach-shortcut-key">Esc</span><span class="peach-shortcut-desc">back / close</span>929            <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>930          </div>931        </div>932933        <div class="peach-settings-footer">934          <span>Peach {version}</span>935          <span class="peach-divider"></span>936          <span>Browser extension</span>937        </div>938      </div>939    </div>940  );941}