1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import type { Canary } from "../../shared/types";6import { ACCENT_COLORS } from "../../shared/types";7import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";8import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";9import {10 DEFAULT_BINDINGS,11 findConflict,12 formatKeybinding,13 keybindingFromEvent,14 SHORTCUT_DESCRIPTORS,15 type Keybinding,16 type ShortcutAction,17} from "../../shared/shortcuts";1819interface LicenseInfo {20 valid: boolean;21 plan: "free" | "pro";22 issued?: string;23}2425interface SettingsProps {26 entriesCount: number;27 breachMonitoring: boolean;28 lockOnClose: boolean;29 autoLockMinutes: number;30 trashRetentionDays: number;31 theme: "dark" | "light";32 accentColor: AccentColorName;33 displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";34 textSize: "small" | "medium" | "large";35 reduceMotion: boolean;36 isPro?: boolean;37 initialSection?: string | null;38 upgradeHighlight?: ProFeature | null;39 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;40 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;41 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;42 onTrashRetentionChange: (days: number) => void | Promise<void>;43 onThemeChange: (theme: "dark" | "light") => void;44 onAccentChange: (name: AccentColorName) => void | Promise<void>;45 onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;46 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;47 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;48 shortcuts: Record<ShortcutAction, Keybinding>;49 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;50 onLicenseChange?: () => void | Promise<void>;51 onBack: () => void;52 onImport: () => void;53 onExport: () => void;54 onBreachCheckDone: () => void;55 onOpenHealth?: () => void;56 onOpenSyncPairing?: (mode: "initiate" | "accept") => void;57 onOpenFileImport?: () => void;58 onOpenPanic?: () => void;59}6061type SectionName =62 | "security"63 | "vault"64 | "sync"65 | "appearance"66 | "shortcuts"67 | "license"68 | "feedback";6970const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7172function formatActivatedSince(issued: string | undefined): string {73 if (!issued) return "Active";74 try {75 const d = new Date(issued);76 if (Number.isNaN(d.getTime())) return "Active";77 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;78 } catch {79 return "Active";80 }81}8283function isSectionName(value: string): value is SectionName {84 return (85 value === "security" ||86 value === "vault" ||87 value === "sync" ||88 value === "appearance" ||89 value === "shortcuts" ||90 value === "license" ||91 value === "feedback"92 );93}9495export function SettingsView(props: SettingsProps) {96 const version = chrome.runtime.getManifest().version;97 const initial = props.initialSection;98 const [activeSection, setActiveSection] = createSignal<SectionName | null>(99 initial && isSectionName(initial) ? (initial as SectionName) : null100 );101102 const [checkingBreaches, setCheckingBreaches] = createSignal(false);103 const [breachResult, setBreachResult] = createSignal<string | null>(null);104 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);105 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");106 const [licenseError, setLicenseError] = createSignal<string | null>(null);107 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);108 const [activatingLicense, setActivatingLicense] = createSignal(false);109110 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);111 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);112 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");113 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);114 115 116 117 const [syncProStatus, setSyncProStatus] = createSignal(false);118 const isPro = () => props.isPro ?? syncProStatus();119 const [syncingNow, setSyncingNow] = createSignal(false);120 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);121 const [passkeySaving, setPasskeySaving] = createSignal(false);122123 const [dedupCount, setDedupCount] = createSignal<number | null>(null);124 const [dedupRunning, setDedupRunning] = createSignal(false);125 const [dedupDone, setDedupDone] = createSignal<number | null>(null);126127 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);128129 const [showChangePw, setShowChangePw] = createSignal(false);130 const [changePwCurrent, setChangePwCurrent] = createSignal("");131 const [changePwNew, setChangePwNew] = createSignal("");132 const [changePwConfirm, setChangePwConfirm] = createSignal("");133 const [changePwSaving, setChangePwSaving] = createSignal(false);134 const [changePwError, setChangePwError] = createSignal<string | null>(null);135 const [changePwSuccess, setChangePwSuccess] = createSignal(false);136137 const [duressEnabled, setDuressEnabled] = createSignal(false);138 const [showDuressSetup, setShowDuressSetup] = createSignal(false);139 const [duressPassword, setDuressPassword] = createSignal("");140 const [duressConfirm, setDuressConfirm] = createSignal("");141 const [duressMainPassword, setDuressMainPassword] = createSignal("");142 const [duressSaving, setDuressSaving] = createSignal(false);143 const [duressError, setDuressError] = createSignal<string | null>(null);144 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);145146 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);147 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);148 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);149150 const [fbCategory, setFbCategory] = createSignal("Bug report");151 const [fbMessage, setFbMessage] = createSignal("");152 const [fbSubmitting, setFbSubmitting] = createSignal(false);153 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);154155 onMount(async () => {156 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([157 sendMessage({ type: "GET_LICENSE" }),158 sendMessage({ type: "GET_SYNC_STATE" }),159 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),160 sendMessage({ type: "IS_DURESS_ENABLED" }),161 sendMessage({ type: "GET_SETTINGS" }),162 sendMessage({ type: "GET_LICENSE_KEY" }),163 ]);164165 if ((licenseRes as { ok: boolean }).ok) {166 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);167 }168169 if ((syncRes as { ok: boolean }).ok) {170 const data = syncRes as { ok: boolean; data?: {171 pairedDevices?: PairedDevice[];172 trustedNetworks?: TrustedNetwork[];173 status?: SyncStatus;174 lastSyncAt?: number;175 isPro?: boolean;176 }};177 if (data.data) {178 setPairedDevices(data.data.pairedDevices ?? []);179 setTrustedNetworks(data.data.trustedNetworks ?? []);180 setSyncStatus(data.data.status ?? "offline");181 setLastSyncAt(data.data.lastSyncAt);182 setSyncProStatus(data.data.isPro ?? false);183 }184 }185186 if ((passkeyRes as { ok: boolean }).ok) {187 setPasskeyState(188 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null189 );190 }191192 if ((duressRes as { ok: boolean }).ok) {193 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);194 }195196 if ((settingsRes as { ok: boolean }).ok) {197 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;198 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);199 }200201 if ((keyRes as { ok: boolean }).ok) {202 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);203 }204205 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });206 if ((entriesRes as { ok: boolean }).ok) {207 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];208 setCanaryEntries(209 entries210 .filter((e) => e.canary?.webhookUrl)211 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))212 );213 }214215 chrome.runtime.onMessage.addListener((msg) => {216 if (msg.type === "SYNC_STATE_UPDATE") {217 setPairedDevices(msg.pairedDevices ?? []);218 setTrustedNetworks(msg.trustedNetworks ?? []);219 setSyncStatus(msg.status ?? "offline");220 setLastSyncAt(msg.lastSyncAt);221 setSyncProStatus(msg.isPro ?? false);222 }223 });224 });225226 async function handleChangePassword() {227 setChangePwError(null);228 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }229 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }230 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }231 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }232 setChangePwSaving(true);233 const res = await sendMessage({234 type: "CHANGE_PASSWORD",235 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },236 }) as { ok: boolean; error?: string };237 if (res.ok) {238 setChangePwSuccess(true);239 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");240 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);241 } else {242 setChangePwError(res.error ?? "failed to change password");243 }244 setChangePwSaving(false);245 }246247 const [copied, setCopied] = createSignal(false);248249 async function copyLicenseKey(key: string) {250 await navigator.clipboard.writeText(key);251 setCopied(true);252 setTimeout(() => setCopied(false), 2000);253 }254255 async function handleActivateLicense() {256 const key = licenseKeyInput().trim();257 if (!key) return;258 setActivatingLicense(true);259 setLicenseError(null);260 setLicenseSuccess(null);261 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });262 setActivatingLicense(false);263 if ((res as { ok: boolean }).ok) {264 const data = (res as { ok: boolean; data?: LicenseInfo }).data;265 if (data?.valid) {266 setLicenseInfo(data);267 setLicenseKey(key);268 setLicenseSuccess("License activated!");269 setLicenseKeyInput("");270 } else {271 setLicenseError("Invalid license key");272 }273 } else {274 setLicenseError("Activation failed");275 }276 }277278 async function handleTogglePasskeys() {279 const current = passkeyState();280 if (!current || !current.supported) return;281 setPasskeySaving(true);282 const res = await sendMessage({283 type: "SET_PASSKEY_PROVIDER_ENABLED",284 payload: { enabled: !current.enabled },285 });286 setPasskeySaving(false);287 if ((res as { ok: boolean }).ok) {288 setPasskeyState(289 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null290 );291 }292 }293294 async function handleCheckAllBreaches() {295 setCheckingBreaches(true);296 setBreachResult(null);297 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });298 setCheckingBreaches(false);299 if ((res as { ok: boolean }).ok) {300 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};301 const breached = Object.values(data).filter((count) => count > 0).length;302 setBreachResult(303 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"304 );305 props.onBreachCheckDone();306 }307 }308309 async function handleSyncNow() {310 if (syncStatus() === "not_trusted") return;311 setSyncingNow(true);312 await sendMessage({ type: "TRIGGER_SYNC" });313 setTimeout(() => setSyncingNow(false), 3000);314 }315316 async function handleRemoveDevice(deviceId: string) {317 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });318 }319320 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {321 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });322 }323324 async function handleMarkNetworkTrusted() {325 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });326 }327328 function formatRelativeTime(timestamp?: number): string {329 if (!timestamp) return "Never";330 const diff = Date.now() - timestamp;331 const minutes = Math.floor(diff / 60000);332 const hours = Math.floor(diff / 3600000);333 const days = Math.floor(diff / 86400000);334 if (minutes < 1) return "Just now";335 if (minutes < 60) return `${minutes}m ago`;336 if (hours < 24) return `${hours}h ago`;337 return `${days}d ago`;338 }339340 function getStatusColor(status: SyncStatus): string {341 switch (status) {342 case "synced": return "var(--peach-sync-ok)";343 case "syncing": return "var(--peach-sync-busy)";344 case "no_peers": return "var(--peach-sync-off)";345 case "not_trusted": return "var(--peach-sync-err)";346 case "offline": return "var(--peach-sync-off)";347 default: return "var(--peach-sync-off)";348 }349 }350351 function getStatusLabel(status: SyncStatus): string {352 switch (status) {353 case "synced": return "Synced";354 case "syncing": return "Syncing";355 case "no_peers": return "No peers visible";356 case "not_trusted": return "Not on a trusted network";357 case "offline": return "Offline";358 default: return "Unknown";359 }360 }361362 function isCurrentNetworkTrusted(): boolean {363 return trustedNetworks().length > 0;364 }365366 async function handleSetupDuress() {367 const mainPw = duressMainPassword().trim();368 const duressPw = duressPassword().trim();369 const confirmPw = duressConfirm().trim();370371 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }372 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }373 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }374 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }375376 setDuressSaving(true);377 setDuressError(null);378379 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };380 if (!verifyRes.ok || !verifyRes.data) {381 setDuressError("incorrect main password");382 setDuressSaving(false);383 return;384 }385386 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };387 setDuressSaving(false);388 if (res.ok) {389 setDuressEnabled(true);390 setShowDuressSetup(false);391 setDuressPassword("");392 setDuressConfirm("");393 setDuressMainPassword("");394 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");395 setTimeout(() => setDuressSuccess(null), 5000);396 } else {397 setDuressError(res.error ?? "failed to set duress password");398 }399 }400401 async function handleClearDeclinedCards() {402 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });403 setDeclinedCardCount(0);404 }405406 async function handleCountDuplicates() {407 setDedupRunning(true);408 setDedupDone(null);409 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };410 setDedupRunning(false);411 if (res.ok) setDedupCount(res.data ?? 0);412 }413414 async function handleDeduplicate() {415 setDedupRunning(true);416 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };417 setDedupRunning(false);418 if (res.ok) {419 setDedupDone(res.data ?? 0);420 setDedupCount(0);421 }422 }423424 async function handleDisableDuress() {425 const mainPw = duressMainPassword().trim();426 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }427428 setDuressSaving(true);429 setDuressError(null);430431 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };432 if (!verifyRes.ok || !verifyRes.data) {433 setDuressError("incorrect main password");434 setDuressSaving(false);435 return;436 }437438 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };439 setDuressSaving(false);440 if (res.ok) {441 setDuressEnabled(false);442 setShowDuressSetup(false);443 setDuressMainPassword("");444 setDuressSuccess("Duress password disabled.");445 setTimeout(() => setDuressSuccess(null), 4000);446 } else {447 setDuressError(res.error ?? "failed to disable duress password");448 }449 }450451 async function handleSubmitFeedback() {452 if (!fbMessage().trim() || fbSubmitting()) return;453 setFbSubmitting(true);454 setFbResult(null);455 const res = await sendMessage({456 type: "SUBMIT_FEEDBACK",457 payload: {458 category: fbCategory(),459 message: fbMessage().trim(),460 version,461 browser: navigator.userAgent,462 },463 }) as { ok: boolean; error?: string };464 setFbSubmitting(false);465 if (res.ok) {466 setFbResult("success");467 setFbMessage("");468 setTimeout(() => setFbResult(null), 4000);469 } else {470 setFbResult("error");471 }472 }473474 function handleHeaderBack() {475 if (activeSection() !== null) {476 setActiveSection(null);477 } else {478 props.onBack();479 }480 }481482 function currentTitle(): string {483 const s = activeSection();484 if (s === null) return "settings";485 if (s === "license") return "account";486 return s;487 }488489 function renderCatRow(opts: {490 section: SectionName;491 name: string;492 desc: string;493 icon: () => any;494 pro?: boolean;495 }) {496 return (497 <button498 class="peach-settings-cat-row"499 onClick={() => setActiveSection(opts.section)}500 >501 <div class="peach-settings-cat-icon">{opts.icon()}</div>502 <div class="peach-settings-cat-content">503 <span class="peach-settings-cat-name">504 {opts.name}505 <Show when={opts.pro && !isPro()}>506 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>507 </Show>508 </span>509 <span class="peach-settings-cat-desc">{opts.desc}</span>510 </div>511 <svg512 class="peach-settings-cat-chevron"513 width="6" height="10" viewBox="0 0 6 10" fill="none"514 stroke="currentColor" stroke-width="1.1"515 stroke-linecap="round" stroke-linejoin="round"516 >517 <path d="M1 1l4 4-4 4" />518 </svg>519 </button>520 );521 }522523 const iconShield = () => (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="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>526 </svg>527 );528 const iconKey = () => (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="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>531 </svg>532 );533 const iconLock = () => (534 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">535 <rect x="3" y="7" width="10" height="7" rx="1.5"/>536 <path d="M6 7V5a3 3 0 016 0v2"/>537 </svg>538 );539 const iconSync = () => (540 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">541 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>542 <path d="M14 6l-2 2-2-2"/>543 <path d="M2 10l2-2 2 2"/>544 </svg>545 );546 const iconSun = () => (547 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">548 <circle cx="8" cy="8" r="3"/>549 <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"/>550 </svg>551 );552 const iconKeys = () => (553 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">554 <rect x="2" y="5" width="5" height="4" rx="1"/>555 <rect x="9" y="7" width="5" height="4" rx="1"/>556 <path d="M7 7h2m-2 2h2"/>557 </svg>558 );559 const iconBubble = () => (560 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">561 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>562 </svg>563 );564565 function renderHome() {566 return (567 <div class="peach-settings-body">568 <p class="peach-settings-nav-label">PROTECTION</p>569 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}570 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}571572 <p class="peach-settings-nav-label">VAULT</p>573 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}574 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}575576 <p class="peach-settings-nav-label">PREFERENCES</p>577 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}578 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}579580 <p class="peach-settings-nav-label">SUPPORT</p>581 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}582583 <div class="peach-settings-footer">584 <span>Peach {version}</span>585 <span class="peach-divider"></span>586 <span>Browser extension</span>587 </div>588 </div>589 );590 }591592 function renderSecurity() {593 return (594 <div class="peach-settings-body">595 <div class="peach-settings-group">596 <p class="peach-settings-sub-label">LOCKING</p>597 <div class="peach-settings-row">598 <span>Lock on close</span>599 <button600 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}601 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}602 aria-label="Toggle lock on close"603 >604 <span class="peach-toggle-knob"></span>605 </button>606 </div>607 <div class="peach-settings-row">608 <span>Auto-lock after</span>609 <select610 class="peach-settings-select"611 value={String(props.autoLockMinutes)}612 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}613 >614 <option value="1">1 minute</option>615 <option value="5">5 minutes</option>616 <option value="15">15 minutes</option>617 <option value="30">30 minutes</option>618 <option value="0">Never</option>619 </select>620 </div>621622 <p class="peach-settings-sub-label">BREACH PROTECTION</p>623 <div class="peach-settings-row">624 <span>Breach monitoring</span>625 <button626 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}627 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}628 aria-label="Toggle breach monitoring"629 >630 <span class="peach-toggle-knob"></span>631 </button>632 </div>633 <div class="peach-settings-row peach-settings-row--meta">634 <span>Check all passwords</span>635 <button636 class="peach-btn peach-btn--subtle peach-btn--sm"637 onClick={() => void handleCheckAllBreaches()}638 disabled={checkingBreaches()}639 >640 {checkingBreaches() ? "checking..." : "run"}641 </button>642 </div>643 <Show when={breachResult()}>644 <div class="peach-settings-row peach-settings-row--meta">645 <span>Last check</span>646 <span class="peach-settings-value">{breachResult()}</span>647 </div>648 </Show>649650 <p class="peach-settings-sub-label">MASTER PASSWORD</p>651 <div class="peach-settings-row peach-settings-row--meta">652 <span>Master password</span>653 <button654 class="peach-btn peach-btn--subtle peach-btn--sm"655 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}656 >657 {showChangePw() ? "cancel" : "change"}658 </button>659 </div>660 <Show when={showChangePw()}>661 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">662 <input663 class="peach-settings-input"664 type="password"665 placeholder="current password"666 value={changePwCurrent()}667 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}668 autocomplete="current-password"669 />670 <input671 class="peach-settings-input"672 type="password"673 placeholder="new password (min 12 chars)"674 value={changePwNew()}675 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}676 autocomplete="new-password"677 />678 <input679 class="peach-settings-input"680 type="password"681 placeholder="confirm new password"682 value={changePwConfirm()}683 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}684 autocomplete="new-password"685 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}686 />687 <Show when={changePwError()}>688 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>689 </Show>690 <Show when={changePwSuccess()}>691 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>692 </Show>693 <button694 class="peach-btn peach-btn--accent peach-btn--sm"695 style="align-self:flex-start"696 onClick={() => void handleChangePassword()}697 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}698 >699 {changePwSaving() ? "saving…" : "save new password"}700 </button>701 </div>702 </Show>703704 <p class="peach-settings-sub-label">DURESS PASSWORD</p>705 <div class="peach-settings-row peach-settings-row--top">706 <div class="peach-settings-row-main">707 <span>Duress password</span>708 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">709 If entered under coercion, shows a decoy empty vault instead of your real one.710 </span>711 </div>712 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>713 {duressEnabled() ? "on" : "off"}714 </div>715 </div>716717 <Show when={duressEnabled() && !showDuressSetup()}>718 <div class="peach-settings-row peach-settings-row--meta">719 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">720 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.721 </span>722 </div>723 </Show>724725 <Show when={duressSuccess()}>726 <div class="peach-settings-row peach-settings-row--meta">727 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>728 </div>729 </Show>730731 <Show when={!showDuressSetup()}>732 <div class="peach-settings-row peach-settings-row--meta">733 <button734 class="peach-btn peach-btn--subtle peach-btn--sm"735 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}736 >737 {duressEnabled() ? "Change / disable" : "Set up"}738 </button>739 </div>740 </Show>741742 <Show when={showDuressSetup()}>743 <div class="peach-duress-form">744 <label class="peach-label">main password (to confirm)</label>745 <input746 class="peach-settings-input"747 type="password"748 placeholder="your current master password"749 autocomplete="current-password"750 value={duressMainPassword()}751 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}752 />753 <Show when={!duressEnabled()}>754 <label class="peach-label">new duress password</label>755 <input756 class="peach-settings-input"757 type="password"758 placeholder="duress password (min 8 chars)"759 autocomplete="new-password"760 value={duressPassword()}761 onInput={(e) => setDuressPassword(e.currentTarget.value)}762 />763 <label class="peach-label">confirm duress password</label>764 <input765 class="peach-settings-input"766 type="password"767 placeholder="confirm duress password"768 autocomplete="new-password"769 value={duressConfirm()}770 onInput={(e) => setDuressConfirm(e.currentTarget.value)}771 />772 </Show>773774 <Show when={duressError()}>775 <p class="peach-hint peach-hint-error">{duressError()}</p>776 </Show>777778 <div class="peach-duress-actions">779 <button780 class="peach-btn peach-btn--subtle peach-btn--sm"781 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}782 >783 cancel784 </button>785 <Show when={duressEnabled()}>786 <button787 class="peach-btn peach-btn--ghost peach-btn--sm"788 style="color:var(--peach-breach)"789 onClick={() => void handleDisableDuress()}790 disabled={duressSaving()}791 >792 {duressSaving() ? "disabling..." : "disable duress"}793 </button>794 </Show>795 <Show when={!duressEnabled()}>796 <button797 class="peach-btn peach-btn--accent peach-btn--sm"798 onClick={() => void handleSetupDuress()}799 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}800 >801 {duressSaving() ? "saving..." : "save"}802 </button>803 </Show>804 </div>805 </div>806 </Show>807808 <p class="peach-settings-sub-label">CANARY ENTRIES</p>809 <div class="peach-settings-row peach-settings-row--top">810 <div class="peach-settings-row-main">811 <span>Canary entries</span>812 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">813 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.814 </span>815 </div>816 <span class="peach-settings-value" style="flex-shrink:0">817 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}818 </span>819 </div>820 <Show when={canaryEntries().length > 0}>821 <For each={canaryEntries()}>822 {(entry) => (823 <div class="peach-settings-row peach-settings-row--meta">824 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>825 <button826 class="peach-btn peach-btn--subtle peach-btn--sm"827 onClick={() => void handleTestCanary(entry)}828 disabled={canaryTestingId() === entry.id}829 style="flex-shrink:0"830 >831 {canaryTestingId() === entry.id832 ? "sending…"833 : canaryTestResult()?.id === entry.id834 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")835 : "test"}836 </button>837 </div>838 )}839 </For>840 </Show>841842 <p class="peach-settings-sub-label">PASSKEYS</p>843 <div class="peach-settings-row peach-settings-row--top">844 <div class="peach-settings-row-main">845 <span>Intercept passkey requests</span>846 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">847 Peach can handle passkey sign-in and creation on supported Chromium browsers.848 </span>849 </div>850 <button851 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}852 onClick={() => void handleTogglePasskeys()}853 aria-label="Toggle passkey interception"854 disabled={!passkeyState()?.supported || passkeySaving()}855 >856 <span class="peach-toggle-knob"></span>857 </button>858 </div>859 <div class="peach-settings-row peach-settings-row--meta">860 <span>Status</span>861 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>862 </div>863 <div class="peach-settings-row peach-settings-row--meta">864 <span>Browser</span>865 <span class="peach-settings-value">866 {passkeyState()?.browserLabel ?? "Unknown"}867 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}868 </span>869 </div>870 <div class="peach-info-notice" style="margin-top:8px">871 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.872 </div>873 <Show when={passkeyState()?.conflict}>874 <div class="peach-settings-row peach-settings-row--meta">875 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>876 </div>877 </Show>878 </div>879 </div>880 );881 }882883 function renderVault() {884 return (885 <div class="peach-settings-body">886 <div class="peach-settings-group">887 <p class="peach-settings-sub-label">ENTRIES</p>888 <div class="peach-settings-row peach-settings-row--meta">889 <span>Entries</span>890 <span class="peach-settings-value">{props.entriesCount}</span>891 </div>892 <div class="peach-settings-row peach-settings-row--meta">893 <span>Duplicates</span>894 <Show when={dedupDone() !== null} fallback={895 <button896 class="peach-btn peach-btn--subtle peach-btn--sm"897 onClick={() => void handleCountDuplicates()}898 disabled={dedupRunning()}899 >900 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}901 </button>902 }>903 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">904 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}905 </span>906 </Show>907 </div>908 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>909 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">910 <div class="peach-restore-warning">911 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.912 </div>913 <div style="display:flex;gap:6px">914 <button915 class="peach-btn peach-btn--subtle peach-btn--sm"916 onClick={() => setDedupCount(null)}917 >918 cancel919 </button>920 <button921 class="peach-btn peach-btn--accent peach-btn--sm"922 onClick={() => void handleDeduplicate()}923 disabled={dedupRunning()}924 >925 {dedupRunning() ? "removing..." : "remove duplicates"}926 </button>927 </div>928 </div>929 </Show>930931 <p class="peach-settings-sub-label">MAINTENANCE</p>932 <div class="peach-settings-row peach-settings-row--meta">933 <span>Trash auto-delete</span>934 <select935 class="peach-settings-select"936 value={String(props.trashRetentionDays)}937 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}938 >939 <option value="7">7 days</option>940 <option value="30">30 days</option>941 <option value="90">90 days</option>942 <option value="0">Never</option>943 </select>944 </div>945 <div class="peach-settings-row peach-settings-row--meta">946 <span>Vault Health</span>947 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>948 open949 </button>950 </div>951 <Show when={declinedCardCount() > 0}>952 <div class="peach-settings-row peach-settings-row--meta">953 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>954 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>955 Clear956 </button>957 </div>958 </Show>959960 <p class="peach-settings-sub-label">DATA</p>961 <div class="peach-settings-row peach-settings-row--meta">962 <span>Import passwords</span>963 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>964 open965 </button>966 </div>967 <div class="peach-settings-row peach-settings-row--meta">968 <span>Export backup</span>969 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>970 open971 </button>972 </div>973 <div class="peach-danger-card">974 <div class="peach-danger-card-head">975 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>976 <h3 class="peach-danger-card-title">Replace vault from backup</h3>977 </div>978 <p class="peach-danger-card-copy">979 Wipes the current vault and restores from a backup file.980 Recoverable only if your backup is good.981 </p>982 <button983 class="peach-danger-card-button"984 onClick={() => props.onOpenFileImport?.()}985 >986 Choose backup file987 </button>988 </div>989990 <Show when={props.onOpenPanic}>991 <div class="peach-danger-card peach-danger-card--hazard">992 <div class="peach-danger-card-head">993 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>994 <h3 class="peach-danger-card-title">Destroy vault</h3>995 </div>996 <p class="peach-danger-card-copy">997 Permanently wipes every entry, setting, and license on this998 device. There is no recovery.999 </p>1000 <button1001 class="peach-danger-card-button"1002 onClick={() => props.onOpenPanic?.()}1003 >1004 Begin destruction1005 </button>1006 </div>1007 </Show>1008 </div>1009 </div>1010 );1011 }10121013 function renderSync() {1014 if (!isPro()) {1015 return (1016 <div class="peach-settings-body">1017 <div class="peach-pro-upgrade">1018 <div class="peach-pro-upgrade-hero">1019 <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">1020 <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1021 <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1022 <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"/>1023 <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"/>1024 <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>1025 <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1026 <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1027 </svg>1028 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1029 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1030 </div>10311032 <div class="peach-pro-features">1033 <div class="peach-pro-feature">1034 <span class="peach-pro-feature-mark">✦</span>1035 <div class="peach-pro-feature-text">1036 <span class="peach-pro-feature-name">Always in sync</span>1037 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1038 </div>1039 </div>1040 <div class="peach-pro-feature">1041 <span class="peach-pro-feature-mark">✦</span>1042 <div class="peach-pro-feature-text">1043 <span class="peach-pro-feature-name">No cloud. No relay.</span>1044 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1045 </div>1046 </div>1047 <div class="peach-pro-feature">1048 <span class="peach-pro-feature-mark">✦</span>1049 <div class="peach-pro-feature-text">1050 <span class="peach-pro-feature-name">Tailscale supported</span>1051 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1052 </div>1053 </div>1054 <div class="peach-pro-feature">1055 <span class="peach-pro-feature-mark">✦</span>1056 <div class="peach-pro-feature-text">1057 <span class="peach-pro-feature-name">Zero friction after setup</span>1058 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1059 </div>1060 </div>1061 </div>10621063 <button1064 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1065 onClick={() => setActiveSection("license")}1066 >1067 Upgrade to Pro1068 </button>1069 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1070 Already have a key? Enter it →1071 </button>1072 </div>1073 </div>1074 );1075 }10761077 return (1078 <div class="peach-settings-body">1079 <div class="peach-settings-group">1080 <div class="peach-settings-row">1081 <span>Status</span>1082 <span class="peach-settings-value peach-settings-value--status">1083 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1084 {getStatusLabel(syncStatus())}1085 </span>1086 </div>1087 <div class="peach-settings-row peach-settings-row--meta">1088 <span>Last sync</span>1089 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1090 </div>1091 <div class="peach-settings-row peach-settings-row--meta">1092 <button1093 class="peach-btn peach-btn--subtle peach-btn--sm"1094 onClick={() => void handleSyncNow()}1095 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1096 >1097 {syncingNow() ? "Syncing..." : "Sync now"}1098 </button>1099 </div>11001101 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1102 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1103 {pairedDevices().map((device) => (1104 <div class="peach-sync-device-row">1105 <div class="peach-sync-device-info">1106 <span class="peach-sync-device-name">{device.displayName}</span>1107 <span class="peach-sync-device-meta">1108 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1109 </span>1110 </div>1111 <button1112 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1113 onClick={() => void handleRemoveDevice(device.deviceId)}1114 >1115 Remove1116 </button>1117 </div>1118 ))}1119 <div class="peach-settings-actions--row">1120 <button1121 class="peach-btn peach-btn--subtle peach-btn--sm"1122 onClick={() => props.onOpenSyncPairing?.("initiate")}1123 >1124 Pair a new device1125 </button>1126 <button1127 class="peach-btn peach-btn--subtle peach-btn--sm"1128 onClick={() => props.onOpenSyncPairing?.("accept")}1129 >1130 Pair with another device1131 </button>1132 </div>1133 </div>11341135 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1136 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1137 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1138 <div class="peach-sync-device-row">1139 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1140 <button1141 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1142 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1143 >1144 Remove1145 </button>1146 </div>1147 ))}1148 <div class="peach-settings-actions--row">1149 <Show when={!isCurrentNetworkTrusted()}>1150 <button1151 class="peach-btn peach-btn--subtle peach-btn--sm"1152 onClick={() => void handleMarkNetworkTrusted()}1153 >1154 Mark current network as trusted1155 </button>1156 </Show>1157 </div>1158 <span class="peach-sync-device-meta">1159 Tailscale is always trusted1160 </span>1161 </div>1162 </div>1163 </div>1164 );1165 }11661167 function renderAppearance() {1168 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1169 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1170 { id: "cormorant", label: "Cormorant", preview: "Aa" },1171 { id: "inter", label: "Inter", preview: "Aa" },1172 { id: "system", label: "System", preview: "Aa" },1173 ];1174 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1175 { id: "small", label: "Small" },1176 { id: "medium", label: "Default" },1177 { id: "large", label: "Large" },1178 ];1179 return (1180 <div class="peach-settings-body">1181 <div class="peach-settings-group">1182 <p class="peach-settings-sub-label">THEME</p>1183 <div class="peach-settings-row peach-settings-row--meta">1184 <span>Theme</span>1185 <button1186 class="peach-btn peach-btn--subtle peach-btn--sm"1187 onClick={() =>1188 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1189 }1190 >1191 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1192 </button>1193 </div>11941195 <p class="peach-settings-sub-label">ACCENT</p>1196 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1197 <span>Accent</span>1198 <div class="peach-accent-picker">1199 <For each={ACCENT_COLORS}>1200 {(color) => (1201 <button1202 class="peach-accent-swatch"1203 classList={{ "is-active": props.accentColor === color.name }}1204 style={{ background: color.hex }}1205 onClick={() => void props.onAccentChange(color.name)}1206 aria-label={color.name}1207 title={color.name}1208 />1209 )}1210 </For>1211 </div>1212 </div>12131214 <p class="peach-settings-sub-label">DISPLAY FONT</p>1215 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1216 <span>Font</span>1217 <div class="peach-font-picker">1218 <For each={fontOptions}>1219 {(opt) => (1220 <button1221 class="peach-font-swatch"1222 classList={{ "is-active": props.displayFont === opt.id }}1223 data-font-preview={opt.id}1224 onClick={() => void props.onDisplayFontChange(opt.id)}1225 aria-label={opt.label}1226 title={opt.label}1227 >1228 <span class="peach-font-swatch-glyph">{opt.preview}</span>1229 <span class="peach-font-swatch-name">{opt.label}</span>1230 </button>1231 )}1232 </For>1233 </div>1234 </div>12351236 <p class="peach-settings-sub-label">TEXT SIZE</p>1237 <div class="peach-settings-row peach-settings-row--meta">1238 <span>Size</span>1239 <div class="peach-pill-group">1240 <For each={sizeOptions}>1241 {(opt) => (1242 <button1243 class="peach-btn peach-btn--ghost peach-btn--sm"1244 classList={{ "is-active": props.textSize === opt.id }}1245 onClick={() => void props.onTextSizeChange(opt.id)}1246 >1247 {opt.label}1248 </button>1249 )}1250 </For>1251 </div>1252 </div>12531254 <p class="peach-settings-sub-label">MOTION</p>1255 <div class="peach-settings-row peach-settings-row--meta">1256 <span>Reduce motion</span>1257 <button1258 class="peach-btn peach-btn--subtle peach-btn--sm"1259 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1260 >1261 {props.reduceMotion ? "On" : "Off"}1262 </button>1263 </div>1264 </div>1265 </div>1266 );1267 }12681269 function renderShortcuts() {1270 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1271 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12721273 function startCapture(action: ShortcutAction) {1274 setConflictMsg(null);1275 setCapturingAction(action);1276 }1277 function cancelCapture() {1278 setCapturingAction(null);1279 setConflictMsg(null);1280 }1281 function commitBinding(action: ShortcutAction, binding: Keybinding) {1282 const conflict = findConflict(props.shortcuts, binding, action);1283 if (conflict) {1284 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1285 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1286 return;1287 }1288 const next = { ...props.shortcuts, [action]: binding };1289 setCapturingAction(null);1290 setConflictMsg(null);1291 void props.onShortcutsChange(next);1292 }1293 function resetAction(action: ShortcutAction) {1294 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1295 void props.onShortcutsChange(next);1296 }1297 function resetAll() {1298 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1299 }13001301 1302 1303 function handleCaptureKey(e: KeyboardEvent) {1304 e.preventDefault();1305 e.stopPropagation();1306 if (e.key === "Escape") { cancelCapture(); return; }1307 const binding = keybindingFromEvent(e);1308 if (!binding) return; 1309 const action = capturingAction();1310 if (action) commitBinding(action, binding);1311 }13121313 return (1314 <div class="peach-settings-body">1315 <div class="peach-settings-group peach-shortcuts-group">1316 <p class="peach-settings-sub-label">ENTRY VIEW</p>1317 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1318 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1319 </For>1320 </div>13211322 <div class="peach-settings-group peach-shortcuts-group">1323 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1324 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1325 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1326 </For>1327 </div>13281329 <Show when={conflictMsg()}>1330 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1331 </Show>13321333 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1334 <div class="peach-settings-row peach-settings-row--meta">1335 <span>Inline autofill on page</span>1336 <a1337 class="peach-settings-link"1338 href="chrome://extensions/shortcuts"1339 onClick={(e) => {1340 e.preventDefault();1341 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1342 }}1343 >1344 ⌘⇧P — customize ↗1345 </a>1346 </div>1347 <button1348 class="peach-btn peach-btn--subtle peach-btn--sm"1349 style="align-self:flex-start"1350 onClick={resetAll}1351 >1352 Reset all to defaults1353 </button>1354 </div>1355 </div>1356 );13571358 function renderRow(1359 desc: typeof SHORTCUT_DESCRIPTORS[number],1360 capturing: () => ShortcutAction | null,1361 onStart: (a: ShortcutAction) => void,1362 onCancel: () => void,1363 onReset: (a: ShortcutAction) => void,1364 onKey: (e: KeyboardEvent) => void,1365 ) {1366 const isActive = () => capturing() === desc.action;1367 const binding = () => props.shortcuts[desc.action];1368 const isCustom = () => {1369 const b = binding();1370 const d = DEFAULT_BINDINGS[desc.action];1371 return b.key !== d.key1372 || Boolean(b.ctrl) !== Boolean(d.ctrl)1373 || Boolean(b.meta) !== Boolean(d.meta)1374 || Boolean(b.shift) !== Boolean(d.shift)1375 || Boolean(b.alt) !== Boolean(d.alt);1376 };1377 return (1378 <div class="peach-shortcut-row">1379 <span class="peach-shortcut-row-label">{desc.label}</span>1380 <button1381 class="peach-shortcut-chip"1382 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1383 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1384 onKeyDown={(e) => { if (isActive()) onKey(e); }}1385 ref={(el) => {1386 1387 if (el) {1388 queueMicrotask(() => { if (isActive()) el.focus(); });1389 }1390 }}1391 >1392 {isActive() ? "Press a key…" : formatKeybinding(binding())}1393 </button>1394 <Show when={isCustom() && !isActive()}>1395 <button1396 class="peach-shortcut-reset"1397 title="Reset to default"1398 onClick={() => onReset(desc.action)}1399 >1400 ↺1401 </button>1402 </Show>1403 </div>1404 );1405 }1406 }14071408 const PRO_FEATURES: Array<{1409 feature: ProFeature;1410 name: string;1411 desc: string;1412 }> = [1413 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1414 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1415 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1416 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1417 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1418 ];14191420 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14211422 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1423 const [deactivating, setDeactivating] = createSignal(false);14241425 async function handleDeactivateLicense() {1426 setDeactivating(true);1427 await sendMessage({ type: "DEACTIVATE_LICENSE" });1428 setLicenseInfo(null);1429 await props.onLicenseChange?.();1430 setDeactivating(false);1431 }14321433 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1434 setCanaryTestingId(entry.id);1435 setCanaryTestResult(null);1436 const res = await sendMessage({1437 type: "FIRE_CANARY_PING",1438 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1439 }) as { ok: boolean };1440 setCanaryTestResult({ id: entry.id, ok: res.ok });1441 setCanaryTestingId(null);1442 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1443 }14441445 function renderLicense() {1446 async function onActivate() {1447 await handleActivateLicense();1448 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1449 await props.onLicenseChange?.();1450 }1451 }14521453 return (1454 <div class="peach-settings-body">1455 <Show1456 when={isPro()}1457 fallback={1458 <Show1459 when={showKeyEntry()}1460 fallback={1461 <div class="peach-pro-upgrade">1462 <div class="peach-pro-upgrade-hero">1463 <div class="peach-pro-upgrade-mark-wrap">1464 <PeachMark class="peach-pro-upgrade-mark" />1465 </div>1466 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1467 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1468 </div>14691470 <div class="peach-pro-features">1471 <For each={PRO_FEATURES}>1472 {(item) => (1473 <div1474 class="peach-pro-feature"1475 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1476 >1477 <span class="peach-pro-feature-mark">✦</span>1478 <div class="peach-pro-feature-text">1479 <span class="peach-pro-feature-name">{item.name}</span>1480 <span class="peach-pro-feature-desc">{item.desc}</span>1481 </div>1482 </div>1483 )}1484 </For>1485 </div>14861487 <div class="peach-pro-upgrade-actions">1488 <p class="peach-pro-pricing">$39 · once · yours forever</p>1489 <a1490 href="https://peachpasswords.com/#pricing"1491 target="_blank"1492 rel="noopener noreferrer"1493 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1494 >1495 Get Peach Pro1496 </a>1497 <div class="peach-pro-key-divider"><span>or</span></div>1498 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1499 Activate a license key1500 </button>1501 </div>1502 </div>1503 }1504 >1505 <div class="peach-pro-key-ceremony">1506 <LockRuneCanvas />1507 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1508 <input1509 class="peach-underline-input peach-pro-key-input"1510 type="text"1511 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1512 value={licenseKeyInput()}1513 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1514 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1515 autocomplete="off"1516 spellcheck={false}1517 />1518 <Show when={licenseError()}>1519 <p class="peach-pro-key-error">That key doesn't look right.</p>1520 </Show>1521 <button1522 class="peach-btn peach-btn--accent peach-pro-key-activate"1523 onClick={() => void onActivate()}1524 disabled={activatingLicense() || !licenseKeyInput().trim()}1525 >1526 {activatingLicense() ? "activating…" : "Activate"}1527 </button>1528 <button1529 class="peach-pro-key-cancel"1530 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1531 >1532 cancel1533 </button>1534 </div>1535 </Show>1536 }1537 >1538 <div class="peach-pro-active">1539 <div class="peach-pro-active-hero">1540 <div class="peach-pro-active-mark-wrap">1541 <PeachMark class="peach-pro-active-mark" />1542 </div>1543 <h2 class="peach-pro-active-title">Peach Pro</h2>1544 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1545 </div>15461547 <Show when={licenseKey()}>1548 <div class="peach-settings-license">1549 <span class="peach-settings-label">LICENSE KEY</span>1550 <div class="peach-settings-license-row">1551 <span class="peach-settings-license-key">{licenseKey()!}</span>1552 <button1553 class="peach-settings-license-copy"1554 onClick={() => copyLicenseKey(licenseKey()!)}1555 >1556 {copied() ? "✓" : "copy"}1557 </button>1558 </div>1559 </div>1560 </Show>15611562 <div class="peach-pro-active-features">1563 <For each={PRO_FEATURES}>1564 {(item) => (1565 <div class="peach-pro-active-feature">1566 <span class="peach-pro-active-feature-mark">✦</span>1567 <span class="peach-pro-active-feature-name">{item.name}</span>1568 </div>1569 )}1570 </For>1571 </div>15721573 <button1574 class="peach-pro-deactivate"1575 onClick={() => void handleDeactivateLicense()}1576 disabled={deactivating()}1577 >1578 {deactivating() ? "Deactivating…" : "Deactivate"}1579 </button>1580 </div>1581 </Show>1582 </div>1583 );1584 }15851586 function renderFeedback() {1587 return (1588 <div class="peach-settings-body">1589 <div class="peach-settings-group peach-feedback-group">1590 <p class="peach-feedback-intro">1591 Help us improve Peach. Every report goes directly to our inbox.1592 </p>15931594 <p class="peach-settings-sub-label">TYPE</p>1595 <div class="peach-feedback-chips">1596 <For each={FEEDBACK_CATEGORIES}>1597 {(cat) => (1598 <button1599 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1600 onClick={() => setFbCategory(cat)}1601 >1602 {cat}1603 </button>1604 )}1605 </For>1606 </div>16071608 <p class="peach-settings-sub-label">MESSAGE</p>1609 <textarea1610 class="peach-feedback-textarea"1611 placeholder="Describe the issue or idea..."1612 value={fbMessage()}1613 onInput={(e) => setFbMessage(e.currentTarget.value)}1614 />16151616 <p class="peach-feedback-meta">1617 Version {version} · will be included automatically1618 </p>16191620 <Show when={fbResult() === "success"}>1621 <p class="peach-feedback-result peach-feedback-result--ok">1622 Sent — thank you1623 </p>1624 </Show>1625 <Show when={fbResult() === "error"}>1626 <p class="peach-feedback-result peach-feedback-result--err">1627 Failed to send — try again1628 </p>1629 </Show>16301631 <button1632 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1633 onClick={() => void handleSubmitFeedback()}1634 disabled={fbSubmitting() || !fbMessage().trim()}1635 >1636 {fbSubmitting() ? "Sending..." : "Send feedback"}1637 </button>16381639 <p class="peach-feedback-anon">1640 Sent anonymously · no account required1641 </p>1642 </div>1643 </div>1644 );1645 }16461647 return (1648 <div class="peach-overlay-view ov-enter-right" style="display:flex">1649 <div class="peach-overlay-header">1650 <button1651 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1652 onClick={handleHeaderBack}1653 aria-label="Back"1654 >1655 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1656 <path d="M6 1L1 6L6 11" />1657 </svg>1658 </button>1659 <span class="peach-overlay-title">{currentTitle()}</span>1660 <div class="peach-overlay-spacer"></div>1661 </div>16621663 <Switch fallback={renderHome()}>1664 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1665 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1666 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1667 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1668 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1669 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1670 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1671 </Switch>1672 </div>1673 );1674}