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