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 [showChangePw, setShowChangePw] = createSignal(false);63 const [changePwCurrent, setChangePwCurrent] = createSignal("");64 const [changePwNew, setChangePwNew] = createSignal("");65 const [changePwConfirm, setChangePwConfirm] = createSignal("");66 const [changePwSaving, setChangePwSaving] = createSignal(false);67 const [changePwError, setChangePwError] = createSignal<string | null>(null);68 const [changePwSuccess, setChangePwSuccess] = createSignal(false);6970 async function handleChangePassword() {71 setChangePwError(null);72 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }73 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }74 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }75 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }76 setChangePwSaving(true);77 const res = await sendMessage({78 type: "CHANGE_PASSWORD",79 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },80 }) as { ok: boolean; error?: string };81 if (res.ok) {82 setChangePwSuccess(true);83 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");84 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);85 } else {86 setChangePwError(res.error ?? "failed to change password");87 }88 setChangePwSaving(false);89 }9091 92 const [duressEnabled, setDuressEnabled] = createSignal(false);93 const [showDuressSetup, setShowDuressSetup] = createSignal(false);94 const [duressPassword, setDuressPassword] = createSignal("");95 const [duressConfirm, setDuressConfirm] = createSignal("");96 const [duressMainPassword, setDuressMainPassword] = createSignal("");97 const [duressSaving, setDuressSaving] = createSignal(false);98 const [duressError, setDuressError] = createSignal<string | null>(null);99 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);100101 onMount(async () => {102 const [licenseRes, syncRes, passkeyRes, duressRes] = await Promise.all([103 sendMessage({ type: "GET_LICENSE" }),104 sendMessage({ type: "GET_SYNC_STATE" }),105 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),106 sendMessage({ type: "IS_DURESS_ENABLED" }),107 ]);108109 if ((licenseRes as { ok: boolean }).ok) {110 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);111 }112113 if ((syncRes as { ok: boolean }).ok) {114 const data = syncRes as { ok: boolean; data?: {115 pairedDevices?: PairedDevice[];116 trustedNetworks?: TrustedNetwork[];117 status?: SyncStatus;118 lastSyncAt?: number;119 isPro?: boolean;120 }};121 if (data.data) {122 setPairedDevices(data.data.pairedDevices ?? []);123 setTrustedNetworks(data.data.trustedNetworks ?? []);124 setSyncStatus(data.data.status ?? "offline");125 setLastSyncAt(data.data.lastSyncAt);126 setIsPro(data.data.isPro ?? false);127 }128 }129130 if ((passkeyRes as { ok: boolean }).ok) {131 setPasskeyState(132 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null133 );134 }135136 if ((duressRes as { ok: boolean }).ok) {137 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);138 }139140 chrome.runtime.onMessage.addListener((msg) => {141 if (msg.type === "SYNC_STATE_UPDATE") {142 setPairedDevices(msg.pairedDevices ?? []);143 setTrustedNetworks(msg.trustedNetworks ?? []);144 setSyncStatus(msg.status ?? "offline");145 setLastSyncAt(msg.lastSyncAt);146 setIsPro(msg.isPro ?? false);147 }148 });149 });150151 async function handleActivateLicense() {152 const key = licenseKeyInput().trim();153 if (!key) return;154 setActivatingLicense(true);155 setLicenseError(null);156 setLicenseSuccess(null);157 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });158 setActivatingLicense(false);159 if ((res as { ok: boolean }).ok) {160 const data = (res as { ok: boolean; data?: LicenseInfo }).data;161 if (data?.valid) {162 setLicenseInfo(data);163 setLicenseSuccess("License activated!");164 setLicenseKeyInput("");165 } else {166 setLicenseError("Invalid license key");167 }168 } else {169 setLicenseError("Activation failed");170 }171 }172173 async function handleTogglePasskeys() {174 const current = passkeyState();175 if (!current || !current.supported) return;176 setPasskeySaving(true);177 const res = await sendMessage({178 type: "SET_PASSKEY_PROVIDER_ENABLED",179 payload: { enabled: !current.enabled },180 });181 setPasskeySaving(false);182 if ((res as { ok: boolean }).ok) {183 setPasskeyState(184 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null185 );186 }187 }188189 function maskLicenseKey(key: string): string {190 if (key.length < 10) return key.slice(0, 4) + "****";191 return key.slice(0, 6) + "****" + key.slice(-4);192 }193194 async function handleCheckAllBreaches() {195 setCheckingBreaches(true);196 setBreachResult(null);197 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });198 setCheckingBreaches(false);199 if ((res as { ok: boolean }).ok) {200 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};201 const breached = Object.values(data).filter((count) => count > 0).length;202 setBreachResult(203 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"204 );205 props.onBreachCheckDone();206 }207 }208209 async function handleSyncNow() {210 if (syncStatus() === "not_trusted") return;211 setSyncingNow(true);212 await sendMessage({ type: "TRIGGER_SYNC" });213 setTimeout(() => setSyncingNow(false), 3000);214 }215216 async function handleRemoveDevice(deviceId: string) {217 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });218 }219220 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {221 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });222 }223224 async function handleMarkNetworkTrusted() {225 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });226 }227228 function formatRelativeTime(timestamp?: number): string {229 if (!timestamp) return "Never";230 const diff = Date.now() - timestamp;231 const minutes = Math.floor(diff / 60000);232 const hours = Math.floor(diff / 3600000);233 const days = Math.floor(diff / 86400000);234 if (minutes < 1) return "Just now";235 if (minutes < 60) return `${minutes}m ago`;236 if (hours < 24) return `${hours}h ago`;237 return `${days}d ago`;238 }239240 function getStatusColor(status: SyncStatus): string {241 switch (status) {242 case "synced": return "var(--peach-sync-ok)";243 case "syncing": return "var(--peach-sync-busy)";244 case "no_peers": return "var(--peach-sync-off)";245 case "not_trusted": return "var(--peach-sync-err)";246 case "offline": return "var(--peach-sync-off)";247 default: return "var(--peach-sync-off)";248 }249 }250251 function getStatusLabel(status: SyncStatus): string {252 switch (status) {253 case "synced": return "Synced";254 case "syncing": return "Syncing";255 case "no_peers": return "No peers visible";256 case "not_trusted": return "Not on a trusted network";257 case "offline": return "Offline";258 default: return "Unknown";259 }260 }261262 function isCurrentNetworkTrusted(): boolean {263 264 265 return trustedNetworks().length > 0;266 }267268 async function handleSetupDuress() {269 const mainPw = duressMainPassword().trim();270 const duressPw = duressPassword().trim();271 const confirmPw = duressConfirm().trim();272273 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }274 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }275 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }276 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }277278 setDuressSaving(true);279 setDuressError(null);280281 282 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };283 if (!verifyRes.ok || !verifyRes.data) {284 setDuressError("incorrect main password");285 setDuressSaving(false);286 return;287 }288289 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };290 setDuressSaving(false);291 if (res.ok) {292 setDuressEnabled(true);293 setShowDuressSetup(false);294 setDuressPassword("");295 setDuressConfirm("");296 setDuressMainPassword("");297 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");298 setTimeout(() => setDuressSuccess(null), 5000);299 } else {300 setDuressError(res.error ?? "failed to set duress password");301 }302 }303304 async function handleCountDuplicates() {305 setDedupRunning(true);306 setDedupDone(null);307 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };308 setDedupRunning(false);309 if (res.ok) setDedupCount(res.data ?? 0);310 }311312 async function handleDeduplicate() {313 setDedupRunning(true);314 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };315 setDedupRunning(false);316 if (res.ok) {317 setDedupDone(res.data ?? 0);318 setDedupCount(0);319 }320 }321322 async function handleDisableDuress() {323 const mainPw = duressMainPassword().trim();324 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }325326 setDuressSaving(true);327 setDuressError(null);328329 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };330 if (!verifyRes.ok || !verifyRes.data) {331 setDuressError("incorrect main password");332 setDuressSaving(false);333 return;334 }335336 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };337 setDuressSaving(false);338 if (res.ok) {339 setDuressEnabled(false);340 setShowDuressSetup(false);341 setDuressMainPassword("");342 setDuressSuccess("Duress password disabled.");343 setTimeout(() => setDuressSuccess(null), 4000);344 } else {345 setDuressError(res.error ?? "failed to disable duress password");346 }347 }348349 function renderSyncSection() {350 if (!isPro()) {351 return (352 <div class="peach-settings-group" style="position:relative">353 <p class="peach-settings-label">sync</p>354 <div class="peach-sync-pro-gate">355 <div class="peach-sync-pro-gate-blur">356 <div class="peach-settings-row">357 <span>Status</span>358 <span class="peach-settings-value peach-settings-value--status">359 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>360 {getStatusLabel(syncStatus())}361 </span>362 </div>363 <div class="peach-settings-row peach-settings-row--meta">364 <span>Last sync</span>365 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>366 </div>367 </div>368 <div class="peach-sync-pro-gate-overlay">369 <span class="peach-sync-pro-badge">Pro</span>370 <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:var(--peach-accent);font-size:13px;font-weight:500">371 Upgrade to Pro →372 </a>373 </div>374 </div>375 </div>376 );377 }378379 return (380 <div class="peach-settings-group">381 <p class="peach-settings-label">sync</p>382 <div class="peach-settings-row">383 <span>Status</span>384 <span class="peach-settings-value peach-settings-value--status">385 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>386 {getStatusLabel(syncStatus())}387 </span>388 </div>389 <div class="peach-settings-row peach-settings-row--meta">390 <span>Last sync</span>391 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>392 </div>393 <div class="peach-settings-row peach-settings-row--meta">394 <button395 class="peach-btn peach-btn--subtle peach-btn--sm"396 onClick={() => void handleSyncNow()}397 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}398 >399 {syncingNow() ? "Syncing..." : "Sync now"}400 </button>401 </div>402403 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">404 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>405 {pairedDevices().map((device) => (406 <div class="peach-sync-device-row">407 <div class="peach-sync-device-info">408 <span class="peach-sync-device-name">{device.displayName}</span>409 <span class="peach-sync-device-meta">410 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}411 </span>412 </div>413 <button414 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"415 onClick={() => void handleRemoveDevice(device.deviceId)}416 >417 Remove418 </button>419 </div>420 ))}421 <div class="peach-settings-actions--row">422 <button423 class="peach-btn peach-btn--subtle peach-btn--sm"424 onClick={() => props.onOpenSyncPairing?.("initiate")}425 >426 Pair a new device427 </button>428 <button429 class="peach-btn peach-btn--subtle peach-btn--sm"430 onClick={() => props.onOpenSyncPairing?.("accept")}431 >432 Pair with another device433 </button>434 </div>435 </div>436437 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">438 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>439 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (440 <div class="peach-sync-device-row">441 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>442 <button443 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"444 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}445 >446 Remove447 </button>448 </div>449 ))}450 <div class="peach-settings-actions--row">451 {!isCurrentNetworkTrusted() && (452 <button453 class="peach-btn peach-btn--subtle peach-btn--sm"454 onClick={() => void handleMarkNetworkTrusted()}455 >456 Mark current network as trusted457 </button>458 )}459 </div>460 <span class="peach-sync-device-meta">461 Tailscale is always trusted462 </span>463 </div>464 </div>465 );466 }467468 return (469 <div class="peach-overlay-view ov-enter-right" style="display:flex">470 <div class="peach-overlay-header">471 <button472 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"473 onClick={props.onBack}474 aria-label="Back"475 >476 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">477 <path d="M6 1L1 6L6 11" />478 </svg>479 </button>480 <span class="peach-overlay-title">settings</span>481 <div class="peach-overlay-spacer"></div>482 </div>483484 <div class="peach-settings-body">485 <div class="peach-settings-group">486 <p class="peach-settings-label">security</p>487 <div class="peach-settings-row">488 <span>Lock on close</span>489 <button490 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}491 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}492 aria-label="Toggle lock on close"493 >494 <span class="peach-toggle-knob"></span>495 </button>496 </div>497 <div class="peach-settings-row">498 <span>Auto-lock after</span>499 <select500 class="peach-settings-select"501 value={String(props.autoLockMinutes)}502 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}503 >504 <option value="1">1 minute</option>505 <option value="5">5 minutes</option>506 <option value="15">15 minutes</option>507 <option value="30">30 minutes</option>508 <option value="0">Never</option>509 </select>510 </div>511 <div class="peach-settings-row">512 <span>Breach monitoring</span>513 <button514 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}515 onClick={() =>516 void props.onBreachMonitoringChange(!props.breachMonitoring)517 }518 aria-label="Toggle breach monitoring"519 >520 <span class="peach-toggle-knob"></span>521 </button>522 </div>523 <div class="peach-settings-row peach-settings-row--meta">524 <span>Check all passwords</span>525 <button526 class="peach-btn peach-btn--subtle peach-btn--sm"527 onClick={() => void handleCheckAllBreaches()}528 disabled={checkingBreaches()}529 >530 {checkingBreaches() ? "checking..." : "run"}531 </button>532 </div>533 {breachResult() && (534 <div class="peach-settings-row peach-settings-row--meta">535 <span>Last check</span>536 <span class="peach-settings-value">{breachResult()}</span>537 </div>538 )}539 {}540 <div class="peach-settings-row peach-settings-row--meta">541 <span>Master password</span>542 <button543 class="peach-btn peach-btn--subtle peach-btn--sm"544 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}545 >546 {showChangePw() ? "cancel" : "change"}547 </button>548 </div>549 <Show when={showChangePw()}>550 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">551 <input552 class="peach-settings-input"553 type="password"554 placeholder="current password"555 value={changePwCurrent()}556 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}557 autocomplete="current-password"558 />559 <input560 class="peach-settings-input"561 type="password"562 placeholder="new password (min 12 chars)"563 value={changePwNew()}564 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}565 autocomplete="new-password"566 />567 <input568 class="peach-settings-input"569 type="password"570 placeholder="confirm new password"571 value={changePwConfirm()}572 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}573 autocomplete="new-password"574 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}575 />576 <Show when={changePwError()}>577 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>578 </Show>579 <Show when={changePwSuccess()}>580 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>581 </Show>582 <button583 class="peach-btn peach-btn--accent peach-btn--sm"584 style="align-self:flex-start"585 onClick={() => void handleChangePassword()}586 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}587 >588 {changePwSaving() ? "saving…" : "save new password"}589 </button>590 </div>591 </Show>592 </div>593594 {}595 <div class="peach-settings-group">596 <p class="peach-settings-label">keyboard shortcuts</p>597 <div class="peach-settings-row peach-settings-row--meta">598 <span>Inline popup shortcut</span>599 <a600 class="peach-settings-link"601 href="chrome://extensions/shortcuts"602 onClick={(e) => {603 e.preventDefault();604 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });605 }}606 >607 Ctrl+Shift+Space — customize ↗608 </a>609 </div>610 </div>611612 {}613 <div class="peach-settings-group">614 <p class="peach-settings-label">duress password</p>615 <div class="peach-settings-row peach-settings-row--top">616 <div class="peach-settings-row-main">617 <span>Duress password</span>618 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">619 If entered under coercion, shows a decoy empty vault instead of your real one.620 </span>621 </div>622 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>623 {duressEnabled() ? "on" : "off"}624 </div>625 </div>626627 <Show when={duressEnabled() && !showDuressSetup()}>628 <div class="peach-settings-row peach-settings-row--meta">629 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">630 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.631 </span>632 </div>633 </Show>634635 <Show when={duressSuccess()}>636 <div class="peach-settings-row peach-settings-row--meta">637 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>638 </div>639 </Show>640641 <Show when={!showDuressSetup()}>642 <div class="peach-settings-row peach-settings-row--meta">643 <button644 class="peach-btn peach-btn--subtle peach-btn--sm"645 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}646 >647 {duressEnabled() ? "Change / disable" : "Set up"}648 </button>649 </div>650 </Show>651652 <Show when={showDuressSetup()}>653 <div class="peach-duress-form">654 <label class="peach-label">main password (to confirm)</label>655 <input656 class="peach-settings-input"657 type="password"658 placeholder="your current master password"659 autocomplete="current-password"660 value={duressMainPassword()}661 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}662 />663 <Show when={!duressEnabled()}>664 <label class="peach-label">new duress password</label>665 <input666 class="peach-settings-input"667 type="password"668 placeholder="duress password (min 8 chars)"669 autocomplete="new-password"670 value={duressPassword()}671 onInput={(e) => setDuressPassword(e.currentTarget.value)}672 />673 <label class="peach-label">confirm duress password</label>674 <input675 class="peach-settings-input"676 type="password"677 placeholder="confirm duress password"678 autocomplete="new-password"679 value={duressConfirm()}680 onInput={(e) => setDuressConfirm(e.currentTarget.value)}681 />682 </Show>683684 <Show when={duressError()}>685 <p class="peach-hint peach-hint-error">{duressError()}</p>686 </Show>687688 <div class="peach-duress-actions">689 <button690 class="peach-btn peach-btn--subtle peach-btn--sm"691 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}692 >693 cancel694 </button>695 <Show when={duressEnabled()}>696 <button697 class="peach-btn peach-btn--ghost peach-btn--sm"698 style="color:var(--peach-breach)"699 onClick={() => void handleDisableDuress()}700 disabled={duressSaving()}701 >702 {duressSaving() ? "disabling..." : "disable duress"}703 </button>704 </Show>705 <Show when={!duressEnabled()}>706 <button707 class="peach-btn peach-btn--accent peach-btn--sm"708 onClick={() => void handleSetupDuress()}709 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}710 >711 {duressSaving() ? "saving..." : "save"}712 </button>713 </Show>714 </div>715 </div>716 </Show>717 </div>718719 <div class="peach-settings-group">720 <p class="peach-settings-label">passkeys</p>721 <div class="peach-settings-row peach-settings-row--top">722 <div class="peach-settings-row-main">723 <span>Intercept passkey requests</span>724 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">725 Peach can handle passkey sign-in and creation on supported Chromium browsers.726 </span>727 </div>728 <button729 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}730 onClick={() => void handleTogglePasskeys()}731 aria-label="Toggle passkey interception"732 disabled={!passkeyState()?.supported || passkeySaving()}733 >734 <span class="peach-toggle-knob"></span>735 </button>736 </div>737 <div class="peach-settings-row peach-settings-row--meta">738 <span>Status</span>739 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>740 </div>741 <div class="peach-settings-row peach-settings-row--meta">742 <span>Browser</span>743 <span class="peach-settings-value">744 {passkeyState()?.browserLabel ?? "Unknown"}745 {passkeyState()?.supported ? " \u2713" : " (interception unavailable)"}746 </span>747 </div>748 <div class="peach-info-notice" style="margin-top:8px">749 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.750 </div>751 {passkeyState()?.conflict && (752 <div class="peach-settings-row peach-settings-row--meta">753 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>754 </div>755 )}756 </div>757758 {renderSyncSection()}759760 <div class="peach-settings-group">761 <p class="peach-settings-label">vault</p>762 <div class="peach-settings-row peach-settings-row--meta">763 <span>Entries</span>764 <span class="peach-settings-value">{props.entriesCount}</span>765 </div>766 <div class="peach-settings-row peach-settings-row--meta">767 <span>Duplicates</span>768 <Show when={dedupDone() !== null} fallback={769 <button770 class="peach-btn peach-btn--subtle peach-btn--sm"771 onClick={() => void handleCountDuplicates()}772 disabled={dedupRunning()}773 >774 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}775 </button>776 }>777 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">778 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}779 </span>780 </Show>781 </div>782 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>783 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">784 <div class="peach-restore-warning">785 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.786 </div>787 <div style="display:flex;gap:6px">788 <button789 class="peach-btn peach-btn--subtle peach-btn--sm"790 onClick={() => setDedupCount(null)}791 >792 cancel793 </button>794 <button795 class="peach-btn peach-btn--accent peach-btn--sm"796 onClick={() => void handleDeduplicate()}797 disabled={dedupRunning()}798 >799 {dedupRunning() ? "removing..." : "remove duplicates"}800 </button>801 </div>802 </div>803 </Show>804 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">805 <span>Accent</span>806 <div class="peach-accent-picker">807 <For each={ACCENT_COLORS}>808 {(color) => (809 <button810 class="peach-accent-swatch"811 classList={{ "is-active": props.accentColor === color.name }}812 style={{ background: color.hex }}813 onClick={() => void props.onAccentChange(color.name)}814 aria-label={color.name}815 title={color.name}816 />817 )}818 </For>819 </div>820 </div>821 <div class="peach-settings-row peach-settings-row--meta">822 <span>Theme</span>823 <button824 class="peach-btn peach-btn--subtle peach-btn--sm"825 onClick={() =>826 props.onThemeChange(props.theme === "dark" ? "light" : "dark")827 }828 >829 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}830 </button>831 </div>832 <div class="peach-settings-row peach-settings-row--meta">833 <span>Trash auto-delete</span>834 <select835 class="peach-settings-select"836 value={String(props.trashRetentionDays)}837 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}838 >839 <option value="7">7 days</option>840 <option value="30">30 days</option>841 <option value="90">90 days</option>842 <option value="0">Never</option>843 </select>844 </div>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">858 <span>Vault Health</span>859 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>860 open861 </button>862 </div>863 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">864 <span>Replace vault from backup</span>865 <div class="peach-restore-warning" style="margin-top:2px">866 This will permanently replace your current vault with the backup.867 </div>868 <div style="display:flex;gap:6px">869 <button870 class="peach-btn peach-btn--subtle peach-btn--sm"871 onClick={() => props.onOpenCJKImport?.()}872 >873 CJK phrase874 </button>875 <button876 class="peach-btn peach-btn--subtle peach-btn--sm"877 onClick={() => props.onOpenFileImport?.()}878 >879 backup file880 </button>881 </div>882 </div>883 </div>884885 <div class="peach-settings-group">886 <p class="peach-settings-label">license</p>887 {licenseInfo()?.valid && licenseInfo()?.plan === "pro" ? (888 <div class="peach-settings-row peach-settings-row--meta">889 <span>Peach Pro</span>890 <span class="peach-settings-value">891 activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}892 </span>893 </div>894 ) : (895 <>896 <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">897 <input898 class="peach-underline-input"899 type="text"900 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"901 value={licenseKeyInput()}902 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}903 style="width:100%;box-sizing:border-box"904 />905 <button906 class="peach-btn peach-btn--subtle peach-btn--sm"907 onClick={() => void handleActivateLicense()}908 disabled={activatingLicense() || !licenseKeyInput().trim()}909 >910 {activatingLicense() ? "activating..." : "Activate"}911 </button>912 </div>913 {licenseError() && (914 <div class="peach-settings-row peach-settings-row--meta">915 <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>916 </div>917 )}918 {licenseSuccess() && (919 <div class="peach-settings-row peach-settings-row--meta">920 <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>921 </div>922 )}923 <div class="peach-settings-row peach-settings-row--meta">924 <a925 href="https://peachpasswords.com/#pricing"926 target="_blank"927 rel="noopener noreferrer"928 class="peach-link"929 style="color:var(--peach-accent)"930 >931 Purchase →932 </a>933 </div>934 </>935 )}936 </div>937938 {}939 <div class="peach-settings-group">940 <p class="peach-settings-label">keyboard shortcuts</p>941 <div class="peach-shortcuts-grid">942 <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>943 <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>944 <span class="peach-shortcut-key">/</span><span class="peach-shortcut-desc">search vault</span>945 <span class="peach-shortcut-key">↑ ↓</span><span class="peach-shortcut-desc">navigate entries</span>946 <span class="peach-shortcut-key">Esc</span><span class="peach-shortcut-desc">back / close</span>947 <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>948 </div>949 </div>950951 <div class="peach-settings-footer">952 <span>Peach {version}</span>953 <span class="peach-divider"></span>954 <span>Browser extension</span>955 </div>956 </div>957 </div>958 );959}