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.onOpenCJKImport?.()}918 >919 CJK phrase920 </button>921 <button922 class="peach-btn peach-btn--subtle peach-btn--sm"923 onClick={() => props.onOpenFileImport?.()}924 >925 backup file926 </button>927 </div>928 </div>929 </div>930 </div>931 );932 }933934 function renderSync() {935 if (!isPro()) {936 return (937 <div class="peach-settings-body">938 <div class="peach-pro-upgrade">939 <div class="peach-pro-upgrade-hero">940 <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">941 <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>942 <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>943 <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"/>944 <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"/>945 <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>946 <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>947 <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>948 </svg>949 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>950 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>951 </div>952953 <div class="peach-pro-features">954 <div class="peach-pro-feature">955 <span class="peach-pro-feature-mark">✦</span>956 <div class="peach-pro-feature-text">957 <span class="peach-pro-feature-name">Always in sync</span>958 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>959 </div>960 </div>961 <div class="peach-pro-feature">962 <span class="peach-pro-feature-mark">✦</span>963 <div class="peach-pro-feature-text">964 <span class="peach-pro-feature-name">No cloud. No relay.</span>965 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>966 </div>967 </div>968 <div class="peach-pro-feature">969 <span class="peach-pro-feature-mark">✦</span>970 <div class="peach-pro-feature-text">971 <span class="peach-pro-feature-name">Tailscale supported</span>972 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>973 </div>974 </div>975 <div class="peach-pro-feature">976 <span class="peach-pro-feature-mark">✦</span>977 <div class="peach-pro-feature-text">978 <span class="peach-pro-feature-name">Zero friction after setup</span>979 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>980 </div>981 </div>982 </div>983984 <button985 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"986 onClick={() => setActiveSection("license")}987 >988 Upgrade to Pro989 </button>990 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>991 Already have a key? Enter it →992 </button>993 </div>994 </div>995 );996 }997998 return (999 <div class="peach-settings-body">1000 <div class="peach-settings-group">1001 <div class="peach-settings-row">1002 <span>Status</span>1003 <span class="peach-settings-value peach-settings-value--status">1004 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1005 {getStatusLabel(syncStatus())}1006 </span>1007 </div>1008 <div class="peach-settings-row peach-settings-row--meta">1009 <span>Last sync</span>1010 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1011 </div>1012 <div class="peach-settings-row peach-settings-row--meta">1013 <button1014 class="peach-btn peach-btn--subtle peach-btn--sm"1015 onClick={() => void handleSyncNow()}1016 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1017 >1018 {syncingNow() ? "Syncing..." : "Sync now"}1019 </button>1020 </div>10211022 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1023 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1024 {pairedDevices().map((device) => (1025 <div class="peach-sync-device-row">1026 <div class="peach-sync-device-info">1027 <span class="peach-sync-device-name">{device.displayName}</span>1028 <span class="peach-sync-device-meta">1029 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1030 </span>1031 </div>1032 <button1033 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1034 onClick={() => void handleRemoveDevice(device.deviceId)}1035 >1036 Remove1037 </button>1038 </div>1039 ))}1040 <div class="peach-settings-actions--row">1041 <button1042 class="peach-btn peach-btn--subtle peach-btn--sm"1043 onClick={() => props.onOpenSyncPairing?.("initiate")}1044 >1045 Pair a new device1046 </button>1047 <button1048 class="peach-btn peach-btn--subtle peach-btn--sm"1049 onClick={() => props.onOpenSyncPairing?.("accept")}1050 >1051 Pair with another device1052 </button>1053 </div>1054 </div>10551056 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1057 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1058 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1059 <div class="peach-sync-device-row">1060 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1061 <button1062 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1063 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1064 >1065 Remove1066 </button>1067 </div>1068 ))}1069 <div class="peach-settings-actions--row">1070 <Show when={!isCurrentNetworkTrusted()}>1071 <button1072 class="peach-btn peach-btn--subtle peach-btn--sm"1073 onClick={() => void handleMarkNetworkTrusted()}1074 >1075 Mark current network as trusted1076 </button>1077 </Show>1078 </div>1079 <span class="peach-sync-device-meta">1080 Tailscale is always trusted1081 </span>1082 </div>1083 </div>1084 </div>1085 );1086 }10871088 function renderAppearance() {1089 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1090 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1091 { id: "cormorant", label: "Cormorant", preview: "Aa" },1092 { id: "inter", label: "Inter", preview: "Aa" },1093 { id: "system", label: "System", preview: "Aa" },1094 ];1095 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1096 { id: "small", label: "Small" },1097 { id: "medium", label: "Default" },1098 { id: "large", label: "Large" },1099 ];1100 return (1101 <div class="peach-settings-body">1102 <div class="peach-settings-group">1103 <p class="peach-settings-sub-label">THEME</p>1104 <div class="peach-settings-row peach-settings-row--meta">1105 <span>Theme</span>1106 <button1107 class="peach-btn peach-btn--subtle peach-btn--sm"1108 onClick={() =>1109 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1110 }1111 >1112 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1113 </button>1114 </div>11151116 <p class="peach-settings-sub-label">ACCENT</p>1117 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1118 <span>Accent</span>1119 <div class="peach-accent-picker">1120 <For each={ACCENT_COLORS}>1121 {(color) => (1122 <button1123 class="peach-accent-swatch"1124 classList={{ "is-active": props.accentColor === color.name }}1125 style={{ background: color.hex }}1126 onClick={() => void props.onAccentChange(color.name)}1127 aria-label={color.name}1128 title={color.name}1129 />1130 )}1131 </For>1132 </div>1133 </div>11341135 <p class="peach-settings-sub-label">DISPLAY FONT</p>1136 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1137 <span>Font</span>1138 <div class="peach-font-picker">1139 <For each={fontOptions}>1140 {(opt) => (1141 <button1142 class="peach-font-swatch"1143 classList={{ "is-active": props.displayFont === opt.id }}1144 data-font-preview={opt.id}1145 onClick={() => void props.onDisplayFontChange(opt.id)}1146 aria-label={opt.label}1147 title={opt.label}1148 >1149 <span class="peach-font-swatch-glyph">{opt.preview}</span>1150 <span class="peach-font-swatch-name">{opt.label}</span>1151 </button>1152 )}1153 </For>1154 </div>1155 </div>11561157 <p class="peach-settings-sub-label">TEXT SIZE</p>1158 <div class="peach-settings-row peach-settings-row--meta">1159 <span>Size</span>1160 <div class="peach-pill-group">1161 <For each={sizeOptions}>1162 {(opt) => (1163 <button1164 class="peach-btn peach-btn--ghost peach-btn--sm"1165 classList={{ "is-active": props.textSize === opt.id }}1166 onClick={() => void props.onTextSizeChange(opt.id)}1167 >1168 {opt.label}1169 </button>1170 )}1171 </For>1172 </div>1173 </div>11741175 <p class="peach-settings-sub-label">MOTION</p>1176 <div class="peach-settings-row peach-settings-row--meta">1177 <span>Reduce motion</span>1178 <button1179 class="peach-btn peach-btn--subtle peach-btn--sm"1180 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1181 >1182 {props.reduceMotion ? "On" : "Off"}1183 </button>1184 </div>1185 </div>1186 </div>1187 );1188 }11891190 function renderShortcuts() {1191 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1192 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);11931194 function startCapture(action: ShortcutAction) {1195 setConflictMsg(null);1196 setCapturingAction(action);1197 }1198 function cancelCapture() {1199 setCapturingAction(null);1200 setConflictMsg(null);1201 }1202 function commitBinding(action: ShortcutAction, binding: Keybinding) {1203 const conflict = findConflict(props.shortcuts, binding, action);1204 if (conflict) {1205 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1206 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1207 return;1208 }1209 const next = { ...props.shortcuts, [action]: binding };1210 setCapturingAction(null);1211 setConflictMsg(null);1212 void props.onShortcutsChange(next);1213 }1214 function resetAction(action: ShortcutAction) {1215 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1216 void props.onShortcutsChange(next);1217 }1218 function resetAll() {1219 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1220 }12211222 1223 1224 function handleCaptureKey(e: KeyboardEvent) {1225 e.preventDefault();1226 e.stopPropagation();1227 if (e.key === "Escape") { cancelCapture(); return; }1228 const binding = keybindingFromEvent(e);1229 if (!binding) return; 1230 const action = capturingAction();1231 if (action) commitBinding(action, binding);1232 }12331234 return (1235 <div class="peach-settings-body">1236 <div class="peach-settings-group peach-shortcuts-group">1237 <p class="peach-settings-sub-label">ENTRY VIEW</p>1238 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1239 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1240 </For>1241 </div>12421243 <div class="peach-settings-group peach-shortcuts-group">1244 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1245 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1246 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1247 </For>1248 </div>12491250 <Show when={conflictMsg()}>1251 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1252 </Show>12531254 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1255 <div class="peach-settings-row peach-settings-row--meta">1256 <span>Inline autofill on page</span>1257 <a1258 class="peach-settings-link"1259 href="chrome://extensions/shortcuts"1260 onClick={(e) => {1261 e.preventDefault();1262 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1263 }}1264 >1265 ⌘⇧P — customize ↗1266 </a>1267 </div>1268 <button1269 class="peach-btn peach-btn--subtle peach-btn--sm"1270 style="align-self:flex-start"1271 onClick={resetAll}1272 >1273 Reset all to defaults1274 </button>1275 </div>1276 </div>1277 );12781279 function renderRow(1280 desc: typeof SHORTCUT_DESCRIPTORS[number],1281 capturing: () => ShortcutAction | null,1282 onStart: (a: ShortcutAction) => void,1283 onCancel: () => void,1284 onReset: (a: ShortcutAction) => void,1285 onKey: (e: KeyboardEvent) => void,1286 ) {1287 const isActive = () => capturing() === desc.action;1288 const binding = () => props.shortcuts[desc.action];1289 const isCustom = () => {1290 const b = binding();1291 const d = DEFAULT_BINDINGS[desc.action];1292 return b.key !== d.key1293 || Boolean(b.ctrl) !== Boolean(d.ctrl)1294 || Boolean(b.meta) !== Boolean(d.meta)1295 || Boolean(b.shift) !== Boolean(d.shift)1296 || Boolean(b.alt) !== Boolean(d.alt);1297 };1298 return (1299 <div class="peach-shortcut-row">1300 <span class="peach-shortcut-row-label">{desc.label}</span>1301 <button1302 class="peach-shortcut-chip"1303 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1304 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1305 onKeyDown={(e) => { if (isActive()) onKey(e); }}1306 ref={(el) => {1307 1308 if (el) {1309 queueMicrotask(() => { if (isActive()) el.focus(); });1310 }1311 }}1312 >1313 {isActive() ? "Press a key…" : formatKeybinding(binding())}1314 </button>1315 <Show when={isCustom() && !isActive()}>1316 <button1317 class="peach-shortcut-reset"1318 title="Reset to default"1319 onClick={() => onReset(desc.action)}1320 >1321 ↺1322 </button>1323 </Show>1324 </div>1325 );1326 }1327 }13281329 const PRO_FEATURES: Array<{1330 feature: ProFeature;1331 name: string;1332 desc: string;1333 }> = [1334 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1335 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1336 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1337 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1338 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1339 ];13401341 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1342 const [deactivating, setDeactivating] = createSignal(false);13431344 async function handleDeactivateLicense() {1345 setDeactivating(true);1346 await sendMessage({ type: "DEACTIVATE_LICENSE" });1347 setLicenseInfo(null);1348 await props.onLicenseChange?.();1349 setDeactivating(false);1350 }13511352 function renderLicense() {1353 return (1354 <div class="peach-settings-body">1355 <Show1356 when={isPro()}1357 fallback={1358 <div class="peach-pro-upgrade">1359 <div class="peach-pro-upgrade-hero">1360 <PeachMark class="peach-pro-upgrade-mark" />1361 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1362 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1363 </div>13641365 <div class="peach-pro-features">1366 <For each={PRO_FEATURES}>1367 {(item) => (1368 <div1369 class="peach-pro-feature"1370 classList={{1371 "is-highlighted": props.upgradeHighlight === item.feature,1372 }}1373 >1374 <span class="peach-pro-feature-mark">✦</span>1375 <div class="peach-pro-feature-text">1376 <span class="peach-pro-feature-name">{item.name}</span>1377 <span class="peach-pro-feature-desc">{item.desc}</span>1378 </div>1379 </div>1380 )}1381 </For>1382 </div>13831384 <p class="peach-pro-pricing">$39 · once · yours forever</p>13851386 <a1387 href="https://peachpasswords.com/#pricing"1388 target="_blank"1389 rel="noopener noreferrer"1390 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1391 >1392 Get Peach Pro1393 </a>13941395 <Show1396 when={showKeyEntry()}1397 fallback={1398 <button1399 class="peach-pro-key-link"1400 onClick={() => setShowKeyEntry(true)}1401 >1402 Already have a key? Enter it →1403 </button>1404 }1405 >1406 <div class="peach-pro-key-entry">1407 <input1408 class="peach-underline-input peach-pro-key-input"1409 type="text"1410 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1411 value={licenseKeyInput()}1412 onInput={(e) =>1413 setLicenseKeyInput(e.currentTarget.value.toUpperCase())1414 }1415 onKeyDown={(e) => {1416 if (e.key === "Enter") void onActivate();1417 }}1418 autocomplete="off"1419 spellcheck={false}1420 />1421 <button1422 class="peach-btn peach-btn--accent peach-btn--sm"1423 onClick={() => void onActivate()}1424 disabled={activatingLicense() || !licenseKeyInput().trim()}1425 >1426 {activatingLicense() ? "activating…" : "Activate"}1427 </button>1428 <Show when={licenseError()}>1429 <p class="peach-pro-key-error">That key doesn't look right.</p>1430 </Show>1431 </div>1432 </Show>1433 </div>1434 }1435 >1436 <div class="peach-pro-active">1437 <div class="peach-pro-active-head">1438 <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1439 <h3 class="peach-pro-active-title">Peach Pro</h3>1440 </div>1441 <p class="peach-pro-active-since">1442 {formatActivatedSince(licenseInfo()?.issued)}1443 </p>1444 <div class="peach-pro-active-divider"></div>1445 <p class="peach-pro-active-thanks">Thank you.</p>1446 <button1447 class="peach-pro-deactivate"1448 onClick={() => void handleDeactivateLicense()}1449 disabled={deactivating()}1450 >1451 {deactivating() ? "Deactivating…" : "Deactivate →"}1452 </button>1453 </div>1454 </Show>1455 </div>1456 );14571458 async function onActivate() {1459 await handleActivateLicense();1460 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1461 await props.onLicenseChange?.();1462 }1463 }1464 }14651466 function renderFeedback() {1467 return (1468 <div class="peach-settings-body">1469 <div class="peach-settings-group peach-feedback-group">1470 <p class="peach-feedback-intro">1471 Help us improve Peach. Every report goes directly to our inbox.1472 </p>14731474 <p class="peach-settings-sub-label">TYPE</p>1475 <div class="peach-feedback-chips">1476 <For each={FEEDBACK_CATEGORIES}>1477 {(cat) => (1478 <button1479 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1480 onClick={() => setFbCategory(cat)}1481 >1482 {cat}1483 </button>1484 )}1485 </For>1486 </div>14871488 <p class="peach-settings-sub-label">MESSAGE</p>1489 <textarea1490 class="peach-feedback-textarea"1491 placeholder="Describe the issue or idea..."1492 value={fbMessage()}1493 onInput={(e) => setFbMessage(e.currentTarget.value)}1494 />14951496 <p class="peach-feedback-meta">1497 Version {version} · will be included automatically1498 </p>14991500 <Show when={fbResult() === "success"}>1501 <p class="peach-feedback-result peach-feedback-result--ok">1502 Sent — thank you1503 </p>1504 </Show>1505 <Show when={fbResult() === "error"}>1506 <p class="peach-feedback-result peach-feedback-result--err">1507 Failed to send — try again1508 </p>1509 </Show>15101511 <button1512 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1513 onClick={() => void handleSubmitFeedback()}1514 disabled={fbSubmitting() || !fbMessage().trim()}1515 >1516 {fbSubmitting() ? "Sending..." : "Send feedback"}1517 </button>15181519 <p class="peach-feedback-anon">1520 Sent anonymously · no account required1521 </p>1522 </div>1523 </div>1524 );1525 }15261527 return (1528 <div class="peach-overlay-view ov-enter-right" style="display:flex">1529 <div class="peach-overlay-header">1530 <button1531 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1532 onClick={handleHeaderBack}1533 aria-label="Back"1534 >1535 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1536 <path d="M6 1L1 6L6 11" />1537 </svg>1538 </button>1539 <span class="peach-overlay-title">{currentTitle()}</span>1540 <div class="peach-overlay-spacer"></div>1541 </div>15421543 <Switch fallback={renderHome()}>1544 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1545 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1546 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1547 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1548 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1549 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1550 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1551 </Switch>1552 </div>1553 );1554}