Settings.tsx

41.3 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  // Declined card fingerprints62  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);6364  // Change master password state65  const [showChangePw, setShowChangePw] = createSignal(false);66  const [changePwCurrent, setChangePwCurrent] = createSignal("");67  const [changePwNew, setChangePwNew] = createSignal("");68  const [changePwConfirm, setChangePwConfirm] = createSignal("");69  const [changePwSaving, setChangePwSaving] = createSignal(false);70  const [changePwError, setChangePwError] = createSignal<string | null>(null);71  const [changePwSuccess, setChangePwSuccess] = createSignal(false);7273  async function handleChangePassword() {74    setChangePwError(null);75    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }76    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }77    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }78    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }79    setChangePwSaving(true);80    const res = await sendMessage({81      type: "CHANGE_PASSWORD",82      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },83    }) as { ok: boolean; error?: string };84    if (res.ok) {85      setChangePwSuccess(true);86      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");87      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);88    } else {89      setChangePwError(res.error ?? "failed to change password");90    }91    setChangePwSaving(false);92  }9394  // Duress password state95  const [duressEnabled, setDuressEnabled] = createSignal(false);96  const [showDuressSetup, setShowDuressSetup] = createSignal(false);97  const [duressPassword, setDuressPassword] = createSignal("");98  const [duressConfirm, setDuressConfirm] = createSignal("");99  const [duressMainPassword, setDuressMainPassword] = createSignal("");100  const [duressSaving, setDuressSaving] = createSignal(false);101  const [duressError, setDuressError] = createSignal<string | null>(null);102  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);103104  onMount(async () => {105    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([106      sendMessage({ type: "GET_LICENSE" }),107      sendMessage({ type: "GET_SYNC_STATE" }),108      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),109      sendMessage({ type: "IS_DURESS_ENABLED" }),110      sendMessage({ type: "GET_SETTINGS" }),111    ]);112113    if ((licenseRes as { ok: boolean }).ok) {114      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);115    }116117    if ((syncRes as { ok: boolean }).ok) {118      const data = syncRes as { ok: boolean; data?: {119        pairedDevices?: PairedDevice[];120        trustedNetworks?: TrustedNetwork[];121        status?: SyncStatus;122        lastSyncAt?: number;123        isPro?: boolean;124      }};125      if (data.data) {126        setPairedDevices(data.data.pairedDevices ?? []);127        setTrustedNetworks(data.data.trustedNetworks ?? []);128        setSyncStatus(data.data.status ?? "offline");129        setLastSyncAt(data.data.lastSyncAt);130        setIsPro(data.data.isPro ?? false);131      }132    }133134    if ((passkeyRes as { ok: boolean }).ok) {135      setPasskeyState(136        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null137      );138    }139140    if ((duressRes as { ok: boolean }).ok) {141      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);142    }143144    if ((settingsRes as { ok: boolean }).ok) {145      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;146      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);147    }148149    chrome.runtime.onMessage.addListener((msg) => {150      if (msg.type === "SYNC_STATE_UPDATE") {151        setPairedDevices(msg.pairedDevices ?? []);152        setTrustedNetworks(msg.trustedNetworks ?? []);153        setSyncStatus(msg.status ?? "offline");154        setLastSyncAt(msg.lastSyncAt);155        setIsPro(msg.isPro ?? false);156      }157    });158  });159160  async function handleActivateLicense() {161    const key = licenseKeyInput().trim();162    if (!key) return;163    setActivatingLicense(true);164    setLicenseError(null);165    setLicenseSuccess(null);166    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });167    setActivatingLicense(false);168    if ((res as { ok: boolean }).ok) {169      const data = (res as { ok: boolean; data?: LicenseInfo }).data;170      if (data?.valid) {171        setLicenseInfo(data);172        setLicenseSuccess("License activated!");173        setLicenseKeyInput("");174      } else {175        setLicenseError("Invalid license key");176      }177    } else {178      setLicenseError("Activation failed");179    }180  }181182  async function handleTogglePasskeys() {183    const current = passkeyState();184    if (!current || !current.supported) return;185    setPasskeySaving(true);186    const res = await sendMessage({187      type: "SET_PASSKEY_PROVIDER_ENABLED",188      payload: { enabled: !current.enabled },189    });190    setPasskeySaving(false);191    if ((res as { ok: boolean }).ok) {192      setPasskeyState(193        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null194      );195    }196  }197198  function maskLicenseKey(key: string): string {199    if (key.length < 10) return key.slice(0, 4) + "****";200    return key.slice(0, 6) + "****" + key.slice(-4);201  }202203  async function handleCheckAllBreaches() {204    setCheckingBreaches(true);205    setBreachResult(null);206    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });207    setCheckingBreaches(false);208    if ((res as { ok: boolean }).ok) {209      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};210      const breached = Object.values(data).filter((count) => count > 0).length;211      setBreachResult(212        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"213      );214      props.onBreachCheckDone();215    }216  }217218  async function handleSyncNow() {219    if (syncStatus() === "not_trusted") return;220    setSyncingNow(true);221    await sendMessage({ type: "TRIGGER_SYNC" });222    setTimeout(() => setSyncingNow(false), 3000);223  }224225  async function handleRemoveDevice(deviceId: string) {226    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });227  }228229  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {230    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });231  }232233  async function handleMarkNetworkTrusted() {234    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });235  }236237  function formatRelativeTime(timestamp?: number): string {238    if (!timestamp) return "Never";239    const diff = Date.now() - timestamp;240    const minutes = Math.floor(diff / 60000);241    const hours = Math.floor(diff / 3600000);242    const days = Math.floor(diff / 86400000);243    if (minutes < 1) return "Just now";244    if (minutes < 60) return `${minutes}m ago`;245    if (hours < 24) return `${hours}h ago`;246    return `${days}d ago`;247  }248249  function getStatusColor(status: SyncStatus): string {250    switch (status) {251      case "synced": return "var(--peach-sync-ok)";252      case "syncing": return "var(--peach-sync-busy)";253      case "no_peers": return "var(--peach-sync-off)";254      case "not_trusted": return "var(--peach-sync-err)";255      case "offline": return "var(--peach-sync-off)";256      default: return "var(--peach-sync-off)";257    }258  }259260  function getStatusLabel(status: SyncStatus): string {261    switch (status) {262      case "synced": return "Synced";263      case "syncing": return "Syncing";264      case "no_peers": return "No peers visible";265      case "not_trusted": return "Not on a trusted network";266      case "offline": return "Offline";267      default: return "Unknown";268    }269  }270271  function isCurrentNetworkTrusted(): boolean {272    // Show the "mark trusted" button only if there are no trusted networks at all273    // (Tailscale is always trusted implicitly; any explicit networks count too)274    return trustedNetworks().length > 0;275  }276277  async function handleSetupDuress() {278    const mainPw = duressMainPassword().trim();279    const duressPw = duressPassword().trim();280    const confirmPw = duressConfirm().trim();281282    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }283    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }284    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }285    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }286287    setDuressSaving(true);288    setDuressError(null);289290    // Verify main password first291    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };292    if (!verifyRes.ok || !verifyRes.data) {293      setDuressError("incorrect main password");294      setDuressSaving(false);295      return;296    }297298    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };299    setDuressSaving(false);300    if (res.ok) {301      setDuressEnabled(true);302      setShowDuressSetup(false);303      setDuressPassword("");304      setDuressConfirm("");305      setDuressMainPassword("");306      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");307      setTimeout(() => setDuressSuccess(null), 5000);308    } else {309      setDuressError(res.error ?? "failed to set duress password");310    }311  }312313  async function handleClearDeclinedCards() {314    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });315    setDeclinedCardCount(0);316  }317318  async function handleCountDuplicates() {319    setDedupRunning(true);320    setDedupDone(null);321    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };322    setDedupRunning(false);323    if (res.ok) setDedupCount(res.data ?? 0);324  }325326  async function handleDeduplicate() {327    setDedupRunning(true);328    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };329    setDedupRunning(false);330    if (res.ok) {331      setDedupDone(res.data ?? 0);332      setDedupCount(0);333    }334  }335336  async function handleDisableDuress() {337    const mainPw = duressMainPassword().trim();338    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }339340    setDuressSaving(true);341    setDuressError(null);342343    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };344    if (!verifyRes.ok || !verifyRes.data) {345      setDuressError("incorrect main password");346      setDuressSaving(false);347      return;348    }349350    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };351    setDuressSaving(false);352    if (res.ok) {353      setDuressEnabled(false);354      setShowDuressSetup(false);355      setDuressMainPassword("");356      setDuressSuccess("Duress password disabled.");357      setTimeout(() => setDuressSuccess(null), 4000);358    } else {359      setDuressError(res.error ?? "failed to disable duress password");360    }361  }362363  function renderSyncSection() {364    if (!isPro()) {365      return (366        <div class="peach-settings-group" style="position:relative">367          <p class="peach-settings-label">sync</p>368          <div class="peach-sync-pro-gate">369            <div class="peach-sync-pro-gate-blur">370              <div class="peach-settings-row">371                <span>Status</span>372                <span class="peach-settings-value peach-settings-value--status">373                  <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>374                  {getStatusLabel(syncStatus())}375                </span>376              </div>377              <div class="peach-settings-row peach-settings-row--meta">378                <span>Last sync</span>379                <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>380              </div>381            </div>382            <div class="peach-sync-pro-gate-overlay">383              <span class="peach-sync-pro-badge">Pro</span>384              <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">385                Upgrade to Pro →386              </a>387            </div>388          </div>389        </div>390      );391    }392393    return (394      <div class="peach-settings-group">395        <p class="peach-settings-label">sync</p>396        <div class="peach-settings-row">397          <span>Status</span>398          <span class="peach-settings-value peach-settings-value--status">399            <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>400            {getStatusLabel(syncStatus())}401          </span>402        </div>403        <div class="peach-settings-row peach-settings-row--meta">404          <span>Last sync</span>405          <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>406        </div>407        <div class="peach-settings-row peach-settings-row--meta">408          <button409            class="peach-btn peach-btn--subtle peach-btn--sm"410            onClick={() => void handleSyncNow()}411            disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}412          >413            {syncingNow() ? "Syncing..." : "Sync now"}414          </button>415        </div>416417        <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">418          <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>419          {pairedDevices().map((device) => (420            <div class="peach-sync-device-row">421              <div class="peach-sync-device-info">422                <span class="peach-sync-device-name">{device.displayName}</span>423                <span class="peach-sync-device-meta">424                  Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}425                </span>426              </div>427              <button428                class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"429                onClick={() => void handleRemoveDevice(device.deviceId)}430              >431                Remove432              </button>433            </div>434          ))}435          <div class="peach-settings-actions--row">436            <button437              class="peach-btn peach-btn--subtle peach-btn--sm"438              onClick={() => props.onOpenSyncPairing?.("initiate")}439            >440              Pair a new device441            </button>442            <button443              class="peach-btn peach-btn--subtle peach-btn--sm"444              onClick={() => props.onOpenSyncPairing?.("accept")}445            >446              Pair with another device447            </button>448          </div>449        </div>450451        <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">452          <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>453          {trustedNetworks().filter(n => !n.isTailscale).map((network) => (454            <div class="peach-sync-device-row">455              <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>456              <button457                class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"458                onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}459              >460                Remove461              </button>462            </div>463          ))}464          <div class="peach-settings-actions--row">465            {!isCurrentNetworkTrusted() && (466              <button467                class="peach-btn peach-btn--subtle peach-btn--sm"468                onClick={() => void handleMarkNetworkTrusted()}469              >470                Mark current network as trusted471              </button>472            )}473          </div>474          <span class="peach-sync-device-meta">475            Tailscale is always trusted476          </span>477        </div>478      </div>479    );480  }481482  return (483    <div class="peach-overlay-view ov-enter-right" style="display:flex">484      <div class="peach-overlay-header">485        <button486          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"487          onClick={props.onBack}488          aria-label="Back"489        >490          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">491            <path d="M6 1L1 6L6 11" />492          </svg>493        </button>494        <span class="peach-overlay-title">settings</span>495        <div class="peach-overlay-spacer"></div>496      </div>497498      <div class="peach-settings-body">499        <div class="peach-settings-group">500          <p class="peach-settings-label">security</p>501          <div class="peach-settings-row">502            <span>Lock on close</span>503            <button504              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}505              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}506              aria-label="Toggle lock on close"507            >508              <span class="peach-toggle-knob"></span>509            </button>510          </div>511          <div class="peach-settings-row">512            <span>Auto-lock after</span>513            <select514              class="peach-settings-select"515              value={String(props.autoLockMinutes)}516              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}517            >518              <option value="1">1 minute</option>519              <option value="5">5 minutes</option>520              <option value="15">15 minutes</option>521              <option value="30">30 minutes</option>522              <option value="0">Never</option>523            </select>524          </div>525          <div class="peach-settings-row">526            <span>Breach monitoring</span>527            <button528              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}529              onClick={() =>530                void props.onBreachMonitoringChange(!props.breachMonitoring)531              }532              aria-label="Toggle breach monitoring"533            >534              <span class="peach-toggle-knob"></span>535            </button>536          </div>537          <div class="peach-settings-row peach-settings-row--meta">538            <span>Check all passwords</span>539            <button540              class="peach-btn peach-btn--subtle peach-btn--sm"541              onClick={() => void handleCheckAllBreaches()}542              disabled={checkingBreaches()}543            >544              {checkingBreaches() ? "checking..." : "run"}545            </button>546          </div>547          {breachResult() && (548            <div class="peach-settings-row peach-settings-row--meta">549              <span>Last check</span>550              <span class="peach-settings-value">{breachResult()}</span>551            </div>552          )}553          {/* ── Change master password ──────────────────────────────────── */}554          <div class="peach-settings-row peach-settings-row--meta">555            <span>Master password</span>556            <button557              class="peach-btn peach-btn--subtle peach-btn--sm"558              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}559            >560              {showChangePw() ? "cancel" : "change"}561            </button>562          </div>563          <Show when={showChangePw()}>564            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">565              <input566                class="peach-settings-input"567                type="password"568                placeholder="current password"569                value={changePwCurrent()}570                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}571                autocomplete="current-password"572              />573              <input574                class="peach-settings-input"575                type="password"576                placeholder="new password (min 12 chars)"577                value={changePwNew()}578                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}579                autocomplete="new-password"580              />581              <input582                class="peach-settings-input"583                type="password"584                placeholder="confirm new password"585                value={changePwConfirm()}586                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}587                autocomplete="new-password"588                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}589              />590              <Show when={changePwError()}>591                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>592              </Show>593              <Show when={changePwSuccess()}>594                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>595              </Show>596              <button597                class="peach-btn peach-btn--accent peach-btn--sm"598                style="align-self:flex-start"599                onClick={() => void handleChangePassword()}600                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}601              >602                {changePwSaving() ? "saving…" : "save new password"}603              </button>604            </div>605          </Show>606        </div>607608        {/* ── Keyboard shortcuts ──────────────────────────────────────────── */}609        <div class="peach-settings-group">610          <p class="peach-settings-label">keyboard shortcuts</p>611          <div class="peach-settings-row peach-settings-row--meta">612            <span>Inline popup shortcut</span>613            <a614              class="peach-settings-link"615              href="chrome://extensions/shortcuts"616              onClick={(e) => {617                e.preventDefault();618                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });619              }}620            >621              Ctrl+Shift+Space — customize ↗622            </a>623          </div>624        </div>625626        {/* ── Cards ─────────────────────────────────────────────────────── */}627        <Show when={declinedCardCount() > 0}>628          <div class="peach-settings-group">629            <p class="peach-settings-label">cards</p>630            <div class="peach-settings-row peach-settings-row--meta">631              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>632              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>633                Clear634              </button>635            </div>636          </div>637        </Show>638639        {/* ── Duress password ─────────────────────────────────────────────── */}640        <div class="peach-settings-group">641          <p class="peach-settings-label">duress password</p>642          <div class="peach-settings-row peach-settings-row--top">643            <div class="peach-settings-row-main">644              <span>Duress password</span>645              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">646                If entered under coercion, shows a decoy empty vault instead of your real one.647              </span>648            </div>649            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>650              {duressEnabled() ? "on" : "off"}651            </div>652          </div>653654          <Show when={duressEnabled() && !showDuressSetup()}>655            <div class="peach-settings-row peach-settings-row--meta">656              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">657                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.658              </span>659            </div>660          </Show>661662          <Show when={duressSuccess()}>663            <div class="peach-settings-row peach-settings-row--meta">664              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>665            </div>666          </Show>667668          <Show when={!showDuressSetup()}>669            <div class="peach-settings-row peach-settings-row--meta">670              <button671                class="peach-btn peach-btn--subtle peach-btn--sm"672                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}673              >674                {duressEnabled() ? "Change / disable" : "Set up"}675              </button>676            </div>677          </Show>678679          <Show when={showDuressSetup()}>680            <div class="peach-duress-form">681              <label class="peach-label">main password (to confirm)</label>682              <input683                class="peach-settings-input"684                type="password"685                placeholder="your current master password"686                autocomplete="current-password"687                value={duressMainPassword()}688                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}689              />690              <Show when={!duressEnabled()}>691                <label class="peach-label">new duress password</label>692                <input693                  class="peach-settings-input"694                  type="password"695                  placeholder="duress password (min 8 chars)"696                  autocomplete="new-password"697                  value={duressPassword()}698                  onInput={(e) => setDuressPassword(e.currentTarget.value)}699                />700                <label class="peach-label">confirm duress password</label>701                <input702                  class="peach-settings-input"703                  type="password"704                  placeholder="confirm duress password"705                  autocomplete="new-password"706                  value={duressConfirm()}707                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}708                />709              </Show>710711              <Show when={duressError()}>712                <p class="peach-hint peach-hint-error">{duressError()}</p>713              </Show>714715              <div class="peach-duress-actions">716                <button717                  class="peach-btn peach-btn--subtle peach-btn--sm"718                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}719                >720                  cancel721                </button>722                <Show when={duressEnabled()}>723                  <button724                    class="peach-btn peach-btn--ghost peach-btn--sm"725                    style="color:var(--peach-breach)"726                    onClick={() => void handleDisableDuress()}727                    disabled={duressSaving()}728                  >729                    {duressSaving() ? "disabling..." : "disable duress"}730                  </button>731                </Show>732                <Show when={!duressEnabled()}>733                  <button734                    class="peach-btn peach-btn--accent peach-btn--sm"735                    onClick={() => void handleSetupDuress()}736                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}737                  >738                    {duressSaving() ? "saving..." : "save"}739                  </button>740                </Show>741              </div>742            </div>743          </Show>744        </div>745746        <div class="peach-settings-group">747          <p class="peach-settings-label">passkeys</p>748          <div class="peach-settings-row peach-settings-row--top">749            <div class="peach-settings-row-main">750              <span>Intercept passkey requests</span>751              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">752                Peach can handle passkey sign-in and creation on supported Chromium browsers.753              </span>754            </div>755            <button756              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}757              onClick={() => void handleTogglePasskeys()}758              aria-label="Toggle passkey interception"759              disabled={!passkeyState()?.supported || passkeySaving()}760            >761              <span class="peach-toggle-knob"></span>762            </button>763          </div>764          <div class="peach-settings-row peach-settings-row--meta">765            <span>Status</span>766            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>767          </div>768          <div class="peach-settings-row peach-settings-row--meta">769            <span>Browser</span>770            <span class="peach-settings-value">771              {passkeyState()?.browserLabel ?? "Unknown"}772              {passkeyState()?.supported ? " \u2713" : " (interception unavailable)"}773            </span>774          </div>775          <div class="peach-info-notice" style="margin-top:8px">776            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.777          </div>778          {passkeyState()?.conflict && (779            <div class="peach-settings-row peach-settings-row--meta">780              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>781            </div>782          )}783        </div>784785        {renderSyncSection()}786787        <div class="peach-settings-group">788          <p class="peach-settings-label">vault</p>789          <div class="peach-settings-row peach-settings-row--meta">790            <span>Entries</span>791            <span class="peach-settings-value">{props.entriesCount}</span>792          </div>793          <div class="peach-settings-row peach-settings-row--meta">794            <span>Duplicates</span>795            <Show when={dedupDone() !== null} fallback={796              <button797                class="peach-btn peach-btn--subtle peach-btn--sm"798                onClick={() => void handleCountDuplicates()}799                disabled={dedupRunning()}800              >801                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}802              </button>803            }>804              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">805                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}806              </span>807            </Show>808          </div>809          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>810            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">811              <div class="peach-restore-warning">812                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.813              </div>814              <div style="display:flex;gap:6px">815                <button816                  class="peach-btn peach-btn--subtle peach-btn--sm"817                  onClick={() => setDedupCount(null)}818                >819                  cancel820                </button>821                <button822                  class="peach-btn peach-btn--accent peach-btn--sm"823                  onClick={() => void handleDeduplicate()}824                  disabled={dedupRunning()}825                >826                  {dedupRunning() ? "removing..." : "remove duplicates"}827                </button>828              </div>829            </div>830          </Show>831          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">832            <span>Accent</span>833            <div class="peach-accent-picker">834              <For each={ACCENT_COLORS}>835                {(color) => (836                  <button837                    class="peach-accent-swatch"838                    classList={{ "is-active": props.accentColor === color.name }}839                    style={{ background: color.hex }}840                    onClick={() => void props.onAccentChange(color.name)}841                    aria-label={color.name}842                    title={color.name}843                  />844                )}845              </For>846            </div>847          </div>848          <div class="peach-settings-row peach-settings-row--meta">849            <span>Theme</span>850            <button851              class="peach-btn peach-btn--subtle peach-btn--sm"852              onClick={() =>853                props.onThemeChange(props.theme === "dark" ? "light" : "dark")854              }855            >856              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}857            </button>858          </div>859          <div class="peach-settings-row peach-settings-row--meta">860            <span>Trash auto-delete</span>861            <select862              class="peach-settings-select"863              value={String(props.trashRetentionDays)}864              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}865            >866              <option value="7">7 days</option>867              <option value="30">30 days</option>868              <option value="90">90 days</option>869              <option value="0">Never</option>870            </select>871          </div>872          <div class="peach-settings-row peach-settings-row--meta">873            <span>Import passwords</span>874            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>875              open876            </button>877          </div>878          <div class="peach-settings-row peach-settings-row--meta">879            <span>Export backup</span>880            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>881              open882            </button>883          </div>884          <div class="peach-settings-row peach-settings-row--meta">885            <span>Vault Health</span>886            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>887              open888            </button>889          </div>890          <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">891            <span>Replace vault from backup</span>892            <div class="peach-restore-warning" style="margin-top:2px">893              This will permanently replace your current vault with the backup.894            </div>895            <div style="display:flex;gap:6px">896              <button897                class="peach-btn peach-btn--subtle peach-btn--sm"898                onClick={() => props.onOpenCJKImport?.()}899              >900                CJK phrase901              </button>902              <button903                class="peach-btn peach-btn--subtle peach-btn--sm"904                onClick={() => props.onOpenFileImport?.()}905              >906                backup file907              </button>908            </div>909          </div>910        </div>911912        <div class="peach-settings-group">913          <p class="peach-settings-label">license</p>914          {licenseInfo()?.valid && licenseInfo()?.plan === "pro" ? (915            <div class="peach-settings-row peach-settings-row--meta">916              <span>Peach Pro</span>917              <span class="peach-settings-value">918                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}919              </span>920            </div>921          ) : (922            <>923              <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">924                <input925                  class="peach-underline-input"926                  type="text"927                  placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"928                  value={licenseKeyInput()}929                  onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}930                  style="width:100%;box-sizing:border-box"931                />932                <button933                  class="peach-btn peach-btn--subtle peach-btn--sm"934                  onClick={() => void handleActivateLicense()}935                  disabled={activatingLicense() || !licenseKeyInput().trim()}936                >937                  {activatingLicense() ? "activating..." : "Activate"}938                </button>939              </div>940              {licenseError() && (941                <div class="peach-settings-row peach-settings-row--meta">942                  <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>943                </div>944              )}945              {licenseSuccess() && (946                <div class="peach-settings-row peach-settings-row--meta">947                  <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>948                </div>949              )}950              <div class="peach-settings-row peach-settings-row--meta">951                <a952                  href="https://peachpasswords.com/#pricing"953                  target="_blank"954                  rel="noopener noreferrer"955                  class="peach-link"956                  style="color:var(--peach-accent)"957                >958                  Purchase →959                </a>960              </div>961            </>962          )}963        </div>964965        {/* ── Keyboard shortcuts ────────────────────────────────────── */}966        <div class="peach-settings-group">967          <p class="peach-settings-label">keyboard shortcuts</p>968          <div class="peach-shortcuts-grid">969            <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>970            <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>971            <span class="peach-shortcut-key">/</span><span class="peach-shortcut-desc">search vault</span>972            <span class="peach-shortcut-key">↑ ↓</span><span class="peach-shortcut-desc">navigate entries</span>973            <span class="peach-shortcut-key">Esc</span><span class="peach-shortcut-desc">back / close</span>974            <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>975          </div>976        </div>977978        <div class="peach-settings-footer">979          <span>Peach {version}</span>980          <span class="peach-divider"></span>981          <span>Browser extension</span>982        </div>983      </div>984    </div>985  );986}