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