1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { ACCENT_COLORS } from "../../shared/types";5import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";6import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";7import {8 DEFAULT_BINDINGS,9 findConflict,10 formatKeybinding,11 keybindingFromEvent,12 SHORTCUT_DESCRIPTORS,13 type Keybinding,14 type ShortcutAction,15} from "../../shared/shortcuts";1617interface LicenseInfo {18 valid: boolean;19 plan: "free" | "pro";20 issued?: string;21}2223interface SettingsProps {24 entriesCount: number;25 breachMonitoring: boolean;26 lockOnClose: boolean;27 autoLockMinutes: number;28 trashRetentionDays: number;29 theme: "dark" | "light";30 accentColor: AccentColorName;31 displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";32 textSize: "small" | "medium" | "large";33 reduceMotion: boolean;34 isPro?: boolean;35 initialSection?: string | null;36 upgradeHighlight?: ProFeature | null;37 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;38 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;39 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;40 onTrashRetentionChange: (days: number) => void | Promise<void>;41 onThemeChange: (theme: "dark" | "light") => void;42 onAccentChange: (name: AccentColorName) => void | Promise<void>;43 onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;44 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;45 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;46 shortcuts: Record<ShortcutAction, Keybinding>;47 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;48 onLicenseChange?: () => void | Promise<void>;49 onBack: () => void;50 onImport: () => void;51 onExport: () => void;52 onBreachCheckDone: () => void;53 onOpenHealth?: () => void;54 onOpenSyncPairing?: (mode: "initiate" | "accept") => void;55 onOpenCJKImport?: () => void;56 onOpenFileImport?: () => void;57}5859type SectionName =60 | "security"61 | "vault"62 | "sync"63 | "appearance"64 | "shortcuts"65 | "license"66 | "feedback";6768const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];6970function formatActivatedSince(issued: string | undefined): string {71 if (!issued) return "Active";72 try {73 const d = new Date(issued);74 if (Number.isNaN(d.getTime())) return "Active";75 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;76 } catch {77 return "Active";78 }79}8081function isSectionName(value: string): value is SectionName {82 return (83 value === "security" ||84 value === "vault" ||85 value === "sync" ||86 value === "appearance" ||87 value === "shortcuts" ||88 value === "license" ||89 value === "feedback"90 );91}9293export function SettingsView(props: SettingsProps) {94 const version = chrome.runtime.getManifest().version;95 const initial = props.initialSection;96 const [activeSection, setActiveSection] = createSignal<SectionName | null>(97 initial && isSectionName(initial) ? (initial as SectionName) : null98 );99100 const [checkingBreaches, setCheckingBreaches] = createSignal(false);101 const [breachResult, setBreachResult] = createSignal<string | null>(null);102 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);103 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");104 const [licenseError, setLicenseError] = createSignal<string | null>(null);105 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);106 const [activatingLicense, setActivatingLicense] = createSignal(false);107108 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);109 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);110 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");111 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);112 113 114 115 const [syncProStatus, setSyncProStatus] = createSignal(false);116 const isPro = () => props.isPro ?? syncProStatus();117 const [syncingNow, setSyncingNow] = createSignal(false);118 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);119 const [passkeySaving, setPasskeySaving] = createSignal(false);120121 const [dedupCount, setDedupCount] = createSignal<number | null>(null);122 const [dedupRunning, setDedupRunning] = createSignal(false);123 const [dedupDone, setDedupDone] = createSignal<number | null>(null);124125 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);126127 const [showChangePw, setShowChangePw] = createSignal(false);128 const [changePwCurrent, setChangePwCurrent] = createSignal("");129 const [changePwNew, setChangePwNew] = createSignal("");130 const [changePwConfirm, setChangePwConfirm] = createSignal("");131 const [changePwSaving, setChangePwSaving] = createSignal(false);132 const [changePwError, setChangePwError] = createSignal<string | null>(null);133 const [changePwSuccess, setChangePwSuccess] = createSignal(false);134135 const [duressEnabled, setDuressEnabled] = createSignal(false);136 const [showDuressSetup, setShowDuressSetup] = createSignal(false);137 const [duressPassword, setDuressPassword] = createSignal("");138 const [duressConfirm, setDuressConfirm] = createSignal("");139 const [duressMainPassword, setDuressMainPassword] = createSignal("");140 const [duressSaving, setDuressSaving] = createSignal(false);141 const [duressError, setDuressError] = createSignal<string | null>(null);142 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);143144 const [fbCategory, setFbCategory] = createSignal("Bug report");145 const [fbMessage, setFbMessage] = createSignal("");146 const [fbSubmitting, setFbSubmitting] = createSignal(false);147 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);148149 onMount(async () => {150 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([151 sendMessage({ type: "GET_LICENSE" }),152 sendMessage({ type: "GET_SYNC_STATE" }),153 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),154 sendMessage({ type: "IS_DURESS_ENABLED" }),155 sendMessage({ type: "GET_SETTINGS" }),156 ]);157158 if ((licenseRes as { ok: boolean }).ok) {159 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);160 }161162 if ((syncRes as { ok: boolean }).ok) {163 const data = syncRes as { ok: boolean; data?: {164 pairedDevices?: PairedDevice[];165 trustedNetworks?: TrustedNetwork[];166 status?: SyncStatus;167 lastSyncAt?: number;168 isPro?: boolean;169 }};170 if (data.data) {171 setPairedDevices(data.data.pairedDevices ?? []);172 setTrustedNetworks(data.data.trustedNetworks ?? []);173 setSyncStatus(data.data.status ?? "offline");174 setLastSyncAt(data.data.lastSyncAt);175 setSyncProStatus(data.data.isPro ?? false);176 }177 }178179 if ((passkeyRes as { ok: boolean }).ok) {180 setPasskeyState(181 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null182 );183 }184185 if ((duressRes as { ok: boolean }).ok) {186 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);187 }188189 if ((settingsRes as { ok: boolean }).ok) {190 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;191 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);192 }193194 chrome.runtime.onMessage.addListener((msg) => {195 if (msg.type === "SYNC_STATE_UPDATE") {196 setPairedDevices(msg.pairedDevices ?? []);197 setTrustedNetworks(msg.trustedNetworks ?? []);198 setSyncStatus(msg.status ?? "offline");199 setLastSyncAt(msg.lastSyncAt);200 setSyncProStatus(msg.isPro ?? false);201 }202 });203 });204205 async function handleChangePassword() {206 setChangePwError(null);207 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }208 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }209 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }210 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }211 setChangePwSaving(true);212 const res = await sendMessage({213 type: "CHANGE_PASSWORD",214 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },215 }) as { ok: boolean; error?: string };216 if (res.ok) {217 setChangePwSuccess(true);218 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");219 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);220 } else {221 setChangePwError(res.error ?? "failed to change password");222 }223 setChangePwSaving(false);224 }225226 async function handleActivateLicense() {227 const key = licenseKeyInput().trim();228 if (!key) return;229 setActivatingLicense(true);230 setLicenseError(null);231 setLicenseSuccess(null);232 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });233 setActivatingLicense(false);234 if ((res as { ok: boolean }).ok) {235 const data = (res as { ok: boolean; data?: LicenseInfo }).data;236 if (data?.valid) {237 setLicenseInfo(data);238 setLicenseSuccess("License activated!");239 setLicenseKeyInput("");240 } else {241 setLicenseError("Invalid license key");242 }243 } else {244 setLicenseError("Activation failed");245 }246 }247248 async function handleTogglePasskeys() {249 const current = passkeyState();250 if (!current || !current.supported) return;251 setPasskeySaving(true);252 const res = await sendMessage({253 type: "SET_PASSKEY_PROVIDER_ENABLED",254 payload: { enabled: !current.enabled },255 });256 setPasskeySaving(false);257 if ((res as { ok: boolean }).ok) {258 setPasskeyState(259 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null260 );261 }262 }263264 async function handleCheckAllBreaches() {265 setCheckingBreaches(true);266 setBreachResult(null);267 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });268 setCheckingBreaches(false);269 if ((res as { ok: boolean }).ok) {270 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};271 const breached = Object.values(data).filter((count) => count > 0).length;272 setBreachResult(273 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"274 );275 props.onBreachCheckDone();276 }277 }278279 async function handleSyncNow() {280 if (syncStatus() === "not_trusted") return;281 setSyncingNow(true);282 await sendMessage({ type: "TRIGGER_SYNC" });283 setTimeout(() => setSyncingNow(false), 3000);284 }285286 async function handleRemoveDevice(deviceId: string) {287 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });288 }289290 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {291 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });292 }293294 async function handleMarkNetworkTrusted() {295 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });296 }297298 function formatRelativeTime(timestamp?: number): string {299 if (!timestamp) return "Never";300 const diff = Date.now() - timestamp;301 const minutes = Math.floor(diff / 60000);302 const hours = Math.floor(diff / 3600000);303 const days = Math.floor(diff / 86400000);304 if (minutes < 1) return "Just now";305 if (minutes < 60) return `${minutes}m ago`;306 if (hours < 24) return `${hours}h ago`;307 return `${days}d ago`;308 }309310 function getStatusColor(status: SyncStatus): string {311 switch (status) {312 case "synced": return "var(--peach-sync-ok)";313 case "syncing": return "var(--peach-sync-busy)";314 case "no_peers": return "var(--peach-sync-off)";315 case "not_trusted": return "var(--peach-sync-err)";316 case "offline": return "var(--peach-sync-off)";317 default: return "var(--peach-sync-off)";318 }319 }320321 function getStatusLabel(status: SyncStatus): string {322 switch (status) {323 case "synced": return "Synced";324 case "syncing": return "Syncing";325 case "no_peers": return "No peers visible";326 case "not_trusted": return "Not on a trusted network";327 case "offline": return "Offline";328 default: return "Unknown";329 }330 }331332 function isCurrentNetworkTrusted(): boolean {333 return trustedNetworks().length > 0;334 }335336 async function handleSetupDuress() {337 const mainPw = duressMainPassword().trim();338 const duressPw = duressPassword().trim();339 const confirmPw = duressConfirm().trim();340341 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }342 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }343 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }344 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }345346 setDuressSaving(true);347 setDuressError(null);348349 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };350 if (!verifyRes.ok || !verifyRes.data) {351 setDuressError("incorrect main password");352 setDuressSaving(false);353 return;354 }355356 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };357 setDuressSaving(false);358 if (res.ok) {359 setDuressEnabled(true);360 setShowDuressSetup(false);361 setDuressPassword("");362 setDuressConfirm("");363 setDuressMainPassword("");364 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");365 setTimeout(() => setDuressSuccess(null), 5000);366 } else {367 setDuressError(res.error ?? "failed to set duress password");368 }369 }370371 async function handleClearDeclinedCards() {372 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });373 setDeclinedCardCount(0);374 }375376 async function handleCountDuplicates() {377 setDedupRunning(true);378 setDedupDone(null);379 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };380 setDedupRunning(false);381 if (res.ok) setDedupCount(res.data ?? 0);382 }383384 async function handleDeduplicate() {385 setDedupRunning(true);386 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };387 setDedupRunning(false);388 if (res.ok) {389 setDedupDone(res.data ?? 0);390 setDedupCount(0);391 }392 }393394 async function handleDisableDuress() {395 const mainPw = duressMainPassword().trim();396 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }397398 setDuressSaving(true);399 setDuressError(null);400401 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };402 if (!verifyRes.ok || !verifyRes.data) {403 setDuressError("incorrect main password");404 setDuressSaving(false);405 return;406 }407408 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };409 setDuressSaving(false);410 if (res.ok) {411 setDuressEnabled(false);412 setShowDuressSetup(false);413 setDuressMainPassword("");414 setDuressSuccess("Duress password disabled.");415 setTimeout(() => setDuressSuccess(null), 4000);416 } else {417 setDuressError(res.error ?? "failed to disable duress password");418 }419 }420421 async function handleSubmitFeedback() {422 if (!fbMessage().trim() || fbSubmitting()) return;423 setFbSubmitting(true);424 setFbResult(null);425 const res = await sendMessage({426 type: "SUBMIT_FEEDBACK",427 payload: {428 category: fbCategory(),429 message: fbMessage().trim(),430 version,431 browser: navigator.userAgent,432 },433 }) as { ok: boolean; error?: string };434 setFbSubmitting(false);435 if (res.ok) {436 setFbResult("success");437 setFbMessage("");438 setTimeout(() => setFbResult(null), 4000);439 } else {440 setFbResult("error");441 }442 }443444 function handleHeaderBack() {445 if (activeSection() !== null) {446 setActiveSection(null);447 } else {448 props.onBack();449 }450 }451452 function currentTitle(): string {453 const s = activeSection();454 if (s === null) return "settings";455 if (s === "license") return "account";456 return s;457 }458459 function renderCatRow(opts: {460 section: SectionName;461 name: string;462 desc: string;463 icon: () => any;464 pro?: boolean;465 }) {466 return (467 <button468 class="peach-settings-cat-row"469 onClick={() => setActiveSection(opts.section)}470 >471 <div class="peach-settings-cat-icon">{opts.icon()}</div>472 <div class="peach-settings-cat-content">473 <span class="peach-settings-cat-name">474 {opts.name}475 <Show when={opts.pro && !isPro()}>476 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>477 </Show>478 </span>479 <span class="peach-settings-cat-desc">{opts.desc}</span>480 </div>481 <svg482 class="peach-settings-cat-chevron"483 width="6" height="10" viewBox="0 0 6 10" fill="none"484 stroke="currentColor" stroke-width="1.1"485 stroke-linecap="round" stroke-linejoin="round"486 >487 <path d="M1 1l4 4-4 4" />488 </svg>489 </button>490 );491 }492493 const iconShield = () => (494 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">495 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>496 </svg>497 );498 const iconKey = () => (499 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">500 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>501 </svg>502 );503 const iconLock = () => (504 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">505 <rect x="3" y="7" width="10" height="7" rx="1.5"/>506 <path d="M6 7V5a3 3 0 016 0v2"/>507 </svg>508 );509 const iconSync = () => (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="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>512 <path d="M14 6l-2 2-2-2"/>513 <path d="M2 10l2-2 2 2"/>514 </svg>515 );516 const iconSun = () => (517 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">518 <circle cx="8" cy="8" r="3"/>519 <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"/>520 </svg>521 );522 const iconKeys = () => (523 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">524 <rect x="2" y="5" width="5" height="4" rx="1"/>525 <rect x="9" y="7" width="5" height="4" rx="1"/>526 <path d="M7 7h2m-2 2h2"/>527 </svg>528 );529 const iconBubble = () => (530 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">531 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>532 </svg>533 );534535 function renderHome() {536 return (537 <div class="peach-settings-body">538 <p class="peach-settings-nav-label">PROTECTION</p>539 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}540 {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}541542 <p class="peach-settings-nav-label">VAULT</p>543 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}544 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}545546 <p class="peach-settings-nav-label">PREFERENCES</p>547 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}548 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}549550 <p class="peach-settings-nav-label">SUPPORT</p>551 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}552553 <div class="peach-settings-footer">554 <span>Peach {version}</span>555 <span class="peach-divider"></span>556 <span>Browser extension</span>557 </div>558 </div>559 );560 }561562 function renderSecurity() {563 return (564 <div class="peach-settings-body">565 <div class="peach-settings-group">566 <p class="peach-settings-sub-label">LOCKING</p>567 <div class="peach-settings-row">568 <span>Lock on close</span>569 <button570 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}571 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}572 aria-label="Toggle lock on close"573 >574 <span class="peach-toggle-knob"></span>575 </button>576 </div>577 <div class="peach-settings-row">578 <span>Auto-lock after</span>579 <select580 class="peach-settings-select"581 value={String(props.autoLockMinutes)}582 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}583 >584 <option value="1">1 minute</option>585 <option value="5">5 minutes</option>586 <option value="15">15 minutes</option>587 <option value="30">30 minutes</option>588 <option value="0">Never</option>589 </select>590 </div>591592 <p class="peach-settings-sub-label">BREACH PROTECTION</p>593 <div class="peach-settings-row">594 <span>Breach monitoring</span>595 <button596 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}597 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}598 aria-label="Toggle breach monitoring"599 >600 <span class="peach-toggle-knob"></span>601 </button>602 </div>603 <div class="peach-settings-row peach-settings-row--meta">604 <span>Check all passwords</span>605 <button606 class="peach-btn peach-btn--subtle peach-btn--sm"607 onClick={() => void handleCheckAllBreaches()}608 disabled={checkingBreaches()}609 >610 {checkingBreaches() ? "checking..." : "run"}611 </button>612 </div>613 <Show when={breachResult()}>614 <div class="peach-settings-row peach-settings-row--meta">615 <span>Last check</span>616 <span class="peach-settings-value">{breachResult()}</span>617 </div>618 </Show>619620 <p class="peach-settings-sub-label">MASTER PASSWORD</p>621 <div class="peach-settings-row peach-settings-row--meta">622 <span>Master password</span>623 <button624 class="peach-btn peach-btn--subtle peach-btn--sm"625 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}626 >627 {showChangePw() ? "cancel" : "change"}628 </button>629 </div>630 <Show when={showChangePw()}>631 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">632 <input633 class="peach-settings-input"634 type="password"635 placeholder="current password"636 value={changePwCurrent()}637 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}638 autocomplete="current-password"639 />640 <input641 class="peach-settings-input"642 type="password"643 placeholder="new password (min 12 chars)"644 value={changePwNew()}645 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}646 autocomplete="new-password"647 />648 <input649 class="peach-settings-input"650 type="password"651 placeholder="confirm new password"652 value={changePwConfirm()}653 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}654 autocomplete="new-password"655 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}656 />657 <Show when={changePwError()}>658 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>659 </Show>660 <Show when={changePwSuccess()}>661 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>662 </Show>663 <button664 class="peach-btn peach-btn--accent peach-btn--sm"665 style="align-self:flex-start"666 onClick={() => void handleChangePassword()}667 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}668 >669 {changePwSaving() ? "saving…" : "save new password"}670 </button>671 </div>672 </Show>673674 <p class="peach-settings-sub-label">DURESS PASSWORD</p>675 <div class="peach-settings-row peach-settings-row--top">676 <div class="peach-settings-row-main">677 <span>Duress password</span>678 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">679 If entered under coercion, shows a decoy empty vault instead of your real one.680 </span>681 </div>682 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>683 {duressEnabled() ? "on" : "off"}684 </div>685 </div>686687 <Show when={duressEnabled() && !showDuressSetup()}>688 <div class="peach-settings-row peach-settings-row--meta">689 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">690 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.691 </span>692 </div>693 </Show>694695 <Show when={duressSuccess()}>696 <div class="peach-settings-row peach-settings-row--meta">697 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>698 </div>699 </Show>700701 <Show when={!showDuressSetup()}>702 <div class="peach-settings-row peach-settings-row--meta">703 <button704 class="peach-btn peach-btn--subtle peach-btn--sm"705 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}706 >707 {duressEnabled() ? "Change / disable" : "Set up"}708 </button>709 </div>710 </Show>711712 <Show when={showDuressSetup()}>713 <div class="peach-duress-form">714 <label class="peach-label">main password (to confirm)</label>715 <input716 class="peach-settings-input"717 type="password"718 placeholder="your current master password"719 autocomplete="current-password"720 value={duressMainPassword()}721 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}722 />723 <Show when={!duressEnabled()}>724 <label class="peach-label">new duress password</label>725 <input726 class="peach-settings-input"727 type="password"728 placeholder="duress password (min 8 chars)"729 autocomplete="new-password"730 value={duressPassword()}731 onInput={(e) => setDuressPassword(e.currentTarget.value)}732 />733 <label class="peach-label">confirm duress password</label>734 <input735 class="peach-settings-input"736 type="password"737 placeholder="confirm duress password"738 autocomplete="new-password"739 value={duressConfirm()}740 onInput={(e) => setDuressConfirm(e.currentTarget.value)}741 />742 </Show>743744 <Show when={duressError()}>745 <p class="peach-hint peach-hint-error">{duressError()}</p>746 </Show>747748 <div class="peach-duress-actions">749 <button750 class="peach-btn peach-btn--subtle peach-btn--sm"751 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}752 >753 cancel754 </button>755 <Show when={duressEnabled()}>756 <button757 class="peach-btn peach-btn--ghost peach-btn--sm"758 style="color:var(--peach-breach)"759 onClick={() => void handleDisableDuress()}760 disabled={duressSaving()}761 >762 {duressSaving() ? "disabling..." : "disable duress"}763 </button>764 </Show>765 <Show when={!duressEnabled()}>766 <button767 class="peach-btn peach-btn--accent peach-btn--sm"768 onClick={() => void handleSetupDuress()}769 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}770 >771 {duressSaving() ? "saving..." : "save"}772 </button>773 </Show>774 </div>775 </div>776 </Show>777778 <p class="peach-settings-sub-label">PASSKEYS</p>779 <div class="peach-settings-row peach-settings-row--top">780 <div class="peach-settings-row-main">781 <span>Intercept passkey requests</span>782 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">783 Peach can handle passkey sign-in and creation on supported Chromium browsers.784 </span>785 </div>786 <button787 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}788 onClick={() => void handleTogglePasskeys()}789 aria-label="Toggle passkey interception"790 disabled={!passkeyState()?.supported || passkeySaving()}791 >792 <span class="peach-toggle-knob"></span>793 </button>794 </div>795 <div class="peach-settings-row peach-settings-row--meta">796 <span>Status</span>797 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>798 </div>799 <div class="peach-settings-row peach-settings-row--meta">800 <span>Browser</span>801 <span class="peach-settings-value">802 {passkeyState()?.browserLabel ?? "Unknown"}803 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}804 </span>805 </div>806 <div class="peach-info-notice" style="margin-top:8px">807 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.808 </div>809 <Show when={passkeyState()?.conflict}>810 <div class="peach-settings-row peach-settings-row--meta">811 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>812 </div>813 </Show>814 </div>815 </div>816 );817 }818819 function renderVault() {820 return (821 <div class="peach-settings-body">822 <div class="peach-settings-group">823 <p class="peach-settings-sub-label">ENTRIES</p>824 <div class="peach-settings-row peach-settings-row--meta">825 <span>Entries</span>826 <span class="peach-settings-value">{props.entriesCount}</span>827 </div>828 <div class="peach-settings-row peach-settings-row--meta">829 <span>Duplicates</span>830 <Show when={dedupDone() !== null} fallback={831 <button832 class="peach-btn peach-btn--subtle peach-btn--sm"833 onClick={() => void handleCountDuplicates()}834 disabled={dedupRunning()}835 >836 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}837 </button>838 }>839 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">840 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}841 </span>842 </Show>843 </div>844 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>845 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">846 <div class="peach-restore-warning">847 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.848 </div>849 <div style="display:flex;gap:6px">850 <button851 class="peach-btn peach-btn--subtle peach-btn--sm"852 onClick={() => setDedupCount(null)}853 >854 cancel855 </button>856 <button857 class="peach-btn peach-btn--accent peach-btn--sm"858 onClick={() => void handleDeduplicate()}859 disabled={dedupRunning()}860 >861 {dedupRunning() ? "removing..." : "remove duplicates"}862 </button>863 </div>864 </div>865 </Show>866867 <p class="peach-settings-sub-label">MAINTENANCE</p>868 <div class="peach-settings-row peach-settings-row--meta">869 <span>Trash auto-delete</span>870 <select871 class="peach-settings-select"872 value={String(props.trashRetentionDays)}873 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}874 >875 <option value="7">7 days</option>876 <option value="30">30 days</option>877 <option value="90">90 days</option>878 <option value="0">Never</option>879 </select>880 </div>881 <div class="peach-settings-row peach-settings-row--meta">882 <span>Vault Health</span>883 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>884 open885 </button>886 </div>887 <Show when={declinedCardCount() > 0}>888 <div class="peach-settings-row peach-settings-row--meta">889 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>890 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>891 Clear892 </button>893 </div>894 </Show>895896 <p class="peach-settings-sub-label">DATA</p>897 <div class="peach-settings-row peach-settings-row--meta">898 <span>Import passwords</span>899 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>900 open901 </button>902 </div>903 <div class="peach-settings-row peach-settings-row--meta">904 <span>Export backup</span>905 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>906 open907 </button>908 </div>909 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">910 <span>Replace vault from backup</span>911 <div class="peach-restore-warning" style="margin-top:2px">912 This will permanently replace your current vault with the backup.913 </div>914 <div style="display:flex;gap:6px">915 <button916 class="peach-btn peach-btn--subtle peach-btn--sm"917 onClick={() => props.onOpenFileImport?.()}918 >919 backup file920 </button>921 </div>922 </div>923 </div>924 </div>925 );926 }927928 function renderSync() {929 if (!isPro()) {930 return (931 <div class="peach-settings-body">932 <div class="peach-pro-upgrade">933 <div class="peach-pro-upgrade-hero">934 <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">935 <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>936 <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>937 <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"/>938 <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"/>939 <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>940 <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>941 <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>942 </svg>943 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>944 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>945 </div>946947 <div class="peach-pro-features">948 <div class="peach-pro-feature">949 <span class="peach-pro-feature-mark">✦</span>950 <div class="peach-pro-feature-text">951 <span class="peach-pro-feature-name">Always in sync</span>952 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>953 </div>954 </div>955 <div class="peach-pro-feature">956 <span class="peach-pro-feature-mark">✦</span>957 <div class="peach-pro-feature-text">958 <span class="peach-pro-feature-name">No cloud. No relay.</span>959 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>960 </div>961 </div>962 <div class="peach-pro-feature">963 <span class="peach-pro-feature-mark">✦</span>964 <div class="peach-pro-feature-text">965 <span class="peach-pro-feature-name">Tailscale supported</span>966 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>967 </div>968 </div>969 <div class="peach-pro-feature">970 <span class="peach-pro-feature-mark">✦</span>971 <div class="peach-pro-feature-text">972 <span class="peach-pro-feature-name">Zero friction after setup</span>973 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>974 </div>975 </div>976 </div>977978 <button979 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"980 onClick={() => setActiveSection("license")}981 >982 Upgrade to Pro983 </button>984 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>985 Already have a key? Enter it →986 </button>987 </div>988 </div>989 );990 }991992 return (993 <div class="peach-settings-body">994 <div class="peach-settings-group">995 <div class="peach-settings-row">996 <span>Status</span>997 <span class="peach-settings-value peach-settings-value--status">998 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>999 {getStatusLabel(syncStatus())}1000 </span>1001 </div>1002 <div class="peach-settings-row peach-settings-row--meta">1003 <span>Last sync</span>1004 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1005 </div>1006 <div class="peach-settings-row peach-settings-row--meta">1007 <button1008 class="peach-btn peach-btn--subtle peach-btn--sm"1009 onClick={() => void handleSyncNow()}1010 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1011 >1012 {syncingNow() ? "Syncing..." : "Sync now"}1013 </button>1014 </div>10151016 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1017 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1018 {pairedDevices().map((device) => (1019 <div class="peach-sync-device-row">1020 <div class="peach-sync-device-info">1021 <span class="peach-sync-device-name">{device.displayName}</span>1022 <span class="peach-sync-device-meta">1023 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1024 </span>1025 </div>1026 <button1027 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1028 onClick={() => void handleRemoveDevice(device.deviceId)}1029 >1030 Remove1031 </button>1032 </div>1033 ))}1034 <div class="peach-settings-actions--row">1035 <button1036 class="peach-btn peach-btn--subtle peach-btn--sm"1037 onClick={() => props.onOpenSyncPairing?.("initiate")}1038 >1039 Pair a new device1040 </button>1041 <button1042 class="peach-btn peach-btn--subtle peach-btn--sm"1043 onClick={() => props.onOpenSyncPairing?.("accept")}1044 >1045 Pair with another device1046 </button>1047 </div>1048 </div>10491050 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1051 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1052 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1053 <div class="peach-sync-device-row">1054 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1055 <button1056 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1057 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1058 >1059 Remove1060 </button>1061 </div>1062 ))}1063 <div class="peach-settings-actions--row">1064 <Show when={!isCurrentNetworkTrusted()}>1065 <button1066 class="peach-btn peach-btn--subtle peach-btn--sm"1067 onClick={() => void handleMarkNetworkTrusted()}1068 >1069 Mark current network as trusted1070 </button>1071 </Show>1072 </div>1073 <span class="peach-sync-device-meta">1074 Tailscale is always trusted1075 </span>1076 </div>1077 </div>1078 </div>1079 );1080 }10811082 function renderAppearance() {1083 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1084 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1085 { id: "cormorant", label: "Cormorant", preview: "Aa" },1086 { id: "inter", label: "Inter", preview: "Aa" },1087 { id: "system", label: "System", preview: "Aa" },1088 ];1089 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1090 { id: "small", label: "Small" },1091 { id: "medium", label: "Default" },1092 { id: "large", label: "Large" },1093 ];1094 return (1095 <div class="peach-settings-body">1096 <div class="peach-settings-group">1097 <p class="peach-settings-sub-label">THEME</p>1098 <div class="peach-settings-row peach-settings-row--meta">1099 <span>Theme</span>1100 <button1101 class="peach-btn peach-btn--subtle peach-btn--sm"1102 onClick={() =>1103 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1104 }1105 >1106 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1107 </button>1108 </div>11091110 <p class="peach-settings-sub-label">ACCENT</p>1111 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1112 <span>Accent</span>1113 <div class="peach-accent-picker">1114 <For each={ACCENT_COLORS}>1115 {(color) => (1116 <button1117 class="peach-accent-swatch"1118 classList={{ "is-active": props.accentColor === color.name }}1119 style={{ background: color.hex }}1120 onClick={() => void props.onAccentChange(color.name)}1121 aria-label={color.name}1122 title={color.name}1123 />1124 )}1125 </For>1126 </div>1127 </div>11281129 <p class="peach-settings-sub-label">DISPLAY FONT</p>1130 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1131 <span>Font</span>1132 <div class="peach-font-picker">1133 <For each={fontOptions}>1134 {(opt) => (1135 <button1136 class="peach-font-swatch"1137 classList={{ "is-active": props.displayFont === opt.id }}1138 data-font-preview={opt.id}1139 onClick={() => void props.onDisplayFontChange(opt.id)}1140 aria-label={opt.label}1141 title={opt.label}1142 >1143 <span class="peach-font-swatch-glyph">{opt.preview}</span>1144 <span class="peach-font-swatch-name">{opt.label}</span>1145 </button>1146 )}1147 </For>1148 </div>1149 </div>11501151 <p class="peach-settings-sub-label">TEXT SIZE</p>1152 <div class="peach-settings-row peach-settings-row--meta">1153 <span>Size</span>1154 <div class="peach-pill-group">1155 <For each={sizeOptions}>1156 {(opt) => (1157 <button1158 class="peach-btn peach-btn--ghost peach-btn--sm"1159 classList={{ "is-active": props.textSize === opt.id }}1160 onClick={() => void props.onTextSizeChange(opt.id)}1161 >1162 {opt.label}1163 </button>1164 )}1165 </For>1166 </div>1167 </div>11681169 <p class="peach-settings-sub-label">MOTION</p>1170 <div class="peach-settings-row peach-settings-row--meta">1171 <span>Reduce motion</span>1172 <button1173 class="peach-btn peach-btn--subtle peach-btn--sm"1174 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1175 >1176 {props.reduceMotion ? "On" : "Off"}1177 </button>1178 </div>1179 </div>1180 </div>1181 );1182 }11831184 function renderShortcuts() {1185 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1186 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);11871188 function startCapture(action: ShortcutAction) {1189 setConflictMsg(null);1190 setCapturingAction(action);1191 }1192 function cancelCapture() {1193 setCapturingAction(null);1194 setConflictMsg(null);1195 }1196 function commitBinding(action: ShortcutAction, binding: Keybinding) {1197 const conflict = findConflict(props.shortcuts, binding, action);1198 if (conflict) {1199 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1200 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1201 return;1202 }1203 const next = { ...props.shortcuts, [action]: binding };1204 setCapturingAction(null);1205 setConflictMsg(null);1206 void props.onShortcutsChange(next);1207 }1208 function resetAction(action: ShortcutAction) {1209 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1210 void props.onShortcutsChange(next);1211 }1212 function resetAll() {1213 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1214 }12151216 1217 1218 function handleCaptureKey(e: KeyboardEvent) {1219 e.preventDefault();1220 e.stopPropagation();1221 if (e.key === "Escape") { cancelCapture(); return; }1222 const binding = keybindingFromEvent(e);1223 if (!binding) return; 1224 const action = capturingAction();1225 if (action) commitBinding(action, binding);1226 }12271228 return (1229 <div class="peach-settings-body">1230 <div class="peach-settings-group peach-shortcuts-group">1231 <p class="peach-settings-sub-label">ENTRY VIEW</p>1232 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1233 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1234 </For>1235 </div>12361237 <div class="peach-settings-group peach-shortcuts-group">1238 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1239 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1240 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1241 </For>1242 </div>12431244 <Show when={conflictMsg()}>1245 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1246 </Show>12471248 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1249 <div class="peach-settings-row peach-settings-row--meta">1250 <span>Inline autofill on page</span>1251 <a1252 class="peach-settings-link"1253 href="chrome://extensions/shortcuts"1254 onClick={(e) => {1255 e.preventDefault();1256 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1257 }}1258 >1259 ⌘⇧P — customize ↗1260 </a>1261 </div>1262 <button1263 class="peach-btn peach-btn--subtle peach-btn--sm"1264 style="align-self:flex-start"1265 onClick={resetAll}1266 >1267 Reset all to defaults1268 </button>1269 </div>1270 </div>1271 );12721273 function renderRow(1274 desc: typeof SHORTCUT_DESCRIPTORS[number],1275 capturing: () => ShortcutAction | null,1276 onStart: (a: ShortcutAction) => void,1277 onCancel: () => void,1278 onReset: (a: ShortcutAction) => void,1279 onKey: (e: KeyboardEvent) => void,1280 ) {1281 const isActive = () => capturing() === desc.action;1282 const binding = () => props.shortcuts[desc.action];1283 const isCustom = () => {1284 const b = binding();1285 const d = DEFAULT_BINDINGS[desc.action];1286 return b.key !== d.key1287 || Boolean(b.ctrl) !== Boolean(d.ctrl)1288 || Boolean(b.meta) !== Boolean(d.meta)1289 || Boolean(b.shift) !== Boolean(d.shift)1290 || Boolean(b.alt) !== Boolean(d.alt);1291 };1292 return (1293 <div class="peach-shortcut-row">1294 <span class="peach-shortcut-row-label">{desc.label}</span>1295 <button1296 class="peach-shortcut-chip"1297 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1298 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1299 onKeyDown={(e) => { if (isActive()) onKey(e); }}1300 ref={(el) => {1301 1302 if (el) {1303 queueMicrotask(() => { if (isActive()) el.focus(); });1304 }1305 }}1306 >1307 {isActive() ? "Press a key…" : formatKeybinding(binding())}1308 </button>1309 <Show when={isCustom() && !isActive()}>1310 <button1311 class="peach-shortcut-reset"1312 title="Reset to default"1313 onClick={() => onReset(desc.action)}1314 >1315 ↺1316 </button>1317 </Show>1318 </div>1319 );1320 }1321 }13221323 const PRO_FEATURES: Array<{1324 feature: ProFeature;1325 name: string;1326 desc: string;1327 }> = [1328 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1329 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1330 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1331 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1332 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1333 ];13341335 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1336 const [deactivating, setDeactivating] = createSignal(false);13371338 async function handleDeactivateLicense() {1339 setDeactivating(true);1340 await sendMessage({ type: "DEACTIVATE_LICENSE" });1341 setLicenseInfo(null);1342 await props.onLicenseChange?.();1343 setDeactivating(false);1344 }13451346 function renderLicense() {1347 return (1348 <div class="peach-settings-body">1349 <Show1350 when={isPro()}1351 fallback={1352 <div class="peach-pro-upgrade">1353 <div class="peach-pro-upgrade-hero">1354 <PeachMark class="peach-pro-upgrade-mark" />1355 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1356 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1357 </div>13581359 <div class="peach-pro-features">1360 <For each={PRO_FEATURES}>1361 {(item) => (1362 <div1363 class="peach-pro-feature"1364 classList={{1365 "is-highlighted": props.upgradeHighlight === item.feature,1366 }}1367 >1368 <span class="peach-pro-feature-mark">✦</span>1369 <div class="peach-pro-feature-text">1370 <span class="peach-pro-feature-name">{item.name}</span>1371 <span class="peach-pro-feature-desc">{item.desc}</span>1372 </div>1373 </div>1374 )}1375 </For>1376 </div>13771378 <p class="peach-pro-pricing">$39 · once · yours forever</p>13791380 <a1381 href="https://peachpasswords.com/#pricing"1382 target="_blank"1383 rel="noopener noreferrer"1384 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1385 >1386 Get Peach Pro1387 </a>13881389 <Show1390 when={showKeyEntry()}1391 fallback={1392 <button1393 class="peach-pro-key-link"1394 onClick={() => setShowKeyEntry(true)}1395 >1396 Already have a key? Enter it →1397 </button>1398 }1399 >1400 <div class="peach-pro-key-entry">1401 <input1402 class="peach-underline-input peach-pro-key-input"1403 type="text"1404 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1405 value={licenseKeyInput()}1406 onInput={(e) =>1407 setLicenseKeyInput(e.currentTarget.value.toUpperCase())1408 }1409 onKeyDown={(e) => {1410 if (e.key === "Enter") void onActivate();1411 }}1412 autocomplete="off"1413 spellcheck={false}1414 />1415 <button1416 class="peach-btn peach-btn--accent peach-btn--sm"1417 onClick={() => void onActivate()}1418 disabled={activatingLicense() || !licenseKeyInput().trim()}1419 >1420 {activatingLicense() ? "activating…" : "Activate"}1421 </button>1422 <Show when={licenseError()}>1423 <p class="peach-pro-key-error">That key doesn't look right.</p>1424 </Show>1425 </div>1426 </Show>1427 </div>1428 }1429 >1430 <div class="peach-pro-active">1431 <div class="peach-pro-active-head">1432 <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1433 <h3 class="peach-pro-active-title">Peach Pro</h3>1434 </div>1435 <p class="peach-pro-active-since">1436 {formatActivatedSince(licenseInfo()?.issued)}1437 </p>1438 <div class="peach-pro-active-divider"></div>1439 <p class="peach-pro-active-thanks">Thank you.</p>1440 <button1441 class="peach-pro-deactivate"1442 onClick={() => void handleDeactivateLicense()}1443 disabled={deactivating()}1444 >1445 {deactivating() ? "Deactivating…" : "Deactivate →"}1446 </button>1447 </div>1448 </Show>1449 </div>1450 );14511452 async function onActivate() {1453 await handleActivateLicense();1454 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1455 await props.onLicenseChange?.();1456 }1457 }1458 }14591460 function renderFeedback() {1461 return (1462 <div class="peach-settings-body">1463 <div class="peach-settings-group peach-feedback-group">1464 <p class="peach-feedback-intro">1465 Help us improve Peach. Every report goes directly to our inbox.1466 </p>14671468 <p class="peach-settings-sub-label">TYPE</p>1469 <div class="peach-feedback-chips">1470 <For each={FEEDBACK_CATEGORIES}>1471 {(cat) => (1472 <button1473 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1474 onClick={() => setFbCategory(cat)}1475 >1476 {cat}1477 </button>1478 )}1479 </For>1480 </div>14811482 <p class="peach-settings-sub-label">MESSAGE</p>1483 <textarea1484 class="peach-feedback-textarea"1485 placeholder="Describe the issue or idea..."1486 value={fbMessage()}1487 onInput={(e) => setFbMessage(e.currentTarget.value)}1488 />14891490 <p class="peach-feedback-meta">1491 Version {version} · will be included automatically1492 </p>14931494 <Show when={fbResult() === "success"}>1495 <p class="peach-feedback-result peach-feedback-result--ok">1496 Sent — thank you1497 </p>1498 </Show>1499 <Show when={fbResult() === "error"}>1500 <p class="peach-feedback-result peach-feedback-result--err">1501 Failed to send — try again1502 </p>1503 </Show>15041505 <button1506 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1507 onClick={() => void handleSubmitFeedback()}1508 disabled={fbSubmitting() || !fbMessage().trim()}1509 >1510 {fbSubmitting() ? "Sending..." : "Send feedback"}1511 </button>15121513 <p class="peach-feedback-anon">1514 Sent anonymously · no account required1515 </p>1516 </div>1517 </div>1518 );1519 }15201521 return (1522 <div class="peach-overlay-view ov-enter-right" style="display:flex">1523 <div class="peach-overlay-header">1524 <button1525 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1526 onClick={handleHeaderBack}1527 aria-label="Back"1528 >1529 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1530 <path d="M6 1L1 6L6 11" />1531 </svg>1532 </button>1533 <span class="peach-overlay-title">{currentTitle()}</span>1534 <div class="peach-overlay-spacer"></div>1535 </div>15361537 <Switch fallback={renderHome()}>1538 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1539 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1540 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1541 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1542 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1543 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1544 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1545 </Switch>1546 </div>1547 );1548}