Settings.tsx

49.0 KB · raw

1import { createSignal, For, Match, onMount, Show, Switch } 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}3637type SectionName =38  | "security"39  | "vault"40  | "sync"41  | "appearance"42  | "shortcuts"43  | "license"44  | "feedback";4546const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];4748export function SettingsView(props: SettingsProps) {49  const version = chrome.runtime.getManifest().version;50  const [activeSection, setActiveSection] = createSignal<SectionName | null>(null);5152  const [checkingBreaches, setCheckingBreaches] = createSignal(false);53  const [breachResult, setBreachResult] = createSignal<string | null>(null);54  const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);55  const [licenseKeyInput, setLicenseKeyInput] = createSignal("");56  const [licenseError, setLicenseError] = createSignal<string | null>(null);57  const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);58  const [activatingLicense, setActivatingLicense] = createSignal(false);5960  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);61  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);62  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");63  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);64  const [isPro, setIsPro] = createSignal(false);65  const [syncingNow, setSyncingNow] = createSignal(false);66  const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);67  const [passkeySaving, setPasskeySaving] = createSignal(false);6869  const [dedupCount, setDedupCount] = createSignal<number | null>(null);70  const [dedupRunning, setDedupRunning] = createSignal(false);71  const [dedupDone, setDedupDone] = createSignal<number | null>(null);7273  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);7475  const [showChangePw, setShowChangePw] = createSignal(false);76  const [changePwCurrent, setChangePwCurrent] = createSignal("");77  const [changePwNew, setChangePwNew] = createSignal("");78  const [changePwConfirm, setChangePwConfirm] = createSignal("");79  const [changePwSaving, setChangePwSaving] = createSignal(false);80  const [changePwError, setChangePwError] = createSignal<string | null>(null);81  const [changePwSuccess, setChangePwSuccess] = createSignal(false);8283  const [duressEnabled, setDuressEnabled] = createSignal(false);84  const [showDuressSetup, setShowDuressSetup] = createSignal(false);85  const [duressPassword, setDuressPassword] = createSignal("");86  const [duressConfirm, setDuressConfirm] = createSignal("");87  const [duressMainPassword, setDuressMainPassword] = createSignal("");88  const [duressSaving, setDuressSaving] = createSignal(false);89  const [duressError, setDuressError] = createSignal<string | null>(null);90  const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);9192  const [fbCategory, setFbCategory] = createSignal("Bug report");93  const [fbMessage, setFbMessage] = createSignal("");94  const [fbSubmitting, setFbSubmitting] = createSignal(false);95  const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);9697  onMount(async () => {98    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([99      sendMessage({ type: "GET_LICENSE" }),100      sendMessage({ type: "GET_SYNC_STATE" }),101      sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),102      sendMessage({ type: "IS_DURESS_ENABLED" }),103      sendMessage({ type: "GET_SETTINGS" }),104    ]);105106    if ((licenseRes as { ok: boolean }).ok) {107      setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);108    }109110    if ((syncRes as { ok: boolean }).ok) {111      const data = syncRes as { ok: boolean; data?: {112        pairedDevices?: PairedDevice[];113        trustedNetworks?: TrustedNetwork[];114        status?: SyncStatus;115        lastSyncAt?: number;116        isPro?: boolean;117      }};118      if (data.data) {119        setPairedDevices(data.data.pairedDevices ?? []);120        setTrustedNetworks(data.data.trustedNetworks ?? []);121        setSyncStatus(data.data.status ?? "offline");122        setLastSyncAt(data.data.lastSyncAt);123        setIsPro(data.data.isPro ?? false);124      }125    }126127    if ((passkeyRes as { ok: boolean }).ok) {128      setPasskeyState(129        (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null130      );131    }132133    if ((duressRes as { ok: boolean }).ok) {134      setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);135    }136137    if ((settingsRes as { ok: boolean }).ok) {138      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;139      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);140    }141142    chrome.runtime.onMessage.addListener((msg) => {143      if (msg.type === "SYNC_STATE_UPDATE") {144        setPairedDevices(msg.pairedDevices ?? []);145        setTrustedNetworks(msg.trustedNetworks ?? []);146        setSyncStatus(msg.status ?? "offline");147        setLastSyncAt(msg.lastSyncAt);148        setIsPro(msg.isPro ?? false);149      }150    });151  });152153  async function handleChangePassword() {154    setChangePwError(null);155    if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }156    if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }157    if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }158    if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }159    setChangePwSaving(true);160    const res = await sendMessage({161      type: "CHANGE_PASSWORD",162      payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },163    }) as { ok: boolean; error?: string };164    if (res.ok) {165      setChangePwSuccess(true);166      setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");167      setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);168    } else {169      setChangePwError(res.error ?? "failed to change password");170    }171    setChangePwSaving(false);172  }173174  async function handleActivateLicense() {175    const key = licenseKeyInput().trim();176    if (!key) return;177    setActivatingLicense(true);178    setLicenseError(null);179    setLicenseSuccess(null);180    const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });181    setActivatingLicense(false);182    if ((res as { ok: boolean }).ok) {183      const data = (res as { ok: boolean; data?: LicenseInfo }).data;184      if (data?.valid) {185        setLicenseInfo(data);186        setLicenseSuccess("License activated!");187        setLicenseKeyInput("");188      } else {189        setLicenseError("Invalid license key");190      }191    } else {192      setLicenseError("Activation failed");193    }194  }195196  async function handleTogglePasskeys() {197    const current = passkeyState();198    if (!current || !current.supported) return;199    setPasskeySaving(true);200    const res = await sendMessage({201      type: "SET_PASSKEY_PROVIDER_ENABLED",202      payload: { enabled: !current.enabled },203    });204    setPasskeySaving(false);205    if ((res as { ok: boolean }).ok) {206      setPasskeyState(207        (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null208      );209    }210  }211212  async function handleCheckAllBreaches() {213    setCheckingBreaches(true);214    setBreachResult(null);215    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });216    setCheckingBreaches(false);217    if ((res as { ok: boolean }).ok) {218      const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};219      const breached = Object.values(data).filter((count) => count > 0).length;220      setBreachResult(221        breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"222      );223      props.onBreachCheckDone();224    }225  }226227  async function handleSyncNow() {228    if (syncStatus() === "not_trusted") return;229    setSyncingNow(true);230    await sendMessage({ type: "TRIGGER_SYNC" });231    setTimeout(() => setSyncingNow(false), 3000);232  }233234  async function handleRemoveDevice(deviceId: string) {235    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });236  }237238  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {239    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });240  }241242  async function handleMarkNetworkTrusted() {243    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });244  }245246  function formatRelativeTime(timestamp?: number): string {247    if (!timestamp) return "Never";248    const diff = Date.now() - timestamp;249    const minutes = Math.floor(diff / 60000);250    const hours = Math.floor(diff / 3600000);251    const days = Math.floor(diff / 86400000);252    if (minutes < 1) return "Just now";253    if (minutes < 60) return `${minutes}m ago`;254    if (hours < 24) return `${hours}h ago`;255    return `${days}d ago`;256  }257258  function getStatusColor(status: SyncStatus): string {259    switch (status) {260      case "synced": return "var(--peach-sync-ok)";261      case "syncing": return "var(--peach-sync-busy)";262      case "no_peers": return "var(--peach-sync-off)";263      case "not_trusted": return "var(--peach-sync-err)";264      case "offline": return "var(--peach-sync-off)";265      default: return "var(--peach-sync-off)";266    }267  }268269  function getStatusLabel(status: SyncStatus): string {270    switch (status) {271      case "synced": return "Synced";272      case "syncing": return "Syncing";273      case "no_peers": return "No peers visible";274      case "not_trusted": return "Not on a trusted network";275      case "offline": return "Offline";276      default: return "Unknown";277    }278  }279280  function isCurrentNetworkTrusted(): boolean {281    return trustedNetworks().length > 0;282  }283284  async function handleSetupDuress() {285    const mainPw = duressMainPassword().trim();286    const duressPw = duressPassword().trim();287    const confirmPw = duressConfirm().trim();288289    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }290    if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }291    if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }292    if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }293294    setDuressSaving(true);295    setDuressError(null);296297    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };298    if (!verifyRes.ok || !verifyRes.data) {299      setDuressError("incorrect main password");300      setDuressSaving(false);301      return;302    }303304    const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };305    setDuressSaving(false);306    if (res.ok) {307      setDuressEnabled(true);308      setShowDuressSetup(false);309      setDuressPassword("");310      setDuressConfirm("");311      setDuressMainPassword("");312      setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");313      setTimeout(() => setDuressSuccess(null), 5000);314    } else {315      setDuressError(res.error ?? "failed to set duress password");316    }317  }318319  async function handleClearDeclinedCards() {320    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });321    setDeclinedCardCount(0);322  }323324  async function handleCountDuplicates() {325    setDedupRunning(true);326    setDedupDone(null);327    const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };328    setDedupRunning(false);329    if (res.ok) setDedupCount(res.data ?? 0);330  }331332  async function handleDeduplicate() {333    setDedupRunning(true);334    const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };335    setDedupRunning(false);336    if (res.ok) {337      setDedupDone(res.data ?? 0);338      setDedupCount(0);339    }340  }341342  async function handleDisableDuress() {343    const mainPw = duressMainPassword().trim();344    if (!mainPw) { setDuressError("enter your main password to confirm"); return; }345346    setDuressSaving(true);347    setDuressError(null);348349    const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };350    if (!verifyRes.ok || !verifyRes.data) {351      setDuressError("incorrect main password");352      setDuressSaving(false);353      return;354    }355356    const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };357    setDuressSaving(false);358    if (res.ok) {359      setDuressEnabled(false);360      setShowDuressSetup(false);361      setDuressMainPassword("");362      setDuressSuccess("Duress password disabled.");363      setTimeout(() => setDuressSuccess(null), 4000);364    } else {365      setDuressError(res.error ?? "failed to disable duress password");366    }367  }368369  async function handleSubmitFeedback() {370    if (!fbMessage().trim() || fbSubmitting()) return;371    setFbSubmitting(true);372    setFbResult(null);373    const res = await sendMessage({374      type: "SUBMIT_FEEDBACK",375      payload: {376        category: fbCategory(),377        message: fbMessage().trim(),378        version,379        browser: navigator.userAgent,380      },381    }) as { ok: boolean; error?: string };382    setFbSubmitting(false);383    if (res.ok) {384      setFbResult("success");385      setFbMessage("");386      setTimeout(() => setFbResult(null), 4000);387    } else {388      setFbResult("error");389    }390  }391392  function handleHeaderBack() {393    if (activeSection() !== null) {394      setActiveSection(null);395    } else {396      props.onBack();397    }398  }399400  function currentTitle(): string {401    const s = activeSection();402    if (s === null) return "settings";403    if (s === "license") return "account";404    return s;405  }406407  function renderCatRow(opts: {408    section: SectionName;409    name: string;410    desc: string;411    icon: () => any;412    pro?: boolean;413  }) {414    return (415      <button416        class="peach-settings-cat-row"417        onClick={() => setActiveSection(opts.section)}418      >419        <div class="peach-settings-cat-icon">{opts.icon()}</div>420        <div class="peach-settings-cat-content">421          <span class="peach-settings-cat-name">422            {opts.name}423            <Show when={opts.pro && !isPro()}>424              <span class="peach-sync-pro-badge" style="margin-left:6px">Pro</span>425            </Show>426          </span>427          <span class="peach-settings-cat-desc">{opts.desc}</span>428        </div>429        <svg430          class="peach-settings-cat-chevron"431          width="6" height="10" viewBox="0 0 6 10" fill="none"432          stroke="currentColor" stroke-width="1.1"433          stroke-linecap="round" stroke-linejoin="round"434        >435          <path d="M1 1l4 4-4 4" />436        </svg>437      </button>438    );439  }440441  const iconShield = () => (442    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">443      <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>444    </svg>445  );446  const iconKey = () => (447    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">448      <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>449    </svg>450  );451  const iconLock = () => (452    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">453      <rect x="3" y="7" width="10" height="7" rx="1.5"/>454      <path d="M6 7V5a3 3 0 016 0v2"/>455    </svg>456  );457  const iconSync = () => (458    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">459      <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>460      <path d="M14 6l-2 2-2-2"/>461      <path d="M2 10l2-2 2 2"/>462    </svg>463  );464  const iconSun = () => (465    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">466      <circle cx="8" cy="8" r="3"/>467      <path d="M8 2v1m0 10v1M2 8h1m10 0h1m-2.1-4.1-.7.7M4.8 11.3l-.7.7m0-7.7.7.7m6.5 6.3-.7.7"/>468    </svg>469  );470  const iconKeys = () => (471    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">472      <rect x="2" y="5" width="5" height="4" rx="1"/>473      <rect x="9" y="7" width="5" height="4" rx="1"/>474      <path d="M7 7h2m-2 2h2"/>475    </svg>476  );477  const iconBubble = () => (478    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">479      <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>480    </svg>481  );482483  function renderHome() {484    return (485      <div class="peach-settings-body">486        <p class="peach-settings-nav-label">PROTECTION</p>487        {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}488        {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}489490        <p class="peach-settings-nav-label">VAULT</p>491        {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}492        {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}493494        <p class="peach-settings-nav-label">PREFERENCES</p>495        {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}496        {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}497498        <p class="peach-settings-nav-label">SUPPORT</p>499        {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}500501        <div class="peach-settings-footer">502          <span>Peach {version}</span>503          <span class="peach-divider"></span>504          <span>Browser extension</span>505        </div>506      </div>507    );508  }509510  function renderSecurity() {511    return (512      <div class="peach-settings-body">513        <div class="peach-settings-group">514          <p class="peach-settings-sub-label">LOCKING</p>515          <div class="peach-settings-row">516            <span>Lock on close</span>517            <button518              class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}519              onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}520              aria-label="Toggle lock on close"521            >522              <span class="peach-toggle-knob"></span>523            </button>524          </div>525          <div class="peach-settings-row">526            <span>Auto-lock after</span>527            <select528              class="peach-settings-select"529              value={String(props.autoLockMinutes)}530              onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}531            >532              <option value="1">1 minute</option>533              <option value="5">5 minutes</option>534              <option value="15">15 minutes</option>535              <option value="30">30 minutes</option>536              <option value="0">Never</option>537            </select>538          </div>539540          <p class="peach-settings-sub-label">BREACH PROTECTION</p>541          <div class="peach-settings-row">542            <span>Breach monitoring</span>543            <button544              class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}545              onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}546              aria-label="Toggle breach monitoring"547            >548              <span class="peach-toggle-knob"></span>549            </button>550          </div>551          <div class="peach-settings-row peach-settings-row--meta">552            <span>Check all passwords</span>553            <button554              class="peach-btn peach-btn--subtle peach-btn--sm"555              onClick={() => void handleCheckAllBreaches()}556              disabled={checkingBreaches()}557            >558              {checkingBreaches() ? "checking..." : "run"}559            </button>560          </div>561          <Show when={breachResult()}>562            <div class="peach-settings-row peach-settings-row--meta">563              <span>Last check</span>564              <span class="peach-settings-value">{breachResult()}</span>565            </div>566          </Show>567568          <p class="peach-settings-sub-label">MASTER PASSWORD</p>569          <div class="peach-settings-row peach-settings-row--meta">570            <span>Master password</span>571            <button572              class="peach-btn peach-btn--subtle peach-btn--sm"573              onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}574            >575              {showChangePw() ? "cancel" : "change"}576            </button>577          </div>578          <Show when={showChangePw()}>579            <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">580              <input581                class="peach-settings-input"582                type="password"583                placeholder="current password"584                value={changePwCurrent()}585                onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}586                autocomplete="current-password"587              />588              <input589                class="peach-settings-input"590                type="password"591                placeholder="new password (min 12 chars)"592                value={changePwNew()}593                onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}594                autocomplete="new-password"595              />596              <input597                class="peach-settings-input"598                type="password"599                placeholder="confirm new password"600                value={changePwConfirm()}601                onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}602                autocomplete="new-password"603                onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}604              />605              <Show when={changePwError()}>606                <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>607              </Show>608              <Show when={changePwSuccess()}>609                <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>610              </Show>611              <button612                class="peach-btn peach-btn--accent peach-btn--sm"613                style="align-self:flex-start"614                onClick={() => void handleChangePassword()}615                disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}616              >617                {changePwSaving() ? "saving…" : "save new password"}618              </button>619            </div>620          </Show>621622          <p class="peach-settings-sub-label">DURESS PASSWORD</p>623          <div class="peach-settings-row peach-settings-row--top">624            <div class="peach-settings-row-main">625              <span>Duress password</span>626              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">627                If entered under coercion, shows a decoy empty vault instead of your real one.628              </span>629            </div>630            <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>631              {duressEnabled() ? "on" : "off"}632            </div>633          </div>634635          <Show when={duressEnabled() && !showDuressSetup()}>636            <div class="peach-settings-row peach-settings-row--meta">637              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">638                If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.639              </span>640            </div>641          </Show>642643          <Show when={duressSuccess()}>644            <div class="peach-settings-row peach-settings-row--meta">645              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>646            </div>647          </Show>648649          <Show when={!showDuressSetup()}>650            <div class="peach-settings-row peach-settings-row--meta">651              <button652                class="peach-btn peach-btn--subtle peach-btn--sm"653                onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}654              >655                {duressEnabled() ? "Change / disable" : "Set up"}656              </button>657            </div>658          </Show>659660          <Show when={showDuressSetup()}>661            <div class="peach-duress-form">662              <label class="peach-label">main password (to confirm)</label>663              <input664                class="peach-settings-input"665                type="password"666                placeholder="your current master password"667                autocomplete="current-password"668                value={duressMainPassword()}669                onInput={(e) => setDuressMainPassword(e.currentTarget.value)}670              />671              <Show when={!duressEnabled()}>672                <label class="peach-label">new duress password</label>673                <input674                  class="peach-settings-input"675                  type="password"676                  placeholder="duress password (min 8 chars)"677                  autocomplete="new-password"678                  value={duressPassword()}679                  onInput={(e) => setDuressPassword(e.currentTarget.value)}680                />681                <label class="peach-label">confirm duress password</label>682                <input683                  class="peach-settings-input"684                  type="password"685                  placeholder="confirm duress password"686                  autocomplete="new-password"687                  value={duressConfirm()}688                  onInput={(e) => setDuressConfirm(e.currentTarget.value)}689                />690              </Show>691692              <Show when={duressError()}>693                <p class="peach-hint peach-hint-error">{duressError()}</p>694              </Show>695696              <div class="peach-duress-actions">697                <button698                  class="peach-btn peach-btn--subtle peach-btn--sm"699                  onClick={() => { setShowDuressSetup(false); setDuressError(null); }}700                >701                  cancel702                </button>703                <Show when={duressEnabled()}>704                  <button705                    class="peach-btn peach-btn--ghost peach-btn--sm"706                    style="color:var(--peach-breach)"707                    onClick={() => void handleDisableDuress()}708                    disabled={duressSaving()}709                  >710                    {duressSaving() ? "disabling..." : "disable duress"}711                  </button>712                </Show>713                <Show when={!duressEnabled()}>714                  <button715                    class="peach-btn peach-btn--accent peach-btn--sm"716                    onClick={() => void handleSetupDuress()}717                    disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}718                  >719                    {duressSaving() ? "saving..." : "save"}720                  </button>721                </Show>722              </div>723            </div>724          </Show>725726          <p class="peach-settings-sub-label">PASSKEYS</p>727          <div class="peach-settings-row peach-settings-row--top">728            <div class="peach-settings-row-main">729              <span>Intercept passkey requests</span>730              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">731                Peach can handle passkey sign-in and creation on supported Chromium browsers.732              </span>733            </div>734            <button735              class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}736              onClick={() => void handleTogglePasskeys()}737              aria-label="Toggle passkey interception"738              disabled={!passkeyState()?.supported || passkeySaving()}739            >740              <span class="peach-toggle-knob"></span>741            </button>742          </div>743          <div class="peach-settings-row peach-settings-row--meta">744            <span>Status</span>745            <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>746          </div>747          <div class="peach-settings-row peach-settings-row--meta">748            <span>Browser</span>749            <span class="peach-settings-value">750              {passkeyState()?.browserLabel ?? "Unknown"}751              {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}752            </span>753          </div>754          <div class="peach-info-notice" style="margin-top:8px">755            Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.756          </div>757          <Show when={passkeyState()?.conflict}>758            <div class="peach-settings-row peach-settings-row--meta">759              <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>760            </div>761          </Show>762        </div>763      </div>764    );765  }766767  function renderVault() {768    return (769      <div class="peach-settings-body">770        <div class="peach-settings-group">771          <p class="peach-settings-sub-label">ENTRIES</p>772          <div class="peach-settings-row peach-settings-row--meta">773            <span>Entries</span>774            <span class="peach-settings-value">{props.entriesCount}</span>775          </div>776          <div class="peach-settings-row peach-settings-row--meta">777            <span>Duplicates</span>778            <Show when={dedupDone() !== null} fallback={779              <button780                class="peach-btn peach-btn--subtle peach-btn--sm"781                onClick={() => void handleCountDuplicates()}782                disabled={dedupRunning()}783              >784                {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}785              </button>786            }>787              <span class="peach-settings-value" style="color:var(--peach-sync-ok)">788                {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}789              </span>790            </Show>791          </div>792          <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>793            <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">794              <div class="peach-restore-warning">795                {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.796              </div>797              <div style="display:flex;gap:6px">798                <button799                  class="peach-btn peach-btn--subtle peach-btn--sm"800                  onClick={() => setDedupCount(null)}801                >802                  cancel803                </button>804                <button805                  class="peach-btn peach-btn--accent peach-btn--sm"806                  onClick={() => void handleDeduplicate()}807                  disabled={dedupRunning()}808                >809                  {dedupRunning() ? "removing..." : "remove duplicates"}810                </button>811              </div>812            </div>813          </Show>814815          <p class="peach-settings-sub-label">MAINTENANCE</p>816          <div class="peach-settings-row peach-settings-row--meta">817            <span>Trash auto-delete</span>818            <select819              class="peach-settings-select"820              value={String(props.trashRetentionDays)}821              onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}822            >823              <option value="7">7 days</option>824              <option value="30">30 days</option>825              <option value="90">90 days</option>826              <option value="0">Never</option>827            </select>828          </div>829          <div class="peach-settings-row peach-settings-row--meta">830            <span>Vault Health</span>831            <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>832              open833            </button>834          </div>835          <Show when={declinedCardCount() > 0}>836            <div class="peach-settings-row peach-settings-row--meta">837              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>838              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>839                Clear840              </button>841            </div>842          </Show>843844          <p class="peach-settings-sub-label">DATA</p>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" style="flex-direction:column;align-items:stretch;gap:6px">858            <span>Replace vault from backup</span>859            <div class="peach-restore-warning" style="margin-top:2px">860              This will permanently replace your current vault with the backup.861            </div>862            <div style="display:flex;gap:6px">863              <button864                class="peach-btn peach-btn--subtle peach-btn--sm"865                onClick={() => props.onOpenCJKImport?.()}866              >867                CJK phrase868              </button>869              <button870                class="peach-btn peach-btn--subtle peach-btn--sm"871                onClick={() => props.onOpenFileImport?.()}872              >873                backup file874              </button>875            </div>876          </div>877        </div>878      </div>879    );880  }881882  function renderSync() {883    if (!isPro()) {884      return (885        <div class="peach-settings-body">886          <div class="peach-settings-group" style="position:relative">887            <div class="peach-sync-pro-gate">888              <div class="peach-sync-pro-gate-blur">889                <div class="peach-settings-row">890                  <span>Status</span>891                  <span class="peach-settings-value peach-settings-value--status">892                    <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>893                    {getStatusLabel(syncStatus())}894                  </span>895                </div>896                <div class="peach-settings-row peach-settings-row--meta">897                  <span>Last sync</span>898                  <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>899                </div>900              </div>901              <div class="peach-sync-pro-gate-overlay">902                <span class="peach-sync-pro-badge">Pro</span>903                <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">904                  Upgrade to Pro →905                </a>906              </div>907            </div>908          </div>909        </div>910      );911    }912913    return (914      <div class="peach-settings-body">915        <div class="peach-settings-group">916          <div class="peach-settings-row">917            <span>Status</span>918            <span class="peach-settings-value peach-settings-value--status">919              <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>920              {getStatusLabel(syncStatus())}921            </span>922          </div>923          <div class="peach-settings-row peach-settings-row--meta">924            <span>Last sync</span>925            <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>926          </div>927          <div class="peach-settings-row peach-settings-row--meta">928            <button929              class="peach-btn peach-btn--subtle peach-btn--sm"930              onClick={() => void handleSyncNow()}931              disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}932            >933              {syncingNow() ? "Syncing..." : "Sync now"}934            </button>935          </div>936937          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">938            <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>939            {pairedDevices().map((device) => (940              <div class="peach-sync-device-row">941                <div class="peach-sync-device-info">942                  <span class="peach-sync-device-name">{device.displayName}</span>943                  <span class="peach-sync-device-meta">944                    Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}945                  </span>946                </div>947                <button948                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"949                  onClick={() => void handleRemoveDevice(device.deviceId)}950                >951                  Remove952                </button>953              </div>954            ))}955            <div class="peach-settings-actions--row">956              <button957                class="peach-btn peach-btn--subtle peach-btn--sm"958                onClick={() => props.onOpenSyncPairing?.("initiate")}959              >960                Pair a new device961              </button>962              <button963                class="peach-btn peach-btn--subtle peach-btn--sm"964                onClick={() => props.onOpenSyncPairing?.("accept")}965              >966                Pair with another device967              </button>968            </div>969          </div>970971          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">972            <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>973            {trustedNetworks().filter(n => !n.isTailscale).map((network) => (974              <div class="peach-sync-device-row">975                <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>976                <button977                  class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"978                  onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}979                >980                  Remove981                </button>982              </div>983            ))}984            <div class="peach-settings-actions--row">985              <Show when={!isCurrentNetworkTrusted()}>986                <button987                  class="peach-btn peach-btn--subtle peach-btn--sm"988                  onClick={() => void handleMarkNetworkTrusted()}989                >990                  Mark current network as trusted991                </button>992              </Show>993            </div>994            <span class="peach-sync-device-meta">995              Tailscale is always trusted996            </span>997          </div>998        </div>999      </div>1000    );1001  }10021003  function renderAppearance() {1004    return (1005      <div class="peach-settings-body">1006        <div class="peach-settings-group">1007          <p class="peach-settings-sub-label">THEME</p>1008          <div class="peach-settings-row peach-settings-row--meta">1009            <span>Theme</span>1010            <button1011              class="peach-btn peach-btn--subtle peach-btn--sm"1012              onClick={() =>1013                props.onThemeChange(props.theme === "dark" ? "light" : "dark")1014              }1015            >1016              {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1017            </button>1018          </div>10191020          <p class="peach-settings-sub-label">ACCENT</p>1021          <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1022            <span>Accent</span>1023            <div class="peach-accent-picker">1024              <For each={ACCENT_COLORS}>1025                {(color) => (1026                  <button1027                    class="peach-accent-swatch"1028                    classList={{ "is-active": props.accentColor === color.name }}1029                    style={{ background: color.hex }}1030                    onClick={() => void props.onAccentChange(color.name)}1031                    aria-label={color.name}1032                    title={color.name}1033                  />1034                )}1035              </For>1036            </div>1037          </div>1038        </div>1039      </div>1040    );1041  }10421043  function renderShortcuts() {1044    return (1045      <div class="peach-settings-body">1046        <div class="peach-settings-group">1047          <div class="peach-settings-row peach-settings-row--meta">1048            <span>Inline popup shortcut</span>1049            <a1050              class="peach-settings-link"1051              href="chrome://extensions/shortcuts"1052              onClick={(e) => {1053                e.preventDefault();1054                chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1055              }}1056            >1057              Ctrl+Shift+Space — customize ↗1058            </a>1059          </div>1060          <div class="peach-shortcuts-grid">1061            <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>1062            <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>1063            <span class="peach-shortcut-key">/</span><span class="peach-shortcut-desc">search vault</span>1064            <span class="peach-shortcut-key">↑ ↓</span><span class="peach-shortcut-desc">navigate entries</span>1065            <span class="peach-shortcut-key">Esc</span><span class="peach-shortcut-desc">back / close</span>1066            <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>1067          </div>1068        </div>1069      </div>1070    );1071  }10721073  function renderLicense() {1074    return (1075      <div class="peach-settings-body">1076        <div class="peach-settings-group">1077          <p class="peach-settings-sub-label">PLAN</p>1078          <Show1079            when={licenseInfo()?.valid && licenseInfo()?.plan === "pro"}1080            fallback={1081              <>1082                <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">1083                  <input1084                    class="peach-underline-input"1085                    type="text"1086                    placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1087                    value={licenseKeyInput()}1088                    onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1089                    style="width:100%;box-sizing:border-box"1090                  />1091                  <button1092                    class="peach-btn peach-btn--subtle peach-btn--sm"1093                    onClick={() => void handleActivateLicense()}1094                    disabled={activatingLicense() || !licenseKeyInput().trim()}1095                  >1096                    {activatingLicense() ? "activating..." : "Activate"}1097                  </button>1098                </div>1099                <Show when={licenseError()}>1100                  <div class="peach-settings-row peach-settings-row--meta">1101                    <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>1102                  </div>1103                </Show>1104                <Show when={licenseSuccess()}>1105                  <div class="peach-settings-row peach-settings-row--meta">1106                    <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>1107                  </div>1108                </Show>1109                <div class="peach-settings-row peach-settings-row--meta">1110                  <a1111                    href="https://peachpasswords.com/#pricing"1112                    target="_blank"1113                    rel="noopener noreferrer"1114                    class="peach-link"1115                    style="color:var(--peach-accent)"1116                  >1117                    Purchase →1118                  </a>1119                </div>1120              </>1121            }1122          >1123            <div class="peach-settings-row peach-settings-row--meta">1124              <span>Peach Pro</span>1125              <span class="peach-settings-value">1126                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}1127              </span>1128            </div>1129          </Show>1130        </div>1131      </div>1132    );1133  }11341135  function renderFeedback() {1136    return (1137      <div class="peach-settings-body">1138        <div class="peach-settings-group">1139          <p class="peach-feedback-intro">1140            Help us improve Peach. Every report goes directly to our inbox.1141          </p>11421143          <p class="peach-settings-sub-label">TYPE</p>1144          <div class="peach-feedback-chips">1145            <For each={FEEDBACK_CATEGORIES}>1146              {(cat) => (1147                <button1148                  class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1149                  onClick={() => setFbCategory(cat)}1150                >1151                  {cat}1152                </button>1153              )}1154            </For>1155          </div>11561157          <p class="peach-settings-sub-label">MESSAGE</p>1158          <textarea1159            class="peach-feedback-textarea"1160            placeholder="Describe the issue or idea..."1161            value={fbMessage()}1162            onInput={(e) => setFbMessage(e.currentTarget.value)}1163            rows={5}1164          />11651166          <p class="peach-feedback-meta">1167            Version {version} · will be included automatically1168          </p>11691170          <Show when={fbResult() === "success"}>1171            <p class="peach-feedback-result peach-feedback-result--ok">1172              Sent — thank you1173            </p>1174          </Show>1175          <Show when={fbResult() === "error"}>1176            <p class="peach-feedback-result peach-feedback-result--err">1177              Failed to send — try again1178            </p>1179          </Show>11801181          <button1182            class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1183            onClick={() => void handleSubmitFeedback()}1184            disabled={fbSubmitting() || !fbMessage().trim()}1185          >1186            {fbSubmitting() ? "Sending..." : "Send feedback"}1187          </button>11881189          <p class="peach-feedback-anon">1190            Sent anonymously · no account required1191          </p>1192        </div>1193      </div>1194    );1195  }11961197  return (1198    <div class="peach-overlay-view ov-enter-right" style="display:flex">1199      <div class="peach-overlay-header">1200        <button1201          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1202          onClick={handleHeaderBack}1203          aria-label="Back"1204        >1205          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1206            <path d="M6 1L1 6L6 11" />1207          </svg>1208        </button>1209        <span class="peach-overlay-title">{currentTitle()}</span>1210        <div class="peach-overlay-spacer"></div>1211      </div>12121213      <Switch fallback={renderHome()}>1214        <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1215        <Match when={activeSection() === "vault"}>{renderVault()}</Match>1216        <Match when={activeSection() === "sync"}>{renderSync()}</Match>1217        <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1218        <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1219        <Match when={activeSection() === "license"}>{renderLicense()}</Match>1220        <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1221      </Switch>1222    </div>1223  );1224}