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 57 const [dedupCount, setDedupCount] = createSignal<number | null>(null);58 const [dedupRunning, setDedupRunning] = createSignal(false);59 const [dedupDone, setDedupDone] = createSignal<number | null>(null);6061 62 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);6364 65 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 95 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 273 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 291 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 {}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 {}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 {}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 {}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 {}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}