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 type { Canary } from "../../shared/types";6import { ACCENT_COLORS } from "../../shared/types";7import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";8import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";9import {10 DEFAULT_BINDINGS,11 findConflict,12 formatKeybinding,13 keybindingFromEvent,14 SHORTCUT_DESCRIPTORS,15 type Keybinding,16 type ShortcutAction,17} from "../../shared/shortcuts";1819interface LicenseInfo {20 valid: boolean;21 plan: "free" | "pro";22 issued?: string;23}2425interface SettingsProps {26 entriesCount: number;27 breachMonitoring: boolean;28 lockOnClose: boolean;29 autoLockMinutes: number;30 trashRetentionDays: number;31 theme: "dark" | "light";32 accentColor: AccentColorName;33 displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";34 textSize: "small" | "medium" | "large";35 reduceMotion: boolean;36 isPro?: boolean;37 initialSection?: string | null;38 upgradeHighlight?: ProFeature | null;39 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;40 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;41 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;42 onTrashRetentionChange: (days: number) => void | Promise<void>;43 onThemeChange: (theme: "dark" | "light") => void;44 onAccentChange: (name: AccentColorName) => void | Promise<void>;45 onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;46 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;47 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;48 shortcuts: Record<ShortcutAction, Keybinding>;49 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;50 onLicenseChange?: () => void | Promise<void>;51 onBack: () => void;52 onImport: () => void;53 onExport: () => void;54 onBreachCheckDone: () => void;55 onOpenHealth?: () => void;56 onOpenSyncPairing?: (mode: "initiate" | "accept") => void;57 onOpenFileImport?: () => void;58}5960type SectionName =61 | "security"62 | "vault"63 | "sync"64 | "appearance"65 | "shortcuts"66 | "license"67 | "feedback";6869const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7071function formatActivatedSince(issued: string | undefined): string {72 if (!issued) return "Active";73 try {74 const d = new Date(issued);75 if (Number.isNaN(d.getTime())) return "Active";76 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;77 } catch {78 return "Active";79 }80}8182function isSectionName(value: string): value is SectionName {83 return (84 value === "security" ||85 value === "vault" ||86 value === "sync" ||87 value === "appearance" ||88 value === "shortcuts" ||89 value === "license" ||90 value === "feedback"91 );92}9394export function SettingsView(props: SettingsProps) {95 const version = chrome.runtime.getManifest().version;96 const initial = props.initialSection;97 const [activeSection, setActiveSection] = createSignal<SectionName | null>(98 initial && isSectionName(initial) ? (initial as SectionName) : null99 );100101 const [checkingBreaches, setCheckingBreaches] = createSignal(false);102 const [breachResult, setBreachResult] = createSignal<string | null>(null);103 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);104 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");105 const [licenseError, setLicenseError] = createSignal<string | null>(null);106 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);107 const [activatingLicense, setActivatingLicense] = createSignal(false);108109 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);110 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);111 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");112 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);113 114 115 116 const [syncProStatus, setSyncProStatus] = createSignal(false);117 const isPro = () => props.isPro ?? syncProStatus();118 const [syncingNow, setSyncingNow] = createSignal(false);119 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);120 const [passkeySaving, setPasskeySaving] = createSignal(false);121122 const [dedupCount, setDedupCount] = createSignal<number | null>(null);123 const [dedupRunning, setDedupRunning] = createSignal(false);124 const [dedupDone, setDedupDone] = createSignal<number | null>(null);125126 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);127128 const [showChangePw, setShowChangePw] = createSignal(false);129 const [changePwCurrent, setChangePwCurrent] = createSignal("");130 const [changePwNew, setChangePwNew] = createSignal("");131 const [changePwConfirm, setChangePwConfirm] = createSignal("");132 const [changePwSaving, setChangePwSaving] = createSignal(false);133 const [changePwError, setChangePwError] = createSignal<string | null>(null);134 const [changePwSuccess, setChangePwSuccess] = createSignal(false);135136 const [duressEnabled, setDuressEnabled] = createSignal(false);137 const [showDuressSetup, setShowDuressSetup] = createSignal(false);138 const [duressPassword, setDuressPassword] = createSignal("");139 const [duressConfirm, setDuressConfirm] = createSignal("");140 const [duressMainPassword, setDuressMainPassword] = createSignal("");141 const [duressSaving, setDuressSaving] = createSignal(false);142 const [duressError, setDuressError] = createSignal<string | null>(null);143 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);144145 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);146 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);147 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);148149 const [fbCategory, setFbCategory] = createSignal("Bug report");150 const [fbMessage, setFbMessage] = createSignal("");151 const [fbSubmitting, setFbSubmitting] = createSignal(false);152 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);153154 onMount(async () => {155 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([156 sendMessage({ type: "GET_LICENSE" }),157 sendMessage({ type: "GET_SYNC_STATE" }),158 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),159 sendMessage({ type: "IS_DURESS_ENABLED" }),160 sendMessage({ type: "GET_SETTINGS" }),161 ]);162163 if ((licenseRes as { ok: boolean }).ok) {164 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);165 }166167 if ((syncRes as { ok: boolean }).ok) {168 const data = syncRes as { ok: boolean; data?: {169 pairedDevices?: PairedDevice[];170 trustedNetworks?: TrustedNetwork[];171 status?: SyncStatus;172 lastSyncAt?: number;173 isPro?: boolean;174 }};175 if (data.data) {176 setPairedDevices(data.data.pairedDevices ?? []);177 setTrustedNetworks(data.data.trustedNetworks ?? []);178 setSyncStatus(data.data.status ?? "offline");179 setLastSyncAt(data.data.lastSyncAt);180 setSyncProStatus(data.data.isPro ?? false);181 }182 }183184 if ((passkeyRes as { ok: boolean }).ok) {185 setPasskeyState(186 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null187 );188 }189190 if ((duressRes as { ok: boolean }).ok) {191 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);192 }193194 if ((settingsRes as { ok: boolean }).ok) {195 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;196 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);197 }198199 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });200 if ((entriesRes as { ok: boolean }).ok) {201 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];202 setCanaryEntries(203 entries204 .filter((e) => e.canary?.webhookUrl)205 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))206 );207 }208209 chrome.runtime.onMessage.addListener((msg) => {210 if (msg.type === "SYNC_STATE_UPDATE") {211 setPairedDevices(msg.pairedDevices ?? []);212 setTrustedNetworks(msg.trustedNetworks ?? []);213 setSyncStatus(msg.status ?? "offline");214 setLastSyncAt(msg.lastSyncAt);215 setSyncProStatus(msg.isPro ?? false);216 }217 });218 });219220 async function handleChangePassword() {221 setChangePwError(null);222 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }223 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }224 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }225 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }226 setChangePwSaving(true);227 const res = await sendMessage({228 type: "CHANGE_PASSWORD",229 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },230 }) as { ok: boolean; error?: string };231 if (res.ok) {232 setChangePwSuccess(true);233 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");234 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);235 } else {236 setChangePwError(res.error ?? "failed to change password");237 }238 setChangePwSaving(false);239 }240241 async function handleActivateLicense() {242 const key = licenseKeyInput().trim();243 if (!key) return;244 setActivatingLicense(true);245 setLicenseError(null);246 setLicenseSuccess(null);247 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });248 setActivatingLicense(false);249 if ((res as { ok: boolean }).ok) {250 const data = (res as { ok: boolean; data?: LicenseInfo }).data;251 if (data?.valid) {252 setLicenseInfo(data);253 setLicenseSuccess("License activated!");254 setLicenseKeyInput("");255 } else {256 setLicenseError("Invalid license key");257 }258 } else {259 setLicenseError("Activation failed");260 }261 }262263 async function handleTogglePasskeys() {264 const current = passkeyState();265 if (!current || !current.supported) return;266 setPasskeySaving(true);267 const res = await sendMessage({268 type: "SET_PASSKEY_PROVIDER_ENABLED",269 payload: { enabled: !current.enabled },270 });271 setPasskeySaving(false);272 if ((res as { ok: boolean }).ok) {273 setPasskeyState(274 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null275 );276 }277 }278279 async function handleCheckAllBreaches() {280 setCheckingBreaches(true);281 setBreachResult(null);282 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });283 setCheckingBreaches(false);284 if ((res as { ok: boolean }).ok) {285 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};286 const breached = Object.values(data).filter((count) => count > 0).length;287 setBreachResult(288 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"289 );290 props.onBreachCheckDone();291 }292 }293294 async function handleSyncNow() {295 if (syncStatus() === "not_trusted") return;296 setSyncingNow(true);297 await sendMessage({ type: "TRIGGER_SYNC" });298 setTimeout(() => setSyncingNow(false), 3000);299 }300301 async function handleRemoveDevice(deviceId: string) {302 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });303 }304305 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {306 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });307 }308309 async function handleMarkNetworkTrusted() {310 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });311 }312313 function formatRelativeTime(timestamp?: number): string {314 if (!timestamp) return "Never";315 const diff = Date.now() - timestamp;316 const minutes = Math.floor(diff / 60000);317 const hours = Math.floor(diff / 3600000);318 const days = Math.floor(diff / 86400000);319 if (minutes < 1) return "Just now";320 if (minutes < 60) return `${minutes}m ago`;321 if (hours < 24) return `${hours}h ago`;322 return `${days}d ago`;323 }324325 function getStatusColor(status: SyncStatus): string {326 switch (status) {327 case "synced": return "var(--peach-sync-ok)";328 case "syncing": return "var(--peach-sync-busy)";329 case "no_peers": return "var(--peach-sync-off)";330 case "not_trusted": return "var(--peach-sync-err)";331 case "offline": return "var(--peach-sync-off)";332 default: return "var(--peach-sync-off)";333 }334 }335336 function getStatusLabel(status: SyncStatus): string {337 switch (status) {338 case "synced": return "Synced";339 case "syncing": return "Syncing";340 case "no_peers": return "No peers visible";341 case "not_trusted": return "Not on a trusted network";342 case "offline": return "Offline";343 default: return "Unknown";344 }345 }346347 function isCurrentNetworkTrusted(): boolean {348 return trustedNetworks().length > 0;349 }350351 async function handleSetupDuress() {352 const mainPw = duressMainPassword().trim();353 const duressPw = duressPassword().trim();354 const confirmPw = duressConfirm().trim();355356 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }357 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }358 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }359 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }360361 setDuressSaving(true);362 setDuressError(null);363364 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };365 if (!verifyRes.ok || !verifyRes.data) {366 setDuressError("incorrect main password");367 setDuressSaving(false);368 return;369 }370371 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };372 setDuressSaving(false);373 if (res.ok) {374 setDuressEnabled(true);375 setShowDuressSetup(false);376 setDuressPassword("");377 setDuressConfirm("");378 setDuressMainPassword("");379 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");380 setTimeout(() => setDuressSuccess(null), 5000);381 } else {382 setDuressError(res.error ?? "failed to set duress password");383 }384 }385386 async function handleClearDeclinedCards() {387 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });388 setDeclinedCardCount(0);389 }390391 async function handleCountDuplicates() {392 setDedupRunning(true);393 setDedupDone(null);394 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };395 setDedupRunning(false);396 if (res.ok) setDedupCount(res.data ?? 0);397 }398399 async function handleDeduplicate() {400 setDedupRunning(true);401 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };402 setDedupRunning(false);403 if (res.ok) {404 setDedupDone(res.data ?? 0);405 setDedupCount(0);406 }407 }408409 async function handleDisableDuress() {410 const mainPw = duressMainPassword().trim();411 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }412413 setDuressSaving(true);414 setDuressError(null);415416 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };417 if (!verifyRes.ok || !verifyRes.data) {418 setDuressError("incorrect main password");419 setDuressSaving(false);420 return;421 }422423 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };424 setDuressSaving(false);425 if (res.ok) {426 setDuressEnabled(false);427 setShowDuressSetup(false);428 setDuressMainPassword("");429 setDuressSuccess("Duress password disabled.");430 setTimeout(() => setDuressSuccess(null), 4000);431 } else {432 setDuressError(res.error ?? "failed to disable duress password");433 }434 }435436 async function handleSubmitFeedback() {437 if (!fbMessage().trim() || fbSubmitting()) return;438 setFbSubmitting(true);439 setFbResult(null);440 const res = await sendMessage({441 type: "SUBMIT_FEEDBACK",442 payload: {443 category: fbCategory(),444 message: fbMessage().trim(),445 version,446 browser: navigator.userAgent,447 },448 }) as { ok: boolean; error?: string };449 setFbSubmitting(false);450 if (res.ok) {451 setFbResult("success");452 setFbMessage("");453 setTimeout(() => setFbResult(null), 4000);454 } else {455 setFbResult("error");456 }457 }458459 function handleHeaderBack() {460 if (activeSection() !== null) {461 setActiveSection(null);462 } else {463 props.onBack();464 }465 }466467 function currentTitle(): string {468 const s = activeSection();469 if (s === null) return "settings";470 if (s === "license") return "account";471 return s;472 }473474 function renderCatRow(opts: {475 section: SectionName;476 name: string;477 desc: string;478 icon: () => any;479 pro?: boolean;480 }) {481 return (482 <button483 class="peach-settings-cat-row"484 onClick={() => setActiveSection(opts.section)}485 >486 <div class="peach-settings-cat-icon">{opts.icon()}</div>487 <div class="peach-settings-cat-content">488 <span class="peach-settings-cat-name">489 {opts.name}490 <Show when={opts.pro && !isPro()}>491 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>492 </Show>493 </span>494 <span class="peach-settings-cat-desc">{opts.desc}</span>495 </div>496 <svg497 class="peach-settings-cat-chevron"498 width="6" height="10" viewBox="0 0 6 10" fill="none"499 stroke="currentColor" stroke-width="1.1"500 stroke-linecap="round" stroke-linejoin="round"501 >502 <path d="M1 1l4 4-4 4" />503 </svg>504 </button>505 );506 }507508 const iconShield = () => (509 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">510 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>511 </svg>512 );513 const iconKey = () => (514 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">515 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>516 </svg>517 );518 const iconLock = () => (519 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">520 <rect x="3" y="7" width="10" height="7" rx="1.5"/>521 <path d="M6 7V5a3 3 0 016 0v2"/>522 </svg>523 );524 const iconSync = () => (525 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">526 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>527 <path d="M14 6l-2 2-2-2"/>528 <path d="M2 10l2-2 2 2"/>529 </svg>530 );531 const iconSun = () => (532 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">533 <circle cx="8" cy="8" r="3"/>534 <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"/>535 </svg>536 );537 const iconKeys = () => (538 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">539 <rect x="2" y="5" width="5" height="4" rx="1"/>540 <rect x="9" y="7" width="5" height="4" rx="1"/>541 <path d="M7 7h2m-2 2h2"/>542 </svg>543 );544 const iconBubble = () => (545 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">546 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>547 </svg>548 );549550 function renderHome() {551 return (552 <div class="peach-settings-body">553 <p class="peach-settings-nav-label">PROTECTION</p>554 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}555 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}556557 <p class="peach-settings-nav-label">VAULT</p>558 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}559 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}560561 <p class="peach-settings-nav-label">PREFERENCES</p>562 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}563 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}564565 <p class="peach-settings-nav-label">SUPPORT</p>566 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}567568 <div class="peach-settings-footer">569 <span>Peach {version}</span>570 <span class="peach-divider"></span>571 <span>Browser extension</span>572 </div>573 </div>574 );575 }576577 function renderSecurity() {578 return (579 <div class="peach-settings-body">580 <div class="peach-settings-group">581 <p class="peach-settings-sub-label">LOCKING</p>582 <div class="peach-settings-row">583 <span>Lock on close</span>584 <button585 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}586 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}587 aria-label="Toggle lock on close"588 >589 <span class="peach-toggle-knob"></span>590 </button>591 </div>592 <div class="peach-settings-row">593 <span>Auto-lock after</span>594 <select595 class="peach-settings-select"596 value={String(props.autoLockMinutes)}597 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}598 >599 <option value="1">1 minute</option>600 <option value="5">5 minutes</option>601 <option value="15">15 minutes</option>602 <option value="30">30 minutes</option>603 <option value="0">Never</option>604 </select>605 </div>606607 <p class="peach-settings-sub-label">BREACH PROTECTION</p>608 <div class="peach-settings-row">609 <span>Breach monitoring</span>610 <button611 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}612 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}613 aria-label="Toggle breach monitoring"614 >615 <span class="peach-toggle-knob"></span>616 </button>617 </div>618 <div class="peach-settings-row peach-settings-row--meta">619 <span>Check all passwords</span>620 <button621 class="peach-btn peach-btn--subtle peach-btn--sm"622 onClick={() => void handleCheckAllBreaches()}623 disabled={checkingBreaches()}624 >625 {checkingBreaches() ? "checking..." : "run"}626 </button>627 </div>628 <Show when={breachResult()}>629 <div class="peach-settings-row peach-settings-row--meta">630 <span>Last check</span>631 <span class="peach-settings-value">{breachResult()}</span>632 </div>633 </Show>634635 <p class="peach-settings-sub-label">MASTER PASSWORD</p>636 <div class="peach-settings-row peach-settings-row--meta">637 <span>Master password</span>638 <button639 class="peach-btn peach-btn--subtle peach-btn--sm"640 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}641 >642 {showChangePw() ? "cancel" : "change"}643 </button>644 </div>645 <Show when={showChangePw()}>646 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">647 <input648 class="peach-settings-input"649 type="password"650 placeholder="current password"651 value={changePwCurrent()}652 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}653 autocomplete="current-password"654 />655 <input656 class="peach-settings-input"657 type="password"658 placeholder="new password (min 12 chars)"659 value={changePwNew()}660 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}661 autocomplete="new-password"662 />663 <input664 class="peach-settings-input"665 type="password"666 placeholder="confirm new password"667 value={changePwConfirm()}668 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}669 autocomplete="new-password"670 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}671 />672 <Show when={changePwError()}>673 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>674 </Show>675 <Show when={changePwSuccess()}>676 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>677 </Show>678 <button679 class="peach-btn peach-btn--accent peach-btn--sm"680 style="align-self:flex-start"681 onClick={() => void handleChangePassword()}682 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}683 >684 {changePwSaving() ? "saving…" : "save new password"}685 </button>686 </div>687 </Show>688689 <p class="peach-settings-sub-label">DURESS PASSWORD</p>690 <div class="peach-settings-row peach-settings-row--top">691 <div class="peach-settings-row-main">692 <span>Duress password</span>693 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">694 If entered under coercion, shows a decoy empty vault instead of your real one.695 </span>696 </div>697 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>698 {duressEnabled() ? "on" : "off"}699 </div>700 </div>701702 <Show when={duressEnabled() && !showDuressSetup()}>703 <div class="peach-settings-row peach-settings-row--meta">704 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">705 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.706 </span>707 </div>708 </Show>709710 <Show when={duressSuccess()}>711 <div class="peach-settings-row peach-settings-row--meta">712 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>713 </div>714 </Show>715716 <Show when={!showDuressSetup()}>717 <div class="peach-settings-row peach-settings-row--meta">718 <button719 class="peach-btn peach-btn--subtle peach-btn--sm"720 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}721 >722 {duressEnabled() ? "Change / disable" : "Set up"}723 </button>724 </div>725 </Show>726727 <Show when={showDuressSetup()}>728 <div class="peach-duress-form">729 <label class="peach-label">main password (to confirm)</label>730 <input731 class="peach-settings-input"732 type="password"733 placeholder="your current master password"734 autocomplete="current-password"735 value={duressMainPassword()}736 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}737 />738 <Show when={!duressEnabled()}>739 <label class="peach-label">new duress password</label>740 <input741 class="peach-settings-input"742 type="password"743 placeholder="duress password (min 8 chars)"744 autocomplete="new-password"745 value={duressPassword()}746 onInput={(e) => setDuressPassword(e.currentTarget.value)}747 />748 <label class="peach-label">confirm duress password</label>749 <input750 class="peach-settings-input"751 type="password"752 placeholder="confirm duress password"753 autocomplete="new-password"754 value={duressConfirm()}755 onInput={(e) => setDuressConfirm(e.currentTarget.value)}756 />757 </Show>758759 <Show when={duressError()}>760 <p class="peach-hint peach-hint-error">{duressError()}</p>761 </Show>762763 <div class="peach-duress-actions">764 <button765 class="peach-btn peach-btn--subtle peach-btn--sm"766 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}767 >768 cancel769 </button>770 <Show when={duressEnabled()}>771 <button772 class="peach-btn peach-btn--ghost peach-btn--sm"773 style="color:var(--peach-breach)"774 onClick={() => void handleDisableDuress()}775 disabled={duressSaving()}776 >777 {duressSaving() ? "disabling..." : "disable duress"}778 </button>779 </Show>780 <Show when={!duressEnabled()}>781 <button782 class="peach-btn peach-btn--accent peach-btn--sm"783 onClick={() => void handleSetupDuress()}784 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}785 >786 {duressSaving() ? "saving..." : "save"}787 </button>788 </Show>789 </div>790 </div>791 </Show>792793 <p class="peach-settings-sub-label">CANARY ENTRIES</p>794 <div class="peach-settings-row peach-settings-row--top">795 <div class="peach-settings-row-main">796 <span>Canary entries</span>797 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">798 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.799 </span>800 </div>801 <span class="peach-settings-value" style="flex-shrink:0">802 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}803 </span>804 </div>805 <Show when={canaryEntries().length > 0}>806 <For each={canaryEntries()}>807 {(entry) => (808 <div class="peach-settings-row peach-settings-row--meta">809 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>810 <button811 class="peach-btn peach-btn--subtle peach-btn--sm"812 onClick={() => void handleTestCanary(entry)}813 disabled={canaryTestingId() === entry.id}814 style="flex-shrink:0"815 >816 {canaryTestingId() === entry.id817 ? "sending…"818 : canaryTestResult()?.id === entry.id819 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")820 : "test"}821 </button>822 </div>823 )}824 </For>825 </Show>826827 <p class="peach-settings-sub-label">PASSKEYS</p>828 <div class="peach-settings-row peach-settings-row--top">829 <div class="peach-settings-row-main">830 <span>Intercept passkey requests</span>831 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">832 Peach can handle passkey sign-in and creation on supported Chromium browsers.833 </span>834 </div>835 <button836 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}837 onClick={() => void handleTogglePasskeys()}838 aria-label="Toggle passkey interception"839 disabled={!passkeyState()?.supported || passkeySaving()}840 >841 <span class="peach-toggle-knob"></span>842 </button>843 </div>844 <div class="peach-settings-row peach-settings-row--meta">845 <span>Status</span>846 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>847 </div>848 <div class="peach-settings-row peach-settings-row--meta">849 <span>Browser</span>850 <span class="peach-settings-value">851 {passkeyState()?.browserLabel ?? "Unknown"}852 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}853 </span>854 </div>855 <div class="peach-info-notice" style="margin-top:8px">856 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.857 </div>858 <Show when={passkeyState()?.conflict}>859 <div class="peach-settings-row peach-settings-row--meta">860 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>861 </div>862 </Show>863 </div>864 </div>865 );866 }867868 function renderVault() {869 return (870 <div class="peach-settings-body">871 <div class="peach-settings-group">872 <p class="peach-settings-sub-label">ENTRIES</p>873 <div class="peach-settings-row peach-settings-row--meta">874 <span>Entries</span>875 <span class="peach-settings-value">{props.entriesCount}</span>876 </div>877 <div class="peach-settings-row peach-settings-row--meta">878 <span>Duplicates</span>879 <Show when={dedupDone() !== null} fallback={880 <button881 class="peach-btn peach-btn--subtle peach-btn--sm"882 onClick={() => void handleCountDuplicates()}883 disabled={dedupRunning()}884 >885 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}886 </button>887 }>888 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">889 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}890 </span>891 </Show>892 </div>893 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>894 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">895 <div class="peach-restore-warning">896 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.897 </div>898 <div style="display:flex;gap:6px">899 <button900 class="peach-btn peach-btn--subtle peach-btn--sm"901 onClick={() => setDedupCount(null)}902 >903 cancel904 </button>905 <button906 class="peach-btn peach-btn--accent peach-btn--sm"907 onClick={() => void handleDeduplicate()}908 disabled={dedupRunning()}909 >910 {dedupRunning() ? "removing..." : "remove duplicates"}911 </button>912 </div>913 </div>914 </Show>915916 <p class="peach-settings-sub-label">MAINTENANCE</p>917 <div class="peach-settings-row peach-settings-row--meta">918 <span>Trash auto-delete</span>919 <select920 class="peach-settings-select"921 value={String(props.trashRetentionDays)}922 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}923 >924 <option value="7">7 days</option>925 <option value="30">30 days</option>926 <option value="90">90 days</option>927 <option value="0">Never</option>928 </select>929 </div>930 <div class="peach-settings-row peach-settings-row--meta">931 <span>Vault Health</span>932 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>933 open934 </button>935 </div>936 <Show when={declinedCardCount() > 0}>937 <div class="peach-settings-row peach-settings-row--meta">938 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>939 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>940 Clear941 </button>942 </div>943 </Show>944945 <p class="peach-settings-sub-label">DATA</p>946 <div class="peach-settings-row peach-settings-row--meta">947 <span>Import passwords</span>948 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>949 open950 </button>951 </div>952 <div class="peach-settings-row peach-settings-row--meta">953 <span>Export backup</span>954 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>955 open956 </button>957 </div>958 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">959 <span>Replace vault from backup</span>960 <div class="peach-restore-warning" style="margin-top:2px">961 This will permanently replace your current vault with the backup.962 </div>963 <div style="display:flex;gap:6px">964 <button965 class="peach-btn peach-btn--subtle peach-btn--sm"966 onClick={() => props.onOpenFileImport?.()}967 >968 backup file969 </button>970 </div>971 </div>972 </div>973 </div>974 );975 }976977 function renderSync() {978 if (!isPro()) {979 return (980 <div class="peach-settings-body">981 <div class="peach-pro-upgrade">982 <div class="peach-pro-upgrade-hero">983 <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">984 <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>985 <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>986 <path d="M13.5 15.5C15 12 17 10 18 10C19 10 21 12 22.5 15.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>987 <path d="M13.5 20.5C15 24 17 26 18 26C19 26 21 24 22.5 20.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>988 <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>989 <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>990 <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>991 </svg>992 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>993 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>994 </div>995996 <div class="peach-pro-features">997 <div class="peach-pro-feature">998 <span class="peach-pro-feature-mark">✦</span>999 <div class="peach-pro-feature-text">1000 <span class="peach-pro-feature-name">Always in sync</span>1001 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1002 </div>1003 </div>1004 <div class="peach-pro-feature">1005 <span class="peach-pro-feature-mark">✦</span>1006 <div class="peach-pro-feature-text">1007 <span class="peach-pro-feature-name">No cloud. No relay.</span>1008 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1009 </div>1010 </div>1011 <div class="peach-pro-feature">1012 <span class="peach-pro-feature-mark">✦</span>1013 <div class="peach-pro-feature-text">1014 <span class="peach-pro-feature-name">Tailscale supported</span>1015 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1016 </div>1017 </div>1018 <div class="peach-pro-feature">1019 <span class="peach-pro-feature-mark">✦</span>1020 <div class="peach-pro-feature-text">1021 <span class="peach-pro-feature-name">Zero friction after setup</span>1022 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1023 </div>1024 </div>1025 </div>10261027 <button1028 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1029 onClick={() => setActiveSection("license")}1030 >1031 Upgrade to Pro1032 </button>1033 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1034 Already have a key? Enter it →1035 </button>1036 </div>1037 </div>1038 );1039 }10401041 return (1042 <div class="peach-settings-body">1043 <div class="peach-settings-group">1044 <div class="peach-settings-row">1045 <span>Status</span>1046 <span class="peach-settings-value peach-settings-value--status">1047 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1048 {getStatusLabel(syncStatus())}1049 </span>1050 </div>1051 <div class="peach-settings-row peach-settings-row--meta">1052 <span>Last sync</span>1053 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1054 </div>1055 <div class="peach-settings-row peach-settings-row--meta">1056 <button1057 class="peach-btn peach-btn--subtle peach-btn--sm"1058 onClick={() => void handleSyncNow()}1059 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1060 >1061 {syncingNow() ? "Syncing..." : "Sync now"}1062 </button>1063 </div>10641065 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1066 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1067 {pairedDevices().map((device) => (1068 <div class="peach-sync-device-row">1069 <div class="peach-sync-device-info">1070 <span class="peach-sync-device-name">{device.displayName}</span>1071 <span class="peach-sync-device-meta">1072 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1073 </span>1074 </div>1075 <button1076 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1077 onClick={() => void handleRemoveDevice(device.deviceId)}1078 >1079 Remove1080 </button>1081 </div>1082 ))}1083 <div class="peach-settings-actions--row">1084 <button1085 class="peach-btn peach-btn--subtle peach-btn--sm"1086 onClick={() => props.onOpenSyncPairing?.("initiate")}1087 >1088 Pair a new device1089 </button>1090 <button1091 class="peach-btn peach-btn--subtle peach-btn--sm"1092 onClick={() => props.onOpenSyncPairing?.("accept")}1093 >1094 Pair with another device1095 </button>1096 </div>1097 </div>10981099 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1100 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1101 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1102 <div class="peach-sync-device-row">1103 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1104 <button1105 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1106 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1107 >1108 Remove1109 </button>1110 </div>1111 ))}1112 <div class="peach-settings-actions--row">1113 <Show when={!isCurrentNetworkTrusted()}>1114 <button1115 class="peach-btn peach-btn--subtle peach-btn--sm"1116 onClick={() => void handleMarkNetworkTrusted()}1117 >1118 Mark current network as trusted1119 </button>1120 </Show>1121 </div>1122 <span class="peach-sync-device-meta">1123 Tailscale is always trusted1124 </span>1125 </div>1126 </div>1127 </div>1128 );1129 }11301131 function renderAppearance() {1132 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1133 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1134 { id: "cormorant", label: "Cormorant", preview: "Aa" },1135 { id: "inter", label: "Inter", preview: "Aa" },1136 { id: "system", label: "System", preview: "Aa" },1137 ];1138 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1139 { id: "small", label: "Small" },1140 { id: "medium", label: "Default" },1141 { id: "large", label: "Large" },1142 ];1143 return (1144 <div class="peach-settings-body">1145 <div class="peach-settings-group">1146 <p class="peach-settings-sub-label">THEME</p>1147 <div class="peach-settings-row peach-settings-row--meta">1148 <span>Theme</span>1149 <button1150 class="peach-btn peach-btn--subtle peach-btn--sm"1151 onClick={() =>1152 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1153 }1154 >1155 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1156 </button>1157 </div>11581159 <p class="peach-settings-sub-label">ACCENT</p>1160 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1161 <span>Accent</span>1162 <div class="peach-accent-picker">1163 <For each={ACCENT_COLORS}>1164 {(color) => (1165 <button1166 class="peach-accent-swatch"1167 classList={{ "is-active": props.accentColor === color.name }}1168 style={{ background: color.hex }}1169 onClick={() => void props.onAccentChange(color.name)}1170 aria-label={color.name}1171 title={color.name}1172 />1173 )}1174 </For>1175 </div>1176 </div>11771178 <p class="peach-settings-sub-label">DISPLAY FONT</p>1179 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1180 <span>Font</span>1181 <div class="peach-font-picker">1182 <For each={fontOptions}>1183 {(opt) => (1184 <button1185 class="peach-font-swatch"1186 classList={{ "is-active": props.displayFont === opt.id }}1187 data-font-preview={opt.id}1188 onClick={() => void props.onDisplayFontChange(opt.id)}1189 aria-label={opt.label}1190 title={opt.label}1191 >1192 <span class="peach-font-swatch-glyph">{opt.preview}</span>1193 <span class="peach-font-swatch-name">{opt.label}</span>1194 </button>1195 )}1196 </For>1197 </div>1198 </div>11991200 <p class="peach-settings-sub-label">TEXT SIZE</p>1201 <div class="peach-settings-row peach-settings-row--meta">1202 <span>Size</span>1203 <div class="peach-pill-group">1204 <For each={sizeOptions}>1205 {(opt) => (1206 <button1207 class="peach-btn peach-btn--ghost peach-btn--sm"1208 classList={{ "is-active": props.textSize === opt.id }}1209 onClick={() => void props.onTextSizeChange(opt.id)}1210 >1211 {opt.label}1212 </button>1213 )}1214 </For>1215 </div>1216 </div>12171218 <p class="peach-settings-sub-label">MOTION</p>1219 <div class="peach-settings-row peach-settings-row--meta">1220 <span>Reduce motion</span>1221 <button1222 class="peach-btn peach-btn--subtle peach-btn--sm"1223 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1224 >1225 {props.reduceMotion ? "On" : "Off"}1226 </button>1227 </div>1228 </div>1229 </div>1230 );1231 }12321233 function renderShortcuts() {1234 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1235 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12361237 function startCapture(action: ShortcutAction) {1238 setConflictMsg(null);1239 setCapturingAction(action);1240 }1241 function cancelCapture() {1242 setCapturingAction(null);1243 setConflictMsg(null);1244 }1245 function commitBinding(action: ShortcutAction, binding: Keybinding) {1246 const conflict = findConflict(props.shortcuts, binding, action);1247 if (conflict) {1248 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1249 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1250 return;1251 }1252 const next = { ...props.shortcuts, [action]: binding };1253 setCapturingAction(null);1254 setConflictMsg(null);1255 void props.onShortcutsChange(next);1256 }1257 function resetAction(action: ShortcutAction) {1258 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1259 void props.onShortcutsChange(next);1260 }1261 function resetAll() {1262 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1263 }12641265 1266 1267 function handleCaptureKey(e: KeyboardEvent) {1268 e.preventDefault();1269 e.stopPropagation();1270 if (e.key === "Escape") { cancelCapture(); return; }1271 const binding = keybindingFromEvent(e);1272 if (!binding) return; 1273 const action = capturingAction();1274 if (action) commitBinding(action, binding);1275 }12761277 return (1278 <div class="peach-settings-body">1279 <div class="peach-settings-group peach-shortcuts-group">1280 <p class="peach-settings-sub-label">ENTRY VIEW</p>1281 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1282 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1283 </For>1284 </div>12851286 <div class="peach-settings-group peach-shortcuts-group">1287 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1288 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1289 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1290 </For>1291 </div>12921293 <Show when={conflictMsg()}>1294 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1295 </Show>12961297 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1298 <div class="peach-settings-row peach-settings-row--meta">1299 <span>Inline autofill on page</span>1300 <a1301 class="peach-settings-link"1302 href="chrome://extensions/shortcuts"1303 onClick={(e) => {1304 e.preventDefault();1305 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1306 }}1307 >1308 ⌘⇧P — customize ↗1309 </a>1310 </div>1311 <button1312 class="peach-btn peach-btn--subtle peach-btn--sm"1313 style="align-self:flex-start"1314 onClick={resetAll}1315 >1316 Reset all to defaults1317 </button>1318 </div>1319 </div>1320 );13211322 function renderRow(1323 desc: typeof SHORTCUT_DESCRIPTORS[number],1324 capturing: () => ShortcutAction | null,1325 onStart: (a: ShortcutAction) => void,1326 onCancel: () => void,1327 onReset: (a: ShortcutAction) => void,1328 onKey: (e: KeyboardEvent) => void,1329 ) {1330 const isActive = () => capturing() === desc.action;1331 const binding = () => props.shortcuts[desc.action];1332 const isCustom = () => {1333 const b = binding();1334 const d = DEFAULT_BINDINGS[desc.action];1335 return b.key !== d.key1336 || Boolean(b.ctrl) !== Boolean(d.ctrl)1337 || Boolean(b.meta) !== Boolean(d.meta)1338 || Boolean(b.shift) !== Boolean(d.shift)1339 || Boolean(b.alt) !== Boolean(d.alt);1340 };1341 return (1342 <div class="peach-shortcut-row">1343 <span class="peach-shortcut-row-label">{desc.label}</span>1344 <button1345 class="peach-shortcut-chip"1346 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1347 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1348 onKeyDown={(e) => { if (isActive()) onKey(e); }}1349 ref={(el) => {1350 1351 if (el) {1352 queueMicrotask(() => { if (isActive()) el.focus(); });1353 }1354 }}1355 >1356 {isActive() ? "Press a key…" : formatKeybinding(binding())}1357 </button>1358 <Show when={isCustom() && !isActive()}>1359 <button1360 class="peach-shortcut-reset"1361 title="Reset to default"1362 onClick={() => onReset(desc.action)}1363 >1364 ↺1365 </button>1366 </Show>1367 </div>1368 );1369 }1370 }13711372 const PRO_FEATURES: Array<{1373 feature: ProFeature;1374 name: string;1375 desc: string;1376 }> = [1377 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1378 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1379 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1380 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1381 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1382 ];13831384 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1385 const [deactivating, setDeactivating] = createSignal(false);13861387 async function handleDeactivateLicense() {1388 setDeactivating(true);1389 await sendMessage({ type: "DEACTIVATE_LICENSE" });1390 setLicenseInfo(null);1391 await props.onLicenseChange?.();1392 setDeactivating(false);1393 }13941395 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1396 setCanaryTestingId(entry.id);1397 setCanaryTestResult(null);1398 const res = await sendMessage({1399 type: "FIRE_CANARY_PING",1400 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1401 }) as { ok: boolean };1402 setCanaryTestResult({ id: entry.id, ok: res.ok });1403 setCanaryTestingId(null);1404 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1405 }14061407 function renderLicense() {1408 async function onActivate() {1409 await handleActivateLicense();1410 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1411 await props.onLicenseChange?.();1412 }1413 }14141415 return (1416 <div class="peach-settings-body">1417 <Show1418 when={isPro()}1419 fallback={1420 <Show1421 when={showKeyEntry()}1422 fallback={1423 <div class="peach-pro-upgrade">1424 <div class="peach-pro-upgrade-hero">1425 <div class="peach-pro-upgrade-mark-wrap">1426 <PeachMark class="peach-pro-upgrade-mark" />1427 </div>1428 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1429 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1430 </div>14311432 <div class="peach-pro-features">1433 <For each={PRO_FEATURES}>1434 {(item) => (1435 <div1436 class="peach-pro-feature"1437 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1438 >1439 <span class="peach-pro-feature-mark">✦</span>1440 <div class="peach-pro-feature-text">1441 <span class="peach-pro-feature-name">{item.name}</span>1442 <span class="peach-pro-feature-desc">{item.desc}</span>1443 </div>1444 </div>1445 )}1446 </For>1447 </div>14481449 <div class="peach-pro-upgrade-actions">1450 <p class="peach-pro-pricing">$39 · once · yours forever</p>1451 <a1452 href="https://peachpasswords.com/#pricing"1453 target="_blank"1454 rel="noopener noreferrer"1455 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1456 >1457 Get Peach Pro1458 </a>1459 <div class="peach-pro-key-divider"><span>or</span></div>1460 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1461 Activate a license key1462 </button>1463 </div>1464 </div>1465 }1466 >1467 <div class="peach-pro-key-ceremony">1468 <LockRuneCanvas />1469 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1470 <input1471 class="peach-underline-input peach-pro-key-input"1472 type="text"1473 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1474 value={licenseKeyInput()}1475 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1476 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1477 autocomplete="off"1478 spellcheck={false}1479 />1480 <Show when={licenseError()}>1481 <p class="peach-pro-key-error">That key doesn't look right.</p>1482 </Show>1483 <button1484 class="peach-btn peach-btn--accent peach-pro-key-activate"1485 onClick={() => void onActivate()}1486 disabled={activatingLicense() || !licenseKeyInput().trim()}1487 >1488 {activatingLicense() ? "activating…" : "Activate"}1489 </button>1490 <button1491 class="peach-pro-key-cancel"1492 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1493 >1494 cancel1495 </button>1496 </div>1497 </Show>1498 }1499 >1500 <div class="peach-pro-active">1501 <div class="peach-pro-active-hero">1502 <div class="peach-pro-active-mark-wrap">1503 <PeachMark class="peach-pro-active-mark" />1504 </div>1505 <h2 class="peach-pro-active-title">Peach Pro</h2>1506 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1507 </div>15081509 <div class="peach-pro-active-features">1510 <For each={PRO_FEATURES}>1511 {(item) => (1512 <div class="peach-pro-active-feature">1513 <span class="peach-pro-active-feature-mark">✦</span>1514 <span class="peach-pro-active-feature-name">{item.name}</span>1515 </div>1516 )}1517 </For>1518 </div>15191520 <button1521 class="peach-pro-deactivate"1522 onClick={() => void handleDeactivateLicense()}1523 disabled={deactivating()}1524 >1525 {deactivating() ? "Deactivating…" : "Deactivate"}1526 </button>1527 </div>1528 </Show>1529 </div>1530 );1531 }15321533 function renderFeedback() {1534 return (1535 <div class="peach-settings-body">1536 <div class="peach-settings-group peach-feedback-group">1537 <p class="peach-feedback-intro">1538 Help us improve Peach. Every report goes directly to our inbox.1539 </p>15401541 <p class="peach-settings-sub-label">TYPE</p>1542 <div class="peach-feedback-chips">1543 <For each={FEEDBACK_CATEGORIES}>1544 {(cat) => (1545 <button1546 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1547 onClick={() => setFbCategory(cat)}1548 >1549 {cat}1550 </button>1551 )}1552 </For>1553 </div>15541555 <p class="peach-settings-sub-label">MESSAGE</p>1556 <textarea1557 class="peach-feedback-textarea"1558 placeholder="Describe the issue or idea..."1559 value={fbMessage()}1560 onInput={(e) => setFbMessage(e.currentTarget.value)}1561 />15621563 <p class="peach-feedback-meta">1564 Version {version} · will be included automatically1565 </p>15661567 <Show when={fbResult() === "success"}>1568 <p class="peach-feedback-result peach-feedback-result--ok">1569 Sent — thank you1570 </p>1571 </Show>1572 <Show when={fbResult() === "error"}>1573 <p class="peach-feedback-result peach-feedback-result--err">1574 Failed to send — try again1575 </p>1576 </Show>15771578 <button1579 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1580 onClick={() => void handleSubmitFeedback()}1581 disabled={fbSubmitting() || !fbMessage().trim()}1582 >1583 {fbSubmitting() ? "Sending..." : "Send feedback"}1584 </button>15851586 <p class="peach-feedback-anon">1587 Sent anonymously · no account required1588 </p>1589 </div>1590 </div>1591 );1592 }15931594 return (1595 <div class="peach-overlay-view ov-enter-right" style="display:flex">1596 <div class="peach-overlay-header">1597 <button1598 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1599 onClick={handleHeaderBack}1600 aria-label="Back"1601 >1602 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1603 <path d="M6 1L1 6L6 11" />1604 </svg>1605 </button>1606 <span class="peach-overlay-title">{currentTitle()}</span>1607 <div class="peach-overlay-spacer"></div>1608 </div>16091610 <Switch fallback={renderHome()}>1611 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1612 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1613 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1614 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1615 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1616 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1617 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1618 </Switch>1619 </div>1620 );1621}