1import { createEffect, 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}6667type SectionName =68 | "security"69 | "vault"70 | "appearance"71 | "shortcuts"72 | "license"73 | "feedback";7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7677function formatActivatedSince(issued: string | undefined): string {78 if (!issued) return "Active";79 try {80 const d = new Date(issued);81 if (Number.isNaN(d.getTime())) return "Active";82 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;83 } catch {84 return "Active";85 }86}8788function isSectionName(value: string): value is SectionName {89 return (90 value === "security" ||91 value === "vault" ||92 value === "appearance" ||93 value === "shortcuts" ||94 value === "license" ||95 value === "feedback"96 );97}9899export function SettingsView(props: SettingsProps) {100 const version = chrome.runtime.getManifest().version;101 const initial = props.initialSection;102 const [activeSection, setActiveSection] = createSignal<SectionName | null>(103 initial && isSectionName(initial) ? (initial as SectionName) : null104 );105106 const [checkingBreaches, setCheckingBreaches] = createSignal(false);107 const [breachResult, setBreachResult] = createSignal<string | null>(null);108 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);109 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");110 const [licenseError, setLicenseError] = createSignal<string | null>(null);111 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);112 const [activatingLicense, setActivatingLicense] = createSignal(false);113114 const isPro = () => props.isPro ?? false;115 const [biometricSupported, setBiometricSupported] = createSignal(false);116 const [biometricStored, setBiometricStored] = createSignal(false);117 const [biometricBusy, setBiometricBusy] = createSignal(false);118 const [biometricError, setBiometricError] = createSignal<string | null>(null);119 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);120 const [passkeySaving, setPasskeySaving] = createSignal(false);121122 const [dedupCount, setDedupCount] = createSignal<number | null>(null);123 const [dedupRunning, setDedupRunning] = createSignal(false);124 const [dedupDone, setDedupDone] = createSignal<number | null>(null);125126 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);127128 129 130 131 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);132 const [historyLoading, setHistoryLoading] = createSignal(false);133 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);134135 async function loadVaultHistory() {136 setHistoryLoading(true);137 try {138 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });139 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];140 setHistorySnapshots(list);141 } finally {142 setHistoryLoading(false);143 }144 }145146 async function handleRestoreVaultSnapshot(id: string) {147 if (historyRestoring()) return;148 setHistoryRestoring(id);149 const res = await sendMessage({150 type: "RESTORE_VAULT_SNAPSHOT",151 payload: { snapshotId: id },152 });153 setHistoryRestoring(null);154 if ((res as { ok: boolean }).ok) {155 156 157 props.onBack();158 }159 }160161 function formatSnapshotTime(savedAt: number): string {162 const diff = Date.now() - savedAt;163 if (diff < 60_000) return "Just now";164 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;165 const d = new Date(savedAt);166 const isToday = new Date().toDateString() === d.toDateString();167 const isYest =168 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();169 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });170 if (isToday) return `Today ${t}`;171 if (isYest) return `Yesterday ${t}`;172 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;173 }174175 function reasonLabel(reason: VaultSnapshot["reason"]): string {176 return (177 {178 local_save: "saved",179 sync_received: "synced",180 import: "imported",181 restore: "restored",182 } as const183 )[reason] ?? reason;184 }185186 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);187188 const [showChangePw, setShowChangePw] = createSignal(false);189 const [changePwCurrent, setChangePwCurrent] = createSignal("");190 const [changePwNew, setChangePwNew] = createSignal("");191 const [changePwConfirm, setChangePwConfirm] = createSignal("");192 const [changePwSaving, setChangePwSaving] = createSignal(false);193 const [changePwError, setChangePwError] = createSignal<string | null>(null);194 const [changePwSuccess, setChangePwSuccess] = createSignal(false);195 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);196197 const [duressEnabled, setDuressEnabled] = createSignal(false);198 const [showDuressSetup, setShowDuressSetup] = createSignal(false);199 const [duressPassword, setDuressPassword] = createSignal("");200 const [duressConfirm, setDuressConfirm] = createSignal("");201 const [duressMainPassword, setDuressMainPassword] = createSignal("");202 const [duressSaving, setDuressSaving] = createSignal(false);203 const [duressError, setDuressError] = createSignal<string | null>(null);204 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);205206 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);207208 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);209 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);210211 const [fbCategory, setFbCategory] = createSignal("Bug report");212 const [fbMessage, setFbMessage] = createSignal("");213 const [fbSubmitting, setFbSubmitting] = createSignal(false);214 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);215216 onMount(async () => {217 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([218 sendMessage({ type: "GET_LICENSE" }),219 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),220 sendMessage({ type: "IS_DURESS_ENABLED" }),221 sendMessage({ type: "GET_SETTINGS" }),222 sendMessage({ type: "GET_LICENSE_KEY" }),223 ]);224225 226 227 void loadVaultHistory();228229 if ((licenseRes as { ok: boolean }).ok) {230 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);231 }232233 if ((passkeyRes as { ok: boolean }).ok) {234 setPasskeyState(235 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null236 );237 }238239 if ((duressRes as { ok: boolean }).ok) {240 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);241 }242243 if ((settingsRes as { ok: boolean }).ok) {244 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;245 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);246 }247248 if ((keyRes as { ok: boolean }).ok) {249 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);250 }251252 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });253 if ((entriesRes as { ok: boolean }).ok) {254 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];255 setCanaryEntries(256 entries257 .filter((e) => e.canary?.webhookUrl)258 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))259 );260 }261262263 if (isNativePlatform()) {264 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");265 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");266 try {267 if (biometric) {268 const a = await biometric.isAvailable();269 setBiometricSupported(a.available);270 }271 if (keystore) {272 const k = await keystore.hasMasterKey();273 setBiometricStored(k.exists);274 }275 } catch { }276 }277 });278279 async function handleEnableBiometrics() {280 setBiometricBusy(true);281 setBiometricError(null);282 try {283 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {284 ok: boolean;285 data?: { masterKeyHex: string };286 error?: string;287 };288 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {289 setBiometricError(keyRes.error === "vault_locked"290 ? "unlock the vault first"291 : "couldn't read derived key");292 return;293 }294 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");295 if (!keystore) {296 setBiometricError("biometric plugin unavailable");297 return;298 }299 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });300 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });301 setBiometricStored(true);302 } catch (e) {303 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");304 } finally {305 setBiometricBusy(false);306 }307 }308309 async function handleDisableBiometrics() {310 setBiometricBusy(true);311 setBiometricError(null);312 try {313 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");314 if (keystore) await keystore.clearMasterKey();315 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });316 setBiometricStored(false);317 } catch (e) {318 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");319 } finally {320 setBiometricBusy(false);321 }322 }323324 async function handleChangePassword() {325 setChangePwError(null);326 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }327 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }328 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }329 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }330 setChangePwSaving(true);331 const res = await sendMessage({332 type: "CHANGE_PASSWORD",333 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },334 }) as { ok: boolean; error?: string };335 if (res.ok) {336 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");337 setShowChangePw(false);338 setShowNewRecoveryWords(true);339 } else {340 setChangePwError(res.error ?? "failed to change password");341 }342 setChangePwSaving(false);343 }344345 const [copied, setCopied] = createSignal(false);346347 async function copyLicenseKey(key: string) {348 await navigator.clipboard.writeText(key);349 setCopied(true);350 setTimeout(() => setCopied(false), 2000);351 }352353 async function handleActivateLicense() {354 const key = licenseKeyInput().trim();355 if (!key) return;356 setActivatingLicense(true);357 setLicenseError(null);358 setLicenseSuccess(null);359 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });360 setActivatingLicense(false);361 if ((res as { ok: boolean }).ok) {362 const data = (res as { ok: boolean; data?: LicenseInfo }).data;363 if (data?.valid) {364 setLicenseInfo(data);365 setLicenseKey(key);366 setLicenseSuccess("License activated!");367 setLicenseKeyInput("");368 } else {369 setLicenseError("Invalid license key");370 }371 } else {372 setLicenseError("Activation failed");373 }374 }375376 async function handleTogglePasskeys() {377 const current = passkeyState();378 if (!current || !current.supported) return;379 setPasskeySaving(true);380 const res = await sendMessage({381 type: "SET_PASSKEY_PROVIDER_ENABLED",382 payload: { enabled: !current.enabled },383 });384 setPasskeySaving(false);385 if ((res as { ok: boolean }).ok) {386 setPasskeyState(387 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null388 );389 }390 }391392 async function handleCheckAllBreaches() {393 setCheckingBreaches(true);394 setBreachResult(null);395 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });396 setCheckingBreaches(false);397 if ((res as { ok: boolean }).ok) {398 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};399 const breached = Object.values(data).filter((count) => count > 0).length;400 setBreachResult(401 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"402 );403 props.onBreachCheckDone();404 }405 }406407408 async function handleSetupDuress() {409 const mainPw = duressMainPassword().trim();410 const duressPw = duressPassword().trim();411 const confirmPw = duressConfirm().trim();412413 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }414 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }415 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }416 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }417418 setDuressSaving(true);419 setDuressError(null);420421 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };422 if (!verifyRes.ok || !verifyRes.data) {423 setDuressError("incorrect main password");424 setDuressSaving(false);425 return;426 }427428 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };429 setDuressSaving(false);430 if (res.ok) {431 setDuressEnabled(true);432 setShowDuressSetup(false);433 setDuressPassword("");434 setDuressConfirm("");435 setDuressMainPassword("");436 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");437 setTimeout(() => setDuressSuccess(null), 5000);438 } else {439 setDuressError(res.error ?? "failed to set duress password");440 }441 }442443 async function handleClearDeclinedCards() {444 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });445 setDeclinedCardCount(0);446 }447448 async function handleCountDuplicates() {449 setDedupRunning(true);450 setDedupDone(null);451 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };452 setDedupRunning(false);453 if (res.ok) setDedupCount(res.data ?? 0);454 }455456 async function handleDeduplicate() {457 setDedupRunning(true);458 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };459 setDedupRunning(false);460 if (res.ok) {461 setDedupDone(res.data ?? 0);462 setDedupCount(0);463 }464 }465466 async function handleDisableDuress() {467 const mainPw = duressMainPassword().trim();468 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }469470 setDuressSaving(true);471 setDuressError(null);472473 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };474 if (!verifyRes.ok || !verifyRes.data) {475 setDuressError("incorrect main password");476 setDuressSaving(false);477 return;478 }479480 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };481 setDuressSaving(false);482 if (res.ok) {483 setDuressEnabled(false);484 setShowDuressSetup(false);485 setDuressMainPassword("");486 setDuressSuccess("Duress password disabled.");487 setTimeout(() => setDuressSuccess(null), 4000);488 } else {489 setDuressError(res.error ?? "failed to disable duress password");490 }491 }492493 async function handleSubmitFeedback() {494 if (!fbMessage().trim() || fbSubmitting()) return;495 setFbSubmitting(true);496 setFbResult(null);497 const res = await sendMessage({498 type: "SUBMIT_FEEDBACK",499 payload: {500 category: fbCategory(),501 message: fbMessage().trim(),502 version,503 browser: navigator.userAgent,504 },505 }) as { ok: boolean; error?: string };506 setFbSubmitting(false);507 if (res.ok) {508 setFbResult("success");509 setFbMessage("");510 setTimeout(() => setFbResult(null), 4000);511 } else {512 setFbResult("error");513 }514 }515516 function handleHeaderBack() {517 if (activeSection() !== null) {518 setActiveSection(null);519 } else {520 props.onBack();521 }522 }523524 function currentTitle(): string {525 const s = activeSection();526 if (s === null) return "settings";527 if (s === "license") return "account";528 return s;529 }530531 function renderCatRow(opts: {532 section: SectionName;533 name: string;534 desc: string;535 icon: () => any;536 pro?: boolean;537 }) {538 return (539 <button540 class="peach-settings-cat-row"541 onClick={() => setActiveSection(opts.section)}542 >543 <div class="peach-settings-cat-icon">{opts.icon()}</div>544 <div class="peach-settings-cat-content">545 <span class="peach-settings-cat-name">546 {opts.name}547 <Show when={opts.pro && !isPro()}>548 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>549 </Show>550 </span>551 <span class="peach-settings-cat-desc">{opts.desc}</span>552 </div>553 <svg554 class="peach-settings-cat-chevron"555 width="6" height="10" viewBox="0 0 6 10" fill="none"556 stroke="currentColor" stroke-width="1.1"557 stroke-linecap="round" stroke-linejoin="round"558 >559 <path d="M1 1l4 4-4 4" />560 </svg>561 </button>562 );563 }564565 const iconShield = () => (566 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">567 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>568 </svg>569 );570 const iconKey = () => (571 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">572 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>573 </svg>574 );575 const iconLock = () => (576 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">577 <rect x="3" y="7" width="10" height="7" rx="1.5"/>578 <path d="M6 7V5a3 3 0 016 0v2"/>579 </svg>580 );581 const iconSync = () => (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 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>584 <path d="M14 6l-2 2-2-2"/>585 <path d="M2 10l2-2 2 2"/>586 </svg>587 );588 const iconSun = () => (589 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">590 <circle cx="8" cy="8" r="3"/>591 <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"/>592 </svg>593 );594 const iconKeys = () => (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 <rect x="2" y="5" width="5" height="4" rx="1"/>597 <rect x="9" y="7" width="5" height="4" rx="1"/>598 <path d="M7 7h2m-2 2h2"/>599 </svg>600 );601 const iconBubble = () => (602 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">603 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>604 </svg>605 );606607 function renderHome() {608 return (609 <div class="peach-settings-body">610 <p class="peach-settings-nav-label">PROTECTION</p>611 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}612 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}613614 <p class="peach-settings-nav-label">VAULT</p>615 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}616 <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>617 <div class="peach-settings-cat-icon">{iconSync()}</div>618 <div class="peach-settings-cat-content">619 <span class="peach-settings-cat-name">Sync</span>620 <span class="peach-settings-cat-desc">Pair and sync across devices</span>621 </div>622 <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">623 <path d="M1 1l4 4-4 4" />624 </svg>625 </button>626627 <p class="peach-settings-nav-label">PREFERENCES</p>628 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}629 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}630631 <p class="peach-settings-nav-label">SUPPORT</p>632 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}633634 <div class="peach-settings-footer">635 <span>Peach {version}</span>636 <span class="peach-divider"></span>637 <span>Browser extension</span>638 </div>639 </div>640 );641 }642643 function renderSecurity() {644 return (645 <div class="peach-settings-body">646 <div class="peach-settings-group">647 <p class="peach-settings-sub-label">LOCKING</p>648 <div class="peach-settings-row">649 <span>Lock on close</span>650 <button651 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}652 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}653 aria-label="Toggle lock on close"654 >655 <span class="peach-toggle-knob"></span>656 </button>657 </div>658 <div class="peach-settings-row">659 <span>Auto-lock after</span>660 <select661 class="peach-settings-select"662 value={String(props.autoLockMinutes)}663 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}664 >665 <option value="1">1 minute</option>666 <option value="5">5 minutes</option>667 <option value="15">15 minutes</option>668 <option value="30">30 minutes</option>669 <option value="0">Never</option>670 </select>671 </div>672673 <Show when={isNativePlatform() && biometricSupported()}>674 <p class="peach-settings-sub-label">BIOMETRICS</p>675 <div class="peach-settings-row">676 <span>Unlock with biometrics</span>677 <button678 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}679 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}680 disabled={biometricBusy()}681 aria-label="Toggle biometric unlock"682 >683 <span class="peach-toggle-knob"></span>684 </button>685 </div>686 <Show when={biometricError()}>687 <div class="peach-settings-row peach-settings-row--meta">688 <span class="peach-hint peach-hint-error">{biometricError()}</span>689 </div>690 </Show>691 </Show>692693 <p class="peach-settings-sub-label">BREACH PROTECTION</p>694 <div class="peach-settings-row">695 <span>Breach monitoring</span>696 <button697 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}698 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}699 aria-label="Toggle breach monitoring"700 >701 <span class="peach-toggle-knob"></span>702 </button>703 </div>704 <div class="peach-settings-row peach-settings-row--meta">705 <span>Check all passwords</span>706 <button707 class="peach-btn peach-btn--subtle peach-btn--sm"708 onClick={() => void handleCheckAllBreaches()}709 disabled={checkingBreaches()}710 >711 {checkingBreaches() ? "checking..." : "run"}712 </button>713 </div>714 <Show when={breachResult()}>715 <div class="peach-settings-row peach-settings-row--meta">716 <span>Last check</span>717 <span class="peach-settings-value">{breachResult()}</span>718 </div>719 </Show>720721 <p class="peach-settings-sub-label">MASTER PASSWORD</p>722 <div class="peach-settings-row peach-settings-row--meta">723 <span>Master password</span>724 <button725 class="peach-btn peach-btn--subtle peach-btn--sm"726 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}727 >728 {showChangePw() ? "cancel" : "change"}729 </button>730 </div>731 <Show when={showChangePw()}>732 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">733 <input734 class="peach-settings-input"735 type="password"736 placeholder="current password"737 value={changePwCurrent()}738 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}739 autocomplete="current-password"740 />741 <input742 class="peach-settings-input"743 type="password"744 placeholder="new password (min 12 chars)"745 value={changePwNew()}746 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}747 autocomplete="new-password"748 />749 <input750 class="peach-settings-input"751 type="password"752 placeholder="confirm new password"753 value={changePwConfirm()}754 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}755 autocomplete="new-password"756 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}757 />758 <Show when={changePwError()}>759 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>760 </Show>761 <Show when={changePwSuccess()}>762 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>763 </Show>764 <button765 class="peach-btn peach-btn--accent peach-btn--sm"766 style="align-self:flex-start"767 onClick={() => void handleChangePassword()}768 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}769 >770 {changePwSaving() ? "saving…" : "save new password"}771 </button>772 </div>773 </Show>774775 <p class="peach-settings-sub-label">DURESS PASSWORD</p>776 <div class="peach-settings-row peach-settings-row--top">777 <div class="peach-settings-row-main">778 <span>Duress password</span>779 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">780 If entered under coercion, shows a decoy empty vault instead of your real one.781 </span>782 </div>783 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>784 {duressEnabled() ? "on" : "off"}785 </div>786 </div>787788 <Show when={duressEnabled() && !showDuressSetup()}>789 <div class="peach-settings-row peach-settings-row--meta">790 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">791 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.792 </span>793 </div>794 </Show>795796 <Show when={duressSuccess()}>797 <div class="peach-settings-row peach-settings-row--meta">798 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>799 </div>800 </Show>801802 <Show when={!showDuressSetup()}>803 <div class="peach-settings-row peach-settings-row--meta">804 <button805 class="peach-btn peach-btn--subtle peach-btn--sm"806 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}807 >808 {duressEnabled() ? "Change / disable" : "Set up"}809 </button>810 </div>811 </Show>812813 <Show when={showDuressSetup()}>814 <div class="peach-duress-form">815 <label class="peach-label">main password (to confirm)</label>816 <input817 class="peach-settings-input"818 type="password"819 placeholder="your current master password"820 autocomplete="current-password"821 value={duressMainPassword()}822 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}823 />824 <Show when={!duressEnabled()}>825 <label class="peach-label">new duress password</label>826 <input827 class="peach-settings-input"828 type="password"829 placeholder="duress password (min 8 chars)"830 autocomplete="new-password"831 value={duressPassword()}832 onInput={(e) => setDuressPassword(e.currentTarget.value)}833 />834 <label class="peach-label">confirm duress password</label>835 <input836 class="peach-settings-input"837 type="password"838 placeholder="confirm duress password"839 autocomplete="new-password"840 value={duressConfirm()}841 onInput={(e) => setDuressConfirm(e.currentTarget.value)}842 />843 </Show>844845 <Show when={duressError()}>846 <p class="peach-hint peach-hint-error">{duressError()}</p>847 </Show>848849 <div class="peach-duress-actions">850 <button851 class="peach-btn peach-btn--subtle peach-btn--sm"852 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}853 >854 cancel855 </button>856 <Show when={duressEnabled()}>857 <button858 class="peach-btn peach-btn--ghost peach-btn--sm"859 style="color:var(--peach-breach)"860 onClick={() => void handleDisableDuress()}861 disabled={duressSaving()}862 >863 {duressSaving() ? "disabling..." : "disable duress"}864 </button>865 </Show>866 <Show when={!duressEnabled()}>867 <button868 class="peach-btn peach-btn--accent peach-btn--sm"869 onClick={() => void handleSetupDuress()}870 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}871 >872 {duressSaving() ? "saving..." : "save"}873 </button>874 </Show>875 </div>876 </div>877 </Show>878879 <p class="peach-settings-sub-label">CANARY ENTRIES</p>880 <div class="peach-settings-row peach-settings-row--top">881 <div class="peach-settings-row-main">882 <span>Canary entries</span>883 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">884 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.885 </span>886 </div>887 <span class="peach-settings-value" style="flex-shrink:0">888 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}889 </span>890 </div>891 <Show when={canaryEntries().length > 0}>892 <For each={canaryEntries()}>893 {(entry) => (894 <div class="peach-settings-row peach-settings-row--meta">895 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>896 <button897 class="peach-btn peach-btn--subtle peach-btn--sm"898 onClick={() => void handleTestCanary(entry)}899 disabled={canaryTestingId() === entry.id}900 style="flex-shrink:0"901 >902 {canaryTestingId() === entry.id903 ? "sending…"904 : canaryTestResult()?.id === entry.id905 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")906 : "test"}907 </button>908 </div>909 )}910 </For>911 </Show>912913 <p class="peach-settings-sub-label">PASSKEYS</p>914 <div class="peach-settings-row peach-settings-row--top">915 <div class="peach-settings-row-main">916 <span>Intercept passkey requests</span>917 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">918 Peach can handle passkey sign-in and creation on supported Chromium browsers.919 </span>920 </div>921 <button922 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}923 onClick={() => void handleTogglePasskeys()}924 aria-label="Toggle passkey interception"925 disabled={!passkeyState()?.supported || passkeySaving()}926 >927 <span class="peach-toggle-knob"></span>928 </button>929 </div>930 <div class="peach-settings-row peach-settings-row--meta">931 <span>Status</span>932 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>933 </div>934 <div class="peach-settings-row peach-settings-row--meta">935 <span>Browser</span>936 <span class="peach-settings-value">937 {passkeyState()?.browserLabel ?? "Unknown"}938 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}939 </span>940 </div>941 <div class="peach-info-notice" style="margin-top:8px">942 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.943 </div>944 <Show when={passkeyState()?.conflict}>945 <div class="peach-settings-row peach-settings-row--meta">946 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>947 </div>948 </Show>949 </div>950 </div>951 );952 }953954 function renderVault() {955 return (956 <div class="peach-settings-body">957 <div class="peach-settings-group">958 <p class="peach-settings-sub-label">ENTRIES</p>959 <div class="peach-settings-row peach-settings-row--meta">960 <span>Entries</span>961 <span class="peach-settings-value">{props.entriesCount}</span>962 </div>963 <div class="peach-settings-row peach-settings-row--meta">964 <span>Duplicates</span>965 <Show when={dedupDone() !== null} fallback={966 <button967 class="peach-btn peach-btn--subtle peach-btn--sm"968 onClick={() => void handleCountDuplicates()}969 disabled={dedupRunning()}970 >971 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}972 </button>973 }>974 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">975 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}976 </span>977 </Show>978 </div>979 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>980 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">981 <div class="peach-restore-warning">982 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.983 </div>984 <div style="display:flex;gap:6px">985 <button986 class="peach-btn peach-btn--subtle peach-btn--sm"987 onClick={() => setDedupCount(null)}988 >989 cancel990 </button>991 <button992 class="peach-btn peach-btn--accent peach-btn--sm"993 onClick={() => void handleDeduplicate()}994 disabled={dedupRunning()}995 >996 {dedupRunning() ? "removing..." : "remove duplicates"}997 </button>998 </div>999 </div>1000 </Show>10011002 <p class="peach-settings-sub-label">MAINTENANCE</p>1003 <div class="peach-settings-row peach-settings-row--meta">1004 <span>Trash auto-delete</span>1005 <select1006 class="peach-settings-select"1007 value={String(props.trashRetentionDays)}1008 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1009 >1010 <option value="7">7 days</option>1011 <option value="30">30 days</option>1012 <option value="90">90 days</option>1013 <option value="0">Never</option>1014 </select>1015 </div>1016 <div class="peach-settings-row peach-settings-row--meta">1017 <span>Vault Health</span>1018 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1019 open1020 </button>1021 </div>1022 <Show when={declinedCardCount() > 0}>1023 <div class="peach-settings-row peach-settings-row--meta">1024 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1025 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1026 Clear1027 </button>1028 </div>1029 </Show>10301031 <p class="peach-settings-sub-label">DATA</p>1032 <div class="peach-settings-row peach-settings-row--meta">1033 <span>Import passwords</span>1034 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1035 open1036 </button>1037 </div>1038 <div class="peach-settings-row peach-settings-row--meta">1039 <span>Export backup</span>1040 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1041 open1042 </button>1043 </div>10441045 <p class="peach-settings-sub-label">VAULT HISTORY</p>1046 <Show when={historyLoading()}>1047 <p class="peach-settings-hint">Loading…</p>1048 </Show>1049 <Show when={!historyLoading() && historySnapshots().length === 0}>1050 <p class="peach-settings-hint">1051 No snapshots yet. Peach saves automatically before every change —1052 saves, syncs, and imports.1053 </p>1054 </Show>1055 <Show when={!historyLoading() && historySnapshots().length > 0}>1056 <p class="peach-settings-hint">1057 Restoring locks your vault. Unlock with the password that1058 encrypted that snapshot to confirm.1059 </p>1060 <div class="peach-history-list">1061 <For each={historySnapshots()}>1062 {(snap) => (1063 <div class="peach-history-row">1064 <div class="peach-history-meta">1065 <span class="peach-history-time">1066 {formatSnapshotTime(snap.savedAt)}1067 </span>1068 <span class="peach-history-sub">1069 {[1070 snap.entryCount !== null1071 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1072 : null,1073 reasonLabel(snap.reason),1074 ]1075 .filter(Boolean)1076 .join(" · ")}1077 </span>1078 </div>1079 <button1080 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1081 historyRestoring() === snap.id ? " is-busy" : ""1082 }`}1083 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1084 disabled={historyRestoring() !== null}1085 >1086 <Show when={historyRestoring() === snap.id} fallback="Restore">1087 …1088 </Show>1089 </button>1090 </div>1091 )}1092 </For>1093 </div>1094 <p class="peach-history-footer">1095 {historySnapshots().length} of 10 snapshots1096 </p>1097 </Show>10981099 <button1100 class="peach-settings-advanced-toggle"1101 onClick={() => setShowVaultAdvanced((v) => !v)}1102 aria-expanded={showVaultAdvanced()}1103 >1104 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1105 <span class="peach-settings-advanced-chevron">1106 {showVaultAdvanced() ? "▲" : "▼"}1107 </span>1108 </button>11091110 <Show when={showVaultAdvanced()}>1111 <div class="peach-danger-card">1112 <div class="peach-danger-card-head">1113 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1114 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1115 </div>1116 <p class="peach-danger-card-copy">1117 Wipes the current vault and restores from a backup file.1118 Recoverable only if your backup is good.1119 </p>1120 <button1121 class="peach-danger-card-button"1122 onClick={() => props.onOpenFileImport?.()}1123 >1124 Choose backup file1125 </button>1126 </div>11271128 <Show when={props.onOpenPanic}>1129 <div class="peach-danger-card peach-danger-card--hazard">1130 <div class="peach-danger-card-head">1131 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1132 <h3 class="peach-danger-card-title">Destroy vault</h3>1133 </div>1134 <p class="peach-danger-card-copy">1135 Permanently wipes every entry, setting, and license on this1136 device. There is no recovery.1137 </p>1138 <button1139 class="peach-danger-card-button"1140 onClick={() => props.onOpenPanic?.()}1141 >1142 Begin destruction1143 </button>1144 </div>1145 </Show>1146 </Show>1147 </div>1148 </div>1149 );1150 }11511152 function renderAppearance() {1153 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1154 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1155 { id: "cormorant", label: "Cormorant", preview: "Aa" },1156 { id: "inter", label: "Inter", preview: "Aa" },1157 { id: "system", label: "System", preview: "Aa" },1158 ];1159 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1160 { id: "small", label: "Small" },1161 { id: "medium", label: "Default" },1162 { id: "large", label: "Large" },1163 ];1164 return (1165 <div class="peach-settings-body">1166 <div class="peach-settings-group">1167 <p class="peach-settings-sub-label">THEME</p>1168 <div class="peach-settings-row peach-settings-row--meta">1169 <span>Theme</span>1170 <button1171 class="peach-btn peach-btn--subtle peach-btn--sm"1172 onClick={() =>1173 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1174 }1175 >1176 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1177 </button>1178 </div>11791180 <p class="peach-settings-sub-label">ACCENT</p>1181 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1182 <span>Accent</span>1183 <div class="peach-accent-picker">1184 <For each={ACCENT_COLORS}>1185 {(color) => (1186 <button1187 class="peach-accent-swatch"1188 classList={{ "is-active": props.accentColor === color.name }}1189 style={{ background: color.hex }}1190 onClick={() => void props.onAccentChange(color.name)}1191 aria-label={color.name}1192 title={color.name}1193 />1194 )}1195 </For>1196 </div>1197 </div>11981199 <p class="peach-settings-sub-label">DISPLAY FONT</p>1200 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1201 <span>Font</span>1202 <div class="peach-font-picker">1203 <For each={fontOptions}>1204 {(opt) => (1205 <button1206 class="peach-font-swatch"1207 classList={{ "is-active": props.displayFont === opt.id }}1208 data-font-preview={opt.id}1209 onClick={() => void props.onDisplayFontChange(opt.id)}1210 aria-label={opt.label}1211 title={opt.label}1212 >1213 <span class="peach-font-swatch-glyph">{opt.preview}</span>1214 <span class="peach-font-swatch-name">{opt.label}</span>1215 </button>1216 )}1217 </For>1218 </div>1219 </div>12201221 <p class="peach-settings-sub-label">TEXT SIZE</p>1222 <div class="peach-settings-row peach-settings-row--meta">1223 <span>Size</span>1224 <div class="peach-pill-group">1225 <For each={sizeOptions}>1226 {(opt) => (1227 <button1228 class="peach-btn peach-btn--ghost peach-btn--sm"1229 classList={{ "is-active": props.textSize === opt.id }}1230 onClick={() => void props.onTextSizeChange(opt.id)}1231 >1232 {opt.label}1233 </button>1234 )}1235 </For>1236 </div>1237 </div>12381239 <p class="peach-settings-sub-label">MOTION</p>1240 <div class="peach-settings-row peach-settings-row--meta">1241 <span>Reduce motion</span>1242 <button1243 class="peach-btn peach-btn--subtle peach-btn--sm"1244 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1245 >1246 {props.reduceMotion ? "On" : "Off"}1247 </button>1248 </div>1249 </div>1250 </div>1251 );1252 }12531254 function renderShortcuts() {1255 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1256 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12571258 function startCapture(action: ShortcutAction) {1259 setConflictMsg(null);1260 setCapturingAction(action);1261 }1262 function cancelCapture() {1263 setCapturingAction(null);1264 setConflictMsg(null);1265 }1266 function commitBinding(action: ShortcutAction, binding: Keybinding) {1267 const conflict = findConflict(props.shortcuts, binding, action);1268 if (conflict) {1269 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1270 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1271 return;1272 }1273 const next = { ...props.shortcuts, [action]: binding };1274 setCapturingAction(null);1275 setConflictMsg(null);1276 void props.onShortcutsChange(next);1277 }1278 function resetAction(action: ShortcutAction) {1279 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1280 void props.onShortcutsChange(next);1281 }1282 function resetAll() {1283 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1284 }12851286 1287 1288 function handleCaptureKey(e: KeyboardEvent) {1289 e.preventDefault();1290 e.stopPropagation();1291 if (e.key === "Escape") { cancelCapture(); return; }1292 const binding = keybindingFromEvent(e);1293 if (!binding) return; 1294 const action = capturingAction();1295 if (action) commitBinding(action, binding);1296 }12971298 return (1299 <div class="peach-settings-body">1300 <div class="peach-settings-group peach-shortcuts-group">1301 <p class="peach-settings-sub-label">ENTRY VIEW</p>1302 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1303 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1304 </For>1305 </div>13061307 <div class="peach-settings-group peach-shortcuts-group">1308 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1309 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1310 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1311 </For>1312 </div>13131314 <Show when={conflictMsg()}>1315 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1316 </Show>13171318 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1319 <div class="peach-settings-row peach-settings-row--meta">1320 <span>Inline autofill on page</span>1321 <a1322 class="peach-settings-link"1323 href="chrome://extensions/shortcuts"1324 onClick={(e) => {1325 e.preventDefault();1326 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1327 }}1328 >1329 ⌘⇧P — customize ↗1330 </a>1331 </div>1332 <button1333 class="peach-btn peach-btn--subtle peach-btn--sm"1334 style="align-self:flex-start"1335 onClick={resetAll}1336 >1337 Reset all to defaults1338 </button>1339 </div>1340 </div>1341 );13421343 function renderRow(1344 desc: typeof SHORTCUT_DESCRIPTORS[number],1345 capturing: () => ShortcutAction | null,1346 onStart: (a: ShortcutAction) => void,1347 onCancel: () => void,1348 onReset: (a: ShortcutAction) => void,1349 onKey: (e: KeyboardEvent) => void,1350 ) {1351 const isActive = () => capturing() === desc.action;1352 const binding = () => props.shortcuts[desc.action];1353 const isCustom = () => {1354 const b = binding();1355 const d = DEFAULT_BINDINGS[desc.action];1356 return b.key !== d.key1357 || Boolean(b.ctrl) !== Boolean(d.ctrl)1358 || Boolean(b.meta) !== Boolean(d.meta)1359 || Boolean(b.shift) !== Boolean(d.shift)1360 || Boolean(b.alt) !== Boolean(d.alt);1361 };1362 return (1363 <div class="peach-shortcut-row">1364 <span class="peach-shortcut-row-label">{desc.label}</span>1365 <button1366 class="peach-shortcut-chip"1367 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1368 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1369 onKeyDown={(e) => { if (isActive()) onKey(e); }}1370 ref={(el) => {1371 1372 if (el) {1373 queueMicrotask(() => { if (isActive()) el.focus(); });1374 }1375 }}1376 >1377 {isActive() ? "Press a key…" : formatKeybinding(binding())}1378 </button>1379 <Show when={isCustom() && !isActive()}>1380 <button1381 class="peach-shortcut-reset"1382 title="Reset to default"1383 onClick={() => onReset(desc.action)}1384 >1385 ↺1386 </button>1387 </Show>1388 </div>1389 );1390 }1391 }13921393 const PRO_FEATURES: Array<{1394 feature: ProFeature;1395 name: string;1396 desc: string;1397 }> = [1398 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1399 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1400 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1401 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1402 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1403 ];14041405 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14061407 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1408 const [deactivating, setDeactivating] = createSignal(false);14091410 async function handleDeactivateLicense() {1411 setDeactivating(true);1412 await sendMessage({ type: "DEACTIVATE_LICENSE" });1413 setLicenseInfo(null);1414 await props.onLicenseChange?.();1415 setDeactivating(false);1416 }14171418 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1419 setCanaryTestingId(entry.id);1420 setCanaryTestResult(null);1421 const res = await sendMessage({1422 type: "FIRE_CANARY_PING",1423 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1424 }) as { ok: boolean };1425 setCanaryTestResult({ id: entry.id, ok: res.ok });1426 setCanaryTestingId(null);1427 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1428 }14291430 function renderLicense() {1431 async function onActivate() {1432 await handleActivateLicense();1433 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1434 await props.onLicenseChange?.();1435 }1436 }14371438 return (1439 <div class="peach-settings-body">1440 <Show1441 when={isPro()}1442 fallback={1443 <Show1444 when={showKeyEntry()}1445 fallback={1446 <div class="peach-pro-upgrade">1447 <div class="peach-pro-upgrade-hero">1448 <div class="peach-pro-upgrade-mark-wrap">1449 <PeachMark class="peach-pro-upgrade-mark" />1450 </div>1451 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1452 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1453 </div>14541455 <div class="peach-pro-features">1456 <For each={PRO_FEATURES}>1457 {(item) => (1458 <div1459 class="peach-pro-feature"1460 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1461 >1462 <span class="peach-pro-feature-mark">✦</span>1463 <div class="peach-pro-feature-text">1464 <span class="peach-pro-feature-name">{item.name}</span>1465 <span class="peach-pro-feature-desc">{item.desc}</span>1466 </div>1467 </div>1468 )}1469 </For>1470 </div>14711472 <div class="peach-pro-upgrade-actions">1473 <p class="peach-pro-pricing">$39 · once · yours forever</p>1474 <a1475 href="https://peachpasswords.com/#pricing"1476 target="_blank"1477 rel="noopener noreferrer"1478 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1479 >1480 Get Peach Pro1481 </a>1482 <div class="peach-pro-key-divider"><span>or</span></div>1483 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1484 Activate a license key1485 </button>1486 </div>1487 </div>1488 }1489 >1490 <div class="peach-pro-key-ceremony">1491 <LockRuneCanvas />1492 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1493 <input1494 class="peach-underline-input peach-pro-key-input"1495 type="text"1496 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1497 value={licenseKeyInput()}1498 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1499 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1500 autocomplete="off"1501 spellcheck={false}1502 />1503 <Show when={licenseError()}>1504 <p class="peach-pro-key-error">That key doesn't look right.</p>1505 </Show>1506 <button1507 class="peach-btn peach-btn--accent peach-pro-key-activate"1508 onClick={() => void onActivate()}1509 disabled={activatingLicense() || !licenseKeyInput().trim()}1510 >1511 {activatingLicense() ? "activating…" : "Activate"}1512 </button>1513 <button1514 class="peach-pro-key-cancel"1515 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1516 >1517 cancel1518 </button>1519 </div>1520 </Show>1521 }1522 >1523 <div class="peach-pro-active">1524 <div class="peach-pro-active-hero">1525 <div class="peach-pro-active-mark-wrap">1526 <PeachMark class="peach-pro-active-mark" />1527 </div>1528 <h2 class="peach-pro-active-title">Peach Pro</h2>1529 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1530 </div>15311532 <Show when={licenseKey()}>1533 <div class="peach-settings-license">1534 <span class="peach-settings-label">LICENSE KEY</span>1535 <div class="peach-settings-license-row">1536 <span class="peach-settings-license-key">{licenseKey()!}</span>1537 <button1538 class="peach-settings-license-copy"1539 onClick={() => copyLicenseKey(licenseKey()!)}1540 >1541 {copied() ? "✓" : "copy"}1542 </button>1543 </div>1544 </div>1545 </Show>15461547 <div class="peach-pro-active-features">1548 <For each={PRO_FEATURES}>1549 {(item) => (1550 <div class="peach-pro-active-feature">1551 <span class="peach-pro-active-feature-mark">✦</span>1552 <span class="peach-pro-active-feature-name">{item.name}</span>1553 </div>1554 )}1555 </For>1556 </div>15571558 <button1559 class="peach-pro-deactivate"1560 onClick={() => void handleDeactivateLicense()}1561 disabled={deactivating()}1562 >1563 {deactivating() ? "Deactivating…" : "Deactivate"}1564 </button>1565 </div>1566 </Show>1567 </div>1568 );1569 }15701571 function renderFeedback() {1572 return (1573 <div class="peach-settings-body">1574 <div class="peach-settings-group peach-feedback-group">1575 <p class="peach-feedback-intro">1576 Help us improve Peach. Every report goes directly to our inbox.1577 </p>15781579 <p class="peach-settings-sub-label">TYPE</p>1580 <div class="peach-feedback-chips">1581 <For each={FEEDBACK_CATEGORIES}>1582 {(cat) => (1583 <button1584 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1585 onClick={() => setFbCategory(cat)}1586 >1587 {cat}1588 </button>1589 )}1590 </For>1591 </div>15921593 <p class="peach-settings-sub-label">MESSAGE</p>1594 <textarea1595 class="peach-feedback-textarea"1596 placeholder="Describe the issue or idea..."1597 value={fbMessage()}1598 onInput={(e) => setFbMessage(e.currentTarget.value)}1599 />16001601 <p class="peach-feedback-meta">1602 Version {version} · will be included automatically1603 </p>16041605 <Show when={fbResult() === "success"}>1606 <p class="peach-feedback-result peach-feedback-result--ok">1607 Sent — thank you1608 </p>1609 </Show>1610 <Show when={fbResult() === "error"}>1611 <p class="peach-feedback-result peach-feedback-result--err">1612 Failed to send — try again1613 </p>1614 </Show>16151616 <button1617 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1618 onClick={() => void handleSubmitFeedback()}1619 disabled={fbSubmitting() || !fbMessage().trim()}1620 >1621 {fbSubmitting() ? "Sending..." : "Send feedback"}1622 </button>16231624 <p class="peach-feedback-anon">1625 Sent anonymously · no account required1626 </p>1627 </div>1628 </div>1629 );1630 }16311632 return (1633 <Show1634 when={showNewRecoveryWords()}1635 fallback={1636 <div class="peach-overlay-view ov-enter-right" style="display:flex">1637 <div class="peach-overlay-header">1638 <button1639 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1640 onClick={handleHeaderBack}1641 aria-label="Back"1642 >1643 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1644 <path d="M6 1L1 6L6 11" />1645 </svg>1646 </button>1647 <span class="peach-overlay-title">{currentTitle()}</span>1648 <div class="peach-overlay-spacer"></div>1649 </div>16501651 <Switch fallback={renderHome()}>1652 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1653 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1654 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1655 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1656 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1657 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1658 </Switch>1659 </div>1660 }1661 >1662 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1663 </Show>1664 );1665}