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 onOpenPanic?: () => void;59}6061type SectionName =62 | "security"63 | "vault"64 | "sync"65 | "appearance"66 | "shortcuts"67 | "license"68 | "feedback";6970const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7172function formatActivatedSince(issued: string | undefined): string {73 if (!issued) return "Active";74 try {75 const d = new Date(issued);76 if (Number.isNaN(d.getTime())) return "Active";77 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;78 } catch {79 return "Active";80 }81}8283function isSectionName(value: string): value is SectionName {84 return (85 value === "security" ||86 value === "vault" ||87 value === "sync" ||88 value === "appearance" ||89 value === "shortcuts" ||90 value === "license" ||91 value === "feedback"92 );93}9495export function SettingsView(props: SettingsProps) {96 const version = chrome.runtime.getManifest().version;97 const initial = props.initialSection;98 const [activeSection, setActiveSection] = createSignal<SectionName | null>(99 initial && isSectionName(initial) ? (initial as SectionName) : null100 );101102 const [checkingBreaches, setCheckingBreaches] = createSignal(false);103 const [breachResult, setBreachResult] = createSignal<string | null>(null);104 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);105 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");106 const [licenseError, setLicenseError] = createSignal<string | null>(null);107 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);108 const [activatingLicense, setActivatingLicense] = createSignal(false);109110 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);111 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);112 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");113 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);114 115 116 117 const [syncProStatus, setSyncProStatus] = createSignal(false);118 const isPro = () => props.isPro ?? syncProStatus();119 const [syncingNow, setSyncingNow] = createSignal(false);120 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);121 const [passkeySaving, setPasskeySaving] = createSignal(false);122123 const [dedupCount, setDedupCount] = createSignal<number | null>(null);124 const [dedupRunning, setDedupRunning] = createSignal(false);125 const [dedupDone, setDedupDone] = createSignal<number | null>(null);126127 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);128129 const [showChangePw, setShowChangePw] = createSignal(false);130 const [changePwCurrent, setChangePwCurrent] = createSignal("");131 const [changePwNew, setChangePwNew] = createSignal("");132 const [changePwConfirm, setChangePwConfirm] = createSignal("");133 const [changePwSaving, setChangePwSaving] = createSignal(false);134 const [changePwError, setChangePwError] = createSignal<string | null>(null);135 const [changePwSuccess, setChangePwSuccess] = createSignal(false);136137 const [duressEnabled, setDuressEnabled] = createSignal(false);138 const [showDuressSetup, setShowDuressSetup] = createSignal(false);139 const [duressPassword, setDuressPassword] = createSignal("");140 const [duressConfirm, setDuressConfirm] = createSignal("");141 const [duressMainPassword, setDuressMainPassword] = createSignal("");142 const [duressSaving, setDuressSaving] = createSignal(false);143 const [duressError, setDuressError] = createSignal<string | null>(null);144 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);145146 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);147 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);148 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);149150 const [fbCategory, setFbCategory] = createSignal("Bug report");151 const [fbMessage, setFbMessage] = createSignal("");152 const [fbSubmitting, setFbSubmitting] = createSignal(false);153 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);154155 onMount(async () => {156 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([157 sendMessage({ type: "GET_LICENSE" }),158 sendMessage({ type: "GET_SYNC_STATE" }),159 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),160 sendMessage({ type: "IS_DURESS_ENABLED" }),161 sendMessage({ type: "GET_SETTINGS" }),162 ]);163164 if ((licenseRes as { ok: boolean }).ok) {165 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);166 }167168 if ((syncRes as { ok: boolean }).ok) {169 const data = syncRes as { ok: boolean; data?: {170 pairedDevices?: PairedDevice[];171 trustedNetworks?: TrustedNetwork[];172 status?: SyncStatus;173 lastSyncAt?: number;174 isPro?: boolean;175 }};176 if (data.data) {177 setPairedDevices(data.data.pairedDevices ?? []);178 setTrustedNetworks(data.data.trustedNetworks ?? []);179 setSyncStatus(data.data.status ?? "offline");180 setLastSyncAt(data.data.lastSyncAt);181 setSyncProStatus(data.data.isPro ?? false);182 }183 }184185 if ((passkeyRes as { ok: boolean }).ok) {186 setPasskeyState(187 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null188 );189 }190191 if ((duressRes as { ok: boolean }).ok) {192 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);193 }194195 if ((settingsRes as { ok: boolean }).ok) {196 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;197 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);198 }199200 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });201 if ((entriesRes as { ok: boolean }).ok) {202 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];203 setCanaryEntries(204 entries205 .filter((e) => e.canary?.webhookUrl)206 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))207 );208 }209210 chrome.runtime.onMessage.addListener((msg) => {211 if (msg.type === "SYNC_STATE_UPDATE") {212 setPairedDevices(msg.pairedDevices ?? []);213 setTrustedNetworks(msg.trustedNetworks ?? []);214 setSyncStatus(msg.status ?? "offline");215 setLastSyncAt(msg.lastSyncAt);216 setSyncProStatus(msg.isPro ?? false);217 }218 });219 });220221 async function handleChangePassword() {222 setChangePwError(null);223 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }224 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }225 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }226 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }227 setChangePwSaving(true);228 const res = await sendMessage({229 type: "CHANGE_PASSWORD",230 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },231 }) as { ok: boolean; error?: string };232 if (res.ok) {233 setChangePwSuccess(true);234 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");235 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);236 } else {237 setChangePwError(res.error ?? "failed to change password");238 }239 setChangePwSaving(false);240 }241242 async function handleActivateLicense() {243 const key = licenseKeyInput().trim();244 if (!key) return;245 setActivatingLicense(true);246 setLicenseError(null);247 setLicenseSuccess(null);248 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });249 setActivatingLicense(false);250 if ((res as { ok: boolean }).ok) {251 const data = (res as { ok: boolean; data?: LicenseInfo }).data;252 if (data?.valid) {253 setLicenseInfo(data);254 setLicenseSuccess("License activated!");255 setLicenseKeyInput("");256 } else {257 setLicenseError("Invalid license key");258 }259 } else {260 setLicenseError("Activation failed");261 }262 }263264 async function handleTogglePasskeys() {265 const current = passkeyState();266 if (!current || !current.supported) return;267 setPasskeySaving(true);268 const res = await sendMessage({269 type: "SET_PASSKEY_PROVIDER_ENABLED",270 payload: { enabled: !current.enabled },271 });272 setPasskeySaving(false);273 if ((res as { ok: boolean }).ok) {274 setPasskeyState(275 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null276 );277 }278 }279280 async function handleCheckAllBreaches() {281 setCheckingBreaches(true);282 setBreachResult(null);283 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });284 setCheckingBreaches(false);285 if ((res as { ok: boolean }).ok) {286 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};287 const breached = Object.values(data).filter((count) => count > 0).length;288 setBreachResult(289 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"290 );291 props.onBreachCheckDone();292 }293 }294295 async function handleSyncNow() {296 if (syncStatus() === "not_trusted") return;297 setSyncingNow(true);298 await sendMessage({ type: "TRIGGER_SYNC" });299 setTimeout(() => setSyncingNow(false), 3000);300 }301302 async function handleRemoveDevice(deviceId: string) {303 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });304 }305306 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {307 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });308 }309310 async function handleMarkNetworkTrusted() {311 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });312 }313314 function formatRelativeTime(timestamp?: number): string {315 if (!timestamp) return "Never";316 const diff = Date.now() - timestamp;317 const minutes = Math.floor(diff / 60000);318 const hours = Math.floor(diff / 3600000);319 const days = Math.floor(diff / 86400000);320 if (minutes < 1) return "Just now";321 if (minutes < 60) return `${minutes}m ago`;322 if (hours < 24) return `${hours}h ago`;323 return `${days}d ago`;324 }325326 function getStatusColor(status: SyncStatus): string {327 switch (status) {328 case "synced": return "var(--peach-sync-ok)";329 case "syncing": return "var(--peach-sync-busy)";330 case "no_peers": return "var(--peach-sync-off)";331 case "not_trusted": return "var(--peach-sync-err)";332 case "offline": return "var(--peach-sync-off)";333 default: return "var(--peach-sync-off)";334 }335 }336337 function getStatusLabel(status: SyncStatus): string {338 switch (status) {339 case "synced": return "Synced";340 case "syncing": return "Syncing";341 case "no_peers": return "No peers visible";342 case "not_trusted": return "Not on a trusted network";343 case "offline": return "Offline";344 default: return "Unknown";345 }346 }347348 function isCurrentNetworkTrusted(): boolean {349 return trustedNetworks().length > 0;350 }351352 async function handleSetupDuress() {353 const mainPw = duressMainPassword().trim();354 const duressPw = duressPassword().trim();355 const confirmPw = duressConfirm().trim();356357 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }358 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }359 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }360 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }361362 setDuressSaving(true);363 setDuressError(null);364365 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };366 if (!verifyRes.ok || !verifyRes.data) {367 setDuressError("incorrect main password");368 setDuressSaving(false);369 return;370 }371372 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };373 setDuressSaving(false);374 if (res.ok) {375 setDuressEnabled(true);376 setShowDuressSetup(false);377 setDuressPassword("");378 setDuressConfirm("");379 setDuressMainPassword("");380 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");381 setTimeout(() => setDuressSuccess(null), 5000);382 } else {383 setDuressError(res.error ?? "failed to set duress password");384 }385 }386387 async function handleClearDeclinedCards() {388 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });389 setDeclinedCardCount(0);390 }391392 async function handleCountDuplicates() {393 setDedupRunning(true);394 setDedupDone(null);395 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };396 setDedupRunning(false);397 if (res.ok) setDedupCount(res.data ?? 0);398 }399400 async function handleDeduplicate() {401 setDedupRunning(true);402 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };403 setDedupRunning(false);404 if (res.ok) {405 setDedupDone(res.data ?? 0);406 setDedupCount(0);407 }408 }409410 async function handleDisableDuress() {411 const mainPw = duressMainPassword().trim();412 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }413414 setDuressSaving(true);415 setDuressError(null);416417 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };418 if (!verifyRes.ok || !verifyRes.data) {419 setDuressError("incorrect main password");420 setDuressSaving(false);421 return;422 }423424 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };425 setDuressSaving(false);426 if (res.ok) {427 setDuressEnabled(false);428 setShowDuressSetup(false);429 setDuressMainPassword("");430 setDuressSuccess("Duress password disabled.");431 setTimeout(() => setDuressSuccess(null), 4000);432 } else {433 setDuressError(res.error ?? "failed to disable duress password");434 }435 }436437 async function handleSubmitFeedback() {438 if (!fbMessage().trim() || fbSubmitting()) return;439 setFbSubmitting(true);440 setFbResult(null);441 const res = await sendMessage({442 type: "SUBMIT_FEEDBACK",443 payload: {444 category: fbCategory(),445 message: fbMessage().trim(),446 version,447 browser: navigator.userAgent,448 },449 }) as { ok: boolean; error?: string };450 setFbSubmitting(false);451 if (res.ok) {452 setFbResult("success");453 setFbMessage("");454 setTimeout(() => setFbResult(null), 4000);455 } else {456 setFbResult("error");457 }458 }459460 function handleHeaderBack() {461 if (activeSection() !== null) {462 setActiveSection(null);463 } else {464 props.onBack();465 }466 }467468 function currentTitle(): string {469 const s = activeSection();470 if (s === null) return "settings";471 if (s === "license") return "account";472 return s;473 }474475 function renderCatRow(opts: {476 section: SectionName;477 name: string;478 desc: string;479 icon: () => any;480 pro?: boolean;481 }) {482 return (483 <button484 class="peach-settings-cat-row"485 onClick={() => setActiveSection(opts.section)}486 >487 <div class="peach-settings-cat-icon">{opts.icon()}</div>488 <div class="peach-settings-cat-content">489 <span class="peach-settings-cat-name">490 {opts.name}491 <Show when={opts.pro && !isPro()}>492 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>493 </Show>494 </span>495 <span class="peach-settings-cat-desc">{opts.desc}</span>496 </div>497 <svg498 class="peach-settings-cat-chevron"499 width="6" height="10" viewBox="0 0 6 10" fill="none"500 stroke="currentColor" stroke-width="1.1"501 stroke-linecap="round" stroke-linejoin="round"502 >503 <path d="M1 1l4 4-4 4" />504 </svg>505 </button>506 );507 }508509 const iconShield = () => (510 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">511 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>512 </svg>513 );514 const iconKey = () => (515 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">516 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>517 </svg>518 );519 const iconLock = () => (520 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">521 <rect x="3" y="7" width="10" height="7" rx="1.5"/>522 <path d="M6 7V5a3 3 0 016 0v2"/>523 </svg>524 );525 const iconSync = () => (526 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">527 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>528 <path d="M14 6l-2 2-2-2"/>529 <path d="M2 10l2-2 2 2"/>530 </svg>531 );532 const iconSun = () => (533 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">534 <circle cx="8" cy="8" r="3"/>535 <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"/>536 </svg>537 );538 const iconKeys = () => (539 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">540 <rect x="2" y="5" width="5" height="4" rx="1"/>541 <rect x="9" y="7" width="5" height="4" rx="1"/>542 <path d="M7 7h2m-2 2h2"/>543 </svg>544 );545 const iconBubble = () => (546 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">547 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>548 </svg>549 );550551 function renderHome() {552 return (553 <div class="peach-settings-body">554 <p class="peach-settings-nav-label">PROTECTION</p>555 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}556 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}557558 <p class="peach-settings-nav-label">VAULT</p>559 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}560 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}561562 <p class="peach-settings-nav-label">PREFERENCES</p>563 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}564 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}565566 <p class="peach-settings-nav-label">SUPPORT</p>567 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}568569 <div class="peach-settings-footer">570 <span>Peach {version}</span>571 <span class="peach-divider"></span>572 <span>Browser extension</span>573 </div>574 </div>575 );576 }577578 function renderSecurity() {579 return (580 <div class="peach-settings-body">581 <div class="peach-settings-group">582 <p class="peach-settings-sub-label">LOCKING</p>583 <div class="peach-settings-row">584 <span>Lock on close</span>585 <button586 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}587 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}588 aria-label="Toggle lock on close"589 >590 <span class="peach-toggle-knob"></span>591 </button>592 </div>593 <div class="peach-settings-row">594 <span>Auto-lock after</span>595 <select596 class="peach-settings-select"597 value={String(props.autoLockMinutes)}598 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}599 >600 <option value="1">1 minute</option>601 <option value="5">5 minutes</option>602 <option value="15">15 minutes</option>603 <option value="30">30 minutes</option>604 <option value="0">Never</option>605 </select>606 </div>607608 <p class="peach-settings-sub-label">BREACH PROTECTION</p>609 <div class="peach-settings-row">610 <span>Breach monitoring</span>611 <button612 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}613 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}614 aria-label="Toggle breach monitoring"615 >616 <span class="peach-toggle-knob"></span>617 </button>618 </div>619 <div class="peach-settings-row peach-settings-row--meta">620 <span>Check all passwords</span>621 <button622 class="peach-btn peach-btn--subtle peach-btn--sm"623 onClick={() => void handleCheckAllBreaches()}624 disabled={checkingBreaches()}625 >626 {checkingBreaches() ? "checking..." : "run"}627 </button>628 </div>629 <Show when={breachResult()}>630 <div class="peach-settings-row peach-settings-row--meta">631 <span>Last check</span>632 <span class="peach-settings-value">{breachResult()}</span>633 </div>634 </Show>635636 <p class="peach-settings-sub-label">MASTER PASSWORD</p>637 <div class="peach-settings-row peach-settings-row--meta">638 <span>Master password</span>639 <button640 class="peach-btn peach-btn--subtle peach-btn--sm"641 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}642 >643 {showChangePw() ? "cancel" : "change"}644 </button>645 </div>646 <Show when={showChangePw()}>647 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">648 <input649 class="peach-settings-input"650 type="password"651 placeholder="current password"652 value={changePwCurrent()}653 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}654 autocomplete="current-password"655 />656 <input657 class="peach-settings-input"658 type="password"659 placeholder="new password (min 12 chars)"660 value={changePwNew()}661 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}662 autocomplete="new-password"663 />664 <input665 class="peach-settings-input"666 type="password"667 placeholder="confirm new password"668 value={changePwConfirm()}669 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}670 autocomplete="new-password"671 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}672 />673 <Show when={changePwError()}>674 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>675 </Show>676 <Show when={changePwSuccess()}>677 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>678 </Show>679 <button680 class="peach-btn peach-btn--accent peach-btn--sm"681 style="align-self:flex-start"682 onClick={() => void handleChangePassword()}683 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}684 >685 {changePwSaving() ? "saving…" : "save new password"}686 </button>687 </div>688 </Show>689690 <p class="peach-settings-sub-label">DURESS PASSWORD</p>691 <div class="peach-settings-row peach-settings-row--top">692 <div class="peach-settings-row-main">693 <span>Duress password</span>694 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">695 If entered under coercion, shows a decoy empty vault instead of your real one.696 </span>697 </div>698 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>699 {duressEnabled() ? "on" : "off"}700 </div>701 </div>702703 <Show when={duressEnabled() && !showDuressSetup()}>704 <div class="peach-settings-row peach-settings-row--meta">705 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">706 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.707 </span>708 </div>709 </Show>710711 <Show when={duressSuccess()}>712 <div class="peach-settings-row peach-settings-row--meta">713 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>714 </div>715 </Show>716717 <Show when={!showDuressSetup()}>718 <div class="peach-settings-row peach-settings-row--meta">719 <button720 class="peach-btn peach-btn--subtle peach-btn--sm"721 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}722 >723 {duressEnabled() ? "Change / disable" : "Set up"}724 </button>725 </div>726 </Show>727728 <Show when={showDuressSetup()}>729 <div class="peach-duress-form">730 <label class="peach-label">main password (to confirm)</label>731 <input732 class="peach-settings-input"733 type="password"734 placeholder="your current master password"735 autocomplete="current-password"736 value={duressMainPassword()}737 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}738 />739 <Show when={!duressEnabled()}>740 <label class="peach-label">new duress password</label>741 <input742 class="peach-settings-input"743 type="password"744 placeholder="duress password (min 8 chars)"745 autocomplete="new-password"746 value={duressPassword()}747 onInput={(e) => setDuressPassword(e.currentTarget.value)}748 />749 <label class="peach-label">confirm duress password</label>750 <input751 class="peach-settings-input"752 type="password"753 placeholder="confirm duress password"754 autocomplete="new-password"755 value={duressConfirm()}756 onInput={(e) => setDuressConfirm(e.currentTarget.value)}757 />758 </Show>759760 <Show when={duressError()}>761 <p class="peach-hint peach-hint-error">{duressError()}</p>762 </Show>763764 <div class="peach-duress-actions">765 <button766 class="peach-btn peach-btn--subtle peach-btn--sm"767 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}768 >769 cancel770 </button>771 <Show when={duressEnabled()}>772 <button773 class="peach-btn peach-btn--ghost peach-btn--sm"774 style="color:var(--peach-breach)"775 onClick={() => void handleDisableDuress()}776 disabled={duressSaving()}777 >778 {duressSaving() ? "disabling..." : "disable duress"}779 </button>780 </Show>781 <Show when={!duressEnabled()}>782 <button783 class="peach-btn peach-btn--accent peach-btn--sm"784 onClick={() => void handleSetupDuress()}785 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}786 >787 {duressSaving() ? "saving..." : "save"}788 </button>789 </Show>790 </div>791 </div>792 </Show>793794 <p class="peach-settings-sub-label">CANARY ENTRIES</p>795 <div class="peach-settings-row peach-settings-row--top">796 <div class="peach-settings-row-main">797 <span>Canary entries</span>798 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">799 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.800 </span>801 </div>802 <span class="peach-settings-value" style="flex-shrink:0">803 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}804 </span>805 </div>806 <Show when={canaryEntries().length > 0}>807 <For each={canaryEntries()}>808 {(entry) => (809 <div class="peach-settings-row peach-settings-row--meta">810 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>811 <button812 class="peach-btn peach-btn--subtle peach-btn--sm"813 onClick={() => void handleTestCanary(entry)}814 disabled={canaryTestingId() === entry.id}815 style="flex-shrink:0"816 >817 {canaryTestingId() === entry.id818 ? "sending…"819 : canaryTestResult()?.id === entry.id820 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")821 : "test"}822 </button>823 </div>824 )}825 </For>826 </Show>827828 <p class="peach-settings-sub-label">PASSKEYS</p>829 <div class="peach-settings-row peach-settings-row--top">830 <div class="peach-settings-row-main">831 <span>Intercept passkey requests</span>832 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">833 Peach can handle passkey sign-in and creation on supported Chromium browsers.834 </span>835 </div>836 <button837 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}838 onClick={() => void handleTogglePasskeys()}839 aria-label="Toggle passkey interception"840 disabled={!passkeyState()?.supported || passkeySaving()}841 >842 <span class="peach-toggle-knob"></span>843 </button>844 </div>845 <div class="peach-settings-row peach-settings-row--meta">846 <span>Status</span>847 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>848 </div>849 <div class="peach-settings-row peach-settings-row--meta">850 <span>Browser</span>851 <span class="peach-settings-value">852 {passkeyState()?.browserLabel ?? "Unknown"}853 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}854 </span>855 </div>856 <div class="peach-info-notice" style="margin-top:8px">857 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.858 </div>859 <Show when={passkeyState()?.conflict}>860 <div class="peach-settings-row peach-settings-row--meta">861 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>862 </div>863 </Show>864 </div>865 </div>866 );867 }868869 function renderVault() {870 return (871 <div class="peach-settings-body">872 <div class="peach-settings-group">873 <p class="peach-settings-sub-label">ENTRIES</p>874 <div class="peach-settings-row peach-settings-row--meta">875 <span>Entries</span>876 <span class="peach-settings-value">{props.entriesCount}</span>877 </div>878 <div class="peach-settings-row peach-settings-row--meta">879 <span>Duplicates</span>880 <Show when={dedupDone() !== null} fallback={881 <button882 class="peach-btn peach-btn--subtle peach-btn--sm"883 onClick={() => void handleCountDuplicates()}884 disabled={dedupRunning()}885 >886 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}887 </button>888 }>889 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">890 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}891 </span>892 </Show>893 </div>894 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>895 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">896 <div class="peach-restore-warning">897 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.898 </div>899 <div style="display:flex;gap:6px">900 <button901 class="peach-btn peach-btn--subtle peach-btn--sm"902 onClick={() => setDedupCount(null)}903 >904 cancel905 </button>906 <button907 class="peach-btn peach-btn--accent peach-btn--sm"908 onClick={() => void handleDeduplicate()}909 disabled={dedupRunning()}910 >911 {dedupRunning() ? "removing..." : "remove duplicates"}912 </button>913 </div>914 </div>915 </Show>916917 <p class="peach-settings-sub-label">MAINTENANCE</p>918 <div class="peach-settings-row peach-settings-row--meta">919 <span>Trash auto-delete</span>920 <select921 class="peach-settings-select"922 value={String(props.trashRetentionDays)}923 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}924 >925 <option value="7">7 days</option>926 <option value="30">30 days</option>927 <option value="90">90 days</option>928 <option value="0">Never</option>929 </select>930 </div>931 <div class="peach-settings-row peach-settings-row--meta">932 <span>Vault Health</span>933 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>934 open935 </button>936 </div>937 <Show when={declinedCardCount() > 0}>938 <div class="peach-settings-row peach-settings-row--meta">939 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>940 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>941 Clear942 </button>943 </div>944 </Show>945946 <p class="peach-settings-sub-label">DATA</p>947 <div class="peach-settings-row peach-settings-row--meta">948 <span>Import passwords</span>949 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>950 open951 </button>952 </div>953 <div class="peach-settings-row peach-settings-row--meta">954 <span>Export backup</span>955 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>956 open957 </button>958 </div>959 <div class="peach-danger-card">960 <div class="peach-danger-card-head">961 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>962 <h3 class="peach-danger-card-title">Replace vault from backup</h3>963 </div>964 <p class="peach-danger-card-copy">965 Wipes the current vault and restores from a backup file.966 Recoverable only if your backup is good.967 </p>968 <button969 class="peach-danger-card-button"970 onClick={() => props.onOpenFileImport?.()}971 >972 Choose backup file973 </button>974 </div>975976 <Show when={props.onOpenPanic}>977 <div class="peach-danger-card peach-danger-card--hazard">978 <div class="peach-danger-card-head">979 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>980 <h3 class="peach-danger-card-title">Destroy vault</h3>981 </div>982 <p class="peach-danger-card-copy">983 Permanently wipes every entry, setting, and license on this984 device. There is no recovery.985 </p>986 <button987 class="peach-danger-card-button"988 onClick={() => props.onOpenPanic?.()}989 >990 Begin destruction991 </button>992 </div>993 </Show>994 </div>995 </div>996 );997 }998999 function renderSync() {1000 if (!isPro()) {1001 return (1002 <div class="peach-settings-body">1003 <div class="peach-pro-upgrade">1004 <div class="peach-pro-upgrade-hero">1005 <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">1006 <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1007 <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1008 <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"/>1009 <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"/>1010 <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>1011 <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1012 <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1013 </svg>1014 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1015 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1016 </div>10171018 <div class="peach-pro-features">1019 <div class="peach-pro-feature">1020 <span class="peach-pro-feature-mark">✦</span>1021 <div class="peach-pro-feature-text">1022 <span class="peach-pro-feature-name">Always in sync</span>1023 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1024 </div>1025 </div>1026 <div class="peach-pro-feature">1027 <span class="peach-pro-feature-mark">✦</span>1028 <div class="peach-pro-feature-text">1029 <span class="peach-pro-feature-name">No cloud. No relay.</span>1030 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1031 </div>1032 </div>1033 <div class="peach-pro-feature">1034 <span class="peach-pro-feature-mark">✦</span>1035 <div class="peach-pro-feature-text">1036 <span class="peach-pro-feature-name">Tailscale supported</span>1037 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1038 </div>1039 </div>1040 <div class="peach-pro-feature">1041 <span class="peach-pro-feature-mark">✦</span>1042 <div class="peach-pro-feature-text">1043 <span class="peach-pro-feature-name">Zero friction after setup</span>1044 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1045 </div>1046 </div>1047 </div>10481049 <button1050 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1051 onClick={() => setActiveSection("license")}1052 >1053 Upgrade to Pro1054 </button>1055 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1056 Already have a key? Enter it →1057 </button>1058 </div>1059 </div>1060 );1061 }10621063 return (1064 <div class="peach-settings-body">1065 <div class="peach-settings-group">1066 <div class="peach-settings-row">1067 <span>Status</span>1068 <span class="peach-settings-value peach-settings-value--status">1069 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1070 {getStatusLabel(syncStatus())}1071 </span>1072 </div>1073 <div class="peach-settings-row peach-settings-row--meta">1074 <span>Last sync</span>1075 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1076 </div>1077 <div class="peach-settings-row peach-settings-row--meta">1078 <button1079 class="peach-btn peach-btn--subtle peach-btn--sm"1080 onClick={() => void handleSyncNow()}1081 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1082 >1083 {syncingNow() ? "Syncing..." : "Sync now"}1084 </button>1085 </div>10861087 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1088 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1089 {pairedDevices().map((device) => (1090 <div class="peach-sync-device-row">1091 <div class="peach-sync-device-info">1092 <span class="peach-sync-device-name">{device.displayName}</span>1093 <span class="peach-sync-device-meta">1094 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1095 </span>1096 </div>1097 <button1098 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1099 onClick={() => void handleRemoveDevice(device.deviceId)}1100 >1101 Remove1102 </button>1103 </div>1104 ))}1105 <div class="peach-settings-actions--row">1106 <button1107 class="peach-btn peach-btn--subtle peach-btn--sm"1108 onClick={() => props.onOpenSyncPairing?.("initiate")}1109 >1110 Pair a new device1111 </button>1112 <button1113 class="peach-btn peach-btn--subtle peach-btn--sm"1114 onClick={() => props.onOpenSyncPairing?.("accept")}1115 >1116 Pair with another device1117 </button>1118 </div>1119 </div>11201121 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1122 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1123 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1124 <div class="peach-sync-device-row">1125 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1126 <button1127 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1128 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1129 >1130 Remove1131 </button>1132 </div>1133 ))}1134 <div class="peach-settings-actions--row">1135 <Show when={!isCurrentNetworkTrusted()}>1136 <button1137 class="peach-btn peach-btn--subtle peach-btn--sm"1138 onClick={() => void handleMarkNetworkTrusted()}1139 >1140 Mark current network as trusted1141 </button>1142 </Show>1143 </div>1144 <span class="peach-sync-device-meta">1145 Tailscale is always trusted1146 </span>1147 </div>1148 </div>1149 </div>1150 );1151 }11521153 function renderAppearance() {1154 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1155 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1156 { id: "cormorant", label: "Cormorant", preview: "Aa" },1157 { id: "inter", label: "Inter", preview: "Aa" },1158 { id: "system", label: "System", preview: "Aa" },1159 ];1160 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1161 { id: "small", label: "Small" },1162 { id: "medium", label: "Default" },1163 { id: "large", label: "Large" },1164 ];1165 return (1166 <div class="peach-settings-body">1167 <div class="peach-settings-group">1168 <p class="peach-settings-sub-label">THEME</p>1169 <div class="peach-settings-row peach-settings-row--meta">1170 <span>Theme</span>1171 <button1172 class="peach-btn peach-btn--subtle peach-btn--sm"1173 onClick={() =>1174 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1175 }1176 >1177 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1178 </button>1179 </div>11801181 <p class="peach-settings-sub-label">ACCENT</p>1182 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1183 <span>Accent</span>1184 <div class="peach-accent-picker">1185 <For each={ACCENT_COLORS}>1186 {(color) => (1187 <button1188 class="peach-accent-swatch"1189 classList={{ "is-active": props.accentColor === color.name }}1190 style={{ background: color.hex }}1191 onClick={() => void props.onAccentChange(color.name)}1192 aria-label={color.name}1193 title={color.name}1194 />1195 )}1196 </For>1197 </div>1198 </div>11991200 <p class="peach-settings-sub-label">DISPLAY FONT</p>1201 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1202 <span>Font</span>1203 <div class="peach-font-picker">1204 <For each={fontOptions}>1205 {(opt) => (1206 <button1207 class="peach-font-swatch"1208 classList={{ "is-active": props.displayFont === opt.id }}1209 data-font-preview={opt.id}1210 onClick={() => void props.onDisplayFontChange(opt.id)}1211 aria-label={opt.label}1212 title={opt.label}1213 >1214 <span class="peach-font-swatch-glyph">{opt.preview}</span>1215 <span class="peach-font-swatch-name">{opt.label}</span>1216 </button>1217 )}1218 </For>1219 </div>1220 </div>12211222 <p class="peach-settings-sub-label">TEXT SIZE</p>1223 <div class="peach-settings-row peach-settings-row--meta">1224 <span>Size</span>1225 <div class="peach-pill-group">1226 <For each={sizeOptions}>1227 {(opt) => (1228 <button1229 class="peach-btn peach-btn--ghost peach-btn--sm"1230 classList={{ "is-active": props.textSize === opt.id }}1231 onClick={() => void props.onTextSizeChange(opt.id)}1232 >1233 {opt.label}1234 </button>1235 )}1236 </For>1237 </div>1238 </div>12391240 <p class="peach-settings-sub-label">MOTION</p>1241 <div class="peach-settings-row peach-settings-row--meta">1242 <span>Reduce motion</span>1243 <button1244 class="peach-btn peach-btn--subtle peach-btn--sm"1245 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1246 >1247 {props.reduceMotion ? "On" : "Off"}1248 </button>1249 </div>1250 </div>1251 </div>1252 );1253 }12541255 function renderShortcuts() {1256 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1257 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12581259 function startCapture(action: ShortcutAction) {1260 setConflictMsg(null);1261 setCapturingAction(action);1262 }1263 function cancelCapture() {1264 setCapturingAction(null);1265 setConflictMsg(null);1266 }1267 function commitBinding(action: ShortcutAction, binding: Keybinding) {1268 const conflict = findConflict(props.shortcuts, binding, action);1269 if (conflict) {1270 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1271 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1272 return;1273 }1274 const next = { ...props.shortcuts, [action]: binding };1275 setCapturingAction(null);1276 setConflictMsg(null);1277 void props.onShortcutsChange(next);1278 }1279 function resetAction(action: ShortcutAction) {1280 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1281 void props.onShortcutsChange(next);1282 }1283 function resetAll() {1284 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1285 }12861287 1288 1289 function handleCaptureKey(e: KeyboardEvent) {1290 e.preventDefault();1291 e.stopPropagation();1292 if (e.key === "Escape") { cancelCapture(); return; }1293 const binding = keybindingFromEvent(e);1294 if (!binding) return; 1295 const action = capturingAction();1296 if (action) commitBinding(action, binding);1297 }12981299 return (1300 <div class="peach-settings-body">1301 <div class="peach-settings-group peach-shortcuts-group">1302 <p class="peach-settings-sub-label">ENTRY VIEW</p>1303 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1304 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1305 </For>1306 </div>13071308 <div class="peach-settings-group peach-shortcuts-group">1309 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1310 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1311 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1312 </For>1313 </div>13141315 <Show when={conflictMsg()}>1316 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1317 </Show>13181319 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1320 <div class="peach-settings-row peach-settings-row--meta">1321 <span>Inline autofill on page</span>1322 <a1323 class="peach-settings-link"1324 href="chrome://extensions/shortcuts"1325 onClick={(e) => {1326 e.preventDefault();1327 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1328 }}1329 >1330 ⌘⇧P — customize ↗1331 </a>1332 </div>1333 <button1334 class="peach-btn peach-btn--subtle peach-btn--sm"1335 style="align-self:flex-start"1336 onClick={resetAll}1337 >1338 Reset all to defaults1339 </button>1340 </div>1341 </div>1342 );13431344 function renderRow(1345 desc: typeof SHORTCUT_DESCRIPTORS[number],1346 capturing: () => ShortcutAction | null,1347 onStart: (a: ShortcutAction) => void,1348 onCancel: () => void,1349 onReset: (a: ShortcutAction) => void,1350 onKey: (e: KeyboardEvent) => void,1351 ) {1352 const isActive = () => capturing() === desc.action;1353 const binding = () => props.shortcuts[desc.action];1354 const isCustom = () => {1355 const b = binding();1356 const d = DEFAULT_BINDINGS[desc.action];1357 return b.key !== d.key1358 || Boolean(b.ctrl) !== Boolean(d.ctrl)1359 || Boolean(b.meta) !== Boolean(d.meta)1360 || Boolean(b.shift) !== Boolean(d.shift)1361 || Boolean(b.alt) !== Boolean(d.alt);1362 };1363 return (1364 <div class="peach-shortcut-row">1365 <span class="peach-shortcut-row-label">{desc.label}</span>1366 <button1367 class="peach-shortcut-chip"1368 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1369 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1370 onKeyDown={(e) => { if (isActive()) onKey(e); }}1371 ref={(el) => {1372 1373 if (el) {1374 queueMicrotask(() => { if (isActive()) el.focus(); });1375 }1376 }}1377 >1378 {isActive() ? "Press a key…" : formatKeybinding(binding())}1379 </button>1380 <Show when={isCustom() && !isActive()}>1381 <button1382 class="peach-shortcut-reset"1383 title="Reset to default"1384 onClick={() => onReset(desc.action)}1385 >1386 ↺1387 </button>1388 </Show>1389 </div>1390 );1391 }1392 }13931394 const PRO_FEATURES: Array<{1395 feature: ProFeature;1396 name: string;1397 desc: string;1398 }> = [1399 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1400 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1401 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1402 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1403 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1404 ];14051406 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1407 const [deactivating, setDeactivating] = createSignal(false);14081409 async function handleDeactivateLicense() {1410 setDeactivating(true);1411 await sendMessage({ type: "DEACTIVATE_LICENSE" });1412 setLicenseInfo(null);1413 await props.onLicenseChange?.();1414 setDeactivating(false);1415 }14161417 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1418 setCanaryTestingId(entry.id);1419 setCanaryTestResult(null);1420 const res = await sendMessage({1421 type: "FIRE_CANARY_PING",1422 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1423 }) as { ok: boolean };1424 setCanaryTestResult({ id: entry.id, ok: res.ok });1425 setCanaryTestingId(null);1426 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1427 }14281429 function renderLicense() {1430 async function onActivate() {1431 await handleActivateLicense();1432 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1433 await props.onLicenseChange?.();1434 }1435 }14361437 return (1438 <div class="peach-settings-body">1439 <Show1440 when={isPro()}1441 fallback={1442 <Show1443 when={showKeyEntry()}1444 fallback={1445 <div class="peach-pro-upgrade">1446 <div class="peach-pro-upgrade-hero">1447 <div class="peach-pro-upgrade-mark-wrap">1448 <PeachMark class="peach-pro-upgrade-mark" />1449 </div>1450 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1451 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1452 </div>14531454 <div class="peach-pro-features">1455 <For each={PRO_FEATURES}>1456 {(item) => (1457 <div1458 class="peach-pro-feature"1459 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1460 >1461 <span class="peach-pro-feature-mark">✦</span>1462 <div class="peach-pro-feature-text">1463 <span class="peach-pro-feature-name">{item.name}</span>1464 <span class="peach-pro-feature-desc">{item.desc}</span>1465 </div>1466 </div>1467 )}1468 </For>1469 </div>14701471 <div class="peach-pro-upgrade-actions">1472 <p class="peach-pro-pricing">$39 · once · yours forever</p>1473 <a1474 href="https://peachpasswords.com/#pricing"1475 target="_blank"1476 rel="noopener noreferrer"1477 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1478 >1479 Get Peach Pro1480 </a>1481 <div class="peach-pro-key-divider"><span>or</span></div>1482 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1483 Activate a license key1484 </button>1485 </div>1486 </div>1487 }1488 >1489 <div class="peach-pro-key-ceremony">1490 <LockRuneCanvas />1491 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1492 <input1493 class="peach-underline-input peach-pro-key-input"1494 type="text"1495 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1496 value={licenseKeyInput()}1497 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1498 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1499 autocomplete="off"1500 spellcheck={false}1501 />1502 <Show when={licenseError()}>1503 <p class="peach-pro-key-error">That key doesn't look right.</p>1504 </Show>1505 <button1506 class="peach-btn peach-btn--accent peach-pro-key-activate"1507 onClick={() => void onActivate()}1508 disabled={activatingLicense() || !licenseKeyInput().trim()}1509 >1510 {activatingLicense() ? "activating…" : "Activate"}1511 </button>1512 <button1513 class="peach-pro-key-cancel"1514 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1515 >1516 cancel1517 </button>1518 </div>1519 </Show>1520 }1521 >1522 <div class="peach-pro-active">1523 <div class="peach-pro-active-hero">1524 <div class="peach-pro-active-mark-wrap">1525 <PeachMark class="peach-pro-active-mark" />1526 </div>1527 <h2 class="peach-pro-active-title">Peach Pro</h2>1528 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1529 </div>15301531 <div class="peach-pro-active-features">1532 <For each={PRO_FEATURES}>1533 {(item) => (1534 <div class="peach-pro-active-feature">1535 <span class="peach-pro-active-feature-mark">✦</span>1536 <span class="peach-pro-active-feature-name">{item.name}</span>1537 </div>1538 )}1539 </For>1540 </div>15411542 <button1543 class="peach-pro-deactivate"1544 onClick={() => void handleDeactivateLicense()}1545 disabled={deactivating()}1546 >1547 {deactivating() ? "Deactivating…" : "Deactivate"}1548 </button>1549 </div>1550 </Show>1551 </div>1552 );1553 }15541555 function renderFeedback() {1556 return (1557 <div class="peach-settings-body">1558 <div class="peach-settings-group peach-feedback-group">1559 <p class="peach-feedback-intro">1560 Help us improve Peach. Every report goes directly to our inbox.1561 </p>15621563 <p class="peach-settings-sub-label">TYPE</p>1564 <div class="peach-feedback-chips">1565 <For each={FEEDBACK_CATEGORIES}>1566 {(cat) => (1567 <button1568 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1569 onClick={() => setFbCategory(cat)}1570 >1571 {cat}1572 </button>1573 )}1574 </For>1575 </div>15761577 <p class="peach-settings-sub-label">MESSAGE</p>1578 <textarea1579 class="peach-feedback-textarea"1580 placeholder="Describe the issue or idea..."1581 value={fbMessage()}1582 onInput={(e) => setFbMessage(e.currentTarget.value)}1583 />15841585 <p class="peach-feedback-meta">1586 Version {version} · will be included automatically1587 </p>15881589 <Show when={fbResult() === "success"}>1590 <p class="peach-feedback-result peach-feedback-result--ok">1591 Sent — thank you1592 </p>1593 </Show>1594 <Show when={fbResult() === "error"}>1595 <p class="peach-feedback-result peach-feedback-result--err">1596 Failed to send — try again1597 </p>1598 </Show>15991600 <button1601 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1602 onClick={() => void handleSubmitFeedback()}1603 disabled={fbSubmitting() || !fbMessage().trim()}1604 >1605 {fbSubmitting() ? "Sending..." : "Send feedback"}1606 </button>16071608 <p class="peach-feedback-anon">1609 Sent anonymously · no account required1610 </p>1611 </div>1612 </div>1613 );1614 }16151616 return (1617 <div class="peach-overlay-view ov-enter-right" style="display:flex">1618 <div class="peach-overlay-header">1619 <button1620 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1621 onClick={handleHeaderBack}1622 aria-label="Back"1623 >1624 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1625 <path d="M6 1L1 6L6 11" />1626 </svg>1627 </button>1628 <span class="peach-overlay-title">{currentTitle()}</span>1629 <div class="peach-overlay-spacer"></div>1630 </div>16311632 <Switch fallback={renderHome()}>1633 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1634 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1635 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1636 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1637 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1638 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1639 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1640 </Switch>1641 </div>1642 );1643}