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 onOpenFileImport?: () => void;56}5758type SectionName =59 | "security"60 | "vault"61 | "sync"62 | "appearance"63 | "shortcuts"64 | "license"65 | "feedback";6667const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];6869function formatActivatedSince(issued: string | undefined): string {70 if (!issued) return "Active";71 try {72 const d = new Date(issued);73 if (Number.isNaN(d.getTime())) return "Active";74 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;75 } catch {76 return "Active";77 }78}7980function isSectionName(value: string): value is SectionName {81 return (82 value === "security" ||83 value === "vault" ||84 value === "sync" ||85 value === "appearance" ||86 value === "shortcuts" ||87 value === "license" ||88 value === "feedback"89 );90}9192export function SettingsView(props: SettingsProps) {93 const version = chrome.runtime.getManifest().version;94 const initial = props.initialSection;95 const [activeSection, setActiveSection] = createSignal<SectionName | null>(96 initial && isSectionName(initial) ? (initial as SectionName) : null97 );9899 const [checkingBreaches, setCheckingBreaches] = createSignal(false);100 const [breachResult, setBreachResult] = createSignal<string | null>(null);101 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);102 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");103 const [licenseError, setLicenseError] = createSignal<string | null>(null);104 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);105 const [activatingLicense, setActivatingLicense] = createSignal(false);106107 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);108 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);109 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");110 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);111 112 113 114 const [syncProStatus, setSyncProStatus] = createSignal(false);115 const isPro = () => props.isPro ?? syncProStatus();116 const [syncingNow, setSyncingNow] = createSignal(false);117 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);118 const [passkeySaving, setPasskeySaving] = createSignal(false);119120 const [dedupCount, setDedupCount] = createSignal<number | null>(null);121 const [dedupRunning, setDedupRunning] = createSignal(false);122 const [dedupDone, setDedupDone] = createSignal<number | null>(null);123124 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);125126 const [showChangePw, setShowChangePw] = createSignal(false);127 const [changePwCurrent, setChangePwCurrent] = createSignal("");128 const [changePwNew, setChangePwNew] = createSignal("");129 const [changePwConfirm, setChangePwConfirm] = createSignal("");130 const [changePwSaving, setChangePwSaving] = createSignal(false);131 const [changePwError, setChangePwError] = createSignal<string | null>(null);132 const [changePwSuccess, setChangePwSuccess] = createSignal(false);133134 const [duressEnabled, setDuressEnabled] = createSignal(false);135 const [showDuressSetup, setShowDuressSetup] = createSignal(false);136 const [duressPassword, setDuressPassword] = createSignal("");137 const [duressConfirm, setDuressConfirm] = createSignal("");138 const [duressMainPassword, setDuressMainPassword] = createSignal("");139 const [duressSaving, setDuressSaving] = createSignal(false);140 const [duressError, setDuressError] = createSignal<string | null>(null);141 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);142143 const [fbCategory, setFbCategory] = createSignal("Bug report");144 const [fbMessage, setFbMessage] = createSignal("");145 const [fbSubmitting, setFbSubmitting] = createSignal(false);146 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);147148 onMount(async () => {149 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([150 sendMessage({ type: "GET_LICENSE" }),151 sendMessage({ type: "GET_SYNC_STATE" }),152 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),153 sendMessage({ type: "IS_DURESS_ENABLED" }),154 sendMessage({ type: "GET_SETTINGS" }),155 ]);156157 if ((licenseRes as { ok: boolean }).ok) {158 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);159 }160161 if ((syncRes as { ok: boolean }).ok) {162 const data = syncRes as { ok: boolean; data?: {163 pairedDevices?: PairedDevice[];164 trustedNetworks?: TrustedNetwork[];165 status?: SyncStatus;166 lastSyncAt?: number;167 isPro?: boolean;168 }};169 if (data.data) {170 setPairedDevices(data.data.pairedDevices ?? []);171 setTrustedNetworks(data.data.trustedNetworks ?? []);172 setSyncStatus(data.data.status ?? "offline");173 setLastSyncAt(data.data.lastSyncAt);174 setSyncProStatus(data.data.isPro ?? false);175 }176 }177178 if ((passkeyRes as { ok: boolean }).ok) {179 setPasskeyState(180 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null181 );182 }183184 if ((duressRes as { ok: boolean }).ok) {185 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);186 }187188 if ((settingsRes as { ok: boolean }).ok) {189 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;190 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);191 }192193 chrome.runtime.onMessage.addListener((msg) => {194 if (msg.type === "SYNC_STATE_UPDATE") {195 setPairedDevices(msg.pairedDevices ?? []);196 setTrustedNetworks(msg.trustedNetworks ?? []);197 setSyncStatus(msg.status ?? "offline");198 setLastSyncAt(msg.lastSyncAt);199 setSyncProStatus(msg.isPro ?? false);200 }201 });202 });203204 async function handleChangePassword() {205 setChangePwError(null);206 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }207 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }208 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }209 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }210 setChangePwSaving(true);211 const res = await sendMessage({212 type: "CHANGE_PASSWORD",213 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },214 }) as { ok: boolean; error?: string };215 if (res.ok) {216 setChangePwSuccess(true);217 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");218 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);219 } else {220 setChangePwError(res.error ?? "failed to change password");221 }222 setChangePwSaving(false);223 }224225 async function handleActivateLicense() {226 const key = licenseKeyInput().trim();227 if (!key) return;228 setActivatingLicense(true);229 setLicenseError(null);230 setLicenseSuccess(null);231 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });232 setActivatingLicense(false);233 if ((res as { ok: boolean }).ok) {234 const data = (res as { ok: boolean; data?: LicenseInfo }).data;235 if (data?.valid) {236 setLicenseInfo(data);237 setLicenseSuccess("License activated!");238 setLicenseKeyInput("");239 } else {240 setLicenseError("Invalid license key");241 }242 } else {243 setLicenseError("Activation failed");244 }245 }246247 async function handleTogglePasskeys() {248 const current = passkeyState();249 if (!current || !current.supported) return;250 setPasskeySaving(true);251 const res = await sendMessage({252 type: "SET_PASSKEY_PROVIDER_ENABLED",253 payload: { enabled: !current.enabled },254 });255 setPasskeySaving(false);256 if ((res as { ok: boolean }).ok) {257 setPasskeyState(258 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null259 );260 }261 }262263 async function handleCheckAllBreaches() {264 setCheckingBreaches(true);265 setBreachResult(null);266 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });267 setCheckingBreaches(false);268 if ((res as { ok: boolean }).ok) {269 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};270 const breached = Object.values(data).filter((count) => count > 0).length;271 setBreachResult(272 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"273 );274 props.onBreachCheckDone();275 }276 }277278 async function handleSyncNow() {279 if (syncStatus() === "not_trusted") return;280 setSyncingNow(true);281 await sendMessage({ type: "TRIGGER_SYNC" });282 setTimeout(() => setSyncingNow(false), 3000);283 }284285 async function handleRemoveDevice(deviceId: string) {286 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });287 }288289 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {290 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });291 }292293 async function handleMarkNetworkTrusted() {294 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });295 }296297 function formatRelativeTime(timestamp?: number): string {298 if (!timestamp) return "Never";299 const diff = Date.now() - timestamp;300 const minutes = Math.floor(diff / 60000);301 const hours = Math.floor(diff / 3600000);302 const days = Math.floor(diff / 86400000);303 if (minutes < 1) return "Just now";304 if (minutes < 60) return `${minutes}m ago`;305 if (hours < 24) return `${hours}h ago`;306 return `${days}d ago`;307 }308309 function getStatusColor(status: SyncStatus): string {310 switch (status) {311 case "synced": return "var(--peach-sync-ok)";312 case "syncing": return "var(--peach-sync-busy)";313 case "no_peers": return "var(--peach-sync-off)";314 case "not_trusted": return "var(--peach-sync-err)";315 case "offline": return "var(--peach-sync-off)";316 default: return "var(--peach-sync-off)";317 }318 }319320 function getStatusLabel(status: SyncStatus): string {321 switch (status) {322 case "synced": return "Synced";323 case "syncing": return "Syncing";324 case "no_peers": return "No peers visible";325 case "not_trusted": return "Not on a trusted network";326 case "offline": return "Offline";327 default: return "Unknown";328 }329 }330331 function isCurrentNetworkTrusted(): boolean {332 return trustedNetworks().length > 0;333 }334335 async function handleSetupDuress() {336 const mainPw = duressMainPassword().trim();337 const duressPw = duressPassword().trim();338 const confirmPw = duressConfirm().trim();339340 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }341 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }342 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }343 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }344345 setDuressSaving(true);346 setDuressError(null);347348 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };349 if (!verifyRes.ok || !verifyRes.data) {350 setDuressError("incorrect main password");351 setDuressSaving(false);352 return;353 }354355 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };356 setDuressSaving(false);357 if (res.ok) {358 setDuressEnabled(true);359 setShowDuressSetup(false);360 setDuressPassword("");361 setDuressConfirm("");362 setDuressMainPassword("");363 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");364 setTimeout(() => setDuressSuccess(null), 5000);365 } else {366 setDuressError(res.error ?? "failed to set duress password");367 }368 }369370 async function handleClearDeclinedCards() {371 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });372 setDeclinedCardCount(0);373 }374375 async function handleCountDuplicates() {376 setDedupRunning(true);377 setDedupDone(null);378 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };379 setDedupRunning(false);380 if (res.ok) setDedupCount(res.data ?? 0);381 }382383 async function handleDeduplicate() {384 setDedupRunning(true);385 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };386 setDedupRunning(false);387 if (res.ok) {388 setDedupDone(res.data ?? 0);389 setDedupCount(0);390 }391 }392393 async function handleDisableDuress() {394 const mainPw = duressMainPassword().trim();395 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }396397 setDuressSaving(true);398 setDuressError(null);399400 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };401 if (!verifyRes.ok || !verifyRes.data) {402 setDuressError("incorrect main password");403 setDuressSaving(false);404 return;405 }406407 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };408 setDuressSaving(false);409 if (res.ok) {410 setDuressEnabled(false);411 setShowDuressSetup(false);412 setDuressMainPassword("");413 setDuressSuccess("Duress password disabled.");414 setTimeout(() => setDuressSuccess(null), 4000);415 } else {416 setDuressError(res.error ?? "failed to disable duress password");417 }418 }419420 async function handleSubmitFeedback() {421 if (!fbMessage().trim() || fbSubmitting()) return;422 setFbSubmitting(true);423 setFbResult(null);424 const res = await sendMessage({425 type: "SUBMIT_FEEDBACK",426 payload: {427 category: fbCategory(),428 message: fbMessage().trim(),429 version,430 browser: navigator.userAgent,431 },432 }) as { ok: boolean; error?: string };433 setFbSubmitting(false);434 if (res.ok) {435 setFbResult("success");436 setFbMessage("");437 setTimeout(() => setFbResult(null), 4000);438 } else {439 setFbResult("error");440 }441 }442443 function handleHeaderBack() {444 if (activeSection() !== null) {445 setActiveSection(null);446 } else {447 props.onBack();448 }449 }450451 function currentTitle(): string {452 const s = activeSection();453 if (s === null) return "settings";454 if (s === "license") return "account";455 return s;456 }457458 function renderCatRow(opts: {459 section: SectionName;460 name: string;461 desc: string;462 icon: () => any;463 pro?: boolean;464 }) {465 return (466 <button467 class="peach-settings-cat-row"468 onClick={() => setActiveSection(opts.section)}469 >470 <div class="peach-settings-cat-icon">{opts.icon()}</div>471 <div class="peach-settings-cat-content">472 <span class="peach-settings-cat-name">473 {opts.name}474 <Show when={opts.pro && !isPro()}>475 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>476 </Show>477 </span>478 <span class="peach-settings-cat-desc">{opts.desc}</span>479 </div>480 <svg481 class="peach-settings-cat-chevron"482 width="6" height="10" viewBox="0 0 6 10" fill="none"483 stroke="currentColor" stroke-width="1.1"484 stroke-linecap="round" stroke-linejoin="round"485 >486 <path d="M1 1l4 4-4 4" />487 </svg>488 </button>489 );490 }491492 const iconShield = () => (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="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>495 </svg>496 );497 const iconKey = () => (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 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>500 </svg>501 );502 const iconLock = () => (503 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">504 <rect x="3" y="7" width="10" height="7" rx="1.5"/>505 <path d="M6 7V5a3 3 0 016 0v2"/>506 </svg>507 );508 const iconSync = () => (509 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">510 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>511 <path d="M14 6l-2 2-2-2"/>512 <path d="M2 10l2-2 2 2"/>513 </svg>514 );515 const iconSun = () => (516 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">517 <circle cx="8" cy="8" r="3"/>518 <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"/>519 </svg>520 );521 const iconKeys = () => (522 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">523 <rect x="2" y="5" width="5" height="4" rx="1"/>524 <rect x="9" y="7" width="5" height="4" rx="1"/>525 <path d="M7 7h2m-2 2h2"/>526 </svg>527 );528 const iconBubble = () => (529 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">530 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>531 </svg>532 );533534 function renderHome() {535 return (536 <div class="peach-settings-body">537 <p class="peach-settings-nav-label">PROTECTION</p>538 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}539 {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}540541 <p class="peach-settings-nav-label">VAULT</p>542 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}543 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}544545 <p class="peach-settings-nav-label">PREFERENCES</p>546 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}547 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}548549 <p class="peach-settings-nav-label">SUPPORT</p>550 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}551552 <div class="peach-settings-footer">553 <span>Peach {version}</span>554 <span class="peach-divider"></span>555 <span>Browser extension</span>556 </div>557 </div>558 );559 }560561 function renderSecurity() {562 return (563 <div class="peach-settings-body">564 <div class="peach-settings-group">565 <p class="peach-settings-sub-label">LOCKING</p>566 <div class="peach-settings-row">567 <span>Lock on close</span>568 <button569 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}570 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}571 aria-label="Toggle lock on close"572 >573 <span class="peach-toggle-knob"></span>574 </button>575 </div>576 <div class="peach-settings-row">577 <span>Auto-lock after</span>578 <select579 class="peach-settings-select"580 value={String(props.autoLockMinutes)}581 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}582 >583 <option value="1">1 minute</option>584 <option value="5">5 minutes</option>585 <option value="15">15 minutes</option>586 <option value="30">30 minutes</option>587 <option value="0">Never</option>588 </select>589 </div>590591 <p class="peach-settings-sub-label">BREACH PROTECTION</p>592 <div class="peach-settings-row">593 <span>Breach monitoring</span>594 <button595 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}596 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}597 aria-label="Toggle breach monitoring"598 >599 <span class="peach-toggle-knob"></span>600 </button>601 </div>602 <div class="peach-settings-row peach-settings-row--meta">603 <span>Check all passwords</span>604 <button605 class="peach-btn peach-btn--subtle peach-btn--sm"606 onClick={() => void handleCheckAllBreaches()}607 disabled={checkingBreaches()}608 >609 {checkingBreaches() ? "checking..." : "run"}610 </button>611 </div>612 <Show when={breachResult()}>613 <div class="peach-settings-row peach-settings-row--meta">614 <span>Last check</span>615 <span class="peach-settings-value">{breachResult()}</span>616 </div>617 </Show>618619 <p class="peach-settings-sub-label">MASTER PASSWORD</p>620 <div class="peach-settings-row peach-settings-row--meta">621 <span>Master password</span>622 <button623 class="peach-btn peach-btn--subtle peach-btn--sm"624 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}625 >626 {showChangePw() ? "cancel" : "change"}627 </button>628 </div>629 <Show when={showChangePw()}>630 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">631 <input632 class="peach-settings-input"633 type="password"634 placeholder="current password"635 value={changePwCurrent()}636 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}637 autocomplete="current-password"638 />639 <input640 class="peach-settings-input"641 type="password"642 placeholder="new password (min 12 chars)"643 value={changePwNew()}644 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}645 autocomplete="new-password"646 />647 <input648 class="peach-settings-input"649 type="password"650 placeholder="confirm new password"651 value={changePwConfirm()}652 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}653 autocomplete="new-password"654 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}655 />656 <Show when={changePwError()}>657 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>658 </Show>659 <Show when={changePwSuccess()}>660 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>661 </Show>662 <button663 class="peach-btn peach-btn--accent peach-btn--sm"664 style="align-self:flex-start"665 onClick={() => void handleChangePassword()}666 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}667 >668 {changePwSaving() ? "saving…" : "save new password"}669 </button>670 </div>671 </Show>672673 <p class="peach-settings-sub-label">DURESS PASSWORD</p>674 <div class="peach-settings-row peach-settings-row--top">675 <div class="peach-settings-row-main">676 <span>Duress password</span>677 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">678 If entered under coercion, shows a decoy empty vault instead of your real one.679 </span>680 </div>681 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>682 {duressEnabled() ? "on" : "off"}683 </div>684 </div>685686 <Show when={duressEnabled() && !showDuressSetup()}>687 <div class="peach-settings-row peach-settings-row--meta">688 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">689 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.690 </span>691 </div>692 </Show>693694 <Show when={duressSuccess()}>695 <div class="peach-settings-row peach-settings-row--meta">696 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>697 </div>698 </Show>699700 <Show when={!showDuressSetup()}>701 <div class="peach-settings-row peach-settings-row--meta">702 <button703 class="peach-btn peach-btn--subtle peach-btn--sm"704 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}705 >706 {duressEnabled() ? "Change / disable" : "Set up"}707 </button>708 </div>709 </Show>710711 <Show when={showDuressSetup()}>712 <div class="peach-duress-form">713 <label class="peach-label">main password (to confirm)</label>714 <input715 class="peach-settings-input"716 type="password"717 placeholder="your current master password"718 autocomplete="current-password"719 value={duressMainPassword()}720 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}721 />722 <Show when={!duressEnabled()}>723 <label class="peach-label">new duress password</label>724 <input725 class="peach-settings-input"726 type="password"727 placeholder="duress password (min 8 chars)"728 autocomplete="new-password"729 value={duressPassword()}730 onInput={(e) => setDuressPassword(e.currentTarget.value)}731 />732 <label class="peach-label">confirm duress password</label>733 <input734 class="peach-settings-input"735 type="password"736 placeholder="confirm duress password"737 autocomplete="new-password"738 value={duressConfirm()}739 onInput={(e) => setDuressConfirm(e.currentTarget.value)}740 />741 </Show>742743 <Show when={duressError()}>744 <p class="peach-hint peach-hint-error">{duressError()}</p>745 </Show>746747 <div class="peach-duress-actions">748 <button749 class="peach-btn peach-btn--subtle peach-btn--sm"750 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}751 >752 cancel753 </button>754 <Show when={duressEnabled()}>755 <button756 class="peach-btn peach-btn--ghost peach-btn--sm"757 style="color:var(--peach-breach)"758 onClick={() => void handleDisableDuress()}759 disabled={duressSaving()}760 >761 {duressSaving() ? "disabling..." : "disable duress"}762 </button>763 </Show>764 <Show when={!duressEnabled()}>765 <button766 class="peach-btn peach-btn--accent peach-btn--sm"767 onClick={() => void handleSetupDuress()}768 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}769 >770 {duressSaving() ? "saving..." : "save"}771 </button>772 </Show>773 </div>774 </div>775 </Show>776777 <p class="peach-settings-sub-label">PASSKEYS</p>778 <div class="peach-settings-row peach-settings-row--top">779 <div class="peach-settings-row-main">780 <span>Intercept passkey requests</span>781 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">782 Peach can handle passkey sign-in and creation on supported Chromium browsers.783 </span>784 </div>785 <button786 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}787 onClick={() => void handleTogglePasskeys()}788 aria-label="Toggle passkey interception"789 disabled={!passkeyState()?.supported || passkeySaving()}790 >791 <span class="peach-toggle-knob"></span>792 </button>793 </div>794 <div class="peach-settings-row peach-settings-row--meta">795 <span>Status</span>796 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>797 </div>798 <div class="peach-settings-row peach-settings-row--meta">799 <span>Browser</span>800 <span class="peach-settings-value">801 {passkeyState()?.browserLabel ?? "Unknown"}802 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}803 </span>804 </div>805 <div class="peach-info-notice" style="margin-top:8px">806 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.807 </div>808 <Show when={passkeyState()?.conflict}>809 <div class="peach-settings-row peach-settings-row--meta">810 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>811 </div>812 </Show>813 </div>814 </div>815 );816 }817818 function renderVault() {819 return (820 <div class="peach-settings-body">821 <div class="peach-settings-group">822 <p class="peach-settings-sub-label">ENTRIES</p>823 <div class="peach-settings-row peach-settings-row--meta">824 <span>Entries</span>825 <span class="peach-settings-value">{props.entriesCount}</span>826 </div>827 <div class="peach-settings-row peach-settings-row--meta">828 <span>Duplicates</span>829 <Show when={dedupDone() !== null} fallback={830 <button831 class="peach-btn peach-btn--subtle peach-btn--sm"832 onClick={() => void handleCountDuplicates()}833 disabled={dedupRunning()}834 >835 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}836 </button>837 }>838 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">839 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}840 </span>841 </Show>842 </div>843 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>844 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">845 <div class="peach-restore-warning">846 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.847 </div>848 <div style="display:flex;gap:6px">849 <button850 class="peach-btn peach-btn--subtle peach-btn--sm"851 onClick={() => setDedupCount(null)}852 >853 cancel854 </button>855 <button856 class="peach-btn peach-btn--accent peach-btn--sm"857 onClick={() => void handleDeduplicate()}858 disabled={dedupRunning()}859 >860 {dedupRunning() ? "removing..." : "remove duplicates"}861 </button>862 </div>863 </div>864 </Show>865866 <p class="peach-settings-sub-label">MAINTENANCE</p>867 <div class="peach-settings-row peach-settings-row--meta">868 <span>Trash auto-delete</span>869 <select870 class="peach-settings-select"871 value={String(props.trashRetentionDays)}872 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}873 >874 <option value="7">7 days</option>875 <option value="30">30 days</option>876 <option value="90">90 days</option>877 <option value="0">Never</option>878 </select>879 </div>880 <div class="peach-settings-row peach-settings-row--meta">881 <span>Vault Health</span>882 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>883 open884 </button>885 </div>886 <Show when={declinedCardCount() > 0}>887 <div class="peach-settings-row peach-settings-row--meta">888 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>889 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>890 Clear891 </button>892 </div>893 </Show>894895 <p class="peach-settings-sub-label">DATA</p>896 <div class="peach-settings-row peach-settings-row--meta">897 <span>Import passwords</span>898 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>899 open900 </button>901 </div>902 <div class="peach-settings-row peach-settings-row--meta">903 <span>Export backup</span>904 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>905 open906 </button>907 </div>908 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">909 <span>Replace vault from backup</span>910 <div class="peach-restore-warning" style="margin-top:2px">911 This will permanently replace your current vault with the backup.912 </div>913 <div style="display:flex;gap:6px">914 <button915 class="peach-btn peach-btn--subtle peach-btn--sm"916 onClick={() => props.onOpenFileImport?.()}917 >918 backup file919 </button>920 </div>921 </div>922 </div>923 </div>924 );925 }926927 function renderSync() {928 if (!isPro()) {929 return (930 <div class="peach-settings-body">931 <div class="peach-pro-upgrade">932 <div class="peach-pro-upgrade-hero">933 <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">934 <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>935 <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>936 <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"/>937 <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"/>938 <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>939 <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>940 <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>941 </svg>942 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>943 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>944 </div>945946 <div class="peach-pro-features">947 <div class="peach-pro-feature">948 <span class="peach-pro-feature-mark">✦</span>949 <div class="peach-pro-feature-text">950 <span class="peach-pro-feature-name">Always in sync</span>951 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>952 </div>953 </div>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">No cloud. No relay.</span>958 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</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">Tailscale supported</span>965 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</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">Zero friction after setup</span>972 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>973 </div>974 </div>975 </div>976977 <button978 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"979 onClick={() => setActiveSection("license")}980 >981 Upgrade to Pro982 </button>983 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>984 Already have a key? Enter it →985 </button>986 </div>987 </div>988 );989 }990991 return (992 <div class="peach-settings-body">993 <div class="peach-settings-group">994 <div class="peach-settings-row">995 <span>Status</span>996 <span class="peach-settings-value peach-settings-value--status">997 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>998 {getStatusLabel(syncStatus())}999 </span>1000 </div>1001 <div class="peach-settings-row peach-settings-row--meta">1002 <span>Last sync</span>1003 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1004 </div>1005 <div class="peach-settings-row peach-settings-row--meta">1006 <button1007 class="peach-btn peach-btn--subtle peach-btn--sm"1008 onClick={() => void handleSyncNow()}1009 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1010 >1011 {syncingNow() ? "Syncing..." : "Sync now"}1012 </button>1013 </div>10141015 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1016 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1017 {pairedDevices().map((device) => (1018 <div class="peach-sync-device-row">1019 <div class="peach-sync-device-info">1020 <span class="peach-sync-device-name">{device.displayName}</span>1021 <span class="peach-sync-device-meta">1022 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1023 </span>1024 </div>1025 <button1026 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1027 onClick={() => void handleRemoveDevice(device.deviceId)}1028 >1029 Remove1030 </button>1031 </div>1032 ))}1033 <div class="peach-settings-actions--row">1034 <button1035 class="peach-btn peach-btn--subtle peach-btn--sm"1036 onClick={() => props.onOpenSyncPairing?.("initiate")}1037 >1038 Pair a new device1039 </button>1040 <button1041 class="peach-btn peach-btn--subtle peach-btn--sm"1042 onClick={() => props.onOpenSyncPairing?.("accept")}1043 >1044 Pair with another device1045 </button>1046 </div>1047 </div>10481049 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1050 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1051 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1052 <div class="peach-sync-device-row">1053 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1054 <button1055 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1056 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1057 >1058 Remove1059 </button>1060 </div>1061 ))}1062 <div class="peach-settings-actions--row">1063 <Show when={!isCurrentNetworkTrusted()}>1064 <button1065 class="peach-btn peach-btn--subtle peach-btn--sm"1066 onClick={() => void handleMarkNetworkTrusted()}1067 >1068 Mark current network as trusted1069 </button>1070 </Show>1071 </div>1072 <span class="peach-sync-device-meta">1073 Tailscale is always trusted1074 </span>1075 </div>1076 </div>1077 </div>1078 );1079 }10801081 function renderAppearance() {1082 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1083 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1084 { id: "cormorant", label: "Cormorant", preview: "Aa" },1085 { id: "inter", label: "Inter", preview: "Aa" },1086 { id: "system", label: "System", preview: "Aa" },1087 ];1088 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1089 { id: "small", label: "Small" },1090 { id: "medium", label: "Default" },1091 { id: "large", label: "Large" },1092 ];1093 return (1094 <div class="peach-settings-body">1095 <div class="peach-settings-group">1096 <p class="peach-settings-sub-label">THEME</p>1097 <div class="peach-settings-row peach-settings-row--meta">1098 <span>Theme</span>1099 <button1100 class="peach-btn peach-btn--subtle peach-btn--sm"1101 onClick={() =>1102 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1103 }1104 >1105 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1106 </button>1107 </div>11081109 <p class="peach-settings-sub-label">ACCENT</p>1110 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1111 <span>Accent</span>1112 <div class="peach-accent-picker">1113 <For each={ACCENT_COLORS}>1114 {(color) => (1115 <button1116 class="peach-accent-swatch"1117 classList={{ "is-active": props.accentColor === color.name }}1118 style={{ background: color.hex }}1119 onClick={() => void props.onAccentChange(color.name)}1120 aria-label={color.name}1121 title={color.name}1122 />1123 )}1124 </For>1125 </div>1126 </div>11271128 <p class="peach-settings-sub-label">DISPLAY FONT</p>1129 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1130 <span>Font</span>1131 <div class="peach-font-picker">1132 <For each={fontOptions}>1133 {(opt) => (1134 <button1135 class="peach-font-swatch"1136 classList={{ "is-active": props.displayFont === opt.id }}1137 data-font-preview={opt.id}1138 onClick={() => void props.onDisplayFontChange(opt.id)}1139 aria-label={opt.label}1140 title={opt.label}1141 >1142 <span class="peach-font-swatch-glyph">{opt.preview}</span>1143 <span class="peach-font-swatch-name">{opt.label}</span>1144 </button>1145 )}1146 </For>1147 </div>1148 </div>11491150 <p class="peach-settings-sub-label">TEXT SIZE</p>1151 <div class="peach-settings-row peach-settings-row--meta">1152 <span>Size</span>1153 <div class="peach-pill-group">1154 <For each={sizeOptions}>1155 {(opt) => (1156 <button1157 class="peach-btn peach-btn--ghost peach-btn--sm"1158 classList={{ "is-active": props.textSize === opt.id }}1159 onClick={() => void props.onTextSizeChange(opt.id)}1160 >1161 {opt.label}1162 </button>1163 )}1164 </For>1165 </div>1166 </div>11671168 <p class="peach-settings-sub-label">MOTION</p>1169 <div class="peach-settings-row peach-settings-row--meta">1170 <span>Reduce motion</span>1171 <button1172 class="peach-btn peach-btn--subtle peach-btn--sm"1173 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1174 >1175 {props.reduceMotion ? "On" : "Off"}1176 </button>1177 </div>1178 </div>1179 </div>1180 );1181 }11821183 function renderShortcuts() {1184 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1185 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);11861187 function startCapture(action: ShortcutAction) {1188 setConflictMsg(null);1189 setCapturingAction(action);1190 }1191 function cancelCapture() {1192 setCapturingAction(null);1193 setConflictMsg(null);1194 }1195 function commitBinding(action: ShortcutAction, binding: Keybinding) {1196 const conflict = findConflict(props.shortcuts, binding, action);1197 if (conflict) {1198 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1199 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1200 return;1201 }1202 const next = { ...props.shortcuts, [action]: binding };1203 setCapturingAction(null);1204 setConflictMsg(null);1205 void props.onShortcutsChange(next);1206 }1207 function resetAction(action: ShortcutAction) {1208 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1209 void props.onShortcutsChange(next);1210 }1211 function resetAll() {1212 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1213 }12141215 1216 1217 function handleCaptureKey(e: KeyboardEvent) {1218 e.preventDefault();1219 e.stopPropagation();1220 if (e.key === "Escape") { cancelCapture(); return; }1221 const binding = keybindingFromEvent(e);1222 if (!binding) return; 1223 const action = capturingAction();1224 if (action) commitBinding(action, binding);1225 }12261227 return (1228 <div class="peach-settings-body">1229 <div class="peach-settings-group peach-shortcuts-group">1230 <p class="peach-settings-sub-label">ENTRY VIEW</p>1231 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1232 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1233 </For>1234 </div>12351236 <div class="peach-settings-group peach-shortcuts-group">1237 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1238 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1239 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1240 </For>1241 </div>12421243 <Show when={conflictMsg()}>1244 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1245 </Show>12461247 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1248 <div class="peach-settings-row peach-settings-row--meta">1249 <span>Inline autofill on page</span>1250 <a1251 class="peach-settings-link"1252 href="chrome://extensions/shortcuts"1253 onClick={(e) => {1254 e.preventDefault();1255 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1256 }}1257 >1258 ⌘⇧P — customize ↗1259 </a>1260 </div>1261 <button1262 class="peach-btn peach-btn--subtle peach-btn--sm"1263 style="align-self:flex-start"1264 onClick={resetAll}1265 >1266 Reset all to defaults1267 </button>1268 </div>1269 </div>1270 );12711272 function renderRow(1273 desc: typeof SHORTCUT_DESCRIPTORS[number],1274 capturing: () => ShortcutAction | null,1275 onStart: (a: ShortcutAction) => void,1276 onCancel: () => void,1277 onReset: (a: ShortcutAction) => void,1278 onKey: (e: KeyboardEvent) => void,1279 ) {1280 const isActive = () => capturing() === desc.action;1281 const binding = () => props.shortcuts[desc.action];1282 const isCustom = () => {1283 const b = binding();1284 const d = DEFAULT_BINDINGS[desc.action];1285 return b.key !== d.key1286 || Boolean(b.ctrl) !== Boolean(d.ctrl)1287 || Boolean(b.meta) !== Boolean(d.meta)1288 || Boolean(b.shift) !== Boolean(d.shift)1289 || Boolean(b.alt) !== Boolean(d.alt);1290 };1291 return (1292 <div class="peach-shortcut-row">1293 <span class="peach-shortcut-row-label">{desc.label}</span>1294 <button1295 class="peach-shortcut-chip"1296 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1297 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1298 onKeyDown={(e) => { if (isActive()) onKey(e); }}1299 ref={(el) => {1300 1301 if (el) {1302 queueMicrotask(() => { if (isActive()) el.focus(); });1303 }1304 }}1305 >1306 {isActive() ? "Press a key…" : formatKeybinding(binding())}1307 </button>1308 <Show when={isCustom() && !isActive()}>1309 <button1310 class="peach-shortcut-reset"1311 title="Reset to default"1312 onClick={() => onReset(desc.action)}1313 >1314 ↺1315 </button>1316 </Show>1317 </div>1318 );1319 }1320 }13211322 const PRO_FEATURES: Array<{1323 feature: ProFeature;1324 name: string;1325 desc: string;1326 }> = [1327 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1328 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1329 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1330 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1331 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1332 ];13331334 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1335 const [deactivating, setDeactivating] = createSignal(false);13361337 async function handleDeactivateLicense() {1338 setDeactivating(true);1339 await sendMessage({ type: "DEACTIVATE_LICENSE" });1340 setLicenseInfo(null);1341 await props.onLicenseChange?.();1342 setDeactivating(false);1343 }13441345 function renderLicense() {1346 return (1347 <div class="peach-settings-body">1348 <Show1349 when={isPro()}1350 fallback={1351 <div class="peach-pro-upgrade">1352 <div class="peach-pro-upgrade-hero">1353 <PeachMark class="peach-pro-upgrade-mark" />1354 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1355 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1356 </div>13571358 <div class="peach-pro-features">1359 <For each={PRO_FEATURES}>1360 {(item) => (1361 <div1362 class="peach-pro-feature"1363 classList={{1364 "is-highlighted": props.upgradeHighlight === item.feature,1365 }}1366 >1367 <span class="peach-pro-feature-mark">✦</span>1368 <div class="peach-pro-feature-text">1369 <span class="peach-pro-feature-name">{item.name}</span>1370 <span class="peach-pro-feature-desc">{item.desc}</span>1371 </div>1372 </div>1373 )}1374 </For>1375 </div>13761377 <p class="peach-pro-pricing">$39 · once · yours forever</p>13781379 <a1380 href="https://peachpasswords.com/#pricing"1381 target="_blank"1382 rel="noopener noreferrer"1383 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1384 >1385 Get Peach Pro1386 </a>13871388 <Show1389 when={showKeyEntry()}1390 fallback={1391 <button1392 class="peach-pro-key-link"1393 onClick={() => setShowKeyEntry(true)}1394 >1395 Already have a key? Enter it →1396 </button>1397 }1398 >1399 <div class="peach-pro-key-entry">1400 <input1401 class="peach-underline-input peach-pro-key-input"1402 type="text"1403 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1404 value={licenseKeyInput()}1405 onInput={(e) =>1406 setLicenseKeyInput(e.currentTarget.value)1407 }1408 onKeyDown={(e) => {1409 if (e.key === "Enter") void onActivate();1410 }}1411 autocomplete="off"1412 spellcheck={false}1413 />1414 <Show when={licenseError()}>1415 <p class="peach-pro-key-error">That key doesn't look right.</p>1416 </Show>1417 <button1418 class="peach-btn peach-btn--accent peach-btn--sm"1419 onClick={() => void onActivate()}1420 disabled={activatingLicense() || !licenseKeyInput().trim()}1421 >1422 {activatingLicense() ? "activating…" : "Activate"}1423 </button>1424 </div>1425 </Show>1426 </div>1427 }1428 >1429 <div class="peach-pro-active">1430 <div class="peach-pro-active-head">1431 <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1432 <h3 class="peach-pro-active-title">Peach Pro</h3>1433 </div>1434 <p class="peach-pro-active-since">1435 {formatActivatedSince(licenseInfo()?.issued)}1436 </p>1437 <div class="peach-pro-active-divider"></div>1438 <p class="peach-pro-active-thanks">Thank you.</p>1439 <button1440 class="peach-pro-deactivate"1441 onClick={() => void handleDeactivateLicense()}1442 disabled={deactivating()}1443 >1444 {deactivating() ? "Deactivating…" : "Deactivate →"}1445 </button>1446 </div>1447 </Show>1448 </div>1449 );14501451 async function onActivate() {1452 await handleActivateLicense();1453 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1454 await props.onLicenseChange?.();1455 }1456 }1457 }14581459 function renderFeedback() {1460 return (1461 <div class="peach-settings-body">1462 <div class="peach-settings-group peach-feedback-group">1463 <p class="peach-feedback-intro">1464 Help us improve Peach. Every report goes directly to our inbox.1465 </p>14661467 <p class="peach-settings-sub-label">TYPE</p>1468 <div class="peach-feedback-chips">1469 <For each={FEEDBACK_CATEGORIES}>1470 {(cat) => (1471 <button1472 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1473 onClick={() => setFbCategory(cat)}1474 >1475 {cat}1476 </button>1477 )}1478 </For>1479 </div>14801481 <p class="peach-settings-sub-label">MESSAGE</p>1482 <textarea1483 class="peach-feedback-textarea"1484 placeholder="Describe the issue or idea..."1485 value={fbMessage()}1486 onInput={(e) => setFbMessage(e.currentTarget.value)}1487 />14881489 <p class="peach-feedback-meta">1490 Version {version} · will be included automatically1491 </p>14921493 <Show when={fbResult() === "success"}>1494 <p class="peach-feedback-result peach-feedback-result--ok">1495 Sent — thank you1496 </p>1497 </Show>1498 <Show when={fbResult() === "error"}>1499 <p class="peach-feedback-result peach-feedback-result--err">1500 Failed to send — try again1501 </p>1502 </Show>15031504 <button1505 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1506 onClick={() => void handleSubmitFeedback()}1507 disabled={fbSubmitting() || !fbMessage().trim()}1508 >1509 {fbSubmitting() ? "Sending..." : "Send feedback"}1510 </button>15111512 <p class="peach-feedback-anon">1513 Sent anonymously · no account required1514 </p>1515 </div>1516 </div>1517 );1518 }15191520 return (1521 <div class="peach-overlay-view ov-enter-right" style="display:flex">1522 <div class="peach-overlay-header">1523 <button1524 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1525 onClick={handleHeaderBack}1526 aria-label="Back"1527 >1528 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1529 <path d="M6 1L1 6L6 11" />1530 </svg>1531 </button>1532 <span class="peach-overlay-title">{currentTitle()}</span>1533 <div class="peach-overlay-spacer"></div>1534 </div>15351536 <Switch fallback={renderHome()}>1537 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1538 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1539 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1540 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1541 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1542 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1543 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1544 </Switch>1545 </div>1546 );1547}