1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import type { Canary } from "../../shared/types";7import { ACCENT_COLORS } from "../../shared/types";8import type {9 AccentColorName,10 PasskeyProviderState,11 ProFeature,12 VaultSnapshot,13} from "../../shared/types";14import {15 DEFAULT_BINDINGS,16 findConflict,17 formatKeybinding,18 keybindingFromEvent,19 SHORTCUT_DESCRIPTORS,20 type Keybinding,21 type ShortcutAction,22} from "../../shared/shortcuts";23import { isNativePlatform, nativePlugin } from "../../shared/native";2425interface LicenseInfo {26 valid: boolean;27 plan: "free" | "pro";28 issued?: string;29}3031interface SettingsProps {32 entriesCount: number;33 breachMonitoring: boolean;34 lockOnClose: boolean;35 autoLockMinutes: number;36 trashRetentionDays: number;37 theme: "dark" | "light";38 accentColor: AccentColorName;39 textSize: "small" | "medium" | "large";40 reduceMotion: boolean;41 isPro?: boolean;42 initialSection?: string | null;43 upgradeHighlight?: ProFeature | null;44 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;45 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;46 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;47 onTrashRetentionChange: (days: number) => void | Promise<void>;48 onThemeChange: (theme: "dark" | "light") => void;49 onAccentChange: (name: AccentColorName) => void | Promise<void>;50 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;51 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;52 shortcuts: Record<ShortcutAction, Keybinding>;53 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;54 onLicenseChange?: () => void | Promise<void>;55 onBack: () => void;56 onImport: () => void;57 onExport: () => void;58 onBreachCheckDone: () => void;59 onOpenHealth?: () => void;60 onOpenSyncPairing?: () => void;61 onOpenFileImport?: () => void;62 onOpenPanic?: () => void;63 onOpenCodexGenerate?: () => void;64 onOpenCodexRestore?: () => void;65}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 manifest = chrome.runtime.getManifest();101 const version = manifest.version;102 const isAndroid = (manifest as any).version_name === 'mobile';103 const initial = props.initialSection;104 const [activeSection, setActiveSection] = createSignal<SectionName | null>(105 initial && isSectionName(initial) ? (initial as SectionName) : null106 );107108 const [checkingBreaches, setCheckingBreaches] = createSignal(false);109 const [breachResult, setBreachResult] = createSignal<string | null>(null);110 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);111 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");112 const [licenseError, setLicenseError] = createSignal<string | null>(null);113 const [, setLicenseSuccess] = createSignal<string | null>(null);114 const [activatingLicense, setActivatingLicense] = createSignal(false);115116 const isPro = () => props.isPro ?? false;117 const [biometricSupported, setBiometricSupported] = createSignal(false);118 const [biometricStored, setBiometricStored] = createSignal(false);119 const [biometricBusy, setBiometricBusy] = createSignal(false);120 const [biometricError, setBiometricError] = createSignal<string | null>(null);121 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);122 const [passkeySaving, setPasskeySaving] = createSignal(false);123124 const [dedupCount, setDedupCount] = createSignal<number | null>(null);125 const [dedupRunning, setDedupRunning] = createSignal(false);126 const [dedupDone, setDedupDone] = createSignal<number | null>(null);127128 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);129130 131 132 133 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);134 const [historyLoading, setHistoryLoading] = createSignal(false);135 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);136137 async function loadVaultHistory() {138 setHistoryLoading(true);139 try {140 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });141 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];142 setHistorySnapshots(list);143 } finally {144 setHistoryLoading(false);145 }146 }147148 async function handleRestoreVaultSnapshot(id: string) {149 if (historyRestoring()) return;150 setHistoryRestoring(id);151 const res = await sendMessage({152 type: "RESTORE_VAULT_SNAPSHOT",153 payload: { snapshotId: id },154 });155 setHistoryRestoring(null);156 if ((res as { ok: boolean }).ok) {157 158 159 props.onBack();160 }161 }162163 function formatSnapshotTime(savedAt: number): string {164 const diff = Date.now() - savedAt;165 if (diff < 60_000) return "Just now";166 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;167 const d = new Date(savedAt);168 const isToday = new Date().toDateString() === d.toDateString();169 const isYest =170 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();171 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });172 if (isToday) return `Today ${t}`;173 if (isYest) return `Yesterday ${t}`;174 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;175 }176177 function reasonLabel(reason: VaultSnapshot["reason"]): string {178 return (179 {180 local_save: "saved",181 sync_received: "synced",182 import: "imported",183 restore: "restored",184 } as const185 )[reason] ?? reason;186 }187188 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);189190 const [showChangePw, setShowChangePw] = createSignal(false);191 const [changePwCurrent, setChangePwCurrent] = createSignal("");192 const [changePwNew, setChangePwNew] = createSignal("");193 const [changePwConfirm, setChangePwConfirm] = createSignal("");194 const [changePwSaving, setChangePwSaving] = createSignal(false);195 const [changePwError, setChangePwError] = createSignal<string | null>(null);196 const [changePwSuccess] = createSignal(false);197 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);198199 const [duressEnabled, setDuressEnabled] = createSignal(false);200 const [showDuressSetup, setShowDuressSetup] = createSignal(false);201 const [duressPassword, setDuressPassword] = createSignal("");202 const [duressConfirm, setDuressConfirm] = createSignal("");203 const [duressMainPassword, setDuressMainPassword] = createSignal("");204 const [duressSaving, setDuressSaving] = createSignal(false);205 const [duressError, setDuressError] = createSignal<string | null>(null);206 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);207208 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);209210 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);211 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);212213 const [fbCategory, setFbCategory] = createSignal("Bug report");214 const [fbMessage, setFbMessage] = createSignal("");215 const [fbSubmitting, setFbSubmitting] = createSignal(false);216 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);217218 onMount(async () => {219 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([220 sendMessage({ type: "GET_LICENSE" }),221 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),222 sendMessage({ type: "IS_DURESS_ENABLED" }),223 sendMessage({ type: "GET_SETTINGS" }),224 sendMessage({ type: "GET_LICENSE_KEY" }),225 ]);226227 228 229 void loadVaultHistory();230231 if ((licenseRes as { ok: boolean }).ok) {232 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);233 }234235 if ((passkeyRes as { ok: boolean }).ok) {236 setPasskeyState(237 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null238 );239 }240241 if ((duressRes as { ok: boolean }).ok) {242 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);243 }244245 if ((settingsRes as { ok: boolean }).ok) {246 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;247 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);248 }249250 if ((keyRes as { ok: boolean }).ok) {251 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);252 }253254 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });255 if ((entriesRes as { ok: boolean }).ok) {256 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];257 setCanaryEntries(258 entries259 .filter((e) => e.canary?.webhookUrl)260 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))261 );262 }263264265 if (isNativePlatform()) {266 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");267 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");268 try {269 if (biometric) {270 const a = await biometric.isAvailable();271 setBiometricSupported(a.available);272 }273 if (keystore) {274 const k = await keystore.hasMasterKey();275 setBiometricStored(k.exists);276 }277 } catch { }278 }279 });280281 async function handleEnableBiometrics() {282 setBiometricBusy(true);283 setBiometricError(null);284 try {285 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {286 ok: boolean;287 data?: { masterKeyHex: string };288 error?: string;289 };290 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {291 setBiometricError(keyRes.error === "vault_locked"292 ? "unlock the vault first"293 : "couldn't read derived key");294 return;295 }296 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");297 if (!keystore) {298 setBiometricError("biometric plugin unavailable");299 return;300 }301 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });302 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });303 setBiometricStored(true);304 } catch (e) {305 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");306 } finally {307 setBiometricBusy(false);308 }309 }310311 async function handleDisableBiometrics() {312 setBiometricBusy(true);313 setBiometricError(null);314 try {315 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");316 if (keystore) await keystore.clearMasterKey();317 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });318 setBiometricStored(false);319 } catch (e) {320 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");321 } finally {322 setBiometricBusy(false);323 }324 }325326 async function handleChangePassword() {327 setChangePwError(null);328 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }329 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }330 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }331 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }332 setChangePwSaving(true);333 const res = await sendMessage({334 type: "CHANGE_PASSWORD",335 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },336 }) as { ok: boolean; error?: string };337 if (res.ok) {338 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");339 setShowChangePw(false);340 setShowNewRecoveryWords(true);341 } else {342 setChangePwError(res.error ?? "failed to change password");343 }344 setChangePwSaving(false);345 }346347 const [copied, setCopied] = createSignal(false);348349 async function copyLicenseKey(key: string) {350 await navigator.clipboard.writeText(key);351 setCopied(true);352 setTimeout(() => setCopied(false), 2000);353 }354355 async function handleActivateLicense() {356 const key = licenseKeyInput().trim();357 if (!key) return;358 setActivatingLicense(true);359 setLicenseError(null);360 setLicenseSuccess(null);361 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });362 setActivatingLicense(false);363 if ((res as { ok: boolean }).ok) {364 const data = (res as { ok: boolean; data?: LicenseInfo }).data;365 if (data?.valid) {366 setLicenseInfo(data);367 setLicenseKey(key);368 setLicenseSuccess("License activated!");369 setLicenseKeyInput("");370 } else {371 setLicenseError("Invalid license key");372 }373 } else {374 setLicenseError("Activation failed");375 }376 }377378 async function handleTogglePasskeys() {379 const current = passkeyState();380 if (!current || !current.supported) return;381 setPasskeySaving(true);382 const res = await sendMessage({383 type: "SET_PASSKEY_PROVIDER_ENABLED",384 payload: { enabled: !current.enabled },385 });386 setPasskeySaving(false);387 if ((res as { ok: boolean }).ok) {388 setPasskeyState(389 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null390 );391 }392 }393394 async function handleCheckAllBreaches() {395 setCheckingBreaches(true);396 setBreachResult(null);397 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });398 setCheckingBreaches(false);399 if ((res as { ok: boolean }).ok) {400 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};401 const breached = Object.values(data).filter((count) => count > 0).length;402 setBreachResult(403 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"404 );405 props.onBreachCheckDone();406 }407 }408409410 async function handleSetupDuress() {411 const mainPw = duressMainPassword().trim();412 const duressPw = duressPassword().trim();413 const confirmPw = duressConfirm().trim();414415 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }416 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }417 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }418 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }419420 setDuressSaving(true);421 setDuressError(null);422423 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };424 if (!verifyRes.ok || !verifyRes.data) {425 setDuressError("incorrect main password");426 setDuressSaving(false);427 return;428 }429430 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };431 setDuressSaving(false);432 if (res.ok) {433 setDuressEnabled(true);434 setShowDuressSetup(false);435 setDuressPassword("");436 setDuressConfirm("");437 setDuressMainPassword("");438 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");439 setTimeout(() => setDuressSuccess(null), 5000);440 } else {441 setDuressError(res.error ?? "failed to set duress password");442 }443 }444445 async function handleClearDeclinedCards() {446 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });447 setDeclinedCardCount(0);448 }449450 async function handleCountDuplicates() {451 setDedupRunning(true);452 setDedupDone(null);453 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };454 setDedupRunning(false);455 if (res.ok) setDedupCount(res.data ?? 0);456 }457458 async function handleDeduplicate() {459 setDedupRunning(true);460 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };461 setDedupRunning(false);462 if (res.ok) {463 setDedupDone(res.data ?? 0);464 setDedupCount(0);465 }466 }467468 async function handleDisableDuress() {469 const mainPw = duressMainPassword().trim();470 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }471472 setDuressSaving(true);473 setDuressError(null);474475 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };476 if (!verifyRes.ok || !verifyRes.data) {477 setDuressError("incorrect main password");478 setDuressSaving(false);479 return;480 }481482 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };483 setDuressSaving(false);484 if (res.ok) {485 setDuressEnabled(false);486 setShowDuressSetup(false);487 setDuressMainPassword("");488 setDuressSuccess("Duress password disabled.");489 setTimeout(() => setDuressSuccess(null), 4000);490 } else {491 setDuressError(res.error ?? "failed to disable duress password");492 }493 }494495 async function handleSubmitFeedback() {496 if (!fbMessage().trim() || fbSubmitting()) return;497 setFbSubmitting(true);498 setFbResult(null);499 const res = await sendMessage({500 type: "SUBMIT_FEEDBACK",501 payload: {502 category: fbCategory(),503 message: fbMessage().trim(),504 version,505 browser: navigator.userAgent,506 },507 }) as { ok: boolean; error?: string };508 setFbSubmitting(false);509 if (res.ok) {510 setFbResult("success");511 setFbMessage("");512 setTimeout(() => setFbResult(null), 4000);513 } else {514 setFbResult("error");515 }516 }517518 function handleHeaderBack() {519 if (activeSection() !== null) {520 setActiveSection(null);521 } else {522 props.onBack();523 }524 }525526 function currentTitle(): string {527 const s = activeSection();528 if (s === null) return "settings";529 if (s === "license") return "account";530 return s;531 }532533 function renderCatRow(opts: {534 section: SectionName;535 name: string;536 desc: string;537 icon: () => any;538 pro?: boolean;539 }) {540 return (541 <button542 class="peach-settings-cat-row"543 onClick={() => setActiveSection(opts.section)}544 >545 <div class="peach-settings-cat-icon">{opts.icon()}</div>546 <div class="peach-settings-cat-content">547 <span class="peach-settings-cat-name">548 {opts.name}549 <Show when={opts.pro && !isPro()}>550 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>551 </Show>552 </span>553 <span class="peach-settings-cat-desc">{opts.desc}</span>554 </div>555 <svg556 class="peach-settings-cat-chevron"557 width="6" height="10" viewBox="0 0 6 10" fill="none"558 stroke="currentColor" stroke-width="1.1"559 stroke-linecap="round" stroke-linejoin="round"560 >561 <path d="M1 1l4 4-4 4" />562 </svg>563 </button>564 );565 }566567 const iconShield = () => (568 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">569 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>570 </svg>571 );572 const iconKey = () => (573 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">574 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>575 </svg>576 );577 const iconLock = () => (578 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">579 <rect x="3" y="7" width="10" height="7" rx="1.5"/>580 <path d="M6 7V5a3 3 0 016 0v2"/>581 </svg>582 );583 const iconSync = () => (584 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">585 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>586 <path d="M14 6l-2 2-2-2"/>587 <path d="M2 10l2-2 2 2"/>588 </svg>589 );590 const iconSun = () => (591 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">592 <circle cx="8" cy="8" r="3"/>593 <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"/>594 </svg>595 );596 const iconKeys = () => (597 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">598 <rect x="2" y="5" width="5" height="4" rx="1"/>599 <rect x="9" y="7" width="5" height="4" rx="1"/>600 <path d="M7 7h2m-2 2h2"/>601 </svg>602 );603 const iconBubble = () => (604 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">605 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>606 </svg>607 );608609 function renderHome() {610 return (611 <div class="peach-settings-body">612 <p class="peach-settings-nav-label">PROTECTION</p>613 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}614 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}615616 <p class="peach-settings-nav-label">VAULT</p>617 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}618 <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>619 <div class="peach-settings-cat-icon">{iconSync()}</div>620 <div class="peach-settings-cat-content">621 <span class="peach-settings-cat-name">Sync</span>622 <span class="peach-settings-cat-desc">Pair and sync across devices</span>623 </div>624 <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">625 <path d="M1 1l4 4-4 4" />626 </svg>627 </button>628629 <p class="peach-settings-nav-label">PREFERENCES</p>630 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}631 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}632633 <p class="peach-settings-nav-label">SUPPORT</p>634 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}635636 <div class="peach-settings-footer">637 <span>Peach {version}</span>638 <span class="peach-divider"></span>639 <span>{isAndroid ? 'Android app' : 'Browser extension'}</span>640 </div>641 </div>642 );643 }644645 function renderSecurity() {646 return (647 <div class="peach-settings-body">648 <div class="peach-settings-group">649 <p class="peach-settings-sub-label">LOCKING</p>650 <div class="peach-settings-row">651 <span>Lock on close</span>652 <button653 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}654 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}655 aria-label="Toggle lock on close"656 >657 <span class="peach-toggle-knob"></span>658 </button>659 </div>660 <div class="peach-settings-row">661 <span>Auto-lock after</span>662 <select663 class="peach-settings-select"664 value={String(props.autoLockMinutes)}665 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}666 >667 <option value="1">1 minute</option>668 <option value="5">5 minutes</option>669 <option value="15">15 minutes</option>670 <option value="30">30 minutes</option>671 <option value="0">Never</option>672 </select>673 </div>674675 <Show when={isNativePlatform() && biometricSupported()}>676 <p class="peach-settings-sub-label">BIOMETRICS</p>677 <div class="peach-settings-row">678 <span>Unlock with biometrics</span>679 <button680 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}681 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}682 disabled={biometricBusy()}683 aria-label="Toggle biometric unlock"684 >685 <span class="peach-toggle-knob"></span>686 </button>687 </div>688 <Show when={biometricError()}>689 <div class="peach-settings-row peach-settings-row--meta">690 <span class="peach-hint peach-hint-error">{biometricError()}</span>691 </div>692 </Show>693 </Show>694695 <p class="peach-settings-sub-label">BREACH PROTECTION</p>696 <div class="peach-settings-row">697 <span>Breach monitoring</span>698 <button699 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}700 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}701 aria-label="Toggle breach monitoring"702 >703 <span class="peach-toggle-knob"></span>704 </button>705 </div>706 <div class="peach-settings-row peach-settings-row--meta">707 <span>Check all passwords</span>708 <button709 class="peach-btn peach-btn--subtle peach-btn--sm"710 onClick={() => void handleCheckAllBreaches()}711 disabled={checkingBreaches()}712 >713 {checkingBreaches() ? "checking..." : "run"}714 </button>715 </div>716 <Show when={breachResult()}>717 <div class="peach-settings-row peach-settings-row--meta">718 <span>Last check</span>719 <span class="peach-settings-value">{breachResult()}</span>720 </div>721 </Show>722723 <p class="peach-settings-sub-label">MASTER PASSWORD</p>724 <div class="peach-settings-row peach-settings-row--meta">725 <span>Master password</span>726 <button727 class="peach-btn peach-btn--subtle peach-btn--sm"728 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}729 >730 {showChangePw() ? "cancel" : "change"}731 </button>732 </div>733 <Show when={showChangePw()}>734 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">735 <input736 class="peach-settings-input"737 type="password"738 placeholder="current password"739 value={changePwCurrent()}740 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}741 autocomplete="current-password"742 />743 <input744 class="peach-settings-input"745 type="password"746 placeholder="new password (min 12 chars)"747 value={changePwNew()}748 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}749 autocomplete="new-password"750 />751 <input752 class="peach-settings-input"753 type="password"754 placeholder="confirm new password"755 value={changePwConfirm()}756 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}757 autocomplete="new-password"758 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}759 />760 <Show when={changePwError()}>761 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>762 </Show>763 <Show when={changePwSuccess()}>764 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>765 </Show>766 <button767 class="peach-btn peach-btn--accent peach-btn--sm"768 style="align-self:flex-start"769 onClick={() => void handleChangePassword()}770 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}771 >772 {changePwSaving() ? "saving…" : "save new password"}773 </button>774 </div>775 </Show>776777 <p class="peach-settings-sub-label">DURESS PASSWORD</p>778 <div class="peach-settings-row peach-settings-row--top">779 <div class="peach-settings-row-main">780 <span>Duress password</span>781 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">782 If entered under coercion, shows a decoy empty vault instead of your real one.783 </span>784 </div>785 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>786 {duressEnabled() ? "on" : "off"}787 </div>788 </div>789790 <Show when={duressEnabled() && !showDuressSetup()}>791 <div class="peach-settings-row peach-settings-row--meta">792 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">793 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.794 </span>795 </div>796 </Show>797798 <Show when={duressSuccess()}>799 <div class="peach-settings-row peach-settings-row--meta">800 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>801 </div>802 </Show>803804 <Show when={!showDuressSetup()}>805 <div class="peach-settings-row peach-settings-row--meta">806 <button807 class="peach-btn peach-btn--subtle peach-btn--sm"808 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}809 >810 {duressEnabled() ? "Change / disable" : "Set up"}811 </button>812 </div>813 </Show>814815 <Show when={showDuressSetup()}>816 <div class="peach-duress-form">817 <label class="peach-label">main password (to confirm)</label>818 <input819 class="peach-settings-input"820 type="password"821 placeholder="your current master password"822 autocomplete="current-password"823 value={duressMainPassword()}824 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}825 />826 <Show when={!duressEnabled()}>827 <label class="peach-label">new duress password</label>828 <input829 class="peach-settings-input"830 type="password"831 placeholder="duress password (min 8 chars)"832 autocomplete="new-password"833 value={duressPassword()}834 onInput={(e) => setDuressPassword(e.currentTarget.value)}835 />836 <label class="peach-label">confirm duress password</label>837 <input838 class="peach-settings-input"839 type="password"840 placeholder="confirm duress password"841 autocomplete="new-password"842 value={duressConfirm()}843 onInput={(e) => setDuressConfirm(e.currentTarget.value)}844 />845 </Show>846847 <Show when={duressError()}>848 <p class="peach-hint peach-hint-error">{duressError()}</p>849 </Show>850851 <div class="peach-duress-actions">852 <button853 class="peach-btn peach-btn--subtle peach-btn--sm"854 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}855 >856 cancel857 </button>858 <Show when={duressEnabled()}>859 <button860 class="peach-btn peach-btn--ghost peach-btn--sm"861 style="color:var(--peach-breach)"862 onClick={() => void handleDisableDuress()}863 disabled={duressSaving()}864 >865 {duressSaving() ? "disabling..." : "disable duress"}866 </button>867 </Show>868 <Show when={!duressEnabled()}>869 <button870 class="peach-btn peach-btn--accent peach-btn--sm"871 onClick={() => void handleSetupDuress()}872 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}873 >874 {duressSaving() ? "saving..." : "save"}875 </button>876 </Show>877 </div>878 </div>879 </Show>880881 <p class="peach-settings-sub-label">CANARY ENTRIES</p>882 <div class="peach-settings-row peach-settings-row--top">883 <div class="peach-settings-row-main">884 <span>Canary entries</span>885 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">886 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.887 </span>888 </div>889 <span class="peach-settings-value" style="flex-shrink:0">890 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}891 </span>892 </div>893 <Show when={canaryEntries().length > 0}>894 <For each={canaryEntries()}>895 {(entry) => (896 <div class="peach-settings-row peach-settings-row--meta">897 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>898 <button899 class="peach-btn peach-btn--subtle peach-btn--sm"900 onClick={() => void handleTestCanary(entry)}901 disabled={canaryTestingId() === entry.id}902 style="flex-shrink:0"903 >904 {canaryTestingId() === entry.id905 ? "sending…"906 : canaryTestResult()?.id === entry.id907 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")908 : "test"}909 </button>910 </div>911 )}912 </For>913 </Show>914915 <p class="peach-settings-sub-label">PASSKEYS</p>916 <div class="peach-settings-row peach-settings-row--top">917 <div class="peach-settings-row-main">918 <span>Intercept passkey requests</span>919 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">920 Peach can handle passkey sign-in and creation on supported Chromium browsers.921 </span>922 </div>923 <button924 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}925 onClick={() => void handleTogglePasskeys()}926 aria-label="Toggle passkey interception"927 disabled={!passkeyState()?.supported || passkeySaving()}928 >929 <span class="peach-toggle-knob"></span>930 </button>931 </div>932 <div class="peach-settings-row peach-settings-row--meta">933 <span>Status</span>934 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>935 </div>936 <div class="peach-settings-row peach-settings-row--meta">937 <span>Browser</span>938 <span class="peach-settings-value">939 {passkeyState()?.browserLabel ?? "Unknown"}940 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}941 </span>942 </div>943 <div class="peach-info-notice" style="margin-top:8px">944 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.945 </div>946 <Show when={passkeyState()?.conflict}>947 <div class="peach-settings-row peach-settings-row--meta">948 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>949 </div>950 </Show>951 </div>952 </div>953 );954 }955956 function renderVault() {957 return (958 <div class="peach-settings-body">959 <div class="peach-settings-group">960 <p class="peach-settings-sub-label">ENTRIES</p>961 <div class="peach-settings-row peach-settings-row--meta">962 <span>Entries</span>963 <span class="peach-settings-value">{props.entriesCount}</span>964 </div>965 <div class="peach-settings-row peach-settings-row--meta">966 <span>Duplicates</span>967 <Show when={dedupDone() !== null} fallback={968 <button969 class="peach-btn peach-btn--subtle peach-btn--sm"970 onClick={() => void handleCountDuplicates()}971 disabled={dedupRunning()}972 >973 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}974 </button>975 }>976 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">977 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}978 </span>979 </Show>980 </div>981 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>982 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">983 <div class="peach-restore-warning">984 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.985 </div>986 <div style="display:flex;gap:6px">987 <button988 class="peach-btn peach-btn--subtle peach-btn--sm"989 onClick={() => setDedupCount(null)}990 >991 cancel992 </button>993 <button994 class="peach-btn peach-btn--accent peach-btn--sm"995 onClick={() => void handleDeduplicate()}996 disabled={dedupRunning()}997 >998 {dedupRunning() ? "removing..." : "remove duplicates"}999 </button>1000 </div>1001 </div>1002 </Show>10031004 <p class="peach-settings-sub-label">MAINTENANCE</p>1005 <div class="peach-settings-row peach-settings-row--meta">1006 <span>Trash auto-delete</span>1007 <select1008 class="peach-settings-select"1009 value={String(props.trashRetentionDays)}1010 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1011 >1012 <option value="7">7 days</option>1013 <option value="30">30 days</option>1014 <option value="90">90 days</option>1015 <option value="0">Never</option>1016 </select>1017 </div>1018 <div class="peach-settings-row peach-settings-row--meta">1019 <span>Vault Health</span>1020 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1021 open1022 </button>1023 </div>1024 <Show when={declinedCardCount() > 0}>1025 <div class="peach-settings-row peach-settings-row--meta">1026 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1027 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1028 Clear1029 </button>1030 </div>1031 </Show>10321033 <p class="peach-settings-sub-label">DATA</p>1034 <div class="peach-settings-row peach-settings-row--meta">1035 <span>Import passwords</span>1036 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1037 open1038 </button>1039 </div>1040 <div class="peach-settings-row peach-settings-row--meta">1041 <span>Export backup</span>1042 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1043 open1044 </button>1045 </div>1046 <div class="peach-settings-row peach-settings-row--meta">1047 <span>1048 Peach Codex1049 <Show when={!isPro()}>1050 <span class="peach-pro-badge">Pro</span>1051 </Show>1052 </span>1053 <button1054 class="peach-btn peach-btn--subtle peach-btn--sm"1055 onClick={() => props.onOpenCodexGenerate?.()}1056 >1057 open1058 </button>1059 </div>1060 {1061}1062 <div class="peach-settings-row peach-settings-row--meta">1063 <span>Restore from Codex</span>1064 <button1065 class="peach-btn peach-btn--subtle peach-btn--sm"1066 onClick={() => props.onOpenCodexRestore?.()}1067 >1068 open1069 </button>1070 </div>10711072 <p class="peach-settings-sub-label">VAULT HISTORY</p>1073 <Show when={historyLoading()}>1074 <p class="peach-settings-hint">Loading…</p>1075 </Show>1076 <Show when={!historyLoading() && historySnapshots().length === 0}>1077 <p class="peach-settings-hint">1078 No snapshots yet. Peach saves automatically before every change —1079 saves, syncs, and imports.1080 </p>1081 </Show>1082 <Show when={!historyLoading() && historySnapshots().length > 0}>1083 <p class="peach-settings-hint">1084 Restoring locks your vault. Unlock with the password that1085 encrypted that snapshot to confirm.1086 </p>1087 <div class="peach-history-list">1088 <For each={historySnapshots()}>1089 {(snap) => (1090 <div class="peach-history-row">1091 <div class="peach-history-meta">1092 <span class="peach-history-time">1093 {formatSnapshotTime(snap.savedAt)}1094 </span>1095 <span class="peach-history-sub">1096 {[1097 snap.entryCount !== null1098 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1099 : null,1100 reasonLabel(snap.reason),1101 ]1102 .filter(Boolean)1103 .join(" · ")}1104 </span>1105 </div>1106 <button1107 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1108 historyRestoring() === snap.id ? " is-busy" : ""1109 }`}1110 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1111 disabled={historyRestoring() !== null}1112 >1113 <Show when={historyRestoring() === snap.id} fallback="Restore">1114 …1115 </Show>1116 </button>1117 </div>1118 )}1119 </For>1120 </div>1121 <p class="peach-history-footer">1122 {historySnapshots().length} of 10 snapshots1123 </p>1124 </Show>11251126 <button1127 class="peach-settings-advanced-toggle"1128 onClick={() => setShowVaultAdvanced((v) => !v)}1129 aria-expanded={showVaultAdvanced()}1130 >1131 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1132 <span class="peach-settings-advanced-chevron">1133 {showVaultAdvanced() ? "▲" : "▼"}1134 </span>1135 </button>11361137 <Show when={showVaultAdvanced()}>1138 <div class="peach-danger-card">1139 <div class="peach-danger-card-head">1140 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1141 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1142 </div>1143 <p class="peach-danger-card-copy">1144 Wipes the current vault and restores from a backup file.1145 Recoverable only if your backup is good.1146 </p>1147 <button1148 class="peach-danger-card-button"1149 onClick={() => props.onOpenFileImport?.()}1150 >1151 Choose backup file1152 </button>1153 </div>11541155 <Show when={props.onOpenPanic}>1156 <div class="peach-danger-card peach-danger-card--hazard">1157 <div class="peach-danger-card-head">1158 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1159 <h3 class="peach-danger-card-title">Destroy vault</h3>1160 </div>1161 <p class="peach-danger-card-copy">1162 Permanently wipes every entry, setting, and license on this1163 device. There is no recovery.1164 </p>1165 <button1166 class="peach-danger-card-button"1167 onClick={() => props.onOpenPanic?.()}1168 >1169 Begin destruction1170 </button>1171 </div>1172 </Show>1173 </Show>1174 </div>1175 </div>1176 );1177 }11781179 function renderAppearance() {1180 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1181 { id: "small", label: "Small" },1182 { id: "medium", label: "Default" },1183 { id: "large", label: "Large" },1184 ];1185 return (1186 <div class="peach-settings-body">1187 <div class="peach-settings-group">1188 <p class="peach-settings-sub-label">THEME</p>1189 <div class="peach-settings-row peach-settings-row--meta">1190 <span>Theme</span>1191 <button1192 class="peach-btn peach-btn--subtle peach-btn--sm"1193 onClick={() =>1194 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1195 }1196 >1197 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1198 </button>1199 </div>12001201 <p class="peach-settings-sub-label">ACCENT</p>1202 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1203 <span>Accent</span>1204 <div class="peach-accent-picker">1205 <For each={ACCENT_COLORS}>1206 {(color) => (1207 <button1208 class="peach-accent-swatch"1209 classList={{ "is-active": props.accentColor === color.name }}1210 style={{ background: color.hex }}1211 onClick={() => void props.onAccentChange(color.name)}1212 aria-label={color.name}1213 title={color.name}1214 />1215 )}1216 </For>1217 </div>1218 </div>12191220 <p class="peach-settings-sub-label">TEXT SIZE</p>1221 <div class="peach-settings-row peach-settings-row--meta">1222 <span>Size</span>1223 <div class="peach-pill-group">1224 <For each={sizeOptions}>1225 {(opt) => (1226 <button1227 class="peach-btn peach-btn--ghost peach-btn--sm"1228 classList={{ "is-active": props.textSize === opt.id }}1229 onClick={() => void props.onTextSizeChange(opt.id)}1230 >1231 {opt.label}1232 </button>1233 )}1234 </For>1235 </div>1236 </div>12371238 <p class="peach-settings-sub-label">MOTION</p>1239 <div class="peach-settings-row peach-settings-row--meta">1240 <span>Reduce motion</span>1241 <button1242 class="peach-btn peach-btn--subtle peach-btn--sm"1243 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1244 >1245 {props.reduceMotion ? "On" : "Off"}1246 </button>1247 </div>1248 </div>1249 </div>1250 );1251 }12521253 function renderShortcuts() {1254 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1255 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12561257 function startCapture(action: ShortcutAction) {1258 setConflictMsg(null);1259 setCapturingAction(action);1260 }1261 function cancelCapture() {1262 setCapturingAction(null);1263 setConflictMsg(null);1264 }1265 function commitBinding(action: ShortcutAction, binding: Keybinding) {1266 const conflict = findConflict(props.shortcuts, binding, action);1267 if (conflict) {1268 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1269 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1270 return;1271 }1272 const next = { ...props.shortcuts, [action]: binding };1273 setCapturingAction(null);1274 setConflictMsg(null);1275 void props.onShortcutsChange(next);1276 }1277 function resetAction(action: ShortcutAction) {1278 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1279 void props.onShortcutsChange(next);1280 }1281 function resetAll() {1282 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1283 }12841285 1286 1287 function handleCaptureKey(e: KeyboardEvent) {1288 e.preventDefault();1289 e.stopPropagation();1290 if (e.key === "Escape") { cancelCapture(); return; }1291 const binding = keybindingFromEvent(e);1292 if (!binding) return; 1293 const action = capturingAction();1294 if (action) commitBinding(action, binding);1295 }12961297 return (1298 <div class="peach-settings-body">1299 <div class="peach-settings-group peach-shortcuts-group">1300 <p class="peach-settings-sub-label">ENTRY VIEW</p>1301 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1302 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1303 </For>1304 </div>13051306 <div class="peach-settings-group peach-shortcuts-group">1307 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1308 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1309 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1310 </For>1311 </div>13121313 <Show when={conflictMsg()}>1314 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1315 </Show>13161317 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1318 <div class="peach-settings-row peach-settings-row--meta">1319 <span>Inline autofill on page</span>1320 <a1321 class="peach-settings-link"1322 href="chrome://extensions/shortcuts"1323 onClick={(e) => {1324 e.preventDefault();1325 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1326 }}1327 >1328 ⌘⇧P — customize ↗1329 </a>1330 </div>1331 <button1332 class="peach-btn peach-btn--subtle peach-btn--sm"1333 style="align-self:flex-start"1334 onClick={resetAll}1335 >1336 Reset all to defaults1337 </button>1338 </div>1339 </div>1340 );13411342 function renderRow(1343 desc: typeof SHORTCUT_DESCRIPTORS[number],1344 capturing: () => ShortcutAction | null,1345 onStart: (a: ShortcutAction) => void,1346 onCancel: () => void,1347 onReset: (a: ShortcutAction) => void,1348 onKey: (e: KeyboardEvent) => void,1349 ) {1350 const isActive = () => capturing() === desc.action;1351 const binding = () => props.shortcuts[desc.action];1352 const isCustom = () => {1353 const b = binding();1354 const d = DEFAULT_BINDINGS[desc.action];1355 return b.key !== d.key1356 || Boolean(b.ctrl) !== Boolean(d.ctrl)1357 || Boolean(b.meta) !== Boolean(d.meta)1358 || Boolean(b.shift) !== Boolean(d.shift)1359 || Boolean(b.alt) !== Boolean(d.alt);1360 };1361 return (1362 <div class="peach-shortcut-row">1363 <span class="peach-shortcut-row-label">{desc.label}</span>1364 <button1365 class="peach-shortcut-chip"1366 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1367 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1368 onKeyDown={(e) => { if (isActive()) onKey(e); }}1369 ref={(el) => {1370 1371 if (el) {1372 queueMicrotask(() => { if (isActive()) el.focus(); });1373 }1374 }}1375 >1376 {isActive() ? "Press a key…" : formatKeybinding(binding())}1377 </button>1378 <Show when={isCustom() && !isActive()}>1379 <button1380 class="peach-shortcut-reset"1381 title="Reset to default"1382 onClick={() => onReset(desc.action)}1383 >1384 ↺1385 </button>1386 </Show>1387 </div>1388 );1389 }1390 }13911392 const PRO_FEATURES: Array<{1393 feature: ProFeature;1394 name: string;1395 desc: string;1396 }> = [1397 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1398 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1399 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1400 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1401 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1402 ];14031404 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1405 const [showKey, setShowKey] = createSignal(false);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 <Show1537 when={showKey()}1538 fallback={1539 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1540 ••••••••••••••••••••••••••••1541 </span>1542 }1543 >1544 <span class="peach-settings-license-key">{licenseKey()!}</span>1545 </Show>1546 <div class="peach-settings-license-actions">1547 <button1548 class="peach-settings-license-copy"1549 title="Copy license key"1550 onClick={() => copyLicenseKey(licenseKey()!)}1551 >1552 {copied() ? "✓" : "copy"}1553 </button>1554 <button1555 class="peach-settings-license-toggle"1556 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1557 title={showKey() ? "Hide" : "Reveal"}1558 onClick={() => setShowKey(v => !v)}1559 >1560 <Show1561 when={showKey()}1562 fallback={1563 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1564 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1565 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1566 </svg>1567 }1568 >1569 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1570 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1571 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1572 <line x1="2" y1="2" x2="12" y2="12" />1573 </svg>1574 </Show>1575 </button>1576 </div>1577 </div>1578 </div>1579 </Show>15801581 <div class="peach-pro-active-features">1582 <For each={PRO_FEATURES}>1583 {(item) => (1584 <div class="peach-pro-active-feature">1585 <span class="peach-pro-active-feature-mark">✦</span>1586 <span class="peach-pro-active-feature-name">{item.name}</span>1587 </div>1588 )}1589 </For>1590 </div>15911592 <button1593 class="peach-pro-deactivate"1594 onClick={() => void handleDeactivateLicense()}1595 disabled={deactivating()}1596 >1597 {deactivating() ? "Deactivating…" : "Deactivate"}1598 </button>1599 </div>1600 </Show>1601 </div>1602 );1603 }16041605 function renderFeedback() {1606 return (1607 <div class="peach-settings-body">1608 <div class="peach-settings-group peach-feedback-group">1609 <p class="peach-feedback-intro">1610 Help us improve Peach. Every report goes directly to our inbox.1611 </p>16121613 <p class="peach-settings-sub-label">TYPE</p>1614 <div class="peach-feedback-chips">1615 <For each={FEEDBACK_CATEGORIES}>1616 {(cat) => (1617 <button1618 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1619 onClick={() => setFbCategory(cat)}1620 >1621 {cat}1622 </button>1623 )}1624 </For>1625 </div>16261627 <p class="peach-settings-sub-label">MESSAGE</p>1628 <textarea1629 class="peach-feedback-textarea"1630 placeholder="Describe the issue or idea..."1631 value={fbMessage()}1632 onInput={(e) => setFbMessage(e.currentTarget.value)}1633 />16341635 <p class="peach-feedback-meta">1636 Version {version} · will be included automatically1637 </p>16381639 <Show when={fbResult() === "success"}>1640 <p class="peach-feedback-result peach-feedback-result--ok">1641 Sent — thank you1642 </p>1643 </Show>1644 <Show when={fbResult() === "error"}>1645 <p class="peach-feedback-result peach-feedback-result--err">1646 Failed to send — try again1647 </p>1648 </Show>16491650 <button1651 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1652 onClick={() => void handleSubmitFeedback()}1653 disabled={fbSubmitting() || !fbMessage().trim()}1654 >1655 {fbSubmitting() ? "Sending..." : "Send feedback"}1656 </button>16571658 <p class="peach-feedback-anon">1659 Sent anonymously · no account required1660 </p>1661 </div>1662 </div>1663 );1664 }16651666 return (1667 <Show1668 when={showNewRecoveryWords()}1669 fallback={1670 <div class="peach-overlay-view ov-enter-right" style="display:flex">1671 <div class="peach-overlay-header">1672 <button1673 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1674 onClick={handleHeaderBack}1675 aria-label="Back"1676 >1677 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1678 <path d="M6 1L1 6L6 11" />1679 </svg>1680 </button>1681 <span class="peach-overlay-title">{currentTitle()}</span>1682 <div class="peach-overlay-spacer"></div>1683 </div>16841685 <Switch fallback={renderHome()}>1686 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1687 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1688 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1689 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1690 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1691 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1692 </Switch>1693 </div>1694 }1695 >1696 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1697 </Show>1698 );1699}