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 onOpenCodexRestore?: () => void;67}6869type SectionName =70 | "security"71 | "vault"72 | "appearance"73 | "shortcuts"74 | "license"75 | "feedback";7677const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7879function formatActivatedSince(issued: string | undefined): string {80 if (!issued) return "Active";81 try {82 const d = new Date(issued);83 if (Number.isNaN(d.getTime())) return "Active";84 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;85 } catch {86 return "Active";87 }88}8990function isSectionName(value: string): value is SectionName {91 return (92 value === "security" ||93 value === "vault" ||94 value === "appearance" ||95 value === "shortcuts" ||96 value === "license" ||97 value === "feedback"98 );99}100101export function SettingsView(props: SettingsProps) {102 const manifest = chrome.runtime.getManifest();103 const version = manifest.version;104 const isAndroid = (manifest as any).version_name === 'mobile';105 const initial = props.initialSection;106 const [activeSection, setActiveSection] = createSignal<SectionName | null>(107 initial && isSectionName(initial) ? (initial as SectionName) : null108 );109110 const [checkingBreaches, setCheckingBreaches] = createSignal(false);111 const [breachResult, setBreachResult] = createSignal<string | null>(null);112 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);113 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");114 const [licenseError, setLicenseError] = createSignal<string | null>(null);115 const [, setLicenseSuccess] = createSignal<string | null>(null);116 const [activatingLicense, setActivatingLicense] = createSignal(false);117118 const isPro = () => props.isPro ?? false;119 const [biometricSupported, setBiometricSupported] = createSignal(false);120 const [biometricStored, setBiometricStored] = createSignal(false);121 const [biometricBusy, setBiometricBusy] = createSignal(false);122 const [biometricError, setBiometricError] = createSignal<string | null>(null);123 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);124 const [passkeySaving, setPasskeySaving] = createSignal(false);125126 const [dedupCount, setDedupCount] = createSignal<number | null>(null);127 const [dedupRunning, setDedupRunning] = createSignal(false);128 const [dedupDone, setDedupDone] = createSignal<number | null>(null);129130 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);131132 133 134 135 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);136 const [historyLoading, setHistoryLoading] = createSignal(false);137 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);138139 async function loadVaultHistory() {140 setHistoryLoading(true);141 try {142 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });143 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];144 setHistorySnapshots(list);145 } finally {146 setHistoryLoading(false);147 }148 }149150 async function handleRestoreVaultSnapshot(id: string) {151 if (historyRestoring()) return;152 setHistoryRestoring(id);153 const res = await sendMessage({154 type: "RESTORE_VAULT_SNAPSHOT",155 payload: { snapshotId: id },156 });157 setHistoryRestoring(null);158 if ((res as { ok: boolean }).ok) {159 160 161 props.onBack();162 }163 }164165 function formatSnapshotTime(savedAt: number): string {166 const diff = Date.now() - savedAt;167 if (diff < 60_000) return "Just now";168 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;169 const d = new Date(savedAt);170 const isToday = new Date().toDateString() === d.toDateString();171 const isYest =172 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();173 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });174 if (isToday) return `Today ${t}`;175 if (isYest) return `Yesterday ${t}`;176 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;177 }178179 function reasonLabel(reason: VaultSnapshot["reason"]): string {180 return (181 {182 local_save: "saved",183 sync_received: "synced",184 import: "imported",185 restore: "restored",186 } as const187 )[reason] ?? reason;188 }189190 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);191192 const [showChangePw, setShowChangePw] = createSignal(false);193 const [changePwCurrent, setChangePwCurrent] = createSignal("");194 const [changePwNew, setChangePwNew] = createSignal("");195 const [changePwConfirm, setChangePwConfirm] = createSignal("");196 const [changePwSaving, setChangePwSaving] = createSignal(false);197 const [changePwError, setChangePwError] = createSignal<string | null>(null);198 const [changePwSuccess] = createSignal(false);199 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);200201 const [duressEnabled, setDuressEnabled] = createSignal(false);202 const [showDuressSetup, setShowDuressSetup] = createSignal(false);203 const [duressPassword, setDuressPassword] = createSignal("");204 const [duressConfirm, setDuressConfirm] = createSignal("");205 const [duressMainPassword, setDuressMainPassword] = createSignal("");206 const [duressSaving, setDuressSaving] = createSignal(false);207 const [duressError, setDuressError] = createSignal<string | null>(null);208 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);209210 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);211212 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);213 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);214215 const [fbCategory, setFbCategory] = createSignal("Bug report");216 const [fbMessage, setFbMessage] = createSignal("");217 const [fbSubmitting, setFbSubmitting] = createSignal(false);218 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);219220 onMount(async () => {221 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([222 sendMessage({ type: "GET_LICENSE" }),223 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),224 sendMessage({ type: "IS_DURESS_ENABLED" }),225 sendMessage({ type: "GET_SETTINGS" }),226 sendMessage({ type: "GET_LICENSE_KEY" }),227 ]);228229 230 231 void loadVaultHistory();232233 if ((licenseRes as { ok: boolean }).ok) {234 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);235 }236237 if ((passkeyRes as { ok: boolean }).ok) {238 setPasskeyState(239 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null240 );241 }242243 if ((duressRes as { ok: boolean }).ok) {244 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);245 }246247 if ((settingsRes as { ok: boolean }).ok) {248 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;249 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);250 }251252 if ((keyRes as { ok: boolean }).ok) {253 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);254 }255256 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });257 if ((entriesRes as { ok: boolean }).ok) {258 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];259 setCanaryEntries(260 entries261 .filter((e) => e.canary?.webhookUrl)262 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))263 );264 }265266267 if (isNativePlatform()) {268 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");269 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");270 try {271 if (biometric) {272 const a = await biometric.isAvailable();273 setBiometricSupported(a.available);274 }275 if (keystore) {276 const k = await keystore.hasMasterKey();277 setBiometricStored(k.exists);278 }279 } catch { }280 }281 });282283 async function handleEnableBiometrics() {284 setBiometricBusy(true);285 setBiometricError(null);286 try {287 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {288 ok: boolean;289 data?: { masterKeyHex: string };290 error?: string;291 };292 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {293 setBiometricError(keyRes.error === "vault_locked"294 ? "unlock the vault first"295 : "couldn't read derived key");296 return;297 }298 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");299 if (!keystore) {300 setBiometricError("biometric plugin unavailable");301 return;302 }303 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });304 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });305 setBiometricStored(true);306 } catch (e) {307 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");308 } finally {309 setBiometricBusy(false);310 }311 }312313 async function handleDisableBiometrics() {314 setBiometricBusy(true);315 setBiometricError(null);316 try {317 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");318 if (keystore) await keystore.clearMasterKey();319 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });320 setBiometricStored(false);321 } catch (e) {322 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");323 } finally {324 setBiometricBusy(false);325 }326 }327328 async function handleChangePassword() {329 setChangePwError(null);330 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }331 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }332 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }333 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }334 setChangePwSaving(true);335 const res = await sendMessage({336 type: "CHANGE_PASSWORD",337 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },338 }) as { ok: boolean; error?: string };339 if (res.ok) {340 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");341 setShowChangePw(false);342 setShowNewRecoveryWords(true);343 } else {344 setChangePwError(res.error ?? "failed to change password");345 }346 setChangePwSaving(false);347 }348349 const [copied, setCopied] = createSignal(false);350351 async function copyLicenseKey(key: string) {352 await navigator.clipboard.writeText(key);353 setCopied(true);354 setTimeout(() => setCopied(false), 2000);355 }356357 async function handleActivateLicense() {358 const key = licenseKeyInput().trim();359 if (!key) return;360 setActivatingLicense(true);361 setLicenseError(null);362 setLicenseSuccess(null);363 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });364 setActivatingLicense(false);365 if ((res as { ok: boolean }).ok) {366 const data = (res as { ok: boolean; data?: LicenseInfo }).data;367 if (data?.valid) {368 setLicenseInfo(data);369 setLicenseKey(key);370 setLicenseSuccess("License activated!");371 setLicenseKeyInput("");372 } else {373 setLicenseError("Invalid license key");374 }375 } else {376 setLicenseError("Activation failed");377 }378 }379380 async function handleTogglePasskeys() {381 const current = passkeyState();382 if (!current || !current.supported) return;383 setPasskeySaving(true);384 const res = await sendMessage({385 type: "SET_PASSKEY_PROVIDER_ENABLED",386 payload: { enabled: !current.enabled },387 });388 setPasskeySaving(false);389 if ((res as { ok: boolean }).ok) {390 setPasskeyState(391 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null392 );393 }394 }395396 async function handleCheckAllBreaches() {397 setCheckingBreaches(true);398 setBreachResult(null);399 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });400 setCheckingBreaches(false);401 if ((res as { ok: boolean }).ok) {402 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};403 const breached = Object.values(data).filter((count) => count > 0).length;404 setBreachResult(405 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"406 );407 props.onBreachCheckDone();408 }409 }410411412 async function handleSetupDuress() {413 const mainPw = duressMainPassword().trim();414 const duressPw = duressPassword().trim();415 const confirmPw = duressConfirm().trim();416417 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }418 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }419 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }420 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }421422 setDuressSaving(true);423 setDuressError(null);424425 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };426 if (!verifyRes.ok || !verifyRes.data) {427 setDuressError("incorrect main password");428 setDuressSaving(false);429 return;430 }431432 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };433 setDuressSaving(false);434 if (res.ok) {435 setDuressEnabled(true);436 setShowDuressSetup(false);437 setDuressPassword("");438 setDuressConfirm("");439 setDuressMainPassword("");440 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");441 setTimeout(() => setDuressSuccess(null), 5000);442 } else {443 setDuressError(res.error ?? "failed to set duress password");444 }445 }446447 async function handleClearDeclinedCards() {448 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });449 setDeclinedCardCount(0);450 }451452 async function handleCountDuplicates() {453 setDedupRunning(true);454 setDedupDone(null);455 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };456 setDedupRunning(false);457 if (res.ok) setDedupCount(res.data ?? 0);458 }459460 async function handleDeduplicate() {461 setDedupRunning(true);462 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };463 setDedupRunning(false);464 if (res.ok) {465 setDedupDone(res.data ?? 0);466 setDedupCount(0);467 }468 }469470 async function handleDisableDuress() {471 const mainPw = duressMainPassword().trim();472 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }473474 setDuressSaving(true);475 setDuressError(null);476477 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };478 if (!verifyRes.ok || !verifyRes.data) {479 setDuressError("incorrect main password");480 setDuressSaving(false);481 return;482 }483484 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };485 setDuressSaving(false);486 if (res.ok) {487 setDuressEnabled(false);488 setShowDuressSetup(false);489 setDuressMainPassword("");490 setDuressSuccess("Duress password disabled.");491 setTimeout(() => setDuressSuccess(null), 4000);492 } else {493 setDuressError(res.error ?? "failed to disable duress password");494 }495 }496497 async function handleSubmitFeedback() {498 if (!fbMessage().trim() || fbSubmitting()) return;499 setFbSubmitting(true);500 setFbResult(null);501 const res = await sendMessage({502 type: "SUBMIT_FEEDBACK",503 payload: {504 category: fbCategory(),505 message: fbMessage().trim(),506 version,507 browser: navigator.userAgent,508 },509 }) as { ok: boolean; error?: string };510 setFbSubmitting(false);511 if (res.ok) {512 setFbResult("success");513 setFbMessage("");514 setTimeout(() => setFbResult(null), 4000);515 } else {516 setFbResult("error");517 }518 }519520 function handleHeaderBack() {521 if (activeSection() !== null) {522 setActiveSection(null);523 } else {524 props.onBack();525 }526 }527528 function currentTitle(): string {529 const s = activeSection();530 if (s === null) return "settings";531 if (s === "license") return "account";532 return s;533 }534535 function renderCatRow(opts: {536 section: SectionName;537 name: string;538 desc: string;539 icon: () => any;540 pro?: boolean;541 }) {542 return (543 <button544 class="peach-settings-cat-row"545 onClick={() => setActiveSection(opts.section)}546 >547 <div class="peach-settings-cat-icon">{opts.icon()}</div>548 <div class="peach-settings-cat-content">549 <span class="peach-settings-cat-name">550 {opts.name}551 <Show when={opts.pro && !isPro()}>552 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>553 </Show>554 </span>555 <span class="peach-settings-cat-desc">{opts.desc}</span>556 </div>557 <svg558 class="peach-settings-cat-chevron"559 width="6" height="10" viewBox="0 0 6 10" fill="none"560 stroke="currentColor" stroke-width="1.1"561 stroke-linecap="round" stroke-linejoin="round"562 >563 <path d="M1 1l4 4-4 4" />564 </svg>565 </button>566 );567 }568569 const iconShield = () => (570 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">571 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>572 </svg>573 );574 const iconKey = () => (575 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">576 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>577 </svg>578 );579 const iconLock = () => (580 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">581 <rect x="3" y="7" width="10" height="7" rx="1.5"/>582 <path d="M6 7V5a3 3 0 016 0v2"/>583 </svg>584 );585 const iconSync = () => (586 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">587 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>588 <path d="M14 6l-2 2-2-2"/>589 <path d="M2 10l2-2 2 2"/>590 </svg>591 );592 const iconSun = () => (593 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">594 <circle cx="8" cy="8" r="3"/>595 <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"/>596 </svg>597 );598 const iconKeys = () => (599 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">600 <rect x="2" y="5" width="5" height="4" rx="1"/>601 <rect x="9" y="7" width="5" height="4" rx="1"/>602 <path d="M7 7h2m-2 2h2"/>603 </svg>604 );605 const iconBubble = () => (606 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">607 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>608 </svg>609 );610611 function renderHome() {612 return (613 <div class="peach-settings-body">614 <p class="peach-settings-nav-label">PROTECTION</p>615 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}616 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}617618 <p class="peach-settings-nav-label">VAULT</p>619 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}620 <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>621 <div class="peach-settings-cat-icon">{iconSync()}</div>622 <div class="peach-settings-cat-content">623 <span class="peach-settings-cat-name">Sync</span>624 <span class="peach-settings-cat-desc">Pair and sync across devices</span>625 </div>626 <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">627 <path d="M1 1l4 4-4 4" />628 </svg>629 </button>630631 <p class="peach-settings-nav-label">PREFERENCES</p>632 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}633 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}634635 <p class="peach-settings-nav-label">SUPPORT</p>636 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}637638 <div class="peach-settings-footer">639 <span>Peach {version}</span>640 <span class="peach-divider"></span>641 <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>642 </div>643 </div>644 );645 }646647 function renderSecurity() {648 return (649 <div class="peach-settings-body">650 <div class="peach-settings-group">651 <p class="peach-settings-sub-label">LOCKING</p>652 <div class="peach-settings-row">653 <span>Lock on close</span>654 <button655 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}656 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}657 aria-label="Toggle lock on close"658 >659 <span class="peach-toggle-knob"></span>660 </button>661 </div>662 <div class="peach-settings-row">663 <span>Auto-lock after</span>664 <select665 class="peach-settings-select"666 value={String(props.autoLockMinutes)}667 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}668 >669 <option value="1">1 minute</option>670 <option value="5">5 minutes</option>671 <option value="15">15 minutes</option>672 <option value="30">30 minutes</option>673 <option value="0">Never</option>674 </select>675 </div>676677 <Show when={isNativePlatform() && biometricSupported()}>678 <p class="peach-settings-sub-label">BIOMETRICS</p>679 <div class="peach-settings-row">680 <span>Unlock with biometrics</span>681 <button682 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}683 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}684 disabled={biometricBusy()}685 aria-label="Toggle biometric unlock"686 >687 <span class="peach-toggle-knob"></span>688 </button>689 </div>690 <Show when={biometricError()}>691 <div class="peach-settings-row peach-settings-row--meta">692 <span class="peach-hint peach-hint-error">{biometricError()}</span>693 </div>694 </Show>695 </Show>696697 <p class="peach-settings-sub-label">BREACH PROTECTION</p>698 <div class="peach-settings-row">699 <span>Breach monitoring</span>700 <button701 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}702 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}703 aria-label="Toggle breach monitoring"704 >705 <span class="peach-toggle-knob"></span>706 </button>707 </div>708 <div class="peach-settings-row peach-settings-row--meta">709 <span>Check all passwords</span>710 <button711 class="peach-btn peach-btn--subtle peach-btn--sm"712 onClick={() => void handleCheckAllBreaches()}713 disabled={checkingBreaches()}714 >715 {checkingBreaches() ? "checking..." : "run"}716 </button>717 </div>718 <Show when={breachResult()}>719 <div class="peach-settings-row peach-settings-row--meta">720 <span>Last check</span>721 <span class="peach-settings-value">{breachResult()}</span>722 </div>723 </Show>724725 <p class="peach-settings-sub-label">MASTER PASSWORD</p>726 <div class="peach-settings-row peach-settings-row--meta">727 <span>Master password</span>728 <button729 class="peach-btn peach-btn--subtle peach-btn--sm"730 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}731 >732 {showChangePw() ? "cancel" : "change"}733 </button>734 </div>735 <Show when={showChangePw()}>736 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">737 <input738 class="peach-settings-input"739 type="password"740 placeholder="current password"741 value={changePwCurrent()}742 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}743 autocomplete="current-password"744 />745 <input746 class="peach-settings-input"747 type="password"748 placeholder="new password (min 12 chars)"749 value={changePwNew()}750 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}751 autocomplete="new-password"752 />753 <input754 class="peach-settings-input"755 type="password"756 placeholder="confirm new password"757 value={changePwConfirm()}758 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}759 autocomplete="new-password"760 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}761 />762 <Show when={changePwError()}>763 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>764 </Show>765 <Show when={changePwSuccess()}>766 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>767 </Show>768 <button769 class="peach-btn peach-btn--accent peach-btn--sm"770 style="align-self:flex-start"771 onClick={() => void handleChangePassword()}772 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}773 >774 {changePwSaving() ? "saving…" : "save new password"}775 </button>776 </div>777 </Show>778779 <p class="peach-settings-sub-label">DURESS PASSWORD</p>780 <div class="peach-settings-row peach-settings-row--top">781 <div class="peach-settings-row-main">782 <span>Duress password</span>783 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">784 If entered under coercion, shows a decoy empty vault instead of your real one.785 </span>786 </div>787 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>788 {duressEnabled() ? "on" : "off"}789 </div>790 </div>791792 <Show when={duressEnabled() && !showDuressSetup()}>793 <div class="peach-settings-row peach-settings-row--meta">794 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">795 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.796 </span>797 </div>798 </Show>799800 <Show when={duressSuccess()}>801 <div class="peach-settings-row peach-settings-row--meta">802 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>803 </div>804 </Show>805806 <Show when={!showDuressSetup()}>807 <div class="peach-settings-row peach-settings-row--meta">808 <button809 class="peach-btn peach-btn--subtle peach-btn--sm"810 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}811 >812 {duressEnabled() ? "Change / disable" : "Set up"}813 </button>814 </div>815 </Show>816817 <Show when={showDuressSetup()}>818 <div class="peach-duress-form">819 <label class="peach-label">main password (to confirm)</label>820 <input821 class="peach-settings-input"822 type="password"823 placeholder="your current master password"824 autocomplete="current-password"825 value={duressMainPassword()}826 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}827 />828 <Show when={!duressEnabled()}>829 <label class="peach-label">new duress password</label>830 <input831 class="peach-settings-input"832 type="password"833 placeholder="duress password (min 8 chars)"834 autocomplete="new-password"835 value={duressPassword()}836 onInput={(e) => setDuressPassword(e.currentTarget.value)}837 />838 <label class="peach-label">confirm duress password</label>839 <input840 class="peach-settings-input"841 type="password"842 placeholder="confirm duress password"843 autocomplete="new-password"844 value={duressConfirm()}845 onInput={(e) => setDuressConfirm(e.currentTarget.value)}846 />847 </Show>848849 <Show when={duressError()}>850 <p class="peach-hint peach-hint-error">{duressError()}</p>851 </Show>852853 <div class="peach-duress-actions">854 <button855 class="peach-btn peach-btn--subtle peach-btn--sm"856 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}857 >858 cancel859 </button>860 <Show when={duressEnabled()}>861 <button862 class="peach-btn peach-btn--ghost peach-btn--sm"863 style="color:var(--peach-breach)"864 onClick={() => void handleDisableDuress()}865 disabled={duressSaving()}866 >867 {duressSaving() ? "disabling..." : "disable duress"}868 </button>869 </Show>870 <Show when={!duressEnabled()}>871 <button872 class="peach-btn peach-btn--accent peach-btn--sm"873 onClick={() => void handleSetupDuress()}874 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}875 >876 {duressSaving() ? "saving..." : "save"}877 </button>878 </Show>879 </div>880 </div>881 </Show>882883 <p class="peach-settings-sub-label">CANARY ENTRIES</p>884 <div class="peach-settings-row peach-settings-row--top">885 <div class="peach-settings-row-main">886 <span>Canary entries</span>887 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">888 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.889 </span>890 </div>891 <span class="peach-settings-value" style="flex-shrink:0">892 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}893 </span>894 </div>895 <Show when={canaryEntries().length > 0}>896 <For each={canaryEntries()}>897 {(entry) => (898 <div class="peach-settings-row peach-settings-row--meta">899 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>900 <button901 class="peach-btn peach-btn--subtle peach-btn--sm"902 onClick={() => void handleTestCanary(entry)}903 disabled={canaryTestingId() === entry.id}904 style="flex-shrink:0"905 >906 {canaryTestingId() === entry.id907 ? "sending…"908 : canaryTestResult()?.id === entry.id909 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")910 : "test"}911 </button>912 </div>913 )}914 </For>915 </Show>916917 <p class="peach-settings-sub-label">PASSKEYS</p>918 <div class="peach-settings-row peach-settings-row--top">919 <div class="peach-settings-row-main">920 <span>Intercept passkey requests</span>921 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">922 Peach can handle passkey sign-in and creation on supported Chromium browsers.923 </span>924 </div>925 <button926 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}927 onClick={() => void handleTogglePasskeys()}928 aria-label="Toggle passkey interception"929 disabled={!passkeyState()?.supported || passkeySaving()}930 >931 <span class="peach-toggle-knob"></span>932 </button>933 </div>934 <div class="peach-settings-row peach-settings-row--meta">935 <span>Status</span>936 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>937 </div>938 <div class="peach-settings-row peach-settings-row--meta">939 <span>Browser</span>940 <span class="peach-settings-value">941 {passkeyState()?.browserLabel ?? "Unknown"}942 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}943 </span>944 </div>945 <div class="peach-info-notice" style="margin-top:8px">946 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.947 </div>948 <Show when={passkeyState()?.conflict}>949 <div class="peach-settings-row peach-settings-row--meta">950 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>951 </div>952 </Show>953 </div>954 </div>955 );956 }957958 function renderVault() {959 return (960 <div class="peach-settings-body">961 <div class="peach-settings-group">962 <p class="peach-settings-sub-label">ENTRIES</p>963 <div class="peach-settings-row peach-settings-row--meta">964 <span>Entries</span>965 <span class="peach-settings-value">{props.entriesCount}</span>966 </div>967 <div class="peach-settings-row peach-settings-row--meta">968 <span>Duplicates</span>969 <Show when={dedupDone() !== null} fallback={970 <button971 class="peach-btn peach-btn--subtle peach-btn--sm"972 onClick={() => void handleCountDuplicates()}973 disabled={dedupRunning()}974 >975 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}976 </button>977 }>978 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">979 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}980 </span>981 </Show>982 </div>983 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>984 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">985 <div class="peach-restore-warning">986 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.987 </div>988 <div style="display:flex;gap:6px">989 <button990 class="peach-btn peach-btn--subtle peach-btn--sm"991 onClick={() => setDedupCount(null)}992 >993 cancel994 </button>995 <button996 class="peach-btn peach-btn--accent peach-btn--sm"997 onClick={() => void handleDeduplicate()}998 disabled={dedupRunning()}999 >1000 {dedupRunning() ? "removing..." : "remove duplicates"}1001 </button>1002 </div>1003 </div>1004 </Show>10051006 <p class="peach-settings-sub-label">MAINTENANCE</p>1007 <div class="peach-settings-row peach-settings-row--meta">1008 <span>Trash auto-delete</span>1009 <select1010 class="peach-settings-select"1011 value={String(props.trashRetentionDays)}1012 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1013 >1014 <option value="7">7 days</option>1015 <option value="30">30 days</option>1016 <option value="90">90 days</option>1017 <option value="0">Never</option>1018 </select>1019 </div>1020 <div class="peach-settings-row peach-settings-row--meta">1021 <span>Vault Health</span>1022 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1023 open1024 </button>1025 </div>1026 <Show when={declinedCardCount() > 0}>1027 <div class="peach-settings-row peach-settings-row--meta">1028 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1029 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1030 Clear1031 </button>1032 </div>1033 </Show>10341035 <p class="peach-settings-sub-label">DATA</p>1036 <div class="peach-settings-row peach-settings-row--meta">1037 <span>Import passwords</span>1038 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1039 open1040 </button>1041 </div>1042 <div class="peach-settings-row peach-settings-row--meta">1043 <span>Export backup</span>1044 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1045 open1046 </button>1047 </div>1048 <div class="peach-settings-row peach-settings-row--meta">1049 <span>1050 Peach Codex1051 <Show when={!isPro()}>1052 <span class="peach-pro-badge">Pro</span>1053 </Show>1054 </span>1055 <button1056 class="peach-btn peach-btn--subtle peach-btn--sm"1057 onClick={() => props.onOpenCodexGenerate?.()}1058 >1059 open1060 </button>1061 </div>1062 {1063}1064 <div class="peach-settings-row peach-settings-row--meta">1065 <span>Restore from Codex</span>1066 <button1067 class="peach-btn peach-btn--subtle peach-btn--sm"1068 onClick={() => props.onOpenCodexRestore?.()}1069 >1070 open1071 </button>1072 </div>10731074 <p class="peach-settings-sub-label">VAULT HISTORY</p>1075 <Show when={historyLoading()}>1076 <p class="peach-settings-hint">Loading…</p>1077 </Show>1078 <Show when={!historyLoading() && historySnapshots().length === 0}>1079 <p class="peach-settings-hint">1080 No snapshots yet. Peach saves automatically before every change —1081 saves, syncs, and imports.1082 </p>1083 </Show>1084 <Show when={!historyLoading() && historySnapshots().length > 0}>1085 <p class="peach-settings-hint">1086 Restoring locks your vault. Unlock with the password that1087 encrypted that snapshot to confirm.1088 </p>1089 <div class="peach-history-list">1090 <For each={historySnapshots()}>1091 {(snap) => (1092 <div class="peach-history-row">1093 <div class="peach-history-meta">1094 <span class="peach-history-time">1095 {formatSnapshotTime(snap.savedAt)}1096 </span>1097 <span class="peach-history-sub">1098 {[1099 snap.entryCount !== null1100 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1101 : null,1102 reasonLabel(snap.reason),1103 ]1104 .filter(Boolean)1105 .join(" · ")}1106 </span>1107 </div>1108 <button1109 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1110 historyRestoring() === snap.id ? " is-busy" : ""1111 }`}1112 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1113 disabled={historyRestoring() !== null}1114 >1115 <Show when={historyRestoring() === snap.id} fallback="Restore">1116 …1117 </Show>1118 </button>1119 </div>1120 )}1121 </For>1122 </div>1123 <p class="peach-history-footer">1124 {historySnapshots().length} of 10 snapshots1125 </p>1126 </Show>11271128 <button1129 class="peach-settings-advanced-toggle"1130 onClick={() => setShowVaultAdvanced((v) => !v)}1131 aria-expanded={showVaultAdvanced()}1132 >1133 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1134 <span class="peach-settings-advanced-chevron">1135 {showVaultAdvanced() ? "▲" : "▼"}1136 </span>1137 </button>11381139 <Show when={showVaultAdvanced()}>1140 <div class="peach-danger-card">1141 <div class="peach-danger-card-head">1142 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1143 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1144 </div>1145 <p class="peach-danger-card-copy">1146 Wipes the current vault and restores from a backup file.1147 Recoverable only if your backup is good.1148 </p>1149 <button1150 class="peach-danger-card-button"1151 onClick={() => props.onOpenFileImport?.()}1152 >1153 Choose backup file1154 </button>1155 </div>11561157 <Show when={props.onOpenPanic}>1158 <div class="peach-danger-card peach-danger-card--hazard">1159 <div class="peach-danger-card-head">1160 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1161 <h3 class="peach-danger-card-title">Destroy vault</h3>1162 </div>1163 <p class="peach-danger-card-copy">1164 Permanently wipes every entry, setting, and license on this1165 device. There is no recovery.1166 </p>1167 <button1168 class="peach-danger-card-button"1169 onClick={() => props.onOpenPanic?.()}1170 >1171 Begin destruction1172 </button>1173 </div>1174 </Show>1175 </Show>1176 </div>1177 </div>1178 );1179 }11801181 function renderAppearance() {1182 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1183 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1184 { id: "cormorant", label: "Cormorant", preview: "Aa" },1185 { id: "inter", label: "Inter", preview: "Aa" },1186 { id: "system", label: "System", preview: "Aa" },1187 ];1188 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1189 { id: "small", label: "Small" },1190 { id: "medium", label: "Default" },1191 { id: "large", label: "Large" },1192 ];1193 return (1194 <div class="peach-settings-body">1195 <div class="peach-settings-group">1196 <p class="peach-settings-sub-label">THEME</p>1197 <div class="peach-settings-row peach-settings-row--meta">1198 <span>Theme</span>1199 <button1200 class="peach-btn peach-btn--subtle peach-btn--sm"1201 onClick={() =>1202 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1203 }1204 >1205 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1206 </button>1207 </div>12081209 <p class="peach-settings-sub-label">ACCENT</p>1210 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1211 <span>Accent</span>1212 <div class="peach-accent-picker">1213 <For each={ACCENT_COLORS}>1214 {(color) => (1215 <button1216 class="peach-accent-swatch"1217 classList={{ "is-active": props.accentColor === color.name }}1218 style={{ background: color.hex }}1219 onClick={() => void props.onAccentChange(color.name)}1220 aria-label={color.name}1221 title={color.name}1222 />1223 )}1224 </For>1225 </div>1226 </div>12271228 <p class="peach-settings-sub-label">DISPLAY FONT</p>1229 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1230 <span>Font</span>1231 <div class="peach-font-picker">1232 <For each={fontOptions}>1233 {(opt) => (1234 <button1235 class="peach-font-swatch"1236 classList={{ "is-active": props.displayFont === opt.id }}1237 data-font-preview={opt.id}1238 onClick={() => void props.onDisplayFontChange(opt.id)}1239 aria-label={opt.label}1240 title={opt.label}1241 >1242 <span class="peach-font-swatch-glyph">{opt.preview}</span>1243 <span class="peach-font-swatch-name">{opt.label}</span>1244 </button>1245 )}1246 </For>1247 </div>1248 </div>12491250 <p class="peach-settings-sub-label">TEXT SIZE</p>1251 <div class="peach-settings-row peach-settings-row--meta">1252 <span>Size</span>1253 <div class="peach-pill-group">1254 <For each={sizeOptions}>1255 {(opt) => (1256 <button1257 class="peach-btn peach-btn--ghost peach-btn--sm"1258 classList={{ "is-active": props.textSize === opt.id }}1259 onClick={() => void props.onTextSizeChange(opt.id)}1260 >1261 {opt.label}1262 </button>1263 )}1264 </For>1265 </div>1266 </div>12671268 <p class="peach-settings-sub-label">MOTION</p>1269 <div class="peach-settings-row peach-settings-row--meta">1270 <span>Reduce motion</span>1271 <button1272 class="peach-btn peach-btn--subtle peach-btn--sm"1273 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1274 >1275 {props.reduceMotion ? "On" : "Off"}1276 </button>1277 </div>1278 </div>1279 </div>1280 );1281 }12821283 function renderShortcuts() {1284 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1285 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12861287 function startCapture(action: ShortcutAction) {1288 setConflictMsg(null);1289 setCapturingAction(action);1290 }1291 function cancelCapture() {1292 setCapturingAction(null);1293 setConflictMsg(null);1294 }1295 function commitBinding(action: ShortcutAction, binding: Keybinding) {1296 const conflict = findConflict(props.shortcuts, binding, action);1297 if (conflict) {1298 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1299 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1300 return;1301 }1302 const next = { ...props.shortcuts, [action]: binding };1303 setCapturingAction(null);1304 setConflictMsg(null);1305 void props.onShortcutsChange(next);1306 }1307 function resetAction(action: ShortcutAction) {1308 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1309 void props.onShortcutsChange(next);1310 }1311 function resetAll() {1312 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1313 }13141315 1316 1317 function handleCaptureKey(e: KeyboardEvent) {1318 e.preventDefault();1319 e.stopPropagation();1320 if (e.key === "Escape") { cancelCapture(); return; }1321 const binding = keybindingFromEvent(e);1322 if (!binding) return; 1323 const action = capturingAction();1324 if (action) commitBinding(action, binding);1325 }13261327 return (1328 <div class="peach-settings-body">1329 <div class="peach-settings-group peach-shortcuts-group">1330 <p class="peach-settings-sub-label">ENTRY VIEW</p>1331 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1332 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1333 </For>1334 </div>13351336 <div class="peach-settings-group peach-shortcuts-group">1337 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1338 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1339 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1340 </For>1341 </div>13421343 <Show when={conflictMsg()}>1344 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1345 </Show>13461347 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1348 <div class="peach-settings-row peach-settings-row--meta">1349 <span>Inline autofill on page</span>1350 <a1351 class="peach-settings-link"1352 href="chrome://extensions/shortcuts"1353 onClick={(e) => {1354 e.preventDefault();1355 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1356 }}1357 >1358 ⌘⇧P — customize ↗1359 </a>1360 </div>1361 <button1362 class="peach-btn peach-btn--subtle peach-btn--sm"1363 style="align-self:flex-start"1364 onClick={resetAll}1365 >1366 Reset all to defaults1367 </button>1368 </div>1369 </div>1370 );13711372 function renderRow(1373 desc: typeof SHORTCUT_DESCRIPTORS[number],1374 capturing: () => ShortcutAction | null,1375 onStart: (a: ShortcutAction) => void,1376 onCancel: () => void,1377 onReset: (a: ShortcutAction) => void,1378 onKey: (e: KeyboardEvent) => void,1379 ) {1380 const isActive = () => capturing() === desc.action;1381 const binding = () => props.shortcuts[desc.action];1382 const isCustom = () => {1383 const b = binding();1384 const d = DEFAULT_BINDINGS[desc.action];1385 return b.key !== d.key1386 || Boolean(b.ctrl) !== Boolean(d.ctrl)1387 || Boolean(b.meta) !== Boolean(d.meta)1388 || Boolean(b.shift) !== Boolean(d.shift)1389 || Boolean(b.alt) !== Boolean(d.alt);1390 };1391 return (1392 <div class="peach-shortcut-row">1393 <span class="peach-shortcut-row-label">{desc.label}</span>1394 <button1395 class="peach-shortcut-chip"1396 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1397 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1398 onKeyDown={(e) => { if (isActive()) onKey(e); }}1399 ref={(el) => {1400 1401 if (el) {1402 queueMicrotask(() => { if (isActive()) el.focus(); });1403 }1404 }}1405 >1406 {isActive() ? "Press a key…" : formatKeybinding(binding())}1407 </button>1408 <Show when={isCustom() && !isActive()}>1409 <button1410 class="peach-shortcut-reset"1411 title="Reset to default"1412 onClick={() => onReset(desc.action)}1413 >1414 ↺1415 </button>1416 </Show>1417 </div>1418 );1419 }1420 }14211422 const PRO_FEATURES: Array<{1423 feature: ProFeature;1424 name: string;1425 desc: string;1426 }> = [1427 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1428 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1429 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1430 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1431 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1432 ];14331434 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14351436 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1437 const [deactivating, setDeactivating] = createSignal(false);14381439 async function handleDeactivateLicense() {1440 setDeactivating(true);1441 await sendMessage({ type: "DEACTIVATE_LICENSE" });1442 setLicenseInfo(null);1443 await props.onLicenseChange?.();1444 setDeactivating(false);1445 }14461447 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1448 setCanaryTestingId(entry.id);1449 setCanaryTestResult(null);1450 const res = await sendMessage({1451 type: "FIRE_CANARY_PING",1452 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1453 }) as { ok: boolean };1454 setCanaryTestResult({ id: entry.id, ok: res.ok });1455 setCanaryTestingId(null);1456 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1457 }14581459 function renderLicense() {1460 async function onActivate() {1461 await handleActivateLicense();1462 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1463 await props.onLicenseChange?.();1464 }1465 }14661467 return (1468 <div class="peach-settings-body">1469 <Show1470 when={isPro()}1471 fallback={1472 <Show1473 when={showKeyEntry()}1474 fallback={1475 <div class="peach-pro-upgrade">1476 <div class="peach-pro-upgrade-hero">1477 <div class="peach-pro-upgrade-mark-wrap">1478 <PeachMark class="peach-pro-upgrade-mark" />1479 </div>1480 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1481 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1482 </div>14831484 <div class="peach-pro-features">1485 <For each={PRO_FEATURES}>1486 {(item) => (1487 <div1488 class="peach-pro-feature"1489 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1490 >1491 <span class="peach-pro-feature-mark">✦</span>1492 <div class="peach-pro-feature-text">1493 <span class="peach-pro-feature-name">{item.name}</span>1494 <span class="peach-pro-feature-desc">{item.desc}</span>1495 </div>1496 </div>1497 )}1498 </For>1499 </div>15001501 <div class="peach-pro-upgrade-actions">1502 <p class="peach-pro-pricing">$39 · once · yours forever</p>1503 <a1504 href="https://peachpasswords.com/#pricing"1505 target="_blank"1506 rel="noopener noreferrer"1507 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1508 >1509 Get Peach Pro1510 </a>1511 <div class="peach-pro-key-divider"><span>or</span></div>1512 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1513 Activate a license key1514 </button>1515 </div>1516 </div>1517 }1518 >1519 <div class="peach-pro-key-ceremony">1520 <LockRuneCanvas />1521 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1522 <input1523 class="peach-underline-input peach-pro-key-input"1524 type="text"1525 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1526 value={licenseKeyInput()}1527 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1528 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1529 autocomplete="off"1530 spellcheck={false}1531 />1532 <Show when={licenseError()}>1533 <p class="peach-pro-key-error">That key doesn't look right.</p>1534 </Show>1535 <button1536 class="peach-btn peach-btn--accent peach-pro-key-activate"1537 onClick={() => void onActivate()}1538 disabled={activatingLicense() || !licenseKeyInput().trim()}1539 >1540 {activatingLicense() ? "activating…" : "Activate"}1541 </button>1542 <button1543 class="peach-pro-key-cancel"1544 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1545 >1546 cancel1547 </button>1548 </div>1549 </Show>1550 }1551 >1552 <div class="peach-pro-active">1553 <div class="peach-pro-active-hero">1554 <div class="peach-pro-active-mark-wrap">1555 <PeachMark class="peach-pro-active-mark" />1556 </div>1557 <h2 class="peach-pro-active-title">Peach Pro</h2>1558 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1559 </div>15601561 <Show when={licenseKey()}>1562 <div class="peach-settings-license">1563 <span class="peach-settings-label">LICENSE KEY</span>1564 <div class="peach-settings-license-row">1565 <span class="peach-settings-license-key">{licenseKey()!}</span>1566 <button1567 class="peach-settings-license-copy"1568 onClick={() => copyLicenseKey(licenseKey()!)}1569 >1570 {copied() ? "✓" : "copy"}1571 </button>1572 </div>1573 </div>1574 </Show>15751576 <div class="peach-pro-active-features">1577 <For each={PRO_FEATURES}>1578 {(item) => (1579 <div class="peach-pro-active-feature">1580 <span class="peach-pro-active-feature-mark">✦</span>1581 <span class="peach-pro-active-feature-name">{item.name}</span>1582 </div>1583 )}1584 </For>1585 </div>15861587 <button1588 class="peach-pro-deactivate"1589 onClick={() => void handleDeactivateLicense()}1590 disabled={deactivating()}1591 >1592 {deactivating() ? "Deactivating…" : "Deactivate"}1593 </button>1594 </div>1595 </Show>1596 </div>1597 );1598 }15991600 function renderFeedback() {1601 return (1602 <div class="peach-settings-body">1603 <div class="peach-settings-group peach-feedback-group">1604 <p class="peach-feedback-intro">1605 Help us improve Peach. Every report goes directly to our inbox.1606 </p>16071608 <p class="peach-settings-sub-label">TYPE</p>1609 <div class="peach-feedback-chips">1610 <For each={FEEDBACK_CATEGORIES}>1611 {(cat) => (1612 <button1613 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1614 onClick={() => setFbCategory(cat)}1615 >1616 {cat}1617 </button>1618 )}1619 </For>1620 </div>16211622 <p class="peach-settings-sub-label">MESSAGE</p>1623 <textarea1624 class="peach-feedback-textarea"1625 placeholder="Describe the issue or idea..."1626 value={fbMessage()}1627 onInput={(e) => setFbMessage(e.currentTarget.value)}1628 />16291630 <p class="peach-feedback-meta">1631 Version {version} · will be included automatically1632 </p>16331634 <Show when={fbResult() === "success"}>1635 <p class="peach-feedback-result peach-feedback-result--ok">1636 Sent — thank you1637 </p>1638 </Show>1639 <Show when={fbResult() === "error"}>1640 <p class="peach-feedback-result peach-feedback-result--err">1641 Failed to send — try again1642 </p>1643 </Show>16441645 <button1646 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1647 onClick={() => void handleSubmitFeedback()}1648 disabled={fbSubmitting() || !fbMessage().trim()}1649 >1650 {fbSubmitting() ? "Sending..." : "Send feedback"}1651 </button>16521653 <p class="peach-feedback-anon">1654 Sent anonymously · no account required1655 </p>1656 </div>1657 </div>1658 );1659 }16601661 return (1662 <Show1663 when={showNewRecoveryWords()}1664 fallback={1665 <div class="peach-overlay-view ov-enter-right" style="display:flex">1666 <div class="peach-overlay-header">1667 <button1668 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1669 onClick={handleHeaderBack}1670 aria-label="Back"1671 >1672 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1673 <path d="M6 1L1 6L6 11" />1674 </svg>1675 </button>1676 <span class="peach-overlay-title">{currentTitle()}</span>1677 <div class="peach-overlay-spacer"></div>1678 </div>16791680 <Switch fallback={renderHome()}>1681 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1682 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1683 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1684 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1685 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1686 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1687 </Switch>1688 </div>1689 }1690 >1691 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1692 </Show>1693 );1694}