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 isPro?: boolean;32 initialSection?: string | null;33 upgradeHighlight?: ProFeature | null;34 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;35 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;36 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;37 onTrashRetentionChange: (days: number) => void | Promise<void>;38 onThemeChange: (theme: "dark" | "light") => void;39 onAccentChange: (name: AccentColorName) => void | Promise<void>;40 shortcuts: Record<ShortcutAction, Keybinding>;41 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;42 onLicenseChange?: () => void | Promise<void>;43 onBack: () => void;44 onImport: () => void;45 onExport: () => void;46 onBreachCheckDone: () => void;47 onOpenHealth?: () => void;48 onOpenSyncPairing?: (mode: "initiate" | "accept") => void;49 onOpenCJKImport?: () => void;50 onOpenFileImport?: () => void;51}5253type SectionName =54 | "security"55 | "vault"56 | "sync"57 | "appearance"58 | "shortcuts"59 | "license"60 | "feedback";6162const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];6364function formatActivatedSince(issued: string | undefined): string {65 if (!issued) return "Active";66 try {67 const d = new Date(issued);68 if (Number.isNaN(d.getTime())) return "Active";69 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;70 } catch {71 return "Active";72 }73}7475function isSectionName(value: string): value is SectionName {76 return (77 value === "security" ||78 value === "vault" ||79 value === "sync" ||80 value === "appearance" ||81 value === "shortcuts" ||82 value === "license" ||83 value === "feedback"84 );85}8687export function SettingsView(props: SettingsProps) {88 const version = chrome.runtime.getManifest().version;89 const initial = props.initialSection;90 const [activeSection, setActiveSection] = createSignal<SectionName | null>(91 initial && isSectionName(initial) ? (initial as SectionName) : null92 );9394 const [checkingBreaches, setCheckingBreaches] = createSignal(false);95 const [breachResult, setBreachResult] = createSignal<string | null>(null);96 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);97 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");98 const [licenseError, setLicenseError] = createSignal<string | null>(null);99 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);100 const [activatingLicense, setActivatingLicense] = createSignal(false);101102 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);103 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);104 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");105 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);106 107 108 109 const [syncProStatus, setSyncProStatus] = createSignal(false);110 const isPro = () => props.isPro ?? syncProStatus();111 const [syncingNow, setSyncingNow] = createSignal(false);112 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);113 const [passkeySaving, setPasskeySaving] = createSignal(false);114115 const [dedupCount, setDedupCount] = createSignal<number | null>(null);116 const [dedupRunning, setDedupRunning] = createSignal(false);117 const [dedupDone, setDedupDone] = createSignal<number | null>(null);118119 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);120121 const [showChangePw, setShowChangePw] = createSignal(false);122 const [changePwCurrent, setChangePwCurrent] = createSignal("");123 const [changePwNew, setChangePwNew] = createSignal("");124 const [changePwConfirm, setChangePwConfirm] = createSignal("");125 const [changePwSaving, setChangePwSaving] = createSignal(false);126 const [changePwError, setChangePwError] = createSignal<string | null>(null);127 const [changePwSuccess, setChangePwSuccess] = createSignal(false);128129 const [duressEnabled, setDuressEnabled] = createSignal(false);130 const [showDuressSetup, setShowDuressSetup] = createSignal(false);131 const [duressPassword, setDuressPassword] = createSignal("");132 const [duressConfirm, setDuressConfirm] = createSignal("");133 const [duressMainPassword, setDuressMainPassword] = createSignal("");134 const [duressSaving, setDuressSaving] = createSignal(false);135 const [duressError, setDuressError] = createSignal<string | null>(null);136 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);137138 const [fbCategory, setFbCategory] = createSignal("Bug report");139 const [fbMessage, setFbMessage] = createSignal("");140 const [fbSubmitting, setFbSubmitting] = createSignal(false);141 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);142143 onMount(async () => {144 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([145 sendMessage({ type: "GET_LICENSE" }),146 sendMessage({ type: "GET_SYNC_STATE" }),147 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),148 sendMessage({ type: "IS_DURESS_ENABLED" }),149 sendMessage({ type: "GET_SETTINGS" }),150 ]);151152 if ((licenseRes as { ok: boolean }).ok) {153 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);154 }155156 if ((syncRes as { ok: boolean }).ok) {157 const data = syncRes as { ok: boolean; data?: {158 pairedDevices?: PairedDevice[];159 trustedNetworks?: TrustedNetwork[];160 status?: SyncStatus;161 lastSyncAt?: number;162 isPro?: boolean;163 }};164 if (data.data) {165 setPairedDevices(data.data.pairedDevices ?? []);166 setTrustedNetworks(data.data.trustedNetworks ?? []);167 setSyncStatus(data.data.status ?? "offline");168 setLastSyncAt(data.data.lastSyncAt);169 setSyncProStatus(data.data.isPro ?? false);170 }171 }172173 if ((passkeyRes as { ok: boolean }).ok) {174 setPasskeyState(175 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null176 );177 }178179 if ((duressRes as { ok: boolean }).ok) {180 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);181 }182183 if ((settingsRes as { ok: boolean }).ok) {184 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;185 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);186 }187188 chrome.runtime.onMessage.addListener((msg) => {189 if (msg.type === "SYNC_STATE_UPDATE") {190 setPairedDevices(msg.pairedDevices ?? []);191 setTrustedNetworks(msg.trustedNetworks ?? []);192 setSyncStatus(msg.status ?? "offline");193 setLastSyncAt(msg.lastSyncAt);194 setSyncProStatus(msg.isPro ?? false);195 }196 });197 });198199 async function handleChangePassword() {200 setChangePwError(null);201 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }202 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }203 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }204 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }205 setChangePwSaving(true);206 const res = await sendMessage({207 type: "CHANGE_PASSWORD",208 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },209 }) as { ok: boolean; error?: string };210 if (res.ok) {211 setChangePwSuccess(true);212 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");213 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);214 } else {215 setChangePwError(res.error ?? "failed to change password");216 }217 setChangePwSaving(false);218 }219220 async function handleActivateLicense() {221 const key = licenseKeyInput().trim();222 if (!key) return;223 setActivatingLicense(true);224 setLicenseError(null);225 setLicenseSuccess(null);226 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });227 setActivatingLicense(false);228 if ((res as { ok: boolean }).ok) {229 const data = (res as { ok: boolean; data?: LicenseInfo }).data;230 if (data?.valid) {231 setLicenseInfo(data);232 setLicenseSuccess("License activated!");233 setLicenseKeyInput("");234 } else {235 setLicenseError("Invalid license key");236 }237 } else {238 setLicenseError("Activation failed");239 }240 }241242 async function handleTogglePasskeys() {243 const current = passkeyState();244 if (!current || !current.supported) return;245 setPasskeySaving(true);246 const res = await sendMessage({247 type: "SET_PASSKEY_PROVIDER_ENABLED",248 payload: { enabled: !current.enabled },249 });250 setPasskeySaving(false);251 if ((res as { ok: boolean }).ok) {252 setPasskeyState(253 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null254 );255 }256 }257258 async function handleCheckAllBreaches() {259 setCheckingBreaches(true);260 setBreachResult(null);261 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });262 setCheckingBreaches(false);263 if ((res as { ok: boolean }).ok) {264 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};265 const breached = Object.values(data).filter((count) => count > 0).length;266 setBreachResult(267 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"268 );269 props.onBreachCheckDone();270 }271 }272273 async function handleSyncNow() {274 if (syncStatus() === "not_trusted") return;275 setSyncingNow(true);276 await sendMessage({ type: "TRIGGER_SYNC" });277 setTimeout(() => setSyncingNow(false), 3000);278 }279280 async function handleRemoveDevice(deviceId: string) {281 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });282 }283284 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {285 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });286 }287288 async function handleMarkNetworkTrusted() {289 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });290 }291292 function formatRelativeTime(timestamp?: number): string {293 if (!timestamp) return "Never";294 const diff = Date.now() - timestamp;295 const minutes = Math.floor(diff / 60000);296 const hours = Math.floor(diff / 3600000);297 const days = Math.floor(diff / 86400000);298 if (minutes < 1) return "Just now";299 if (minutes < 60) return `${minutes}m ago`;300 if (hours < 24) return `${hours}h ago`;301 return `${days}d ago`;302 }303304 function getStatusColor(status: SyncStatus): string {305 switch (status) {306 case "synced": return "var(--peach-sync-ok)";307 case "syncing": return "var(--peach-sync-busy)";308 case "no_peers": return "var(--peach-sync-off)";309 case "not_trusted": return "var(--peach-sync-err)";310 case "offline": return "var(--peach-sync-off)";311 default: return "var(--peach-sync-off)";312 }313 }314315 function getStatusLabel(status: SyncStatus): string {316 switch (status) {317 case "synced": return "Synced";318 case "syncing": return "Syncing";319 case "no_peers": return "No peers visible";320 case "not_trusted": return "Not on a trusted network";321 case "offline": return "Offline";322 default: return "Unknown";323 }324 }325326 function isCurrentNetworkTrusted(): boolean {327 return trustedNetworks().length > 0;328 }329330 async function handleSetupDuress() {331 const mainPw = duressMainPassword().trim();332 const duressPw = duressPassword().trim();333 const confirmPw = duressConfirm().trim();334335 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }336 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }337 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }338 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }339340 setDuressSaving(true);341 setDuressError(null);342343 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };344 if (!verifyRes.ok || !verifyRes.data) {345 setDuressError("incorrect main password");346 setDuressSaving(false);347 return;348 }349350 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };351 setDuressSaving(false);352 if (res.ok) {353 setDuressEnabled(true);354 setShowDuressSetup(false);355 setDuressPassword("");356 setDuressConfirm("");357 setDuressMainPassword("");358 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");359 setTimeout(() => setDuressSuccess(null), 5000);360 } else {361 setDuressError(res.error ?? "failed to set duress password");362 }363 }364365 async function handleClearDeclinedCards() {366 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });367 setDeclinedCardCount(0);368 }369370 async function handleCountDuplicates() {371 setDedupRunning(true);372 setDedupDone(null);373 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };374 setDedupRunning(false);375 if (res.ok) setDedupCount(res.data ?? 0);376 }377378 async function handleDeduplicate() {379 setDedupRunning(true);380 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };381 setDedupRunning(false);382 if (res.ok) {383 setDedupDone(res.data ?? 0);384 setDedupCount(0);385 }386 }387388 async function handleDisableDuress() {389 const mainPw = duressMainPassword().trim();390 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }391392 setDuressSaving(true);393 setDuressError(null);394395 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };396 if (!verifyRes.ok || !verifyRes.data) {397 setDuressError("incorrect main password");398 setDuressSaving(false);399 return;400 }401402 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };403 setDuressSaving(false);404 if (res.ok) {405 setDuressEnabled(false);406 setShowDuressSetup(false);407 setDuressMainPassword("");408 setDuressSuccess("Duress password disabled.");409 setTimeout(() => setDuressSuccess(null), 4000);410 } else {411 setDuressError(res.error ?? "failed to disable duress password");412 }413 }414415 async function handleSubmitFeedback() {416 if (!fbMessage().trim() || fbSubmitting()) return;417 setFbSubmitting(true);418 setFbResult(null);419 const res = await sendMessage({420 type: "SUBMIT_FEEDBACK",421 payload: {422 category: fbCategory(),423 message: fbMessage().trim(),424 version,425 browser: navigator.userAgent,426 },427 }) as { ok: boolean; error?: string };428 setFbSubmitting(false);429 if (res.ok) {430 setFbResult("success");431 setFbMessage("");432 setTimeout(() => setFbResult(null), 4000);433 } else {434 setFbResult("error");435 }436 }437438 function handleHeaderBack() {439 if (activeSection() !== null) {440 setActiveSection(null);441 } else {442 props.onBack();443 }444 }445446 function currentTitle(): string {447 const s = activeSection();448 if (s === null) return "settings";449 if (s === "license") return "account";450 return s;451 }452453 function renderCatRow(opts: {454 section: SectionName;455 name: string;456 desc: string;457 icon: () => any;458 pro?: boolean;459 }) {460 return (461 <button462 class="peach-settings-cat-row"463 onClick={() => setActiveSection(opts.section)}464 >465 <div class="peach-settings-cat-icon">{opts.icon()}</div>466 <div class="peach-settings-cat-content">467 <span class="peach-settings-cat-name">468 {opts.name}469 <Show when={opts.pro && !isPro()}>470 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>471 </Show>472 </span>473 <span class="peach-settings-cat-desc">{opts.desc}</span>474 </div>475 <svg476 class="peach-settings-cat-chevron"477 width="6" height="10" viewBox="0 0 6 10" fill="none"478 stroke="currentColor" stroke-width="1.1"479 stroke-linecap="round" stroke-linejoin="round"480 >481 <path d="M1 1l4 4-4 4" />482 </svg>483 </button>484 );485 }486487 const iconShield = () => (488 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">489 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>490 </svg>491 );492 const iconKey = () => (493 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">494 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>495 </svg>496 );497 const iconLock = () => (498 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">499 <rect x="3" y="7" width="10" height="7" rx="1.5"/>500 <path d="M6 7V5a3 3 0 016 0v2"/>501 </svg>502 );503 const iconSync = () => (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 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>506 <path d="M14 6l-2 2-2-2"/>507 <path d="M2 10l2-2 2 2"/>508 </svg>509 );510 const iconSun = () => (511 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">512 <circle cx="8" cy="8" r="3"/>513 <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"/>514 </svg>515 );516 const iconKeys = () => (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 <rect x="2" y="5" width="5" height="4" rx="1"/>519 <rect x="9" y="7" width="5" height="4" rx="1"/>520 <path d="M7 7h2m-2 2h2"/>521 </svg>522 );523 const iconBubble = () => (524 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">525 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>526 </svg>527 );528529 function renderHome() {530 return (531 <div class="peach-settings-body">532 <p class="peach-settings-nav-label">PROTECTION</p>533 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}534 {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}535536 <p class="peach-settings-nav-label">VAULT</p>537 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}538 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}539540 <p class="peach-settings-nav-label">PREFERENCES</p>541 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}542 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}543544 <p class="peach-settings-nav-label">SUPPORT</p>545 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}546547 <div class="peach-settings-footer">548 <span>Peach {version}</span>549 <span class="peach-divider"></span>550 <span>Browser extension</span>551 </div>552 </div>553 );554 }555556 function renderSecurity() {557 return (558 <div class="peach-settings-body">559 <div class="peach-settings-group">560 <p class="peach-settings-sub-label">LOCKING</p>561 <div class="peach-settings-row">562 <span>Lock on close</span>563 <button564 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}565 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}566 aria-label="Toggle lock on close"567 >568 <span class="peach-toggle-knob"></span>569 </button>570 </div>571 <div class="peach-settings-row">572 <span>Auto-lock after</span>573 <select574 class="peach-settings-select"575 value={String(props.autoLockMinutes)}576 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}577 >578 <option value="1">1 minute</option>579 <option value="5">5 minutes</option>580 <option value="15">15 minutes</option>581 <option value="30">30 minutes</option>582 <option value="0">Never</option>583 </select>584 </div>585586 <p class="peach-settings-sub-label">BREACH PROTECTION</p>587 <div class="peach-settings-row">588 <span>Breach monitoring</span>589 <button590 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}591 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}592 aria-label="Toggle breach monitoring"593 >594 <span class="peach-toggle-knob"></span>595 </button>596 </div>597 <div class="peach-settings-row peach-settings-row--meta">598 <span>Check all passwords</span>599 <button600 class="peach-btn peach-btn--subtle peach-btn--sm"601 onClick={() => void handleCheckAllBreaches()}602 disabled={checkingBreaches()}603 >604 {checkingBreaches() ? "checking..." : "run"}605 </button>606 </div>607 <Show when={breachResult()}>608 <div class="peach-settings-row peach-settings-row--meta">609 <span>Last check</span>610 <span class="peach-settings-value">{breachResult()}</span>611 </div>612 </Show>613614 <p class="peach-settings-sub-label">MASTER PASSWORD</p>615 <div class="peach-settings-row peach-settings-row--meta">616 <span>Master password</span>617 <button618 class="peach-btn peach-btn--subtle peach-btn--sm"619 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}620 >621 {showChangePw() ? "cancel" : "change"}622 </button>623 </div>624 <Show when={showChangePw()}>625 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">626 <input627 class="peach-settings-input"628 type="password"629 placeholder="current password"630 value={changePwCurrent()}631 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}632 autocomplete="current-password"633 />634 <input635 class="peach-settings-input"636 type="password"637 placeholder="new password (min 12 chars)"638 value={changePwNew()}639 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}640 autocomplete="new-password"641 />642 <input643 class="peach-settings-input"644 type="password"645 placeholder="confirm new password"646 value={changePwConfirm()}647 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}648 autocomplete="new-password"649 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}650 />651 <Show when={changePwError()}>652 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>653 </Show>654 <Show when={changePwSuccess()}>655 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>656 </Show>657 <button658 class="peach-btn peach-btn--accent peach-btn--sm"659 style="align-self:flex-start"660 onClick={() => void handleChangePassword()}661 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}662 >663 {changePwSaving() ? "saving…" : "save new password"}664 </button>665 </div>666 </Show>667668 <p class="peach-settings-sub-label">DURESS PASSWORD</p>669 <div class="peach-settings-row peach-settings-row--top">670 <div class="peach-settings-row-main">671 <span>Duress password</span>672 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">673 If entered under coercion, shows a decoy empty vault instead of your real one.674 </span>675 </div>676 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>677 {duressEnabled() ? "on" : "off"}678 </div>679 </div>680681 <Show when={duressEnabled() && !showDuressSetup()}>682 <div class="peach-settings-row peach-settings-row--meta">683 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">684 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.685 </span>686 </div>687 </Show>688689 <Show when={duressSuccess()}>690 <div class="peach-settings-row peach-settings-row--meta">691 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>692 </div>693 </Show>694695 <Show when={!showDuressSetup()}>696 <div class="peach-settings-row peach-settings-row--meta">697 <button698 class="peach-btn peach-btn--subtle peach-btn--sm"699 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}700 >701 {duressEnabled() ? "Change / disable" : "Set up"}702 </button>703 </div>704 </Show>705706 <Show when={showDuressSetup()}>707 <div class="peach-duress-form">708 <label class="peach-label">main password (to confirm)</label>709 <input710 class="peach-settings-input"711 type="password"712 placeholder="your current master password"713 autocomplete="current-password"714 value={duressMainPassword()}715 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}716 />717 <Show when={!duressEnabled()}>718 <label class="peach-label">new duress password</label>719 <input720 class="peach-settings-input"721 type="password"722 placeholder="duress password (min 8 chars)"723 autocomplete="new-password"724 value={duressPassword()}725 onInput={(e) => setDuressPassword(e.currentTarget.value)}726 />727 <label class="peach-label">confirm duress password</label>728 <input729 class="peach-settings-input"730 type="password"731 placeholder="confirm duress password"732 autocomplete="new-password"733 value={duressConfirm()}734 onInput={(e) => setDuressConfirm(e.currentTarget.value)}735 />736 </Show>737738 <Show when={duressError()}>739 <p class="peach-hint peach-hint-error">{duressError()}</p>740 </Show>741742 <div class="peach-duress-actions">743 <button744 class="peach-btn peach-btn--subtle peach-btn--sm"745 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}746 >747 cancel748 </button>749 <Show when={duressEnabled()}>750 <button751 class="peach-btn peach-btn--ghost peach-btn--sm"752 style="color:var(--peach-breach)"753 onClick={() => void handleDisableDuress()}754 disabled={duressSaving()}755 >756 {duressSaving() ? "disabling..." : "disable duress"}757 </button>758 </Show>759 <Show when={!duressEnabled()}>760 <button761 class="peach-btn peach-btn--accent peach-btn--sm"762 onClick={() => void handleSetupDuress()}763 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}764 >765 {duressSaving() ? "saving..." : "save"}766 </button>767 </Show>768 </div>769 </div>770 </Show>771772 <p class="peach-settings-sub-label">PASSKEYS</p>773 <div class="peach-settings-row peach-settings-row--top">774 <div class="peach-settings-row-main">775 <span>Intercept passkey requests</span>776 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">777 Peach can handle passkey sign-in and creation on supported Chromium browsers.778 </span>779 </div>780 <button781 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}782 onClick={() => void handleTogglePasskeys()}783 aria-label="Toggle passkey interception"784 disabled={!passkeyState()?.supported || passkeySaving()}785 >786 <span class="peach-toggle-knob"></span>787 </button>788 </div>789 <div class="peach-settings-row peach-settings-row--meta">790 <span>Status</span>791 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>792 </div>793 <div class="peach-settings-row peach-settings-row--meta">794 <span>Browser</span>795 <span class="peach-settings-value">796 {passkeyState()?.browserLabel ?? "Unknown"}797 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}798 </span>799 </div>800 <div class="peach-info-notice" style="margin-top:8px">801 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.802 </div>803 <Show when={passkeyState()?.conflict}>804 <div class="peach-settings-row peach-settings-row--meta">805 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>806 </div>807 </Show>808 </div>809 </div>810 );811 }812813 function renderVault() {814 return (815 <div class="peach-settings-body">816 <div class="peach-settings-group">817 <p class="peach-settings-sub-label">ENTRIES</p>818 <div class="peach-settings-row peach-settings-row--meta">819 <span>Entries</span>820 <span class="peach-settings-value">{props.entriesCount}</span>821 </div>822 <div class="peach-settings-row peach-settings-row--meta">823 <span>Duplicates</span>824 <Show when={dedupDone() !== null} fallback={825 <button826 class="peach-btn peach-btn--subtle peach-btn--sm"827 onClick={() => void handleCountDuplicates()}828 disabled={dedupRunning()}829 >830 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}831 </button>832 }>833 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">834 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}835 </span>836 </Show>837 </div>838 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>839 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">840 <div class="peach-restore-warning">841 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.842 </div>843 <div style="display:flex;gap:6px">844 <button845 class="peach-btn peach-btn--subtle peach-btn--sm"846 onClick={() => setDedupCount(null)}847 >848 cancel849 </button>850 <button851 class="peach-btn peach-btn--accent peach-btn--sm"852 onClick={() => void handleDeduplicate()}853 disabled={dedupRunning()}854 >855 {dedupRunning() ? "removing..." : "remove duplicates"}856 </button>857 </div>858 </div>859 </Show>860861 <p class="peach-settings-sub-label">MAINTENANCE</p>862 <div class="peach-settings-row peach-settings-row--meta">863 <span>Trash auto-delete</span>864 <select865 class="peach-settings-select"866 value={String(props.trashRetentionDays)}867 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}868 >869 <option value="7">7 days</option>870 <option value="30">30 days</option>871 <option value="90">90 days</option>872 <option value="0">Never</option>873 </select>874 </div>875 <div class="peach-settings-row peach-settings-row--meta">876 <span>Vault Health</span>877 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>878 open879 </button>880 </div>881 <Show when={declinedCardCount() > 0}>882 <div class="peach-settings-row peach-settings-row--meta">883 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>884 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>885 Clear886 </button>887 </div>888 </Show>889890 <p class="peach-settings-sub-label">DATA</p>891 <div class="peach-settings-row peach-settings-row--meta">892 <span>Import passwords</span>893 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>894 open895 </button>896 </div>897 <div class="peach-settings-row peach-settings-row--meta">898 <span>Export backup</span>899 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>900 open901 </button>902 </div>903 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">904 <span>Replace vault from backup</span>905 <div class="peach-restore-warning" style="margin-top:2px">906 This will permanently replace your current vault with the backup.907 </div>908 <div style="display:flex;gap:6px">909 <button910 class="peach-btn peach-btn--subtle peach-btn--sm"911 onClick={() => props.onOpenCJKImport?.()}912 >913 CJK phrase914 </button>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 return (1084 <div class="peach-settings-body">1085 <div class="peach-settings-group">1086 <p class="peach-settings-sub-label">THEME</p>1087 <div class="peach-settings-row peach-settings-row--meta">1088 <span>Theme</span>1089 <button1090 class="peach-btn peach-btn--subtle peach-btn--sm"1091 onClick={() =>1092 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1093 }1094 >1095 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1096 </button>1097 </div>10981099 <p class="peach-settings-sub-label">ACCENT</p>1100 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1101 <span>Accent</span>1102 <div class="peach-accent-picker">1103 <For each={ACCENT_COLORS}>1104 {(color) => (1105 <button1106 class="peach-accent-swatch"1107 classList={{ "is-active": props.accentColor === color.name }}1108 style={{ background: color.hex }}1109 onClick={() => void props.onAccentChange(color.name)}1110 aria-label={color.name}1111 title={color.name}1112 />1113 )}1114 </For>1115 </div>1116 </div>1117 </div>1118 </div>1119 );1120 }11211122 function renderShortcuts() {1123 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1124 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);11251126 function startCapture(action: ShortcutAction) {1127 setConflictMsg(null);1128 setCapturingAction(action);1129 }1130 function cancelCapture() {1131 setCapturingAction(null);1132 setConflictMsg(null);1133 }1134 function commitBinding(action: ShortcutAction, binding: Keybinding) {1135 const conflict = findConflict(props.shortcuts, binding, action);1136 if (conflict) {1137 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1138 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1139 return;1140 }1141 const next = { ...props.shortcuts, [action]: binding };1142 setCapturingAction(null);1143 setConflictMsg(null);1144 void props.onShortcutsChange(next);1145 }1146 function resetAction(action: ShortcutAction) {1147 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1148 void props.onShortcutsChange(next);1149 }1150 function resetAll() {1151 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1152 }11531154 1155 1156 function handleCaptureKey(e: KeyboardEvent) {1157 e.preventDefault();1158 e.stopPropagation();1159 if (e.key === "Escape") { cancelCapture(); return; }1160 const binding = keybindingFromEvent(e);1161 if (!binding) return; 1162 const action = capturingAction();1163 if (action) commitBinding(action, binding);1164 }11651166 return (1167 <div class="peach-settings-body">1168 <div class="peach-settings-group peach-shortcuts-group">1169 <p class="peach-settings-sub-label">ENTRY VIEW</p>1170 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1171 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1172 </For>1173 </div>11741175 <div class="peach-settings-group peach-shortcuts-group">1176 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1177 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1178 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1179 </For>1180 </div>11811182 <Show when={conflictMsg()}>1183 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1184 </Show>11851186 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1187 <div class="peach-settings-row peach-settings-row--meta">1188 <span>Inline autofill on page</span>1189 <a1190 class="peach-settings-link"1191 href="chrome://extensions/shortcuts"1192 onClick={(e) => {1193 e.preventDefault();1194 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1195 }}1196 >1197 ⌘⇧P — customize ↗1198 </a>1199 </div>1200 <button1201 class="peach-btn peach-btn--subtle peach-btn--sm"1202 style="align-self:flex-start"1203 onClick={resetAll}1204 >1205 Reset all to defaults1206 </button>1207 </div>1208 </div>1209 );12101211 function renderRow(1212 desc: typeof SHORTCUT_DESCRIPTORS[number],1213 capturing: () => ShortcutAction | null,1214 onStart: (a: ShortcutAction) => void,1215 onCancel: () => void,1216 onReset: (a: ShortcutAction) => void,1217 onKey: (e: KeyboardEvent) => void,1218 ) {1219 const isActive = () => capturing() === desc.action;1220 const binding = () => props.shortcuts[desc.action];1221 const isCustom = () => {1222 const b = binding();1223 const d = DEFAULT_BINDINGS[desc.action];1224 return b.key !== d.key1225 || Boolean(b.ctrl) !== Boolean(d.ctrl)1226 || Boolean(b.meta) !== Boolean(d.meta)1227 || Boolean(b.shift) !== Boolean(d.shift)1228 || Boolean(b.alt) !== Boolean(d.alt);1229 };1230 return (1231 <div class="peach-shortcut-row">1232 <span class="peach-shortcut-row-label">{desc.label}</span>1233 <button1234 class="peach-shortcut-chip"1235 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1236 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1237 onKeyDown={(e) => { if (isActive()) onKey(e); }}1238 ref={(el) => {1239 1240 if (el) {1241 queueMicrotask(() => { if (isActive()) el.focus(); });1242 }1243 }}1244 >1245 {isActive() ? "Press a key…" : formatKeybinding(binding())}1246 </button>1247 <Show when={isCustom() && !isActive()}>1248 <button1249 class="peach-shortcut-reset"1250 title="Reset to default"1251 onClick={() => onReset(desc.action)}1252 >1253 ↺1254 </button>1255 </Show>1256 </div>1257 );1258 }1259 }12601261 const PRO_FEATURES: Array<{1262 feature: ProFeature;1263 name: string;1264 desc: string;1265 }> = [1266 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1267 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1268 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1269 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1270 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1271 ];12721273 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1274 const [deactivating, setDeactivating] = createSignal(false);12751276 async function handleDeactivateLicense() {1277 setDeactivating(true);1278 await sendMessage({ type: "DEACTIVATE_LICENSE" });1279 setLicenseInfo(null);1280 await props.onLicenseChange?.();1281 setDeactivating(false);1282 }12831284 function renderLicense() {1285 return (1286 <div class="peach-settings-body">1287 <Show1288 when={isPro()}1289 fallback={1290 <div class="peach-pro-upgrade">1291 <div class="peach-pro-upgrade-hero">1292 <PeachMark class="peach-pro-upgrade-mark" />1293 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1294 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1295 </div>12961297 <div class="peach-pro-features">1298 <For each={PRO_FEATURES}>1299 {(item) => (1300 <div1301 class="peach-pro-feature"1302 classList={{1303 "is-highlighted": props.upgradeHighlight === item.feature,1304 }}1305 >1306 <span class="peach-pro-feature-mark">✦</span>1307 <div class="peach-pro-feature-text">1308 <span class="peach-pro-feature-name">{item.name}</span>1309 <span class="peach-pro-feature-desc">{item.desc}</span>1310 </div>1311 </div>1312 )}1313 </For>1314 </div>13151316 <p class="peach-pro-pricing">$39 · once · yours forever</p>13171318 <a1319 href="https://peachpasswords.com/#pricing"1320 target="_blank"1321 rel="noopener noreferrer"1322 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1323 >1324 Get Peach Pro1325 </a>13261327 <Show1328 when={showKeyEntry()}1329 fallback={1330 <button1331 class="peach-pro-key-link"1332 onClick={() => setShowKeyEntry(true)}1333 >1334 Already have a key? Enter it →1335 </button>1336 }1337 >1338 <div class="peach-pro-key-entry">1339 <input1340 class="peach-underline-input peach-pro-key-input"1341 type="text"1342 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1343 value={licenseKeyInput()}1344 onInput={(e) =>1345 setLicenseKeyInput(e.currentTarget.value.toUpperCase())1346 }1347 onKeyDown={(e) => {1348 if (e.key === "Enter") void onActivate();1349 }}1350 autocomplete="off"1351 spellcheck={false}1352 />1353 <button1354 class="peach-btn peach-btn--accent peach-btn--sm"1355 onClick={() => void onActivate()}1356 disabled={activatingLicense() || !licenseKeyInput().trim()}1357 >1358 {activatingLicense() ? "activating…" : "Activate"}1359 </button>1360 <Show when={licenseError()}>1361 <p class="peach-pro-key-error">That key doesn't look right.</p>1362 </Show>1363 </div>1364 </Show>1365 </div>1366 }1367 >1368 <div class="peach-pro-active">1369 <div class="peach-pro-active-head">1370 <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1371 <h3 class="peach-pro-active-title">Peach Pro</h3>1372 </div>1373 <p class="peach-pro-active-since">1374 {formatActivatedSince(licenseInfo()?.issued)}1375 </p>1376 <div class="peach-pro-active-divider"></div>1377 <p class="peach-pro-active-thanks">Thank you.</p>1378 <button1379 class="peach-pro-deactivate"1380 onClick={() => void handleDeactivateLicense()}1381 disabled={deactivating()}1382 >1383 {deactivating() ? "Deactivating…" : "Deactivate →"}1384 </button>1385 </div>1386 </Show>1387 </div>1388 );13891390 async function onActivate() {1391 await handleActivateLicense();1392 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1393 await props.onLicenseChange?.();1394 }1395 }1396 }13971398 function renderFeedback() {1399 return (1400 <div class="peach-settings-body">1401 <div class="peach-settings-group peach-feedback-group">1402 <p class="peach-feedback-intro">1403 Help us improve Peach. Every report goes directly to our inbox.1404 </p>14051406 <p class="peach-settings-sub-label">TYPE</p>1407 <div class="peach-feedback-chips">1408 <For each={FEEDBACK_CATEGORIES}>1409 {(cat) => (1410 <button1411 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1412 onClick={() => setFbCategory(cat)}1413 >1414 {cat}1415 </button>1416 )}1417 </For>1418 </div>14191420 <p class="peach-settings-sub-label">MESSAGE</p>1421 <textarea1422 class="peach-feedback-textarea"1423 placeholder="Describe the issue or idea..."1424 value={fbMessage()}1425 onInput={(e) => setFbMessage(e.currentTarget.value)}1426 />14271428 <p class="peach-feedback-meta">1429 Version {version} · will be included automatically1430 </p>14311432 <Show when={fbResult() === "success"}>1433 <p class="peach-feedback-result peach-feedback-result--ok">1434 Sent — thank you1435 </p>1436 </Show>1437 <Show when={fbResult() === "error"}>1438 <p class="peach-feedback-result peach-feedback-result--err">1439 Failed to send — try again1440 </p>1441 </Show>14421443 <button1444 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1445 onClick={() => void handleSubmitFeedback()}1446 disabled={fbSubmitting() || !fbMessage().trim()}1447 >1448 {fbSubmitting() ? "Sending..." : "Send feedback"}1449 </button>14501451 <p class="peach-feedback-anon">1452 Sent anonymously · no account required1453 </p>1454 </div>1455 </div>1456 );1457 }14581459 return (1460 <div class="peach-overlay-view ov-enter-right" style="display:flex">1461 <div class="peach-overlay-header">1462 <button1463 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1464 onClick={handleHeaderBack}1465 aria-label="Back"1466 >1467 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1468 <path d="M6 1L1 6L6 11" />1469 </svg>1470 </button>1471 <span class="peach-overlay-title">{currentTitle()}</span>1472 <div class="peach-overlay-spacer"></div>1473 </div>14741475 <Switch fallback={renderHome()}>1476 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1477 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1478 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1479 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1480 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1481 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1482 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1483 </Switch>1484 </div>1485 );1486}