1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { Capacitor, registerPlugin } from "@capacitor/core";3import { sendMessage } from "../App";4import { PeachMark } from "../components/PeachMark";5import { LockRuneCanvas } from "../components/LockRuneCanvas";6import { biometricAvailable } from "../../shared/biometric";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";10import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";11import {12 DEFAULT_BINDINGS,13 findConflict,14 formatKeybinding,15 keybindingFromEvent,16 SHORTCUT_DESCRIPTORS,17 type Keybinding,18 type ShortcutAction,19} from "../../shared/shortcuts";2021interface LicenseInfo {22 valid: boolean;23 plan: "free" | "pro";24 issued?: string;25}2627interface SettingsProps {28 entriesCount: number;29 breachMonitoring: boolean;30 lockOnClose: boolean;31 autoLockMinutes: number;32 trashRetentionDays: number;33 theme: "dark" | "light";34 accentColor: AccentColorName;35 displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";36 textSize: "small" | "medium" | "large";37 reduceMotion: boolean;38 isPro?: boolean;39 initialSection?: string | null;40 upgradeHighlight?: ProFeature | null;41 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;42 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;43 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;44 onTrashRetentionChange: (days: number) => void | Promise<void>;45 onThemeChange: (theme: "dark" | "light") => void;46 onAccentChange: (name: AccentColorName) => void | Promise<void>;47 onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;48 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;49 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;50 shortcuts: Record<ShortcutAction, Keybinding>;51 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;52 onLicenseChange?: () => void | Promise<void>;53 onBack: () => void;54 onImport: () => void;55 onExport: () => void;56 onBreachCheckDone: () => void;57 onOpenHealth?: () => void;58 onOpenSyncPairing?: (mode: "initiate" | "accept") => void;59 onOpenFileImport?: () => void;60 onOpenPanic?: () => void;61}6263type SectionName =64 | "security"65 | "vault"66 | "sync"67 | "appearance"68 | "shortcuts"69 | "license"70 | "feedback"71 | "mobile";7273const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7475function formatActivatedSince(issued: string | undefined): string {76 if (!issued) return "Active";77 try {78 const d = new Date(issued);79 if (Number.isNaN(d.getTime())) return "Active";80 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;81 } catch {82 return "Active";83 }84}8586function isSectionName(value: string): value is SectionName {87 return (88 value === "security" ||89 value === "vault" ||90 value === "sync" ||91 value === "appearance" ||92 value === "shortcuts" ||93 value === "license" ||94 value === "feedback" ||95 value === "mobile"96 );97}9899export function SettingsView(props: SettingsProps) {100 const version = chrome.runtime.getManifest().version;101 const initial = props.initialSection;102 const [activeSection, setActiveSection] = createSignal<SectionName | null>(103 initial && isSectionName(initial) ? (initial as SectionName) : null104 );105106 const [checkingBreaches, setCheckingBreaches] = createSignal(false);107 const [breachResult, setBreachResult] = createSignal<string | null>(null);108 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);109 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");110 const [licenseError, setLicenseError] = createSignal<string | null>(null);111 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);112 const [activatingLicense, setActivatingLicense] = createSignal(false);113114 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);115 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);116 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");117 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);118 119 120 121 const [syncProStatus, setSyncProStatus] = createSignal(false);122 const isPro = () => props.isPro ?? syncProStatus();123 const [syncingNow, setSyncingNow] = createSignal(false);124 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);125 const [passkeySaving, setPasskeySaving] = createSignal(false);126127 const [dedupCount, setDedupCount] = createSignal<number | null>(null);128 const [dedupRunning, setDedupRunning] = createSignal(false);129 const [dedupDone, setDedupDone] = createSignal<number | null>(null);130131 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);132133 const [showChangePw, setShowChangePw] = createSignal(false);134 const [changePwCurrent, setChangePwCurrent] = createSignal("");135 const [changePwNew, setChangePwNew] = createSignal("");136 const [changePwConfirm, setChangePwConfirm] = createSignal("");137 const [changePwSaving, setChangePwSaving] = createSignal(false);138 const [changePwError, setChangePwError] = createSignal<string | null>(null);139 const [changePwSuccess, setChangePwSuccess] = createSignal(false);140141 const [duressEnabled, setDuressEnabled] = createSignal(false);142 const [showDuressSetup, setShowDuressSetup] = createSignal(false);143 const [duressPassword, setDuressPassword] = createSignal("");144 const [duressConfirm, setDuressConfirm] = createSignal("");145 const [duressMainPassword, setDuressMainPassword] = createSignal("");146 const [duressSaving, setDuressSaving] = createSignal(false);147 const [duressError, setDuressError] = createSignal<string | null>(null);148 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);149150 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);151152 153 function getAutofillPlugin() {154 try {155 return registerPlugin<{156 isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;157 openAutofillSettings(): Promise<void>;158 }>("Autofill");159 } catch {160 return null;161 }162 }163 const AutofillPlugin = getAutofillPlugin();164 const [autofillEnabled, setAutofillEnabled] = createSignal(false);165 const [biometricEnabled, setBiometricEnabled] = createSignal(false);166 const isAndroid = Capacitor.getPlatform() === "android";167 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);168 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);169170 const [fbCategory, setFbCategory] = createSignal("Bug report");171 const [fbMessage, setFbMessage] = createSignal("");172 const [fbSubmitting, setFbSubmitting] = createSignal(false);173 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);174175 onMount(async () => {176 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([177 sendMessage({ type: "GET_LICENSE" }),178 sendMessage({ type: "GET_SYNC_STATE" }),179 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),180 sendMessage({ type: "IS_DURESS_ENABLED" }),181 sendMessage({ type: "GET_SETTINGS" }),182 sendMessage({ type: "GET_LICENSE_KEY" }),183 ]);184185 if ((licenseRes as { ok: boolean }).ok) {186 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);187 }188189 if ((syncRes as { ok: boolean }).ok) {190 const data = syncRes as { ok: boolean; data?: {191 pairedDevices?: PairedDevice[];192 trustedNetworks?: TrustedNetwork[];193 status?: SyncStatus;194 lastSyncAt?: number;195 isPro?: boolean;196 }};197 if (data.data) {198 setPairedDevices(data.data.pairedDevices ?? []);199 setTrustedNetworks(data.data.trustedNetworks ?? []);200 setSyncStatus(data.data.status ?? "offline");201 setLastSyncAt(data.data.lastSyncAt);202 setSyncProStatus(data.data.isPro ?? false);203 }204 }205206 if ((passkeyRes as { ok: boolean }).ok) {207 setPasskeyState(208 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null209 );210 }211212 if ((duressRes as { ok: boolean }).ok) {213 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);214 }215216 if ((settingsRes as { ok: boolean }).ok) {217 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;218 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);219 }220221 if ((keyRes as { ok: boolean }).ok) {222 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);223 }224225 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });226 if ((entriesRes as { ok: boolean }).ok) {227 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];228 setCanaryEntries(229 entries230 .filter((e) => e.canary?.webhookUrl)231 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))232 );233 }234235 236 if (isAndroid) {237 if (AutofillPlugin) {238 const { enabled } = await AutofillPlugin.isEnabledAsAutofillService();239 setAutofillEnabled(enabled);240 }241 const bio = await biometricAvailable();242 setBiometricEnabled(bio);243 }244245 chrome.runtime.onMessage.addListener((msg) => {246 if (msg.type === "SYNC_STATE_UPDATE") {247 setPairedDevices(msg.pairedDevices ?? []);248 setTrustedNetworks(msg.trustedNetworks ?? []);249 setSyncStatus(msg.status ?? "offline");250 setLastSyncAt(msg.lastSyncAt);251 setSyncProStatus(msg.isPro ?? false);252 }253 });254 });255256 async function handleChangePassword() {257 setChangePwError(null);258 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }259 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }260 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }261 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }262 setChangePwSaving(true);263 const res = await sendMessage({264 type: "CHANGE_PASSWORD",265 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },266 }) as { ok: boolean; error?: string };267 if (res.ok) {268 setChangePwSuccess(true);269 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");270 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);271 } else {272 setChangePwError(res.error ?? "failed to change password");273 }274 setChangePwSaving(false);275 }276277 const [copied, setCopied] = createSignal(false);278279 async function copyLicenseKey(key: string) {280 await navigator.clipboard.writeText(key);281 setCopied(true);282 setTimeout(() => setCopied(false), 2000);283 }284285 async function handleActivateLicense() {286 const key = licenseKeyInput().trim();287 if (!key) return;288 setActivatingLicense(true);289 setLicenseError(null);290 setLicenseSuccess(null);291 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });292 setActivatingLicense(false);293 if ((res as { ok: boolean }).ok) {294 const data = (res as { ok: boolean; data?: LicenseInfo }).data;295 if (data?.valid) {296 setLicenseInfo(data);297 setLicenseKey(key);298 setLicenseSuccess("License activated!");299 setLicenseKeyInput("");300 } else {301 setLicenseError("Invalid license key");302 }303 } else {304 setLicenseError("Activation failed");305 }306 }307308 async function handleTogglePasskeys() {309 const current = passkeyState();310 if (!current || !current.supported) return;311 setPasskeySaving(true);312 const res = await sendMessage({313 type: "SET_PASSKEY_PROVIDER_ENABLED",314 payload: { enabled: !current.enabled },315 });316 setPasskeySaving(false);317 if ((res as { ok: boolean }).ok) {318 setPasskeyState(319 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null320 );321 }322 }323324 async function handleCheckAllBreaches() {325 setCheckingBreaches(true);326 setBreachResult(null);327 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });328 setCheckingBreaches(false);329 if ((res as { ok: boolean }).ok) {330 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};331 const breached = Object.values(data).filter((count) => count > 0).length;332 setBreachResult(333 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"334 );335 props.onBreachCheckDone();336 }337 }338339 async function handleSyncNow() {340 if (syncStatus() === "not_trusted") return;341 setSyncingNow(true);342 await sendMessage({ type: "TRIGGER_SYNC" });343 setTimeout(() => setSyncingNow(false), 3000);344 }345346 async function handleRemoveDevice(deviceId: string) {347 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });348 }349350 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {351 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });352 }353354 async function handleMarkNetworkTrusted() {355 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });356 }357358 function formatRelativeTime(timestamp?: number): string {359 if (!timestamp) return "Never";360 const diff = Date.now() - timestamp;361 const minutes = Math.floor(diff / 60000);362 const hours = Math.floor(diff / 3600000);363 const days = Math.floor(diff / 86400000);364 if (minutes < 1) return "Just now";365 if (minutes < 60) return `${minutes}m ago`;366 if (hours < 24) return `${hours}h ago`;367 return `${days}d ago`;368 }369370 function getStatusColor(status: SyncStatus): string {371 switch (status) {372 case "synced": return "var(--peach-sync-ok)";373 case "syncing": return "var(--peach-sync-busy)";374 case "no_peers": return "var(--peach-sync-off)";375 case "not_trusted": return "var(--peach-sync-err)";376 case "offline": return "var(--peach-sync-off)";377 default: return "var(--peach-sync-off)";378 }379 }380381 function getStatusLabel(status: SyncStatus): string {382 switch (status) {383 case "synced": return "Synced";384 case "syncing": return "Syncing";385 case "no_peers": return "No peers visible";386 case "not_trusted": return "Not on a trusted network";387 case "offline": return "Offline";388 default: return "Unknown";389 }390 }391392 function isCurrentNetworkTrusted(): boolean {393 return trustedNetworks().length > 0;394 }395396 async function handleSetupDuress() {397 const mainPw = duressMainPassword().trim();398 const duressPw = duressPassword().trim();399 const confirmPw = duressConfirm().trim();400401 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }402 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }403 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }404 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }405406 setDuressSaving(true);407 setDuressError(null);408409 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };410 if (!verifyRes.ok || !verifyRes.data) {411 setDuressError("incorrect main password");412 setDuressSaving(false);413 return;414 }415416 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };417 setDuressSaving(false);418 if (res.ok) {419 setDuressEnabled(true);420 setShowDuressSetup(false);421 setDuressPassword("");422 setDuressConfirm("");423 setDuressMainPassword("");424 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");425 setTimeout(() => setDuressSuccess(null), 5000);426 } else {427 setDuressError(res.error ?? "failed to set duress password");428 }429 }430431 async function handleClearDeclinedCards() {432 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });433 setDeclinedCardCount(0);434 }435436 async function handleCountDuplicates() {437 setDedupRunning(true);438 setDedupDone(null);439 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };440 setDedupRunning(false);441 if (res.ok) setDedupCount(res.data ?? 0);442 }443444 async function handleDeduplicate() {445 setDedupRunning(true);446 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };447 setDedupRunning(false);448 if (res.ok) {449 setDedupDone(res.data ?? 0);450 setDedupCount(0);451 }452 }453454 async function handleDisableDuress() {455 const mainPw = duressMainPassword().trim();456 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }457458 setDuressSaving(true);459 setDuressError(null);460461 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };462 if (!verifyRes.ok || !verifyRes.data) {463 setDuressError("incorrect main password");464 setDuressSaving(false);465 return;466 }467468 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };469 setDuressSaving(false);470 if (res.ok) {471 setDuressEnabled(false);472 setShowDuressSetup(false);473 setDuressMainPassword("");474 setDuressSuccess("Duress password disabled.");475 setTimeout(() => setDuressSuccess(null), 4000);476 } else {477 setDuressError(res.error ?? "failed to disable duress password");478 }479 }480481 async function handleSubmitFeedback() {482 if (!fbMessage().trim() || fbSubmitting()) return;483 setFbSubmitting(true);484 setFbResult(null);485 const res = await sendMessage({486 type: "SUBMIT_FEEDBACK",487 payload: {488 category: fbCategory(),489 message: fbMessage().trim(),490 version,491 browser: navigator.userAgent,492 },493 }) as { ok: boolean; error?: string };494 setFbSubmitting(false);495 if (res.ok) {496 setFbResult("success");497 setFbMessage("");498 setTimeout(() => setFbResult(null), 4000);499 } else {500 setFbResult("error");501 }502 }503504 function handleHeaderBack() {505 if (activeSection() !== null) {506 setActiveSection(null);507 } else {508 props.onBack();509 }510 }511512 function currentTitle(): string {513 const s = activeSection();514 if (s === null) return "settings";515 if (s === "license") return "account";516 return s;517 }518519 function renderCatRow(opts: {520 section: SectionName;521 name: string;522 desc: string;523 icon: () => any;524 pro?: boolean;525 }) {526 return (527 <button528 class="peach-settings-cat-row"529 onClick={() => setActiveSection(opts.section)}530 >531 <div class="peach-settings-cat-icon">{opts.icon()}</div>532 <div class="peach-settings-cat-content">533 <span class="peach-settings-cat-name">534 {opts.name}535 <Show when={opts.pro && !isPro()}>536 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>537 </Show>538 </span>539 <span class="peach-settings-cat-desc">{opts.desc}</span>540 </div>541 <svg542 class="peach-settings-cat-chevron"543 width="6" height="10" viewBox="0 0 6 10" fill="none"544 stroke="currentColor" stroke-width="1.1"545 stroke-linecap="round" stroke-linejoin="round"546 >547 <path d="M1 1l4 4-4 4" />548 </svg>549 </button>550 );551 }552553 const iconShield = () => (554 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">555 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>556 </svg>557 );558 const iconKey = () => (559 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">560 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>561 </svg>562 );563 const iconLock = () => (564 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">565 <rect x="3" y="7" width="10" height="7" rx="1.5"/>566 <path d="M6 7V5a3 3 0 016 0v2"/>567 </svg>568 );569 const iconSync = () => (570 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">571 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>572 <path d="M14 6l-2 2-2-2"/>573 <path d="M2 10l2-2 2 2"/>574 </svg>575 );576 const iconSun = () => (577 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">578 <circle cx="8" cy="8" r="3"/>579 <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"/>580 </svg>581 );582 const iconKeys = () => (583 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">584 <rect x="2" y="5" width="5" height="4" rx="1"/>585 <rect x="9" y="7" width="5" height="4" rx="1"/>586 <path d="M7 7h2m-2 2h2"/>587 </svg>588 );589 const iconBubble = () => (590 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">591 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>592 </svg>593 );594595 function renderHome() {596 return (597 <div class="peach-settings-body">598 <p class="peach-settings-nav-label">PROTECTION</p>599 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}600 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}601602 <p class="peach-settings-nav-label">VAULT</p>603 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}604 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}605606 <p class="peach-settings-nav-label">PREFERENCES</p>607 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}608 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}609 <Show when={isAndroid}>610 <p class="peach-settings-nav-label">MOBILE</p>611 {renderCatRow({ section: "mobile", name: "Mobile", desc: "Autofill & biometric settings", icon: iconSync })}612 </Show>613614 <p class="peach-settings-nav-label">SUPPORT</p>615 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}616617 <div class="peach-settings-footer">618 <span>Peach {version}</span>619 <span class="peach-divider"></span>620 <span>Browser extension</span>621 </div>622 </div>623 );624 }625626 function renderSecurity() {627 return (628 <div class="peach-settings-body">629 <div class="peach-settings-group">630 <p class="peach-settings-sub-label">LOCKING</p>631 <div class="peach-settings-row">632 <span>Lock on close</span>633 <button634 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}635 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}636 aria-label="Toggle lock on close"637 >638 <span class="peach-toggle-knob"></span>639 </button>640 </div>641 <div class="peach-settings-row">642 <span>Auto-lock after</span>643 <select644 class="peach-settings-select"645 value={String(props.autoLockMinutes)}646 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}647 >648 <option value="1">1 minute</option>649 <option value="5">5 minutes</option>650 <option value="15">15 minutes</option>651 <option value="30">30 minutes</option>652 <option value="0">Never</option>653 </select>654 </div>655656 <p class="peach-settings-sub-label">BREACH PROTECTION</p>657 <div class="peach-settings-row">658 <span>Breach monitoring</span>659 <button660 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}661 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}662 aria-label="Toggle breach monitoring"663 >664 <span class="peach-toggle-knob"></span>665 </button>666 </div>667 <div class="peach-settings-row peach-settings-row--meta">668 <span>Check all passwords</span>669 <button670 class="peach-btn peach-btn--subtle peach-btn--sm"671 onClick={() => void handleCheckAllBreaches()}672 disabled={checkingBreaches()}673 >674 {checkingBreaches() ? "checking..." : "run"}675 </button>676 </div>677 <Show when={breachResult()}>678 <div class="peach-settings-row peach-settings-row--meta">679 <span>Last check</span>680 <span class="peach-settings-value">{breachResult()}</span>681 </div>682 </Show>683684 <p class="peach-settings-sub-label">MASTER PASSWORD</p>685 <div class="peach-settings-row peach-settings-row--meta">686 <span>Master password</span>687 <button688 class="peach-btn peach-btn--subtle peach-btn--sm"689 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}690 >691 {showChangePw() ? "cancel" : "change"}692 </button>693 </div>694 <Show when={showChangePw()}>695 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">696 <input697 class="peach-settings-input"698 type="password"699 placeholder="current password"700 value={changePwCurrent()}701 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}702 autocomplete="current-password"703 />704 <input705 class="peach-settings-input"706 type="password"707 placeholder="new password (min 12 chars)"708 value={changePwNew()}709 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}710 autocomplete="new-password"711 />712 <input713 class="peach-settings-input"714 type="password"715 placeholder="confirm new password"716 value={changePwConfirm()}717 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}718 autocomplete="new-password"719 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}720 />721 <Show when={changePwError()}>722 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>723 </Show>724 <Show when={changePwSuccess()}>725 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>726 </Show>727 <button728 class="peach-btn peach-btn--accent peach-btn--sm"729 style="align-self:flex-start"730 onClick={() => void handleChangePassword()}731 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}732 >733 {changePwSaving() ? "saving…" : "save new password"}734 </button>735 </div>736 </Show>737738 <p class="peach-settings-sub-label">DURESS PASSWORD</p>739 <div class="peach-settings-row peach-settings-row--top">740 <div class="peach-settings-row-main">741 <span>Duress password</span>742 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">743 If entered under coercion, shows a decoy empty vault instead of your real one.744 </span>745 </div>746 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>747 {duressEnabled() ? "on" : "off"}748 </div>749 </div>750751 <Show when={duressEnabled() && !showDuressSetup()}>752 <div class="peach-settings-row peach-settings-row--meta">753 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">754 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.755 </span>756 </div>757 </Show>758759 <Show when={duressSuccess()}>760 <div class="peach-settings-row peach-settings-row--meta">761 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>762 </div>763 </Show>764765 <Show when={!showDuressSetup()}>766 <div class="peach-settings-row peach-settings-row--meta">767 <button768 class="peach-btn peach-btn--subtle peach-btn--sm"769 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}770 >771 {duressEnabled() ? "Change / disable" : "Set up"}772 </button>773 </div>774 </Show>775776 <Show when={showDuressSetup()}>777 <div class="peach-duress-form">778 <label class="peach-label">main password (to confirm)</label>779 <input780 class="peach-settings-input"781 type="password"782 placeholder="your current master password"783 autocomplete="current-password"784 value={duressMainPassword()}785 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}786 />787 <Show when={!duressEnabled()}>788 <label class="peach-label">new duress password</label>789 <input790 class="peach-settings-input"791 type="password"792 placeholder="duress password (min 8 chars)"793 autocomplete="new-password"794 value={duressPassword()}795 onInput={(e) => setDuressPassword(e.currentTarget.value)}796 />797 <label class="peach-label">confirm duress password</label>798 <input799 class="peach-settings-input"800 type="password"801 placeholder="confirm duress password"802 autocomplete="new-password"803 value={duressConfirm()}804 onInput={(e) => setDuressConfirm(e.currentTarget.value)}805 />806 </Show>807808 <Show when={duressError()}>809 <p class="peach-hint peach-hint-error">{duressError()}</p>810 </Show>811812 <div class="peach-duress-actions">813 <button814 class="peach-btn peach-btn--subtle peach-btn--sm"815 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}816 >817 cancel818 </button>819 <Show when={duressEnabled()}>820 <button821 class="peach-btn peach-btn--ghost peach-btn--sm"822 style="color:var(--peach-breach)"823 onClick={() => void handleDisableDuress()}824 disabled={duressSaving()}825 >826 {duressSaving() ? "disabling..." : "disable duress"}827 </button>828 </Show>829 <Show when={!duressEnabled()}>830 <button831 class="peach-btn peach-btn--accent peach-btn--sm"832 onClick={() => void handleSetupDuress()}833 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}834 >835 {duressSaving() ? "saving..." : "save"}836 </button>837 </Show>838 </div>839 </div>840 </Show>841842 <p class="peach-settings-sub-label">CANARY ENTRIES</p>843 <div class="peach-settings-row peach-settings-row--top">844 <div class="peach-settings-row-main">845 <span>Canary entries</span>846 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">847 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.848 </span>849 </div>850 <span class="peach-settings-value" style="flex-shrink:0">851 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}852 </span>853 </div>854 <Show when={canaryEntries().length > 0}>855 <For each={canaryEntries()}>856 {(entry) => (857 <div class="peach-settings-row peach-settings-row--meta">858 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>859 <button860 class="peach-btn peach-btn--subtle peach-btn--sm"861 onClick={() => void handleTestCanary(entry)}862 disabled={canaryTestingId() === entry.id}863 style="flex-shrink:0"864 >865 {canaryTestingId() === entry.id866 ? "sending…"867 : canaryTestResult()?.id === entry.id868 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")869 : "test"}870 </button>871 </div>872 )}873 </For>874 </Show>875876 <p class="peach-settings-sub-label">PASSKEYS</p>877 <div class="peach-settings-row peach-settings-row--top">878 <div class="peach-settings-row-main">879 <span>Intercept passkey requests</span>880 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">881 Peach can handle passkey sign-in and creation on supported Chromium browsers.882 </span>883 </div>884 <button885 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}886 onClick={() => void handleTogglePasskeys()}887 aria-label="Toggle passkey interception"888 disabled={!passkeyState()?.supported || passkeySaving()}889 >890 <span class="peach-toggle-knob"></span>891 </button>892 </div>893 <div class="peach-settings-row peach-settings-row--meta">894 <span>Status</span>895 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>896 </div>897 <div class="peach-settings-row peach-settings-row--meta">898 <span>Browser</span>899 <span class="peach-settings-value">900 {passkeyState()?.browserLabel ?? "Unknown"}901 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}902 </span>903 </div>904 <div class="peach-info-notice" style="margin-top:8px">905 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.906 </div>907 <Show when={passkeyState()?.conflict}>908 <div class="peach-settings-row peach-settings-row--meta">909 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>910 </div>911 </Show>912 </div>913 </div>914 );915 }916917 function renderVault() {918 return (919 <div class="peach-settings-body">920 <div class="peach-settings-group">921 <p class="peach-settings-sub-label">ENTRIES</p>922 <div class="peach-settings-row peach-settings-row--meta">923 <span>Entries</span>924 <span class="peach-settings-value">{props.entriesCount}</span>925 </div>926 <div class="peach-settings-row peach-settings-row--meta">927 <span>Duplicates</span>928 <Show when={dedupDone() !== null} fallback={929 <button930 class="peach-btn peach-btn--subtle peach-btn--sm"931 onClick={() => void handleCountDuplicates()}932 disabled={dedupRunning()}933 >934 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}935 </button>936 }>937 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">938 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}939 </span>940 </Show>941 </div>942 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>943 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">944 <div class="peach-restore-warning">945 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.946 </div>947 <div style="display:flex;gap:6px">948 <button949 class="peach-btn peach-btn--subtle peach-btn--sm"950 onClick={() => setDedupCount(null)}951 >952 cancel953 </button>954 <button955 class="peach-btn peach-btn--accent peach-btn--sm"956 onClick={() => void handleDeduplicate()}957 disabled={dedupRunning()}958 >959 {dedupRunning() ? "removing..." : "remove duplicates"}960 </button>961 </div>962 </div>963 </Show>964965 <p class="peach-settings-sub-label">MAINTENANCE</p>966 <div class="peach-settings-row peach-settings-row--meta">967 <span>Trash auto-delete</span>968 <select969 class="peach-settings-select"970 value={String(props.trashRetentionDays)}971 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}972 >973 <option value="7">7 days</option>974 <option value="30">30 days</option>975 <option value="90">90 days</option>976 <option value="0">Never</option>977 </select>978 </div>979 <div class="peach-settings-row peach-settings-row--meta">980 <span>Vault Health</span>981 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>982 open983 </button>984 </div>985 <Show when={declinedCardCount() > 0}>986 <div class="peach-settings-row peach-settings-row--meta">987 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>988 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>989 Clear990 </button>991 </div>992 </Show>993994 <p class="peach-settings-sub-label">DATA</p>995 <div class="peach-settings-row peach-settings-row--meta">996 <span>Import passwords</span>997 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>998 open999 </button>1000 </div>1001 <div class="peach-settings-row peach-settings-row--meta">1002 <span>Export backup</span>1003 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1004 open1005 </button>1006 </div>1007 <div class="peach-danger-card">1008 <div class="peach-danger-card-head">1009 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1010 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1011 </div>1012 <p class="peach-danger-card-copy">1013 Wipes the current vault and restores from a backup file.1014 Recoverable only if your backup is good.1015 </p>1016 <button1017 class="peach-danger-card-button"1018 onClick={() => props.onOpenFileImport?.()}1019 >1020 Choose backup file1021 </button>1022 </div>10231024 <Show when={props.onOpenPanic}>1025 <div class="peach-danger-card peach-danger-card--hazard">1026 <div class="peach-danger-card-head">1027 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1028 <h3 class="peach-danger-card-title">Destroy vault</h3>1029 </div>1030 <p class="peach-danger-card-copy">1031 Permanently wipes every entry, setting, and license on this1032 device. There is no recovery.1033 </p>1034 <button1035 class="peach-danger-card-button"1036 onClick={() => props.onOpenPanic?.()}1037 >1038 Begin destruction1039 </button>1040 </div>1041 </Show>1042 </div>1043 </div>1044 );1045 }10461047 function renderSync() {1048 if (!isPro()) {1049 return (1050 <div class="peach-settings-body">1051 <div class="peach-pro-upgrade">1052 <div class="peach-pro-upgrade-hero">1053 <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">1054 <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1055 <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1056 <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"/>1057 <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"/>1058 <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>1059 <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1060 <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1061 </svg>1062 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1063 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1064 </div>10651066 <div class="peach-pro-features">1067 <div class="peach-pro-feature">1068 <span class="peach-pro-feature-mark">✦</span>1069 <div class="peach-pro-feature-text">1070 <span class="peach-pro-feature-name">Always in sync</span>1071 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1072 </div>1073 </div>1074 <div class="peach-pro-feature">1075 <span class="peach-pro-feature-mark">✦</span>1076 <div class="peach-pro-feature-text">1077 <span class="peach-pro-feature-name">No cloud. No relay.</span>1078 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1079 </div>1080 </div>1081 <div class="peach-pro-feature">1082 <span class="peach-pro-feature-mark">✦</span>1083 <div class="peach-pro-feature-text">1084 <span class="peach-pro-feature-name">Tailscale supported</span>1085 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1086 </div>1087 </div>1088 <div class="peach-pro-feature">1089 <span class="peach-pro-feature-mark">✦</span>1090 <div class="peach-pro-feature-text">1091 <span class="peach-pro-feature-name">Zero friction after setup</span>1092 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1093 </div>1094 </div>1095 </div>10961097 <button1098 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1099 onClick={() => setActiveSection("license")}1100 >1101 Upgrade to Pro1102 </button>1103 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1104 Already have a key? Enter it →1105 </button>1106 </div>1107 </div>1108 );1109 }11101111 return (1112 <div class="peach-settings-body">1113 <div class="peach-settings-group">1114 <div class="peach-settings-row">1115 <span>Status</span>1116 <span class="peach-settings-value peach-settings-value--status">1117 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1118 {getStatusLabel(syncStatus())}1119 </span>1120 </div>1121 <div class="peach-settings-row peach-settings-row--meta">1122 <span>Last sync</span>1123 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1124 </div>1125 <div class="peach-settings-row peach-settings-row--meta">1126 <button1127 class="peach-btn peach-btn--subtle peach-btn--sm"1128 onClick={() => void handleSyncNow()}1129 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1130 >1131 {syncingNow() ? "Syncing..." : "Sync now"}1132 </button>1133 </div>11341135 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1136 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1137 {pairedDevices().map((device) => (1138 <div class="peach-sync-device-row">1139 <div class="peach-sync-device-info">1140 <span class="peach-sync-device-name">{device.displayName}</span>1141 <span class="peach-sync-device-meta">1142 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1143 </span>1144 </div>1145 <button1146 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1147 onClick={() => void handleRemoveDevice(device.deviceId)}1148 >1149 Remove1150 </button>1151 </div>1152 ))}1153 <div class="peach-settings-actions--row">1154 <button1155 class="peach-btn peach-btn--subtle peach-btn--sm"1156 onClick={() => props.onOpenSyncPairing?.("initiate")}1157 >1158 Pair a new device1159 </button>1160 <button1161 class="peach-btn peach-btn--subtle peach-btn--sm"1162 onClick={() => props.onOpenSyncPairing?.("accept")}1163 >1164 Pair with another device1165 </button>1166 </div>1167 </div>11681169 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1170 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1171 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1172 <div class="peach-sync-device-row">1173 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1174 <button1175 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1176 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1177 >1178 Remove1179 </button>1180 </div>1181 ))}1182 <div class="peach-settings-actions--row">1183 <Show when={!isCurrentNetworkTrusted()}>1184 <button1185 class="peach-btn peach-btn--subtle peach-btn--sm"1186 onClick={() => void handleMarkNetworkTrusted()}1187 >1188 Mark current network as trusted1189 </button>1190 </Show>1191 </div>1192 <span class="peach-sync-device-meta">1193 Tailscale is always trusted1194 </span>1195 </div>1196 </div>1197 </div>1198 );1199 }12001201 function renderAppearance() {1202 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1203 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1204 { id: "cormorant", label: "Cormorant", preview: "Aa" },1205 { id: "inter", label: "Inter", preview: "Aa" },1206 { id: "system", label: "System", preview: "Aa" },1207 ];1208 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1209 { id: "small", label: "Small" },1210 { id: "medium", label: "Default" },1211 { id: "large", label: "Large" },1212 ];1213 return (1214 <div class="peach-settings-body">1215 <div class="peach-settings-group">1216 <p class="peach-settings-sub-label">THEME</p>1217 <div class="peach-settings-row peach-settings-row--meta">1218 <span>Theme</span>1219 <button1220 class="peach-btn peach-btn--subtle peach-btn--sm"1221 onClick={() =>1222 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1223 }1224 >1225 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1226 </button>1227 </div>12281229 <p class="peach-settings-sub-label">ACCENT</p>1230 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1231 <span>Accent</span>1232 <div class="peach-accent-picker">1233 <For each={ACCENT_COLORS}>1234 {(color) => (1235 <button1236 class="peach-accent-swatch"1237 classList={{ "is-active": props.accentColor === color.name }}1238 style={{ background: color.hex }}1239 onClick={() => void props.onAccentChange(color.name)}1240 aria-label={color.name}1241 title={color.name}1242 />1243 )}1244 </For>1245 </div>1246 </div>12471248 <p class="peach-settings-sub-label">DISPLAY FONT</p>1249 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1250 <span>Font</span>1251 <div class="peach-font-picker">1252 <For each={fontOptions}>1253 {(opt) => (1254 <button1255 class="peach-font-swatch"1256 classList={{ "is-active": props.displayFont === opt.id }}1257 data-font-preview={opt.id}1258 onClick={() => void props.onDisplayFontChange(opt.id)}1259 aria-label={opt.label}1260 title={opt.label}1261 >1262 <span class="peach-font-swatch-glyph">{opt.preview}</span>1263 <span class="peach-font-swatch-name">{opt.label}</span>1264 </button>1265 )}1266 </For>1267 </div>1268 </div>12691270 <p class="peach-settings-sub-label">TEXT SIZE</p>1271 <div class="peach-settings-row peach-settings-row--meta">1272 <span>Size</span>1273 <div class="peach-pill-group">1274 <For each={sizeOptions}>1275 {(opt) => (1276 <button1277 class="peach-btn peach-btn--ghost peach-btn--sm"1278 classList={{ "is-active": props.textSize === opt.id }}1279 onClick={() => void props.onTextSizeChange(opt.id)}1280 >1281 {opt.label}1282 </button>1283 )}1284 </For>1285 </div>1286 </div>12871288 <p class="peach-settings-sub-label">MOTION</p>1289 <div class="peach-settings-row peach-settings-row--meta">1290 <span>Reduce motion</span>1291 <button1292 class="peach-btn peach-btn--subtle peach-btn--sm"1293 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1294 >1295 {props.reduceMotion ? "On" : "Off"}1296 </button>1297 </div>1298 </div>1299 </div>1300 );1301 }13021303 function renderShortcuts() {1304 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1305 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13061307 function startCapture(action: ShortcutAction) {1308 setConflictMsg(null);1309 setCapturingAction(action);1310 }1311 function cancelCapture() {1312 setCapturingAction(null);1313 setConflictMsg(null);1314 }1315 function commitBinding(action: ShortcutAction, binding: Keybinding) {1316 const conflict = findConflict(props.shortcuts, binding, action);1317 if (conflict) {1318 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1319 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1320 return;1321 }1322 const next = { ...props.shortcuts, [action]: binding };1323 setCapturingAction(null);1324 setConflictMsg(null);1325 void props.onShortcutsChange(next);1326 }1327 function resetAction(action: ShortcutAction) {1328 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1329 void props.onShortcutsChange(next);1330 }1331 function resetAll() {1332 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1333 }13341335 1336 1337 function handleCaptureKey(e: KeyboardEvent) {1338 e.preventDefault();1339 e.stopPropagation();1340 if (e.key === "Escape") { cancelCapture(); return; }1341 const binding = keybindingFromEvent(e);1342 if (!binding) return; 1343 const action = capturingAction();1344 if (action) commitBinding(action, binding);1345 }13461347 return (1348 <div class="peach-settings-body">1349 <div class="peach-settings-group peach-shortcuts-group">1350 <p class="peach-settings-sub-label">ENTRY VIEW</p>1351 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1352 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1353 </For>1354 </div>13551356 <div class="peach-settings-group peach-shortcuts-group">1357 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1358 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1359 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1360 </For>1361 </div>13621363 <Show when={conflictMsg()}>1364 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1365 </Show>13661367 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1368 <div class="peach-settings-row peach-settings-row--meta">1369 <span>Inline autofill on page</span>1370 <a1371 class="peach-settings-link"1372 href="chrome://extensions/shortcuts"1373 onClick={(e) => {1374 e.preventDefault();1375 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1376 }}1377 >1378 ⌘⇧P — customize ↗1379 </a>1380 </div>1381 <button1382 class="peach-btn peach-btn--subtle peach-btn--sm"1383 style="align-self:flex-start"1384 onClick={resetAll}1385 >1386 Reset all to defaults1387 </button>1388 </div>1389 </div>1390 );13911392 function renderRow(1393 desc: typeof SHORTCUT_DESCRIPTORS[number],1394 capturing: () => ShortcutAction | null,1395 onStart: (a: ShortcutAction) => void,1396 onCancel: () => void,1397 onReset: (a: ShortcutAction) => void,1398 onKey: (e: KeyboardEvent) => void,1399 ) {1400 const isActive = () => capturing() === desc.action;1401 const binding = () => props.shortcuts[desc.action];1402 const isCustom = () => {1403 const b = binding();1404 const d = DEFAULT_BINDINGS[desc.action];1405 return b.key !== d.key1406 || Boolean(b.ctrl) !== Boolean(d.ctrl)1407 || Boolean(b.meta) !== Boolean(d.meta)1408 || Boolean(b.shift) !== Boolean(d.shift)1409 || Boolean(b.alt) !== Boolean(d.alt);1410 };1411 return (1412 <div class="peach-shortcut-row">1413 <span class="peach-shortcut-row-label">{desc.label}</span>1414 <button1415 class="peach-shortcut-chip"1416 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1417 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1418 onKeyDown={(e) => { if (isActive()) onKey(e); }}1419 ref={(el) => {1420 1421 if (el) {1422 queueMicrotask(() => { if (isActive()) el.focus(); });1423 }1424 }}1425 >1426 {isActive() ? "Press a key…" : formatKeybinding(binding())}1427 </button>1428 <Show when={isCustom() && !isActive()}>1429 <button1430 class="peach-shortcut-reset"1431 title="Reset to default"1432 onClick={() => onReset(desc.action)}1433 >1434 ↺1435 </button>1436 </Show>1437 </div>1438 );1439 }1440 }14411442 const PRO_FEATURES: Array<{1443 feature: ProFeature;1444 name: string;1445 desc: string;1446 }> = [1447 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1448 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1449 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1450 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1451 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1452 ];14531454 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);14551456 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1457 const [deactivating, setDeactivating] = createSignal(false);14581459 async function handleDeactivateLicense() {1460 setDeactivating(true);1461 await sendMessage({ type: "DEACTIVATE_LICENSE" });1462 setLicenseInfo(null);1463 await props.onLicenseChange?.();1464 setDeactivating(false);1465 }14661467 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1468 setCanaryTestingId(entry.id);1469 setCanaryTestResult(null);1470 const res = await sendMessage({1471 type: "FIRE_CANARY_PING",1472 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1473 }) as { ok: boolean };1474 setCanaryTestResult({ id: entry.id, ok: res.ok });1475 setCanaryTestingId(null);1476 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1477 }14781479 function renderLicense() {1480 async function onActivate() {1481 await handleActivateLicense();1482 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1483 await props.onLicenseChange?.();1484 }1485 }14861487 return (1488 <div class="peach-settings-body">1489 <Show1490 when={isPro()}1491 fallback={1492 <Show1493 when={showKeyEntry()}1494 fallback={1495 <div class="peach-pro-upgrade">1496 <div class="peach-pro-upgrade-hero">1497 <div class="peach-pro-upgrade-mark-wrap">1498 <PeachMark class="peach-pro-upgrade-mark" />1499 </div>1500 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1501 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1502 </div>15031504 <div class="peach-pro-features">1505 <For each={PRO_FEATURES}>1506 {(item) => (1507 <div1508 class="peach-pro-feature"1509 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1510 >1511 <span class="peach-pro-feature-mark">✦</span>1512 <div class="peach-pro-feature-text">1513 <span class="peach-pro-feature-name">{item.name}</span>1514 <span class="peach-pro-feature-desc">{item.desc}</span>1515 </div>1516 </div>1517 )}1518 </For>1519 </div>15201521 <div class="peach-pro-upgrade-actions">1522 <p class="peach-pro-pricing">$39 · once · yours forever</p>1523 <a1524 href="https://peachpasswords.com/#pricing"1525 target="_blank"1526 rel="noopener noreferrer"1527 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1528 >1529 Get Peach Pro1530 </a>1531 <div class="peach-pro-key-divider"><span>or</span></div>1532 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1533 Activate a license key1534 </button>1535 </div>1536 </div>1537 }1538 >1539 <div class="peach-pro-key-ceremony">1540 <LockRuneCanvas />1541 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1542 <input1543 class="peach-underline-input peach-pro-key-input"1544 type="text"1545 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1546 value={licenseKeyInput()}1547 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1548 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1549 autocomplete="off"1550 spellcheck={false}1551 />1552 <Show when={licenseError()}>1553 <p class="peach-pro-key-error">That key doesn't look right.</p>1554 </Show>1555 <button1556 class="peach-btn peach-btn--accent peach-pro-key-activate"1557 onClick={() => void onActivate()}1558 disabled={activatingLicense() || !licenseKeyInput().trim()}1559 >1560 {activatingLicense() ? "activating…" : "Activate"}1561 </button>1562 <button1563 class="peach-pro-key-cancel"1564 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1565 >1566 cancel1567 </button>1568 </div>1569 </Show>1570 }1571 >1572 <div class="peach-pro-active">1573 <div class="peach-pro-active-hero">1574 <div class="peach-pro-active-mark-wrap">1575 <PeachMark class="peach-pro-active-mark" />1576 </div>1577 <h2 class="peach-pro-active-title">Peach Pro</h2>1578 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1579 </div>15801581 <Show when={licenseKey()}>1582 <div class="peach-settings-license">1583 <span class="peach-settings-label">LICENSE KEY</span>1584 <div class="peach-settings-license-row">1585 <span class="peach-settings-license-key">{licenseKey()!}</span>1586 <button1587 class="peach-settings-license-copy"1588 onClick={() => copyLicenseKey(licenseKey()!)}1589 >1590 {copied() ? "✓" : "copy"}1591 </button>1592 </div>1593 </div>1594 </Show>15951596 <div class="peach-pro-active-features">1597 <For each={PRO_FEATURES}>1598 {(item) => (1599 <div class="peach-pro-active-feature">1600 <span class="peach-pro-active-feature-mark">✦</span>1601 <span class="peach-pro-active-feature-name">{item.name}</span>1602 </div>1603 )}1604 </For>1605 </div>16061607 <button1608 class="peach-pro-deactivate"1609 onClick={() => void handleDeactivateLicense()}1610 disabled={deactivating()}1611 >1612 {deactivating() ? "Deactivating…" : "Deactivate"}1613 </button>1614 </div>1615 </Show>1616 </div>1617 );1618 }16191620 function renderFeedback() {1621 return (1622 <div class="peach-settings-body">1623 <div class="peach-settings-group peach-feedback-group">1624 <p class="peach-feedback-intro">1625 Help us improve Peach. Every report goes directly to our inbox.1626 </p>16271628 <p class="peach-settings-sub-label">TYPE</p>1629 <div class="peach-feedback-chips">1630 <For each={FEEDBACK_CATEGORIES}>1631 {(cat) => (1632 <button1633 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1634 onClick={() => setFbCategory(cat)}1635 >1636 {cat}1637 </button>1638 )}1639 </For>1640 </div>16411642 <p class="peach-settings-sub-label">MESSAGE</p>1643 <textarea1644 class="peach-feedback-textarea"1645 placeholder="Describe the issue or idea..."1646 value={fbMessage()}1647 onInput={(e) => setFbMessage(e.currentTarget.value)}1648 />16491650 <p class="peach-feedback-meta">1651 Version {version} · will be included automatically1652 </p>16531654 <Show when={fbResult() === "success"}>1655 <p class="peach-feedback-result peach-feedback-result--ok">1656 Sent — thank you1657 </p>1658 </Show>1659 <Show when={fbResult() === "error"}>1660 <p class="peach-feedback-result peach-feedback-result--err">1661 Failed to send — try again1662 </p>1663 </Show>16641665 <button1666 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1667 onClick={() => void handleSubmitFeedback()}1668 disabled={fbSubmitting() || !fbMessage().trim()}1669 >1670 {fbSubmitting() ? "Sending..." : "Send feedback"}1671 </button>16721673 <p class="peach-feedback-anon">1674 Sent anonymously · no account required1675 </p>1676 </div>1677 </div>1678 );1679 }16801681 function renderMobile() {1682 return (1683 <div class="peach-settings-body">1684 <div class="peach-settings-group">1685 <p class="peach-settings-label">Autofill service</p>1686 <div class="peach-mobile-row">1687 <span class="peach-mobile-status">1688 {autofillEnabled() ? "Enabled" : "Disabled"}1689 </span>1690 <button1691 class="peach-btn peach-btn--subtle peach-btn--sm"1692 onClick={() => void AutofillPlugin?.openAutofillSettings()}1693 >1694 {autofillEnabled() ? "Change" : "Enable"}1695 </button>1696 </div>1697 </div>16981699 <div class="peach-settings-group">1700 <p class="peach-settings-label">Biometric unlock</p>1701 <div class="peach-mobile-row">1702 <span class="peach-mobile-status">1703 {biometricEnabled() ? "Available" : "Not available"}1704 </span>1705 </div>1706 </div>1707 </div>1708 );1709 }17101711 return (1712 <div class="peach-overlay-view ov-enter-right" style="display:flex">1713 <div class="peach-overlay-header">1714 <button1715 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1716 onClick={handleHeaderBack}1717 aria-label="Back"1718 >1719 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1720 <path d="M6 1L1 6L6 11" />1721 </svg>1722 </button>1723 <span class="peach-overlay-title">{currentTitle()}</span>1724 <div class="peach-overlay-spacer"></div>1725 </div>17261727 <Switch fallback={renderHome()}>1728 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1729 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1730 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1731 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1732 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1733 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1734 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1735 <Match when={activeSection() === "mobile"}>{renderMobile()}</Match>1736 </Switch>1737 </div>1738 );1739}