1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import type { Canary } from "../../shared/types";7import { ACCENT_COLORS } from "../../shared/types";8import type {9 AccentColorName,10 PasskeyProviderState,11 ProFeature,12 VaultSnapshot,13} from "../../shared/types";14import {15 DEFAULT_BINDINGS,16 findConflict,17 formatKeybinding,18 keybindingFromEvent,19 SHORTCUT_DESCRIPTORS,20 type Keybinding,21 type ShortcutAction,22} from "../../shared/shortcuts";23import { isNativePlatform, nativePlugin } from "../../shared/native";2425interface LicenseInfo {26 valid: boolean;27 plan: "free" | "pro";28 issued?: string;29}3031interface SettingsProps {32 entriesCount: number;33 breachMonitoring: boolean;34 lockOnClose: boolean;35 autoLockMinutes: number;36 trashRetentionDays: number;37 theme: "dark" | "light";38 accentColor: AccentColorName;39 textSize: "small" | "medium" | "large";40 reduceMotion: boolean;41 isPro?: boolean;42 initialSection?: string | null;43 upgradeHighlight?: ProFeature | null;44 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;45 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;46 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;47 onTrashRetentionChange: (days: number) => void | Promise<void>;48 onThemeChange: (theme: "dark" | "light") => void;49 onAccentChange: (name: AccentColorName) => void | Promise<void>;50 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;51 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;52 shortcuts: Record<ShortcutAction, Keybinding>;53 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;54 onLicenseChange?: () => void | Promise<void>;55 onBack: () => void;56 onImport: () => void;57 onExport: () => void;58 onBreachCheckDone: () => void;59 onOpenHealth?: () => void;60 onOpenSyncPairing?: () => void;61 onOpenFileImport?: () => void;62 onOpenPanic?: () => void;63 onOpenCodexGenerate?: () => void;64 onOpenCodexRestore?: () => void;65 666768 onProGate?: (feature: ProFeature) => void;69}7071type SectionName =72 | "security"73 | "vault"74 | "appearance"75 | "shortcuts"76 | "license"77 | "feedback";7879const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];8081function formatActivatedSince(issued: string | undefined): string {82 if (!issued) return "Active";83 try {84 const d = new Date(issued);85 if (Number.isNaN(d.getTime())) return "Active";86 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;87 } catch {88 return "Active";89 }90}9192function isSectionName(value: string): value is SectionName {93 return (94 value === "security" ||95 value === "vault" ||96 value === "appearance" ||97 value === "shortcuts" ||98 value === "license" ||99 value === "feedback"100 );101}102103export function SettingsView(props: SettingsProps) {104 const manifest = chrome.runtime.getManifest();105 const version = manifest.version;106 const isAndroid = (manifest as any).version_name === 'mobile';107 const initial = props.initialSection;108 const [activeSection, setActiveSection] = createSignal<SectionName | null>(109 initial && isSectionName(initial) ? (initial as SectionName) : null110 );111112 const [checkingBreaches, setCheckingBreaches] = createSignal(false);113 const [breachResult, setBreachResult] = createSignal<string | null>(null);114 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);115 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");116 const [licenseError, setLicenseError] = createSignal<string | null>(null);117 const [, setLicenseSuccess] = createSignal<string | null>(null);118 const [activatingLicense, setActivatingLicense] = createSignal(false);119120 const isPro = () => props.isPro ?? false;121 const [biometricSupported, setBiometricSupported] = createSignal(false);122 const [biometricStored, setBiometricStored] = createSignal(false);123 const [biometricBusy, setBiometricBusy] = createSignal(false);124 const [biometricError, setBiometricError] = createSignal<string | null>(null);125 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);126 const [passkeySaving, setPasskeySaving] = createSignal(false);127128 const [dedupCount, setDedupCount] = createSignal<number | null>(null);129 const [dedupRunning, setDedupRunning] = createSignal(false);130 const [dedupDone, setDedupDone] = createSignal<number | null>(null);131132 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);133134 135 136 137 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);138 const [historyLoading, setHistoryLoading] = createSignal(false);139 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);140141 async function loadVaultHistory() {142 setHistoryLoading(true);143 try {144 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });145 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];146 setHistorySnapshots(list);147 } finally {148 setHistoryLoading(false);149 }150 }151152 async function handleRestoreVaultSnapshot(id: string) {153 if (historyRestoring()) return;154 setHistoryRestoring(id);155 const res = await sendMessage({156 type: "RESTORE_VAULT_SNAPSHOT",157 payload: { snapshotId: id },158 });159 setHistoryRestoring(null);160 if ((res as { ok: boolean }).ok) {161 162 163 props.onBack();164 }165 }166167 function formatSnapshotTime(savedAt: number): string {168 const diff = Date.now() - savedAt;169 if (diff < 60_000) return "Just now";170 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;171 const d = new Date(savedAt);172 const isToday = new Date().toDateString() === d.toDateString();173 const isYest =174 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();175 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });176 if (isToday) return `Today ${t}`;177 if (isYest) return `Yesterday ${t}`;178 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;179 }180181 function reasonLabel(reason: VaultSnapshot["reason"]): string {182 return (183 {184 local_save: "saved",185 sync_received: "synced",186 import: "imported",187 restore: "restored",188 } as const189 )[reason] ?? reason;190 }191192 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);193194 const [showChangePw, setShowChangePw] = createSignal(false);195 const [changePwCurrent, setChangePwCurrent] = createSignal("");196 const [changePwNew, setChangePwNew] = createSignal("");197 const [changePwConfirm, setChangePwConfirm] = createSignal("");198 const [changePwSaving, setChangePwSaving] = createSignal(false);199 const [changePwError, setChangePwError] = createSignal<string | null>(null);200 const [changePwSuccess] = createSignal(false);201 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);202203 const [duressEnabled, setDuressEnabled] = createSignal(false);204 const [showDuressSetup, setShowDuressSetup] = createSignal(false);205 const [duressPassword, setDuressPassword] = createSignal("");206 const [duressConfirm, setDuressConfirm] = createSignal("");207 const [duressMainPassword, setDuressMainPassword] = createSignal("");208 const [duressSaving, setDuressSaving] = createSignal(false);209 const [duressError, setDuressError] = createSignal<string | null>(null);210 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);211212 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);213214 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);215 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);216217 const [fbCategory, setFbCategory] = createSignal("Bug report");218 const [fbMessage, setFbMessage] = createSignal("");219 const [fbSubmitting, setFbSubmitting] = createSignal(false);220 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);221222 onMount(async () => {223 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([224 sendMessage({ type: "GET_LICENSE" }),225 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),226 sendMessage({ type: "IS_DURESS_ENABLED" }),227 sendMessage({ type: "GET_SETTINGS" }),228 sendMessage({ type: "GET_LICENSE_KEY" }),229 ]);230231 232 233 void loadVaultHistory();234235 if ((licenseRes as { ok: boolean }).ok) {236 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);237 }238239 if ((passkeyRes as { ok: boolean }).ok) {240 setPasskeyState(241 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null242 );243 }244245 if ((duressRes as { ok: boolean }).ok) {246 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);247 }248249 if ((settingsRes as { ok: boolean }).ok) {250 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;251 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);252 }253254 if ((keyRes as { ok: boolean }).ok) {255 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);256 }257258 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });259 if ((entriesRes as { ok: boolean }).ok) {260 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];261 setCanaryEntries(262 entries263 .filter((e) => e.canary?.webhookUrl)264 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))265 );266 }267268269 if (isNativePlatform()) {270 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");271 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");272 try {273 if (biometric) {274 const a = await biometric.isAvailable();275 setBiometricSupported(a.available);276 }277 if (keystore) {278 const k = await keystore.hasMasterKey();279 setBiometricStored(k.exists);280 }281 } catch { }282 }283 });284285 async function handleEnableBiometrics() {286 setBiometricBusy(true);287 setBiometricError(null);288 try {289 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {290 ok: boolean;291 data?: { masterKeyHex: string };292 error?: string;293 };294 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {295 setBiometricError(keyRes.error === "vault_locked"296 ? "unlock the vault first"297 : "couldn't read derived key");298 return;299 }300 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");301 if (!keystore) {302 setBiometricError("biometric plugin unavailable");303 return;304 }305 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });306 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });307 setBiometricStored(true);308 } catch (e) {309 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");310 } finally {311 setBiometricBusy(false);312 }313 }314315 async function handleDisableBiometrics() {316 setBiometricBusy(true);317 setBiometricError(null);318 try {319 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");320 if (keystore) await keystore.clearMasterKey();321 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });322 setBiometricStored(false);323 } catch (e) {324 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");325 } finally {326 setBiometricBusy(false);327 }328 }329330 async function handleChangePassword() {331 setChangePwError(null);332 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }333 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }334 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }335 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }336 setChangePwSaving(true);337 const res = await sendMessage({338 type: "CHANGE_PASSWORD",339 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },340 }) as { ok: boolean; error?: string };341 if (res.ok) {342 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");343 setShowChangePw(false);344 setShowNewRecoveryWords(true);345 } else {346 setChangePwError(res.error ?? "failed to change password");347 }348 setChangePwSaving(false);349 }350351 const [copied, setCopied] = createSignal(false);352353 async function copyLicenseKey(key: string) {354 await navigator.clipboard.writeText(key);355 setCopied(true);356 setTimeout(() => setCopied(false), 2000);357 }358359 async function handleActivateLicense() {360 const key = licenseKeyInput().trim();361 if (!key) return;362 setActivatingLicense(true);363 setLicenseError(null);364 setLicenseSuccess(null);365 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });366 setActivatingLicense(false);367 if ((res as { ok: boolean }).ok) {368 const data = (res as { ok: boolean; data?: LicenseInfo }).data;369 if (data?.valid) {370 setLicenseInfo(data);371 setLicenseKey(key);372 setLicenseSuccess("License activated!");373 setLicenseKeyInput("");374 } else {375 setLicenseError("Invalid license key");376 }377 } else {378 setLicenseError("Activation failed");379 }380 }381382 async function handleTogglePasskeys() {383 const current = passkeyState();384 if (!current || !current.supported) return;385 setPasskeySaving(true);386 const res = await sendMessage({387 type: "SET_PASSKEY_PROVIDER_ENABLED",388 payload: { enabled: !current.enabled },389 });390 setPasskeySaving(false);391 if ((res as { ok: boolean }).ok) {392 setPasskeyState(393 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null394 );395 }396 }397398 async function handleCheckAllBreaches() {399 setCheckingBreaches(true);400 setBreachResult(null);401 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });402 setCheckingBreaches(false);403 if ((res as { ok: boolean }).ok) {404 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};405 const breached = Object.values(data).filter((count) => count > 0).length;406 setBreachResult(407 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"408 );409 props.onBreachCheckDone();410 }411 }412413414 async function handleSetupDuress() {415 const mainPw = duressMainPassword().trim();416 const duressPw = duressPassword().trim();417 const confirmPw = duressConfirm().trim();418419 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }420 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }421 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }422 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }423424 setDuressSaving(true);425 setDuressError(null);426427 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };428 if (!verifyRes.ok || !verifyRes.data) {429 setDuressError("incorrect main password");430 setDuressSaving(false);431 return;432 }433434 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };435 setDuressSaving(false);436 if (res.ok) {437 setDuressEnabled(true);438 setShowDuressSetup(false);439 setDuressPassword("");440 setDuressConfirm("");441 setDuressMainPassword("");442 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");443 setTimeout(() => setDuressSuccess(null), 5000);444 } else {445 setDuressError(res.error ?? "failed to set duress password");446 }447 }448449 async function handleClearDeclinedCards() {450 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });451 setDeclinedCardCount(0);452 }453454 async function handleCountDuplicates() {455 setDedupRunning(true);456 setDedupDone(null);457 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };458 setDedupRunning(false);459 if (res.ok) setDedupCount(res.data ?? 0);460 }461462 async function handleDeduplicate() {463 setDedupRunning(true);464 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };465 setDedupRunning(false);466 if (res.ok) {467 setDedupDone(res.data ?? 0);468 setDedupCount(0);469 }470 }471472 async function handleDisableDuress() {473 const mainPw = duressMainPassword().trim();474 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }475476 setDuressSaving(true);477 setDuressError(null);478479 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };480 if (!verifyRes.ok || !verifyRes.data) {481 setDuressError("incorrect main password");482 setDuressSaving(false);483 return;484 }485486 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };487 setDuressSaving(false);488 if (res.ok) {489 setDuressEnabled(false);490 setShowDuressSetup(false);491 setDuressMainPassword("");492 setDuressSuccess("Duress password disabled.");493 setTimeout(() => setDuressSuccess(null), 4000);494 } else {495 setDuressError(res.error ?? "failed to disable duress password");496 }497 }498499 async function handleSubmitFeedback() {500 if (!fbMessage().trim() || fbSubmitting()) return;501 setFbSubmitting(true);502 setFbResult(null);503 const res = await sendMessage({504 type: "SUBMIT_FEEDBACK",505 payload: {506 category: fbCategory(),507 message: fbMessage().trim(),508 version,509 browser: navigator.userAgent,510 },511 }) as { ok: boolean; error?: string };512 setFbSubmitting(false);513 if (res.ok) {514 setFbResult("success");515 setFbMessage("");516 setTimeout(() => setFbResult(null), 4000);517 } else {518 setFbResult("error");519 }520 }521522 function handleHeaderBack() {523 if (activeSection() !== null) {524 setActiveSection(null);525 } else {526 props.onBack();527 }528 }529530 function currentTitle(): string {531 const s = activeSection();532 if (s === null) return "settings";533 if (s === "license") return "account";534 return s;535 }536537 function renderCatRow(opts: {538 section: SectionName;539 name: string;540 desc: string;541 icon: () => any;542 pro?: boolean;543 }) {544 return (545 <button546 class="peach-settings-cat-row"547 onClick={() => setActiveSection(opts.section)}548 >549 <div class="peach-settings-cat-icon">{opts.icon()}</div>550 <div class="peach-settings-cat-content">551 <span class="peach-settings-cat-name">552 {opts.name}553 <Show when={opts.pro && !isPro()}>554 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>555 </Show>556 </span>557 <span class="peach-settings-cat-desc">{opts.desc}</span>558 </div>559 <svg560 class="peach-settings-cat-chevron"561 width="6" height="10" viewBox="0 0 6 10" fill="none"562 stroke="currentColor" stroke-width="1.1"563 stroke-linecap="round" stroke-linejoin="round"564 >565 <path d="M1 1l4 4-4 4" />566 </svg>567 </button>568 );569 }570571 const iconShield = () => (572 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">573 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>574 </svg>575 );576 const iconKey = () => (577 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">578 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>579 </svg>580 );581 const iconLock = () => (582 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">583 <rect x="3" y="7" width="10" height="7" rx="1.5"/>584 <path d="M6 7V5a3 3 0 016 0v2"/>585 </svg>586 );587 const iconSync = () => (588 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">589 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>590 <path d="M14 6l-2 2-2-2"/>591 <path d="M2 10l2-2 2 2"/>592 </svg>593 );594 const iconSun = () => (595 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">596 <circle cx="8" cy="8" r="3"/>597 <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"/>598 </svg>599 );600 const iconKeys = () => (601 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">602 <rect x="2" y="5" width="5" height="4" rx="1"/>603 <rect x="9" y="7" width="5" height="4" rx="1"/>604 <path d="M7 7h2m-2 2h2"/>605 </svg>606 );607 const iconBubble = () => (608 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">609 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>610 </svg>611 );612613 function renderHome() {614 return (615 <div class="peach-settings-body">616 <p class="peach-settings-nav-label">PROTECTION</p>617 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}618 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}619620 <p class="peach-settings-nav-label">VAULT</p>621 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}622 <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>623 <div class="peach-settings-cat-icon">{iconSync()}</div>624 <div class="peach-settings-cat-content">625 <span class="peach-settings-cat-name">Sync</span>626 <span class="peach-settings-cat-desc">Pair and sync across devices</span>627 </div>628 <svg class="peach-settings-cat-chevron" width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">629 <path d="M1 1l4 4-4 4" />630 </svg>631 </button>632633 <p class="peach-settings-nav-label">PREFERENCES</p>634 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}635 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}636637 <p class="peach-settings-nav-label">SUPPORT</p>638 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}639640 <div class="peach-settings-footer">641 <span>Peach {version}</span>642 <span class="peach-divider"></span>643 <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>644 </div>645 </div>646 );647 }648649 function renderSecurity() {650 return (651 <div class="peach-settings-body">652 <div class="peach-settings-group">653 <p class="peach-settings-sub-label">LOCKING</p>654 <div class="peach-settings-row">655 <span>Lock on close</span>656 <button657 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}658 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}659 aria-label="Toggle lock on close"660 >661 <span class="peach-toggle-knob"></span>662 </button>663 </div>664 <div class="peach-settings-row">665 <span>Auto-lock after</span>666 <select667 class="peach-settings-select"668 value={String(props.autoLockMinutes)}669 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}670 >671 <option value="1">1 minute</option>672 <option value="5">5 minutes</option>673 <option value="15">15 minutes</option>674 <option value="30">30 minutes</option>675 <option value="0">Never</option>676 </select>677 </div>678679 <Show when={isNativePlatform() && biometricSupported()}>680 <p class="peach-settings-sub-label">BIOMETRICS</p>681 <div class="peach-settings-row">682 <span>Unlock with biometrics</span>683 <button684 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}685 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}686 disabled={biometricBusy()}687 aria-label="Toggle biometric unlock"688 >689 <span class="peach-toggle-knob"></span>690 </button>691 </div>692 <Show when={biometricError()}>693 <div class="peach-settings-row peach-settings-row--meta">694 <span class="peach-hint peach-hint-error">{biometricError()}</span>695 </div>696 </Show>697 </Show>698699 <p class="peach-settings-sub-label">BREACH PROTECTION</p>700 <div class="peach-settings-row">701 <span>Breach monitoring</span>702 <button703 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}704 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}705 aria-label="Toggle breach monitoring"706 >707 <span class="peach-toggle-knob"></span>708 </button>709 </div>710 <div class="peach-settings-row peach-settings-row--meta">711 <span>Check all passwords</span>712 <button713 class="peach-btn peach-btn--subtle peach-btn--sm"714 onClick={() => void handleCheckAllBreaches()}715 disabled={checkingBreaches()}716 >717 {checkingBreaches() ? "checking..." : "run"}718 </button>719 </div>720 <Show when={breachResult()}>721 <div class="peach-settings-row peach-settings-row--meta">722 <span>Last check</span>723 <span class="peach-settings-value">{breachResult()}</span>724 </div>725 </Show>726727 <p class="peach-settings-sub-label">MASTER PASSWORD</p>728 <div class="peach-settings-row peach-settings-row--meta">729 <span>Master password</span>730 <button731 class="peach-btn peach-btn--subtle peach-btn--sm"732 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}733 >734 {showChangePw() ? "cancel" : "change"}735 </button>736 </div>737 <Show when={showChangePw()}>738 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">739 <input740 class="peach-settings-input"741 type="password"742 placeholder="current password"743 value={changePwCurrent()}744 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}745 autocomplete="current-password"746 />747 <input748 class="peach-settings-input"749 type="password"750 placeholder="new password (min 12 chars)"751 value={changePwNew()}752 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}753 autocomplete="new-password"754 />755 <input756 class="peach-settings-input"757 type="password"758 placeholder="confirm new password"759 value={changePwConfirm()}760 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}761 autocomplete="new-password"762 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}763 />764 <Show when={changePwError()}>765 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>766 </Show>767 <Show when={changePwSuccess()}>768 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>769 </Show>770 <button771 class="peach-btn peach-btn--accent peach-btn--sm"772 style="align-self:flex-start"773 onClick={() => void handleChangePassword()}774 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}775 >776 {changePwSaving() ? "saving…" : "save new password"}777 </button>778 </div>779 </Show>780781 <p class="peach-settings-sub-label">782 DURESS PASSWORD783 <Show when={!isPro()}>784 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>785 </Show>786 </p>787 <div class="peach-settings-row peach-settings-row--top">788 <div class="peach-settings-row-main">789 <span>790 Duress password791 <Show when={!isPro()}>792 <span class="peach-pro-badge">Pro</span>793 </Show>794 </span>795 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">796 If entered under coercion, shows a decoy empty vault instead of your real one.797 </span>798 </div>799 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>800 {duressEnabled() ? "on" : "off"}801 </div>802 </div>803804 <Show when={duressEnabled() && !showDuressSetup()}>805 <div class="peach-settings-row peach-settings-row--meta">806 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">807 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden. The encrypted storage is identical in size whether a hidden vault exists or not, so the hidden vault cannot be proven or disproven without both passwords. This protects against a coercer who demands your password — not against device malware or someone who already has your device unlocked.808 </span>809 </div>810 </Show>811812 <Show when={duressSuccess()}>813 <div class="peach-settings-row peach-settings-row--meta">814 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>815 </div>816 </Show>817818 <Show when={!showDuressSetup()}>819 <div class="peach-settings-row peach-settings-row--meta">820 <button821 class="peach-btn peach-btn--subtle peach-btn--sm"822 onClick={() => {823 824 825 826 827 828 if (!isPro() && !duressEnabled()) {829 props.onProGate?.("duress_vault");830 setActiveSection("license");831 return;832 }833 setShowDuressSetup(true);834 setDuressError(null);835 setDuressSuccess(null);836 setDuressPassword("");837 setDuressConfirm("");838 setDuressMainPassword("");839 }}840 >841 {duressEnabled() ? "Change / disable" : "Set up"}842 </button>843 </div>844 </Show>845846 <Show when={showDuressSetup()}>847 <div class="peach-duress-form">848 <label class="peach-label">main password (to confirm)</label>849 <input850 class="peach-settings-input"851 type="password"852 placeholder="your current master password"853 autocomplete="current-password"854 value={duressMainPassword()}855 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}856 />857 <Show when={!duressEnabled()}>858 <label class="peach-label">new duress password</label>859 <input860 class="peach-settings-input"861 type="password"862 placeholder="duress password (min 8 chars)"863 autocomplete="new-password"864 value={duressPassword()}865 onInput={(e) => setDuressPassword(e.currentTarget.value)}866 />867 <label class="peach-label">confirm duress password</label>868 <input869 class="peach-settings-input"870 type="password"871 placeholder="confirm duress password"872 autocomplete="new-password"873 value={duressConfirm()}874 onInput={(e) => setDuressConfirm(e.currentTarget.value)}875 />876 </Show>877878 <Show when={duressError()}>879 <p class="peach-hint peach-hint-error">{duressError()}</p>880 </Show>881882 <div class="peach-duress-actions">883 <button884 class="peach-btn peach-btn--subtle peach-btn--sm"885 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}886 >887 cancel888 </button>889 <Show when={duressEnabled()}>890 <button891 class="peach-btn peach-btn--ghost peach-btn--sm"892 style="color:var(--peach-breach)"893 onClick={() => void handleDisableDuress()}894 disabled={duressSaving()}895 >896 {duressSaving() ? "disabling..." : "disable duress"}897 </button>898 </Show>899 <Show when={!duressEnabled()}>900 <button901 class="peach-btn peach-btn--accent peach-btn--sm"902 onClick={() => void handleSetupDuress()}903 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}904 >905 {duressSaving() ? "saving..." : "save"}906 </button>907 </Show>908 </div>909 </div>910 </Show>911912 <p class="peach-settings-sub-label">913 CANARY ENTRIES914 <Show when={!isPro()}>915 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>916 </Show>917 </p>918 <div class="peach-settings-row peach-settings-row--top">919 <div class="peach-settings-row-main">920 <span>921 Canary entries922 <Show when={!isPro()}>923 <span class="peach-pro-badge">Pro</span>924 </Show>925 </span>926 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">927 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.928 </span>929 </div>930 <span class="peach-settings-value" style="flex-shrink:0">931 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}932 </span>933 </div>934 <Show when={canaryEntries().length > 0}>935 <For each={canaryEntries()}>936 {(entry) => (937 <div class="peach-settings-row peach-settings-row--meta">938 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>939 <button940 class="peach-btn peach-btn--subtle peach-btn--sm"941 onClick={() => void handleTestCanary(entry)}942 disabled={canaryTestingId() === entry.id}943 style="flex-shrink:0"944 >945 {canaryTestingId() === entry.id946 ? "sending…"947 : canaryTestResult()?.id === entry.id948 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")949 : "test"}950 </button>951 </div>952 )}953 </For>954 </Show>955956 <p class="peach-settings-sub-label">PASSKEYS</p>957 <div class="peach-settings-row peach-settings-row--top">958 <div class="peach-settings-row-main">959 <span>Intercept passkey requests</span>960 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">961 Peach can handle passkey sign-in and creation on supported Chromium browsers.962 </span>963 </div>964 <button965 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}966 onClick={() => void handleTogglePasskeys()}967 aria-label="Toggle passkey interception"968 disabled={!passkeyState()?.supported || passkeySaving()}969 >970 <span class="peach-toggle-knob"></span>971 </button>972 </div>973 <div class="peach-settings-row peach-settings-row--meta">974 <span>Status</span>975 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>976 </div>977 <div class="peach-settings-row peach-settings-row--meta">978 <span>Browser</span>979 <span class="peach-settings-value">980 {passkeyState()?.browserLabel ?? "Unknown"}981 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}982 </span>983 </div>984 <div class="peach-info-notice" style="margin-top:8px">985 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.986 </div>987 <Show when={passkeyState()?.conflict}>988 <div class="peach-settings-row peach-settings-row--meta">989 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>990 </div>991 </Show>992 </div>993 </div>994 );995 }996997 function renderVault() {998 return (999 <div class="peach-settings-body">1000 <div class="peach-settings-group">1001 <p class="peach-settings-sub-label">ENTRIES</p>1002 <div class="peach-settings-row peach-settings-row--meta">1003 <span>Entries</span>1004 <span class="peach-settings-value">{props.entriesCount}</span>1005 </div>1006 <div class="peach-settings-row peach-settings-row--meta">1007 <span>Duplicates</span>1008 <Show when={dedupDone() !== null} fallback={1009 <button1010 class="peach-btn peach-btn--subtle peach-btn--sm"1011 onClick={() => void handleCountDuplicates()}1012 disabled={dedupRunning()}1013 >1014 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1015 </button>1016 }>1017 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1018 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1019 </span>1020 </Show>1021 </div>1022 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1023 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1024 <div class="peach-restore-warning">1025 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1026 </div>1027 <div style="display:flex;gap:6px">1028 <button1029 class="peach-btn peach-btn--subtle peach-btn--sm"1030 onClick={() => setDedupCount(null)}1031 >1032 cancel1033 </button>1034 <button1035 class="peach-btn peach-btn--accent peach-btn--sm"1036 onClick={() => void handleDeduplicate()}1037 disabled={dedupRunning()}1038 >1039 {dedupRunning() ? "removing..." : "remove duplicates"}1040 </button>1041 </div>1042 </div>1043 </Show>10441045 <p class="peach-settings-sub-label">MAINTENANCE</p>1046 <div class="peach-settings-row peach-settings-row--meta">1047 <span>Trash auto-delete</span>1048 <select1049 class="peach-settings-select"1050 value={String(props.trashRetentionDays)}1051 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1052 >1053 <option value="7">7 days</option>1054 <option value="30">30 days</option>1055 <option value="90">90 days</option>1056 <option value="0">Never</option>1057 </select>1058 </div>1059 <div class="peach-settings-row peach-settings-row--meta">1060 <span>Vault Health</span>1061 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1062 open1063 </button>1064 </div>1065 <Show when={declinedCardCount() > 0}>1066 <div class="peach-settings-row peach-settings-row--meta">1067 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1068 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1069 Clear1070 </button>1071 </div>1072 </Show>10731074 <p class="peach-settings-sub-label">DATA</p>1075 <div class="peach-settings-row peach-settings-row--meta">1076 <span>Import passwords</span>1077 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1078 open1079 </button>1080 </div>1081 <div class="peach-settings-row peach-settings-row--meta">1082 <span>Export backup</span>1083 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1084 open1085 </button>1086 </div>1087 <div class="peach-settings-row peach-settings-row--meta">1088 <span>1089 Peach Codex1090 <Show when={!isPro()}>1091 <span class="peach-pro-badge">Pro</span>1092 </Show>1093 </span>1094 <button1095 class="peach-btn peach-btn--subtle peach-btn--sm"1096 onClick={() => props.onOpenCodexGenerate?.()}1097 >1098 open1099 </button>1100 </div>1101 {1102}1103 <div class="peach-settings-row peach-settings-row--meta">1104 <span>Restore from Codex</span>1105 <button1106 class="peach-btn peach-btn--subtle peach-btn--sm"1107 onClick={() => props.onOpenCodexRestore?.()}1108 >1109 open1110 </button>1111 </div>11121113 <p class="peach-settings-sub-label">VAULT HISTORY</p>1114 <Show when={historyLoading()}>1115 <p class="peach-settings-hint">Loading…</p>1116 </Show>1117 <Show when={!historyLoading() && historySnapshots().length === 0}>1118 <p class="peach-settings-hint">1119 No snapshots yet. Peach saves automatically before every change —1120 saves, syncs, and imports.1121 </p>1122 </Show>1123 <Show when={!historyLoading() && historySnapshots().length > 0}>1124 <p class="peach-settings-hint">1125 Restoring locks your vault. Unlock with the password that1126 encrypted that snapshot to confirm.1127 </p>1128 <div class="peach-history-list">1129 <For each={historySnapshots()}>1130 {(snap) => (1131 <div class="peach-history-row">1132 <div class="peach-history-meta">1133 <span class="peach-history-time">1134 {formatSnapshotTime(snap.savedAt)}1135 </span>1136 <span class="peach-history-sub">1137 {[1138 snap.entryCount !== null1139 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1140 : null,1141 reasonLabel(snap.reason),1142 ]1143 .filter(Boolean)1144 .join(" · ")}1145 </span>1146 </div>1147 <button1148 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1149 historyRestoring() === snap.id ? " is-busy" : ""1150 }`}1151 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1152 disabled={historyRestoring() !== null}1153 >1154 <Show when={historyRestoring() === snap.id} fallback="Restore">1155 …1156 </Show>1157 </button>1158 </div>1159 )}1160 </For>1161 </div>1162 <p class="peach-history-footer">1163 {historySnapshots().length} of 10 snapshots1164 </p>1165 </Show>11661167 <button1168 class="peach-settings-advanced-toggle"1169 onClick={() => setShowVaultAdvanced((v) => !v)}1170 aria-expanded={showVaultAdvanced()}1171 >1172 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1173 <span class="peach-settings-advanced-chevron">1174 {showVaultAdvanced() ? "▲" : "▼"}1175 </span>1176 </button>11771178 <Show when={showVaultAdvanced()}>1179 <div class="peach-danger-card">1180 <div class="peach-danger-card-head">1181 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1182 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1183 </div>1184 <p class="peach-danger-card-copy">1185 Wipes the current vault and restores from a backup file.1186 Recoverable only if your backup is good.1187 </p>1188 <button1189 class="peach-danger-card-button"1190 onClick={() => props.onOpenFileImport?.()}1191 >1192 Choose backup file1193 </button>1194 </div>11951196 <Show when={props.onOpenPanic}>1197 <div class="peach-danger-card peach-danger-card--hazard">1198 <div class="peach-danger-card-head">1199 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1200 <h3 class="peach-danger-card-title">Destroy vault</h3>1201 </div>1202 <p class="peach-danger-card-copy">1203 Permanently wipes every entry, setting, and license on this1204 device. There is no recovery.1205 </p>1206 <button1207 class="peach-danger-card-button"1208 onClick={() => props.onOpenPanic?.()}1209 >1210 Begin destruction1211 </button>1212 </div>1213 </Show>1214 </Show>1215 </div>1216 </div>1217 );1218 }12191220 function renderAppearance() {1221 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1222 { id: "small", label: "Small" },1223 { id: "medium", label: "Default" },1224 { id: "large", label: "Large" },1225 ];1226 return (1227 <div class="peach-settings-body">1228 <div class="peach-settings-group">1229 <p class="peach-settings-sub-label">THEME</p>1230 <div class="peach-settings-row peach-settings-row--meta">1231 <span>Theme</span>1232 <button1233 class="peach-btn peach-btn--subtle peach-btn--sm"1234 onClick={() =>1235 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1236 }1237 >1238 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1239 </button>1240 </div>12411242 <p class="peach-settings-sub-label">ACCENT</p>1243 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1244 <span>Accent</span>1245 <div class="peach-accent-picker">1246 <For each={ACCENT_COLORS}>1247 {(color) => (1248 <button1249 class="peach-accent-swatch"1250 classList={{ "is-active": props.accentColor === color.name }}1251 style={{ background: color.hex }}1252 onClick={() => void props.onAccentChange(color.name)}1253 aria-label={color.name}1254 title={color.name}1255 />1256 )}1257 </For>1258 </div>1259 </div>12601261 <p class="peach-settings-sub-label">TEXT SIZE</p>1262 <div class="peach-settings-row peach-settings-row--meta">1263 <span>Size</span>1264 <div class="peach-pill-group">1265 <For each={sizeOptions}>1266 {(opt) => (1267 <button1268 class="peach-btn peach-btn--ghost peach-btn--sm"1269 classList={{ "is-active": props.textSize === opt.id }}1270 onClick={() => void props.onTextSizeChange(opt.id)}1271 >1272 {opt.label}1273 </button>1274 )}1275 </For>1276 </div>1277 </div>12781279 <p class="peach-settings-sub-label">MOTION</p>1280 <div class="peach-settings-row peach-settings-row--meta">1281 <span>Reduce motion</span>1282 <button1283 class="peach-btn peach-btn--subtle peach-btn--sm"1284 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1285 >1286 {props.reduceMotion ? "On" : "Off"}1287 </button>1288 </div>1289 </div>1290 </div>1291 );1292 }12931294 function renderShortcuts() {1295 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1296 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12971298 function startCapture(action: ShortcutAction) {1299 setConflictMsg(null);1300 setCapturingAction(action);1301 }1302 function cancelCapture() {1303 setCapturingAction(null);1304 setConflictMsg(null);1305 }1306 function commitBinding(action: ShortcutAction, binding: Keybinding) {1307 const conflict = findConflict(props.shortcuts, binding, action);1308 if (conflict) {1309 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1310 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1311 return;1312 }1313 const next = { ...props.shortcuts, [action]: binding };1314 setCapturingAction(null);1315 setConflictMsg(null);1316 void props.onShortcutsChange(next);1317 }1318 function resetAction(action: ShortcutAction) {1319 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1320 void props.onShortcutsChange(next);1321 }1322 function resetAll() {1323 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1324 }13251326 1327 1328 function handleCaptureKey(e: KeyboardEvent) {1329 e.preventDefault();1330 e.stopPropagation();1331 if (e.key === "Escape") { cancelCapture(); return; }1332 const binding = keybindingFromEvent(e);1333 if (!binding) return; 1334 const action = capturingAction();1335 if (action) commitBinding(action, binding);1336 }13371338 return (1339 <div class="peach-settings-body">1340 <div class="peach-settings-group peach-shortcuts-group">1341 <p class="peach-settings-sub-label">ENTRY VIEW</p>1342 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1343 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1344 </For>1345 </div>13461347 <div class="peach-settings-group peach-shortcuts-group">1348 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1349 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1350 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1351 </For>1352 </div>13531354 <Show when={conflictMsg()}>1355 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1356 </Show>13571358 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1359 <div class="peach-settings-row peach-settings-row--meta">1360 <span>Inline autofill on page</span>1361 <a1362 class="peach-settings-link"1363 href="chrome://extensions/shortcuts"1364 onClick={(e) => {1365 e.preventDefault();1366 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1367 }}1368 >1369 ⌘⇧P — customize ↗1370 </a>1371 </div>1372 <button1373 class="peach-btn peach-btn--subtle peach-btn--sm"1374 style="align-self:flex-start"1375 onClick={resetAll}1376 >1377 Reset all to defaults1378 </button>1379 </div>1380 </div>1381 );13821383 function renderRow(1384 desc: typeof SHORTCUT_DESCRIPTORS[number],1385 capturing: () => ShortcutAction | null,1386 onStart: (a: ShortcutAction) => void,1387 onCancel: () => void,1388 onReset: (a: ShortcutAction) => void,1389 onKey: (e: KeyboardEvent) => void,1390 ) {1391 const isActive = () => capturing() === desc.action;1392 const binding = () => props.shortcuts[desc.action];1393 const isCustom = () => {1394 const b = binding();1395 const d = DEFAULT_BINDINGS[desc.action];1396 return b.key !== d.key1397 || Boolean(b.ctrl) !== Boolean(d.ctrl)1398 || Boolean(b.meta) !== Boolean(d.meta)1399 || Boolean(b.shift) !== Boolean(d.shift)1400 || Boolean(b.alt) !== Boolean(d.alt);1401 };1402 return (1403 <div class="peach-shortcut-row">1404 <span class="peach-shortcut-row-label">{desc.label}</span>1405 <button1406 class="peach-shortcut-chip"1407 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1408 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1409 onKeyDown={(e) => { if (isActive()) onKey(e); }}1410 ref={(el) => {1411 1412 if (el) {1413 queueMicrotask(() => { if (isActive()) el.focus(); });1414 }1415 }}1416 >1417 {isActive() ? "Press a key…" : formatKeybinding(binding())}1418 </button>1419 <Show when={isCustom() && !isActive()}>1420 <button1421 class="peach-shortcut-reset"1422 title="Reset to default"1423 onClick={() => onReset(desc.action)}1424 >1425 ↺1426 </button>1427 </Show>1428 </div>1429 );1430 }1431 }14321433 const PRO_FEATURES: Array<{1434 feature: ProFeature;1435 name: string;1436 desc: string;1437 }> = [1438 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1439 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1440 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1441 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1442 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1443 { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1444 ];14451446 const PRO_SECURITY_FEATURES: Array<{1447 feature: ProFeature;1448 name: string;1449 desc: string;1450 }> = [1451 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1452 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1453 ];14541455 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1456 const [showKey, setShowKey] = createSignal(false);14571458 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1459 const [deactivating, setDeactivating] = createSignal(false);14601461 async function handleDeactivateLicense() {1462 setDeactivating(true);1463 await sendMessage({ type: "DEACTIVATE_LICENSE" });1464 setLicenseInfo(null);1465 await props.onLicenseChange?.();1466 setDeactivating(false);1467 }14681469 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1470 setCanaryTestingId(entry.id);1471 setCanaryTestResult(null);1472 const res = await sendMessage({1473 type: "FIRE_CANARY_PING",1474 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1475 }) as { ok: boolean };1476 setCanaryTestResult({ id: entry.id, ok: res.ok });1477 setCanaryTestingId(null);1478 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1479 }14801481 function renderLicense() {1482 async function onActivate() {1483 await handleActivateLicense();1484 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1485 await props.onLicenseChange?.();1486 }1487 }14881489 return (1490 <div class="peach-settings-body">1491 <Show1492 when={isPro()}1493 fallback={1494 <div class="peach-pro-upgrade">1495 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>14961497 <h2 class="peach-pro-headline">1498 Every<em>thing,</em><br />every<em>where.</em>1499 </h2>15001501 <p class="peach-pro-subtitle">1502 One key unlocks every Pro feature — for the lifetime of the app.1503 </p>15041505 <div class="peach-pro-grid">1506 <For each={PRO_FEATURES}>1507 {(item, i) => (1508 <div1509 class="peach-pro-grid-item"1510 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1511 >1512 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1513 <span class="peach-pro-grid-name">{item.name}</span>1514 <span class="peach-pro-grid-desc">{item.desc}</span>1515 </div>1516 )}1517 </For>1518 </div>15191520 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1521 <div class="peach-pro-grid">1522 <For each={PRO_SECURITY_FEATURES}>1523 {(item, i) => (1524 <div1525 class="peach-pro-grid-item"1526 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1527 >1528 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1529 <span class="peach-pro-grid-name">{item.name}</span>1530 <span class="peach-pro-grid-desc">{item.desc}</span>1531 </div>1532 )}1533 </For>1534 </div>15351536 <div class="peach-pro-upgrade-actions">1537 <p class="peach-pro-pricing">1538 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1539 </p>1540 <a1541 href="https://peachpasswords.com/#pricing"1542 target="_blank"1543 rel="noopener noreferrer"1544 class="peach-pro-upgrade-cta"1545 >1546 Get Peach Pro1547 </a>1548 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1549 <div class="peach-pro-key-inline">1550 <input1551 class="peach-pro-key-inline-input"1552 type="text"1553 placeholder="PEACH-XXXX-XXXX-XXXX"1554 value={licenseKeyInput()}1555 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1556 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1557 autocomplete="off"1558 spellcheck={false}1559 />1560 <button1561 class="peach-pro-key-apply"1562 onClick={() => void onActivate()}1563 disabled={activatingLicense() || !licenseKeyInput().trim()}1564 >1565 {activatingLicense() ? "…" : "APPLY"}1566 </button>1567 </div>1568 <Show when={licenseError()}>1569 <p class="peach-pro-key-error">{licenseError()}</p>1570 </Show>1571 </div>1572 </div>1573 }1574 >1575 <div class="peach-pro-active">1576 <div class="peach-pro-active-hero">1577 <div class="peach-pro-active-mark-wrap">1578 <PeachMark class="peach-pro-active-mark" />1579 </div>1580 <h2 class="peach-pro-active-title">Peach Pro</h2>1581 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1582 </div>15831584 <Show when={licenseKey()}>1585 <div class="peach-settings-license">1586 <span class="peach-settings-label">LICENSE KEY</span>1587 <div class="peach-settings-license-row">1588 <Show1589 when={showKey()}1590 fallback={1591 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1592 ••••••••••••••••••••••••••••1593 </span>1594 }1595 >1596 <span class="peach-settings-license-key">{licenseKey()!}</span>1597 </Show>1598 <div class="peach-settings-license-actions">1599 <button1600 class="peach-settings-license-copy"1601 title="Copy license key"1602 onClick={() => copyLicenseKey(licenseKey()!)}1603 >1604 {copied() ? "✓" : "copy"}1605 </button>1606 <button1607 class="peach-settings-license-toggle"1608 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1609 title={showKey() ? "Hide" : "Reveal"}1610 onClick={() => setShowKey(v => !v)}1611 >1612 <Show1613 when={showKey()}1614 fallback={1615 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1616 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1617 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1618 </svg>1619 }1620 >1621 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1622 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1623 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1624 <line x1="2" y1="2" x2="12" y2="12" />1625 </svg>1626 </Show>1627 </button>1628 </div>1629 </div>1630 </div>1631 </Show>16321633 <div class="peach-pro-active-features">1634 <For each={PRO_FEATURES}>1635 {(item) => (1636 <div class="peach-pro-active-feature">1637 <span class="peach-pro-active-feature-mark">✦</span>1638 <span class="peach-pro-active-feature-name">{item.name}</span>1639 </div>1640 )}1641 </For>1642 </div>16431644 <button1645 class="peach-pro-deactivate"1646 onClick={() => void handleDeactivateLicense()}1647 disabled={deactivating()}1648 >1649 {deactivating() ? "Deactivating…" : "Deactivate"}1650 </button>1651 </div>1652 </Show>1653 </div>1654 );1655 }16561657 function renderFeedback() {1658 return (1659 <div class="peach-settings-body">1660 <div class="peach-settings-group peach-feedback-group">1661 <p class="peach-feedback-intro">1662 Help us improve Peach. Every report goes directly to our inbox.1663 </p>16641665 <p class="peach-settings-sub-label">TYPE</p>1666 <div class="peach-feedback-chips">1667 <For each={FEEDBACK_CATEGORIES}>1668 {(cat) => (1669 <button1670 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1671 onClick={() => setFbCategory(cat)}1672 >1673 {cat}1674 </button>1675 )}1676 </For>1677 </div>16781679 <p class="peach-settings-sub-label">MESSAGE</p>1680 <textarea1681 class="peach-feedback-textarea"1682 placeholder="Describe the issue or idea..."1683 value={fbMessage()}1684 onInput={(e) => setFbMessage(e.currentTarget.value)}1685 />16861687 <p class="peach-feedback-meta">1688 Version {version} · will be included automatically1689 </p>16901691 <Show when={fbResult() === "success"}>1692 <p class="peach-feedback-result peach-feedback-result--ok">1693 Sent — thank you1694 </p>1695 </Show>1696 <Show when={fbResult() === "error"}>1697 <p class="peach-feedback-result peach-feedback-result--err">1698 Failed to send — try again1699 </p>1700 </Show>17011702 <button1703 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1704 onClick={() => void handleSubmitFeedback()}1705 disabled={fbSubmitting() || !fbMessage().trim()}1706 >1707 {fbSubmitting() ? "Sending..." : "Send feedback"}1708 </button>17091710 <p class="peach-feedback-anon">1711 Sent anonymously · no account required1712 </p>1713 </div>1714 </div>1715 );1716 }17171718 return (1719 <Show1720 when={showNewRecoveryWords()}1721 fallback={1722 <div class="peach-overlay-view ov-enter-right" style="display:flex">1723 <div class="peach-overlay-header">1724 <button1725 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1726 onClick={handleHeaderBack}1727 aria-label="Back"1728 >1729 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1730 <path d="M6 1L1 6L6 11" />1731 </svg>1732 </button>1733 <span class="peach-overlay-title">{currentTitle()}</span>1734 <div class="peach-overlay-spacer"></div>1735 </div>17361737 <Switch fallback={renderHome()}>1738 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1739 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1740 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1741 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1742 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1743 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1744 </Switch>1745 </div>1746 }1747 >1748 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1749 </Show>1750 );1751}