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 displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";40 textSize: "small" | "medium" | "large";41 reduceMotion: boolean;42 isPro?: boolean;43 initialSection?: string | null;44 upgradeHighlight?: ProFeature | null;45 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;46 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;47 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;48 onTrashRetentionChange: (days: number) => void | Promise<void>;49 onThemeChange: (theme: "dark" | "light") => void;50 onAccentChange: (name: AccentColorName) => void | Promise<void>;51 onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;52 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54 shortcuts: Record<ShortcutAction, Keybinding>;55 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56 onLicenseChange?: () => void | Promise<void>;57 onBack: () => void;58 onImport: () => void;59 onExport: () => void;60 onBreachCheckDone: () => void;61 onOpenHealth?: () => void;62 onOpenSyncPairing?: () => void;63 onOpenFileImport?: () => void;64 onOpenPanic?: () => void;65 onOpenCodexGenerate?: () => void;66}6768type SectionName =69 | "security"70 | "vault"71 | "appearance"72 | "shortcuts"73 | "license"74 | "feedback";7576const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7778function formatActivatedSince(issued: string | undefined): string {79 if (!issued) return "Active";80 try {81 const d = new Date(issued);82 if (Number.isNaN(d.getTime())) return "Active";83 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;84 } catch {85 return "Active";86 }87}8889function isSectionName(value: string): value is SectionName {90 return (91 value === "security" ||92 value === "vault" ||93 value === "appearance" ||94 value === "shortcuts" ||95 value === "license" ||96 value === "feedback"97 );98}99100export function SettingsView(props: SettingsProps) {101 const manifest = chrome.runtime.getManifest();102 const version = manifest.version;103 const isAndroid = (manifest as any).version_name === 'mobile';104 const initial = props.initialSection;105 const [activeSection, setActiveSection] = createSignal<SectionName | null>(106 initial && isSectionName(initial) ? (initial as SectionName) : null107 );108109 const [checkingBreaches, setCheckingBreaches] = createSignal(false);110 const [breachResult, setBreachResult] = createSignal<string | null>(null);111 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);112 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");113 const [licenseError, setLicenseError] = createSignal<string | null>(null);114 const [, setLicenseSuccess] = createSignal<string | null>(null);115 const [activatingLicense, setActivatingLicense] = createSignal(false);116117 const isPro = () => props.isPro ?? false;118 const [biometricSupported, setBiometricSupported] = createSignal(false);119 const [biometricStored, setBiometricStored] = createSignal(false);120 const [biometricBusy, setBiometricBusy] = createSignal(false);121 const [biometricError, setBiometricError] = createSignal<string | null>(null);122 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);123 const [passkeySaving, setPasskeySaving] = createSignal(false);124125 const [dedupCount, setDedupCount] = createSignal<number | null>(null);126 const [dedupRunning, setDedupRunning] = createSignal(false);127 const [dedupDone, setDedupDone] = createSignal<number | null>(null);128129 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);130131 132 133 134 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);135 const [historyLoading, setHistoryLoading] = createSignal(false);136 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);137138 async function loadVaultHistory() {139 setHistoryLoading(true);140 try {141 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });142 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];143 setHistorySnapshots(list);144 } finally {145 setHistoryLoading(false);146 }147 }148149 async function handleRestoreVaultSnapshot(id: string) {150 if (historyRestoring()) return;151 setHistoryRestoring(id);152 const res = await sendMessage({153 type: "RESTORE_VAULT_SNAPSHOT",154 payload: { snapshotId: id },155 });156 setHistoryRestoring(null);157 if ((res as { ok: boolean }).ok) {158 159 160 props.onBack();161 }162 }163164 function formatSnapshotTime(savedAt: number): string {165 const diff = Date.now() - savedAt;166 if (diff < 60_000) return "Just now";167 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;168 const d = new Date(savedAt);169 const isToday = new Date().toDateString() === d.toDateString();170 const isYest =171 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();172 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });173 if (isToday) return `Today ${t}`;174 if (isYest) return `Yesterday ${t}`;175 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;176 }177178 function reasonLabel(reason: VaultSnapshot["reason"]): string {179 return (180 {181 local_save: "saved",182 sync_received: "synced",183 import: "imported",184 restore: "restored",185 } as const186 )[reason] ?? reason;187 }188189 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);190191 const [showChangePw, setShowChangePw] = createSignal(false);192 const [changePwCurrent, setChangePwCurrent] = createSignal("");193 const [changePwNew, setChangePwNew] = createSignal("");194 const [changePwConfirm, setChangePwConfirm] = createSignal("");195 const [changePwSaving, setChangePwSaving] = createSignal(false);196 const [changePwError, setChangePwError] = createSignal<string | null>(null);197 const [changePwSuccess] = createSignal(false);198 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);199200 const [duressEnabled, setDuressEnabled] = createSignal(false);201 const [showDuressSetup, setShowDuressSetup] = createSignal(false);202 const [duressPassword, setDuressPassword] = createSignal("");203 const [duressConfirm, setDuressConfirm] = createSignal("");204 const [duressMainPassword, setDuressMainPassword] = createSignal("");205 const [duressSaving, setDuressSaving] = createSignal(false);206 const [duressError, setDuressError] = createSignal<string | null>(null);207 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);208209 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);210211 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);212 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);213214 const [fbCategory, setFbCategory] = createSignal("Bug report");215 const [fbMessage, setFbMessage] = createSignal("");216 const [fbSubmitting, setFbSubmitting] = createSignal(false);217 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);218219 onMount(async () => {220 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([221 sendMessage({ type: "GET_LICENSE" }),222 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),223 sendMessage({ type: "IS_DURESS_ENABLED" }),224 sendMessage({ type: "GET_SETTINGS" }),225 sendMessage({ type: "GET_LICENSE_KEY" }),226 ]);227228 229 230 void loadVaultHistory();231232 if ((licenseRes as { ok: boolean }).ok) {233 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);234 }235236 if ((passkeyRes as { ok: boolean }).ok) {237 setPasskeyState(238 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null239 );240 }241242 if ((duressRes as { ok: boolean }).ok) {243 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);244 }245246 if ((settingsRes as { ok: boolean }).ok) {247 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;248 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);249 }250251 if ((keyRes as { ok: boolean }).ok) {252 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);253 }254255 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });256 if ((entriesRes as { ok: boolean }).ok) {257 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];258 setCanaryEntries(259 entries260 .filter((e) => e.canary?.webhookUrl)261 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))262 );263 }264265266 if (isNativePlatform()) {267 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");268 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");269 try {270 if (biometric) {271 const a = await biometric.isAvailable();272 setBiometricSupported(a.available);273 }274 if (keystore) {275 const k = await keystore.hasMasterKey();276 setBiometricStored(k.exists);277 }278 } catch { }279 }280 });281282 async function handleEnableBiometrics() {283 setBiometricBusy(true);284 setBiometricError(null);285 try {286 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {287 ok: boolean;288 data?: { masterKeyHex: string };289 error?: string;290 };291 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {292 setBiometricError(keyRes.error === "vault_locked"293 ? "unlock the vault first"294 : "couldn't read derived key");295 return;296 }297 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");298 if (!keystore) {299 setBiometricError("biometric plugin unavailable");300 return;301 }302 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });303 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });304 setBiometricStored(true);305 } catch (e) {306 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");307 } finally {308 setBiometricBusy(false);309 }310 }311312 async function handleDisableBiometrics() {313 setBiometricBusy(true);314 setBiometricError(null);315 try {316 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");317 if (keystore) await keystore.clearMasterKey();318 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });319 setBiometricStored(false);320 } catch (e) {321 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");322 } finally {323 setBiometricBusy(false);324 }325 }326327 async function handleChangePassword() {328 setChangePwError(null);329 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }330 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }331 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }332 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }333 setChangePwSaving(true);334 const res = await sendMessage({335 type: "CHANGE_PASSWORD",336 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },337 }) as { ok: boolean; error?: string };338 if (res.ok) {339 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");340 setShowChangePw(false);341 setShowNewRecoveryWords(true);342 } else {343 setChangePwError(res.error ?? "failed to change password");344 }345 setChangePwSaving(false);346 }347348 const [copied, setCopied] = createSignal(false);349350 async function copyLicenseKey(key: string) {351 await navigator.clipboard.writeText(key);352 setCopied(true);353 setTimeout(() => setCopied(false), 2000);354 }355356 async function handleActivateLicense() {357 const key = licenseKeyInput().trim();358 if (!key) return;359 setActivatingLicense(true);360 setLicenseError(null);361 setLicenseSuccess(null);362 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });363 setActivatingLicense(false);364 if ((res as { ok: boolean }).ok) {365 const data = (res as { ok: boolean; data?: LicenseInfo }).data;366 if (data?.valid) {367 setLicenseInfo(data);368 setLicenseKey(key);369 setLicenseSuccess("License activated!");370 setLicenseKeyInput("");371 } else {372 setLicenseError("Invalid license key");373 }374 } else {375 setLicenseError("Activation failed");376 }377 }378379 async function handleTogglePasskeys() {380 const current = passkeyState();381 if (!current || !current.supported) return;382 setPasskeySaving(true);383 const res = await sendMessage({384 type: "SET_PASSKEY_PROVIDER_ENABLED",385 payload: { enabled: !current.enabled },386 });387 setPasskeySaving(false);388 if ((res as { ok: boolean }).ok) {389 setPasskeyState(390 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null391 );392 }393 }394395 async function handleCheckAllBreaches() {396 setCheckingBreaches(true);397 setBreachResult(null);398 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });399 setCheckingBreaches(false);400 if ((res as { ok: boolean }).ok) {401 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};402 const breached = Object.values(data).filter((count) => count > 0).length;403 setBreachResult(404 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"405 );406 props.onBreachCheckDone();407 }408 }409410411 async function handleSetupDuress() {412 const mainPw = duressMainPassword().trim();413 const duressPw = duressPassword().trim();414 const confirmPw = duressConfirm().trim();415416 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }417 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }418 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }419 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }420421 setDuressSaving(true);422 setDuressError(null);423424 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };425 if (!verifyRes.ok || !verifyRes.data) {426 setDuressError("incorrect main password");427 setDuressSaving(false);428 return;429 }430431 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };432 setDuressSaving(false);433 if (res.ok) {434 setDuressEnabled(true);435 setShowDuressSetup(false);436 setDuressPassword("");437 setDuressConfirm("");438 setDuressMainPassword("");439 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");440 setTimeout(() => setDuressSuccess(null), 5000);441 } else {442 setDuressError(res.error ?? "failed to set duress password");443 }444 }445446 async function handleClearDeclinedCards() {447 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });448 setDeclinedCardCount(0);449 }450451 async function handleCountDuplicates() {452 setDedupRunning(true);453 setDedupDone(null);454 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };455 setDedupRunning(false);456 if (res.ok) setDedupCount(res.data ?? 0);457 }458459 async function handleDeduplicate() {460 setDedupRunning(true);461 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };462 setDedupRunning(false);463 if (res.ok) {464 setDedupDone(res.data ?? 0);465 setDedupCount(0);466 }467 }468469 async function handleDisableDuress() {470 const mainPw = duressMainPassword().trim();471 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }472473 setDuressSaving(true);474 setDuressError(null);475476 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };477 if (!verifyRes.ok || !verifyRes.data) {478 setDuressError("incorrect main password");479 setDuressSaving(false);480 return;481 }482483 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };484 setDuressSaving(false);485 if (res.ok) {486 setDuressEnabled(false);487 setShowDuressSetup(false);488 setDuressMainPassword("");489 setDuressSuccess("Duress password disabled.");490 setTimeout(() => setDuressSuccess(null), 4000);491 } else {492 setDuressError(res.error ?? "failed to disable duress password");493 }494 }495496 async function handleSubmitFeedback() {497 if (!fbMessage().trim() || fbSubmitting()) return;498 setFbSubmitting(true);499 setFbResult(null);500 const res = await sendMessage({501 type: "SUBMIT_FEEDBACK",502 payload: {503 category: fbCategory(),504 message: fbMessage().trim(),505 version,506 browser: navigator.userAgent,507 },508 }) as { ok: boolean; error?: string };509 setFbSubmitting(false);510 if (res.ok) {511 setFbResult("success");512 setFbMessage("");513 setTimeout(() => setFbResult(null), 4000);514 } else {515 setFbResult("error");516 }517 }518519 function handleHeaderBack() {520 if (activeSection() !== null) {521 setActiveSection(null);522 } else {523 props.onBack();524 }525 }526527 function currentTitle(): string {528 const s = activeSection();529 if (s === null) return "settings";530 if (s === "license") return "account";531 return s;532 }533534 function renderCatRow(opts: {535 section: SectionName;536 name: string;537 desc: string;538 icon: () => any;539 pro?: boolean;540 }) {541 return (542 <button543 class="peach-settings-cat-row"544 onClick={() => setActiveSection(opts.section)}545 >546 <div class="peach-settings-cat-icon">{opts.icon()}</div>547 <div class="peach-settings-cat-content">548 <span class="peach-settings-cat-name">549 {opts.name}550 <Show when={opts.pro && !isPro()}>551 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>552 </Show>553 </span>554 <span class="peach-settings-cat-desc">{opts.desc}</span>555 </div>556 <svg557 class="peach-settings-cat-chevron"558 width="6" height="10" viewBox="0 0 6 10" fill="none"559 stroke="currentColor" stroke-width="1.1"560 stroke-linecap="round" stroke-linejoin="round"561 >562 <path d="M1 1l4 4-4 4" />563 </svg>564 </button>565 );566 }567568 const iconShield = () => (569 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">570 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>571 </svg>572 );573 const iconKey = () => (574 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">575 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>576 </svg>577 );578 const iconLock = () => (579 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">580 <rect x="3" y="7" width="10" height="7" rx="1.5"/>581 <path d="M6 7V5a3 3 0 016 0v2"/>582 </svg>583 );584 const iconSync = () => (585 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">586 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>587 <path d="M14 6l-2 2-2-2"/>588 <path d="M2 10l2-2 2 2"/>589 </svg>590 );591 const iconSun = () => (592 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">593 <circle cx="8" cy="8" r="3"/>594 <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"/>595 </svg>596 );597 const iconKeys = () => (598 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">599 <rect x="2" y="5" width="5" height="4" rx="1"/>600 <rect x="9" y="7" width="5" height="4" rx="1"/>601 <path d="M7 7h2m-2 2h2"/>602 </svg>603 );604 const iconBubble = () => (605 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">606 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>607 </svg>608 );609610 function renderHome() {611 return (612 <div class="peach-settings-body">613 <p class="peach-settings-nav-label">PROTECTION</p>614 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}615 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}616617 <p class="peach-settings-nav-label">VAULT</p>618 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}619 <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>620 <div class="peach-settings-cat-icon">{iconSync()}</div>621 <div class="peach-settings-cat-content">622 <span class="peach-settings-cat-name">Sync</span>623 <span class="peach-settings-cat-desc">Pair and sync across devices</span>624 </div>625 <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">626 <path d="M1 1l4 4-4 4" />627 </svg>628 </button>629630 <p class="peach-settings-nav-label">PREFERENCES</p>631 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}632 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}633634 <p class="peach-settings-nav-label">SUPPORT</p>635 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}636637 <div class="peach-settings-footer">638 <span>Peach {version}</span>639 <span class="peach-divider"></span>640 <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>641 </div>642 </div>643 );644 }645646 function renderSecurity() {647 return (648 <div class="peach-settings-body">649 <div class="peach-settings-group">650 <p class="peach-settings-sub-label">LOCKING</p>651 <div class="peach-settings-row">652 <span>Lock on close</span>653 <button654 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}655 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}656 aria-label="Toggle lock on close"657 >658 <span class="peach-toggle-knob"></span>659 </button>660 </div>661 <div class="peach-settings-row">662 <span>Auto-lock after</span>663 <select664 class="peach-settings-select"665 value={String(props.autoLockMinutes)}666 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}667 >668 <option value="1">1 minute</option>669 <option value="5">5 minutes</option>670 <option value="15">15 minutes</option>671 <option value="30">30 minutes</option>672 <option value="0">Never</option>673 </select>674 </div>675676 <Show when={isNativePlatform() && biometricSupported()}>677 <p class="peach-settings-sub-label">BIOMETRICS</p>678 <div class="peach-settings-row">679 <span>Unlock with biometrics</span>680 <button681 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}682 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}683 disabled={biometricBusy()}684 aria-label="Toggle biometric unlock"685 >686 <span class="peach-toggle-knob"></span>687 </button>688 </div>689 <Show when={biometricError()}>690 <div class="peach-settings-row peach-settings-row--meta">691 <span class="peach-hint peach-hint-error">{biometricError()}</span>692 </div>693 </Show>694 </Show>695696 <p class="peach-settings-sub-label">BREACH PROTECTION</p>697 <div class="peach-settings-row">698 <span>Breach monitoring</span>699 <button700 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}701 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}702 aria-label="Toggle breach monitoring"703 >704 <span class="peach-toggle-knob"></span>705 </button>706 </div>707 <div class="peach-settings-row peach-settings-row--meta">708 <span>Check all passwords</span>709 <button710 class="peach-btn peach-btn--subtle peach-btn--sm"711 onClick={() => void handleCheckAllBreaches()}712 disabled={checkingBreaches()}713 >714 {checkingBreaches() ? "checking..." : "run"}715 </button>716 </div>717 <Show when={breachResult()}>718 <div class="peach-settings-row peach-settings-row--meta">719 <span>Last check</span>720 <span class="peach-settings-value">{breachResult()}</span>721 </div>722 </Show>723724 <p class="peach-settings-sub-label">MASTER PASSWORD</p>725 <div class="peach-settings-row peach-settings-row--meta">726 <span>Master password</span>727 <button728 class="peach-btn peach-btn--subtle peach-btn--sm"729 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}730 >731 {showChangePw() ? "cancel" : "change"}732 </button>733 </div>734 <Show when={showChangePw()}>735 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">736 <input737 class="peach-settings-input"738 type="password"739 placeholder="current password"740 value={changePwCurrent()}741 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}742 autocomplete="current-password"743 />744 <input745 class="peach-settings-input"746 type="password"747 placeholder="new password (min 12 chars)"748 value={changePwNew()}749 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}750 autocomplete="new-password"751 />752 <input753 class="peach-settings-input"754 type="password"755 placeholder="confirm new password"756 value={changePwConfirm()}757 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}758 autocomplete="new-password"759 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}760 />761 <Show when={changePwError()}>762 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>763 </Show>764 <Show when={changePwSuccess()}>765 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>766 </Show>767 <button768 class="peach-btn peach-btn--accent peach-btn--sm"769 style="align-self:flex-start"770 onClick={() => void handleChangePassword()}771 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}772 >773 {changePwSaving() ? "saving…" : "save new password"}774 </button>775 </div>776 </Show>777778 <p class="peach-settings-sub-label">DURESS PASSWORD</p>779 <div class="peach-settings-row peach-settings-row--top">780 <div class="peach-settings-row-main">781 <span>Duress password</span>782 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">783 If entered under coercion, shows a decoy empty vault instead of your real one.784 </span>785 </div>786 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>787 {duressEnabled() ? "on" : "off"}788 </div>789 </div>790791 <Show when={duressEnabled() && !showDuressSetup()}>792 <div class="peach-settings-row peach-settings-row--meta">793 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">794 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.795 </span>796 </div>797 </Show>798799 <Show when={duressSuccess()}>800 <div class="peach-settings-row peach-settings-row--meta">801 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>802 </div>803 </Show>804805 <Show when={!showDuressSetup()}>806 <div class="peach-settings-row peach-settings-row--meta">807 <button808 class="peach-btn peach-btn--subtle peach-btn--sm"809 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}810 >811 {duressEnabled() ? "Change / disable" : "Set up"}812 </button>813 </div>814 </Show>815816 <Show when={showDuressSetup()}>817 <div class="peach-duress-form">818 <label class="peach-label">main password (to confirm)</label>819 <input820 class="peach-settings-input"821 type="password"822 placeholder="your current master password"823 autocomplete="current-password"824 value={duressMainPassword()}825 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}826 />827 <Show when={!duressEnabled()}>828 <label class="peach-label">new duress password</label>829 <input830 class="peach-settings-input"831 type="password"832 placeholder="duress password (min 8 chars)"833 autocomplete="new-password"834 value={duressPassword()}835 onInput={(e) => setDuressPassword(e.currentTarget.value)}836 />837 <label class="peach-label">confirm duress password</label>838 <input839 class="peach-settings-input"840 type="password"841 placeholder="confirm duress password"842 autocomplete="new-password"843 value={duressConfirm()}844 onInput={(e) => setDuressConfirm(e.currentTarget.value)}845 />846 </Show>847848 <Show when={duressError()}>849 <p class="peach-hint peach-hint-error">{duressError()}</p>850 </Show>851852 <div class="peach-duress-actions">853 <button854 class="peach-btn peach-btn--subtle peach-btn--sm"855 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}856 >857 cancel858 </button>859 <Show when={duressEnabled()}>860 <button861 class="peach-btn peach-btn--ghost peach-btn--sm"862 style="color:var(--peach-breach)"863 onClick={() => void handleDisableDuress()}864 disabled={duressSaving()}865 >866 {duressSaving() ? "disabling..." : "disable duress"}867 </button>868 </Show>869 <Show when={!duressEnabled()}>870 <button871 class="peach-btn peach-btn--accent peach-btn--sm"872 onClick={() => void handleSetupDuress()}873 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}874 >875 {duressSaving() ? "saving..." : "save"}876 </button>877 </Show>878 </div>879 </div>880 </Show>881882 <p class="peach-settings-sub-label">CANARY ENTRIES</p>883 <div class="peach-settings-row peach-settings-row--top">884 <div class="peach-settings-row-main">885 <span>Canary entries</span>886 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">887 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.888 </span>889 </div>890 <span class="peach-settings-value" style="flex-shrink:0">891 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}892 </span>893 </div>894 <Show when={canaryEntries().length > 0}>895 <For each={canaryEntries()}>896 {(entry) => (897 <div class="peach-settings-row peach-settings-row--meta">898 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>899 <button900 class="peach-btn peach-btn--subtle peach-btn--sm"901 onClick={() => void handleTestCanary(entry)}902 disabled={canaryTestingId() === entry.id}903 style="flex-shrink:0"904 >905 {canaryTestingId() === entry.id906 ? "sending…"907 : canaryTestResult()?.id === entry.id908 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")909 : "test"}910 </button>911 </div>912 )}913 </For>914 </Show>915916 <p class="peach-settings-sub-label">PASSKEYS</p>917 <div class="peach-settings-row peach-settings-row--top">918 <div class="peach-settings-row-main">919 <span>Intercept passkey requests</span>920 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">921 Peach can handle passkey sign-in and creation on supported Chromium browsers.922 </span>923 </div>924 <button925 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}926 onClick={() => void handleTogglePasskeys()}927 aria-label="Toggle passkey interception"928 disabled={!passkeyState()?.supported || passkeySaving()}929 >930 <span class="peach-toggle-knob"></span>931 </button>932 </div>933 <div class="peach-settings-row peach-settings-row--meta">934 <span>Status</span>935 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>936 </div>937 <div class="peach-settings-row peach-settings-row--meta">938 <span>Browser</span>939 <span class="peach-settings-value">940 {passkeyState()?.browserLabel ?? "Unknown"}941 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}942 </span>943 </div>944 <div class="peach-info-notice" style="margin-top:8px">945 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.946 </div>947 <Show when={passkeyState()?.conflict}>948 <div class="peach-settings-row peach-settings-row--meta">949 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>950 </div>951 </Show>952 </div>953 </div>954 );955 }956957 function renderVault() {958 return (959 <div class="peach-settings-body">960 <div class="peach-settings-group">961 <p class="peach-settings-sub-label">ENTRIES</p>962 <div class="peach-settings-row peach-settings-row--meta">963 <span>Entries</span>964 <span class="peach-settings-value">{props.entriesCount}</span>965 </div>966 <div class="peach-settings-row peach-settings-row--meta">967 <span>Duplicates</span>968 <Show when={dedupDone() !== null} fallback={969 <button970 class="peach-btn peach-btn--subtle peach-btn--sm"971 onClick={() => void handleCountDuplicates()}972 disabled={dedupRunning()}973 >974 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}975 </button>976 }>977 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">978 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}979 </span>980 </Show>981 </div>982 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>983 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">984 <div class="peach-restore-warning">985 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.986 </div>987 <div style="display:flex;gap:6px">988 <button989 class="peach-btn peach-btn--subtle peach-btn--sm"990 onClick={() => setDedupCount(null)}991 >992 cancel993 </button>994 <button995 class="peach-btn peach-btn--accent peach-btn--sm"996 onClick={() => void handleDeduplicate()}997 disabled={dedupRunning()}998 >999 {dedupRunning() ? "removing..." : "remove duplicates"}1000 </button>1001 </div>1002 </div>1003 </Show>10041005 <p class="peach-settings-sub-label">MAINTENANCE</p>1006 <div class="peach-settings-row peach-settings-row--meta">1007 <span>Trash auto-delete</span>1008 <select1009 class="peach-settings-select"1010 value={String(props.trashRetentionDays)}1011 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1012 >1013 <option value="7">7 days</option>1014 <option value="30">30 days</option>1015 <option value="90">90 days</option>1016 <option value="0">Never</option>1017 </select>1018 </div>1019 <div class="peach-settings-row peach-settings-row--meta">1020 <span>Vault Health</span>1021 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1022 open1023 </button>1024 </div>1025 <Show when={declinedCardCount() > 0}>1026 <div class="peach-settings-row peach-settings-row--meta">1027 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1028 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1029 Clear1030 </button>1031 </div>1032 </Show>10331034 <p class="peach-settings-sub-label">DATA</p>1035 <div class="peach-settings-row peach-settings-row--meta">1036 <span>Import passwords</span>1037 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1038 open1039 </button>1040 </div>1041 <div class="peach-settings-row peach-settings-row--meta">1042 <span>Export backup</span>1043 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1044 open1045 </button>1046 </div>1047 <div class="peach-settings-row peach-settings-row--meta">1048 <span>1049 Peach Codex1050 <Show when={!isPro()}>1051 <span class="peach-pro-badge">Pro</span>1052 </Show>1053 </span>1054 <button1055 class="peach-btn peach-btn--subtle peach-btn--sm"1056 onClick={() => props.onOpenCodexGenerate?.()}1057 >1058 open1059 </button>1060 </div>10611062 <p class="peach-settings-sub-label">VAULT HISTORY</p>1063 <Show when={historyLoading()}>1064 <p class="peach-settings-hint">Loading…</p>1065 </Show>1066 <Show when={!historyLoading() && historySnapshots().length === 0}>1067 <p class="peach-settings-hint">1068 No snapshots yet. Peach saves automatically before every change —1069 saves, syncs, and imports.1070 </p>1071 </Show>1072 <Show when={!historyLoading() && historySnapshots().length > 0}>1073 <p class="peach-settings-hint">1074 Restoring locks your vault. Unlock with the password that1075 encrypted that snapshot to confirm.1076 </p>1077 <div class="peach-history-list">1078 <For each={historySnapshots()}>1079 {(snap) => (1080 <div class="peach-history-row">1081 <div class="peach-history-meta">1082 <span class="peach-history-time">1083 {formatSnapshotTime(snap.savedAt)}1084 </span>1085 <span class="peach-history-sub">1086 {[1087 snap.entryCount !== null1088 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1089 : null,1090 reasonLabel(snap.reason),1091 ]1092 .filter(Boolean)1093 .join(" · ")}1094 </span>1095 </div>1096 <button1097 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1098 historyRestoring() === snap.id ? " is-busy" : ""1099 }`}1100 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1101 disabled={historyRestoring() !== null}1102 >1103 <Show when={historyRestoring() === snap.id} fallback="Restore">1104 …1105 </Show>1106 </button>1107 </div>1108 )}1109 </For>1110 </div>1111 <p class="peach-history-footer">1112 {historySnapshots().length} of 10 snapshots1113 </p>1114 </Show>11151116 <button1117 class="peach-settings-advanced-toggle"1118 onClick={() => setShowVaultAdvanced((v) => !v)}1119 aria-expanded={showVaultAdvanced()}1120 >1121 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1122 <span class="peach-settings-advanced-chevron">1123 {showVaultAdvanced() ? "▲" : "▼"}1124 </span>1125 </button>11261127 <Show when={showVaultAdvanced()}>1128 <div class="peach-danger-card">1129 <div class="peach-danger-card-head">1130 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1131 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1132 </div>1133 <p class="peach-danger-card-copy">1134 Wipes the current vault and restores from a backup file.1135 Recoverable only if your backup is good.1136 </p>1137 <button1138 class="peach-danger-card-button"1139 onClick={() => props.onOpenFileImport?.()}1140 >1141 Choose backup file1142 </button>1143 </div>11441145 <Show when={props.onOpenPanic}>1146 <div class="peach-danger-card peach-danger-card--hazard">1147 <div class="peach-danger-card-head">1148 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1149 <h3 class="peach-danger-card-title">Destroy vault</h3>1150 </div>1151 <p class="peach-danger-card-copy">1152 Permanently wipes every entry, setting, and license on this1153 device. There is no recovery.1154 </p>1155 <button1156 class="peach-danger-card-button"1157 onClick={() => props.onOpenPanic?.()}1158 >1159 Begin destruction1160 </button>1161 </div>1162 </Show>1163 </Show>1164 </div>1165 </div>1166 );1167 }11681169 function renderAppearance() {1170 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1171 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1172 { id: "cormorant", label: "Cormorant", preview: "Aa" },1173 { id: "inter", label: "Inter", preview: "Aa" },1174 { id: "system", label: "System", preview: "Aa" },1175 ];1176 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1177 { id: "small", label: "Small" },1178 { id: "medium", label: "Default" },1179 { id: "large", label: "Large" },1180 ];1181 return (1182 <div class="peach-settings-body">1183 <div class="peach-settings-group">1184 <p class="peach-settings-sub-label">THEME</p>1185 <div class="peach-settings-row peach-settings-row--meta">1186 <span>Theme</span>1187 <button1188 class="peach-btn peach-btn--subtle peach-btn--sm"1189 onClick={() =>1190 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1191 }1192 >1193 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1194 </button>1195 </div>11961197 <p class="peach-settings-sub-label">ACCENT</p>1198 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1199 <span>Accent</span>1200 <div class="peach-accent-picker">1201 <For each={ACCENT_COLORS}>1202 {(color) => (1203 <button1204 class="peach-accent-swatch"1205 classList={{ "is-active": props.accentColor === color.name }}1206 style={{ background: color.hex }}1207 onClick={() => void props.onAccentChange(color.name)}1208 aria-label={color.name}1209 title={color.name}1210 />1211 )}1212 </For>1213 </div>1214 </div>12151216 <p class="peach-settings-sub-label">DISPLAY FONT</p>1217 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1218 <span>Font</span>1219 <div class="peach-font-picker">1220 <For each={fontOptions}>1221 {(opt) => (1222 <button1223 class="peach-font-swatch"1224 classList={{ "is-active": props.displayFont === opt.id }}1225 data-font-preview={opt.id}1226 onClick={() => void props.onDisplayFontChange(opt.id)}1227 aria-label={opt.label}1228 title={opt.label}1229 >1230 <span class="peach-font-swatch-glyph">{opt.preview}</span>1231 <span class="peach-font-swatch-name">{opt.label}</span>1232 </button>1233 )}1234 </For>1235 </div>1236 </div>12371238 <p class="peach-settings-sub-label">TEXT SIZE</p>1239 <div class="peach-settings-row peach-settings-row--meta">1240 <span>Size</span>1241 <div class="peach-pill-group">1242 <For each={sizeOptions}>1243 {(opt) => (1244 <button1245 class="peach-btn peach-btn--ghost peach-btn--sm"1246 classList={{ "is-active": props.textSize === opt.id }}1247 onClick={() => void props.onTextSizeChange(opt.id)}1248 >1249 {opt.label}1250 </button>1251 )}1252 </For>1253 </div>1254 </div>12551256 <p class="peach-settings-sub-label">MOTION</p>1257 <div class="peach-settings-row peach-settings-row--meta">1258 <span>Reduce motion</span>1259 <button1260 class="peach-btn peach-btn--subtle peach-btn--sm"1261 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1262 >1263 {props.reduceMotion ? "On" : "Off"}1264 </button>1265 </div>1266 </div>1267 </div>1268 );1269 }12701271 function renderShortcuts() {1272 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1273 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12741275 function startCapture(action: ShortcutAction) {1276 setConflictMsg(null);1277 setCapturingAction(action);1278 }1279 function cancelCapture() {1280 setCapturingAction(null);1281 setConflictMsg(null);1282 }1283 function commitBinding(action: ShortcutAction, binding: Keybinding) {1284 const conflict = findConflict(props.shortcuts, binding, action);1285 if (conflict) {1286 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1287 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1288 return;1289 }1290 const next = { ...props.shortcuts, [action]: binding };1291 setCapturingAction(null);1292 setConflictMsg(null);1293 void props.onShortcutsChange(next);1294 }1295 function resetAction(action: ShortcutAction) {1296 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1297 void props.onShortcutsChange(next);1298 }1299 function resetAll() {1300 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1301 }13021303 1304 1305 function handleCaptureKey(e: KeyboardEvent) {1306 e.preventDefault();1307 e.stopPropagation();1308 if (e.key === "Escape") { cancelCapture(); return; }1309 const binding = keybindingFromEvent(e);1310 if (!binding) return; 1311 const action = capturingAction();1312 if (action) commitBinding(action, binding);1313 }13141315 return (1316 <div class="peach-settings-body">1317 <div class="peach-settings-group peach-shortcuts-group">1318 <p class="peach-settings-sub-label">ENTRY VIEW</p>1319 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1320 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1321 </For>1322 </div>13231324 <div class="peach-settings-group peach-shortcuts-group">1325 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1326 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1327 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1328 </For>1329 </div>13301331 <Show when={conflictMsg()}>1332 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1333 </Show>13341335 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1336 <div class="peach-settings-row peach-settings-row--meta">1337 <span>Inline autofill on page</span>1338 <a1339 class="peach-settings-link"1340 href="chrome://extensions/shortcuts"1341 onClick={(e) => {1342 e.preventDefault();1343 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1344 }}1345 >1346 ⌘⇧P — customize ↗1347 </a>1348 </div>1349 <button1350 class="peach-btn peach-btn--subtle peach-btn--sm"1351 style="align-self:flex-start"1352 onClick={resetAll}1353 >1354 Reset all to defaults1355 </button>1356 </div>1357 </div>1358 );13591360 function renderRow(1361 desc: typeof SHORTCUT_DESCRIPTORS[number],1362 capturing: () => ShortcutAction | null,1363 onStart: (a: ShortcutAction) => void,1364 onCancel: () => void,1365 onReset: (a: ShortcutAction) => void,1366 onKey: (e: KeyboardEvent) => void,1367 ) {1368 const isActive = () => capturing() === desc.action;1369 const binding = () => props.shortcuts[desc.action];1370 const isCustom = () => {1371 const b = binding();1372 const d = DEFAULT_BINDINGS[desc.action];1373 return b.key !== d.key1374 || Boolean(b.ctrl) !== Boolean(d.ctrl)1375 || Boolean(b.meta) !== Boolean(d.meta)1376 || Boolean(b.shift) !== Boolean(d.shift)1377 || Boolean(b.alt) !== Boolean(d.alt);1378 };1379 return (1380 <div class="peach-shortcut-row">1381 <span class="peach-shortcut-row-label">{desc.label}</span>1382 <button1383 class="peach-shortcut-chip"1384 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1385 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1386 onKeyDown={(e) => { if (isActive()) onKey(e); }}1387 ref={(el) => {1388 1389 if (el) {1390 queueMicrotask(() => { if (isActive()) el.focus(); });1391 }1392 }}1393 >1394 {isActive() ? "Press a key…" : formatKeybinding(binding())}1395 </button>1396 <Show when={isCustom() && !isActive()}>1397 <button1398 class="peach-shortcut-reset"1399 title="Reset to default"1400 onClick={() => onReset(desc.action)}1401 >1402 ↺1403 </button>1404 </Show>1405 </div>1406 );1407 }1408 }14091410 const PRO_FEATURES: Array<{1411 feature: ProFeature;1412 name: string;1413 desc: string;1414 }> = [1415 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1416 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1417 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1418 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1419 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1420 ];14211422 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14231424 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1425 const [deactivating, setDeactivating] = createSignal(false);14261427 async function handleDeactivateLicense() {1428 setDeactivating(true);1429 await sendMessage({ type: "DEACTIVATE_LICENSE" });1430 setLicenseInfo(null);1431 await props.onLicenseChange?.();1432 setDeactivating(false);1433 }14341435 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1436 setCanaryTestingId(entry.id);1437 setCanaryTestResult(null);1438 const res = await sendMessage({1439 type: "FIRE_CANARY_PING",1440 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1441 }) as { ok: boolean };1442 setCanaryTestResult({ id: entry.id, ok: res.ok });1443 setCanaryTestingId(null);1444 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1445 }14461447 function renderLicense() {1448 async function onActivate() {1449 await handleActivateLicense();1450 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1451 await props.onLicenseChange?.();1452 }1453 }14541455 return (1456 <div class="peach-settings-body">1457 <Show1458 when={isPro()}1459 fallback={1460 <Show1461 when={showKeyEntry()}1462 fallback={1463 <div class="peach-pro-upgrade">1464 <div class="peach-pro-upgrade-hero">1465 <div class="peach-pro-upgrade-mark-wrap">1466 <PeachMark class="peach-pro-upgrade-mark" />1467 </div>1468 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1469 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1470 </div>14711472 <div class="peach-pro-features">1473 <For each={PRO_FEATURES}>1474 {(item) => (1475 <div1476 class="peach-pro-feature"1477 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1478 >1479 <span class="peach-pro-feature-mark">✦</span>1480 <div class="peach-pro-feature-text">1481 <span class="peach-pro-feature-name">{item.name}</span>1482 <span class="peach-pro-feature-desc">{item.desc}</span>1483 </div>1484 </div>1485 )}1486 </For>1487 </div>14881489 <div class="peach-pro-upgrade-actions">1490 <p class="peach-pro-pricing">$39 · once · yours forever</p>1491 <a1492 href="https://peachpasswords.com/#pricing"1493 target="_blank"1494 rel="noopener noreferrer"1495 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1496 >1497 Get Peach Pro1498 </a>1499 <div class="peach-pro-key-divider"><span>or</span></div>1500 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1501 Activate a license key1502 </button>1503 </div>1504 </div>1505 }1506 >1507 <div class="peach-pro-key-ceremony">1508 <LockRuneCanvas />1509 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1510 <input1511 class="peach-underline-input peach-pro-key-input"1512 type="text"1513 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1514 value={licenseKeyInput()}1515 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1516 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1517 autocomplete="off"1518 spellcheck={false}1519 />1520 <Show when={licenseError()}>1521 <p class="peach-pro-key-error">That key doesn't look right.</p>1522 </Show>1523 <button1524 class="peach-btn peach-btn--accent peach-pro-key-activate"1525 onClick={() => void onActivate()}1526 disabled={activatingLicense() || !licenseKeyInput().trim()}1527 >1528 {activatingLicense() ? "activating…" : "Activate"}1529 </button>1530 <button1531 class="peach-pro-key-cancel"1532 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1533 >1534 cancel1535 </button>1536 </div>1537 </Show>1538 }1539 >1540 <div class="peach-pro-active">1541 <div class="peach-pro-active-hero">1542 <div class="peach-pro-active-mark-wrap">1543 <PeachMark class="peach-pro-active-mark" />1544 </div>1545 <h2 class="peach-pro-active-title">Peach Pro</h2>1546 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1547 </div>15481549 <Show when={licenseKey()}>1550 <div class="peach-settings-license">1551 <span class="peach-settings-label">LICENSE KEY</span>1552 <div class="peach-settings-license-row">1553 <span class="peach-settings-license-key">{licenseKey()!}</span>1554 <button1555 class="peach-settings-license-copy"1556 onClick={() => copyLicenseKey(licenseKey()!)}1557 >1558 {copied() ? "✓" : "copy"}1559 </button>1560 </div>1561 </div>1562 </Show>15631564 <div class="peach-pro-active-features">1565 <For each={PRO_FEATURES}>1566 {(item) => (1567 <div class="peach-pro-active-feature">1568 <span class="peach-pro-active-feature-mark">✦</span>1569 <span class="peach-pro-active-feature-name">{item.name}</span>1570 </div>1571 )}1572 </For>1573 </div>15741575 <button1576 class="peach-pro-deactivate"1577 onClick={() => void handleDeactivateLicense()}1578 disabled={deactivating()}1579 >1580 {deactivating() ? "Deactivating…" : "Deactivate"}1581 </button>1582 </div>1583 </Show>1584 </div>1585 );1586 }15871588 function renderFeedback() {1589 return (1590 <div class="peach-settings-body">1591 <div class="peach-settings-group peach-feedback-group">1592 <p class="peach-feedback-intro">1593 Help us improve Peach. Every report goes directly to our inbox.1594 </p>15951596 <p class="peach-settings-sub-label">TYPE</p>1597 <div class="peach-feedback-chips">1598 <For each={FEEDBACK_CATEGORIES}>1599 {(cat) => (1600 <button1601 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1602 onClick={() => setFbCategory(cat)}1603 >1604 {cat}1605 </button>1606 )}1607 </For>1608 </div>16091610 <p class="peach-settings-sub-label">MESSAGE</p>1611 <textarea1612 class="peach-feedback-textarea"1613 placeholder="Describe the issue or idea..."1614 value={fbMessage()}1615 onInput={(e) => setFbMessage(e.currentTarget.value)}1616 />16171618 <p class="peach-feedback-meta">1619 Version {version} · will be included automatically1620 </p>16211622 <Show when={fbResult() === "success"}>1623 <p class="peach-feedback-result peach-feedback-result--ok">1624 Sent — thank you1625 </p>1626 </Show>1627 <Show when={fbResult() === "error"}>1628 <p class="peach-feedback-result peach-feedback-result--err">1629 Failed to send — try again1630 </p>1631 </Show>16321633 <button1634 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1635 onClick={() => void handleSubmitFeedback()}1636 disabled={fbSubmitting() || !fbMessage().trim()}1637 >1638 {fbSubmitting() ? "Sending..." : "Send feedback"}1639 </button>16401641 <p class="peach-feedback-anon">1642 Sent anonymously · no account required1643 </p>1644 </div>1645 </div>1646 );1647 }16481649 return (1650 <Show1651 when={showNewRecoveryWords()}1652 fallback={1653 <div class="peach-overlay-view ov-enter-right" style="display:flex">1654 <div class="peach-overlay-header">1655 <button1656 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1657 onClick={handleHeaderBack}1658 aria-label="Back"1659 >1660 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1661 <path d="M6 1L1 6L6 11" />1662 </svg>1663 </button>1664 <span class="peach-overlay-title">{currentTitle()}</span>1665 <div class="peach-overlay-spacer"></div>1666 </div>16671668 <Switch fallback={renderHome()}>1669 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1670 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1671 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1672 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1673 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1674 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1675 </Switch>1676 </div>1677 }1678 >1679 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1680 </Show>1681 );1682}