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 const [resetIdentityConfirm, setResetIdentityConfirm] = createSignal(false);359 const [resetIdentityRunning, setResetIdentityRunning] = createSignal(false);360361 async function handleResetSyncIdentity() {362 setResetIdentityRunning(true);363 const res = await sendMessage({ type: "RESET_SYNC_IDENTITY" });364 setResetIdentityRunning(false);365 if ((res as { ok: boolean }).ok) {366 setPairedDevices([]);367 setResetIdentityConfirm(false);368 }369 }370371 function formatRelativeTime(timestamp?: number): string {372 if (!timestamp) return "Never";373 const diff = Date.now() - timestamp;374 const minutes = Math.floor(diff / 60000);375 const hours = Math.floor(diff / 3600000);376 const days = Math.floor(diff / 86400000);377 if (minutes < 1) return "Just now";378 if (minutes < 60) return `${minutes}m ago`;379 if (hours < 24) return `${hours}h ago`;380 return `${days}d ago`;381 }382383 function getStatusColor(status: SyncStatus): string {384 switch (status) {385 case "synced": return "var(--peach-sync-ok)";386 case "syncing": return "var(--peach-sync-busy)";387 case "no_peers": return "var(--peach-sync-off)";388 case "not_trusted": return "var(--peach-sync-err)";389 case "offline": return "var(--peach-sync-off)";390 default: return "var(--peach-sync-off)";391 }392 }393394 function getStatusLabel(status: SyncStatus): string {395 switch (status) {396 case "synced": return "Synced";397 case "syncing": return "Syncing";398 case "no_peers": return "No peers visible";399 case "not_trusted": return "Not on a trusted network";400 case "offline": return "Offline";401 default: return "Unknown";402 }403 }404405 function isCurrentNetworkTrusted(): boolean {406 return trustedNetworks().length > 0;407 }408409 async function handleSetupDuress() {410 const mainPw = duressMainPassword().trim();411 const duressPw = duressPassword().trim();412 const confirmPw = duressConfirm().trim();413414 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }415 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }416 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }417 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }418419 setDuressSaving(true);420 setDuressError(null);421422 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };423 if (!verifyRes.ok || !verifyRes.data) {424 setDuressError("incorrect main password");425 setDuressSaving(false);426 return;427 }428429 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };430 setDuressSaving(false);431 if (res.ok) {432 setDuressEnabled(true);433 setShowDuressSetup(false);434 setDuressPassword("");435 setDuressConfirm("");436 setDuressMainPassword("");437 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");438 setTimeout(() => setDuressSuccess(null), 5000);439 } else {440 setDuressError(res.error ?? "failed to set duress password");441 }442 }443444 async function handleClearDeclinedCards() {445 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });446 setDeclinedCardCount(0);447 }448449 async function handleCountDuplicates() {450 setDedupRunning(true);451 setDedupDone(null);452 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };453 setDedupRunning(false);454 if (res.ok) setDedupCount(res.data ?? 0);455 }456457 async function handleDeduplicate() {458 setDedupRunning(true);459 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };460 setDedupRunning(false);461 if (res.ok) {462 setDedupDone(res.data ?? 0);463 setDedupCount(0);464 }465 }466467 async function handleDisableDuress() {468 const mainPw = duressMainPassword().trim();469 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }470471 setDuressSaving(true);472 setDuressError(null);473474 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };475 if (!verifyRes.ok || !verifyRes.data) {476 setDuressError("incorrect main password");477 setDuressSaving(false);478 return;479 }480481 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };482 setDuressSaving(false);483 if (res.ok) {484 setDuressEnabled(false);485 setShowDuressSetup(false);486 setDuressMainPassword("");487 setDuressSuccess("Duress password disabled.");488 setTimeout(() => setDuressSuccess(null), 4000);489 } else {490 setDuressError(res.error ?? "failed to disable duress password");491 }492 }493494 async function handleSubmitFeedback() {495 if (!fbMessage().trim() || fbSubmitting()) return;496 setFbSubmitting(true);497 setFbResult(null);498 const res = await sendMessage({499 type: "SUBMIT_FEEDBACK",500 payload: {501 category: fbCategory(),502 message: fbMessage().trim(),503 version,504 browser: navigator.userAgent,505 },506 }) as { ok: boolean; error?: string };507 setFbSubmitting(false);508 if (res.ok) {509 setFbResult("success");510 setFbMessage("");511 setTimeout(() => setFbResult(null), 4000);512 } else {513 setFbResult("error");514 }515 }516517 function handleHeaderBack() {518 if (activeSection() !== null) {519 setActiveSection(null);520 } else {521 props.onBack();522 }523 }524525 function currentTitle(): string {526 const s = activeSection();527 if (s === null) return "settings";528 if (s === "license") return "account";529 return s;530 }531532 function renderCatRow(opts: {533 section: SectionName;534 name: string;535 desc: string;536 icon: () => any;537 pro?: boolean;538 }) {539 return (540 <button541 class="peach-settings-cat-row"542 onClick={() => setActiveSection(opts.section)}543 >544 <div class="peach-settings-cat-icon">{opts.icon()}</div>545 <div class="peach-settings-cat-content">546 <span class="peach-settings-cat-name">547 {opts.name}548 <Show when={opts.pro && !isPro()}>549 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>550 </Show>551 </span>552 <span class="peach-settings-cat-desc">{opts.desc}</span>553 </div>554 <svg555 class="peach-settings-cat-chevron"556 width="6" height="10" viewBox="0 0 6 10" fill="none"557 stroke="currentColor" stroke-width="1.1"558 stroke-linecap="round" stroke-linejoin="round"559 >560 <path d="M1 1l4 4-4 4" />561 </svg>562 </button>563 );564 }565566 const iconShield = () => (567 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">568 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>569 </svg>570 );571 const iconKey = () => (572 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">573 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>574 </svg>575 );576 const iconLock = () => (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 <rect x="3" y="7" width="10" height="7" rx="1.5"/>579 <path d="M6 7V5a3 3 0 016 0v2"/>580 </svg>581 );582 const iconSync = () => (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 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>585 <path d="M14 6l-2 2-2-2"/>586 <path d="M2 10l2-2 2 2"/>587 </svg>588 );589 const iconSun = () => (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 <circle cx="8" cy="8" r="3"/>592 <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"/>593 </svg>594 );595 const iconKeys = () => (596 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">597 <rect x="2" y="5" width="5" height="4" rx="1"/>598 <rect x="9" y="7" width="5" height="4" rx="1"/>599 <path d="M7 7h2m-2 2h2"/>600 </svg>601 );602 const iconBubble = () => (603 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">604 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>605 </svg>606 );607608 function renderHome() {609 return (610 <div class="peach-settings-body">611 <p class="peach-settings-nav-label">PROTECTION</p>612 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}613 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}614615 <p class="peach-settings-nav-label">VAULT</p>616 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}617 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}618619 <p class="peach-settings-nav-label">PREFERENCES</p>620 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}621 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}622 <Show when={isAndroid}>623 <p class="peach-settings-nav-label">MOBILE</p>624 {renderCatRow({ section: "mobile", name: "Mobile", desc: "Autofill & biometric settings", icon: iconSync })}625 </Show>626627 <p class="peach-settings-nav-label">SUPPORT</p>628 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}629630 <div class="peach-settings-footer">631 <span>Peach {version}</span>632 <span class="peach-divider"></span>633 <span>Browser extension</span>634 </div>635 </div>636 );637 }638639 function renderSecurity() {640 return (641 <div class="peach-settings-body">642 <div class="peach-settings-group">643 <p class="peach-settings-sub-label">LOCKING</p>644 <div class="peach-settings-row">645 <span>Lock on close</span>646 <button647 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}648 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}649 aria-label="Toggle lock on close"650 >651 <span class="peach-toggle-knob"></span>652 </button>653 </div>654 <div class="peach-settings-row">655 <span>Auto-lock after</span>656 <select657 class="peach-settings-select"658 value={String(props.autoLockMinutes)}659 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}660 >661 <option value="1">1 minute</option>662 <option value="5">5 minutes</option>663 <option value="15">15 minutes</option>664 <option value="30">30 minutes</option>665 <option value="0">Never</option>666 </select>667 </div>668669 <p class="peach-settings-sub-label">BREACH PROTECTION</p>670 <div class="peach-settings-row">671 <span>Breach monitoring</span>672 <button673 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}674 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}675 aria-label="Toggle breach monitoring"676 >677 <span class="peach-toggle-knob"></span>678 </button>679 </div>680 <div class="peach-settings-row peach-settings-row--meta">681 <span>Check all passwords</span>682 <button683 class="peach-btn peach-btn--subtle peach-btn--sm"684 onClick={() => void handleCheckAllBreaches()}685 disabled={checkingBreaches()}686 >687 {checkingBreaches() ? "checking..." : "run"}688 </button>689 </div>690 <Show when={breachResult()}>691 <div class="peach-settings-row peach-settings-row--meta">692 <span>Last check</span>693 <span class="peach-settings-value">{breachResult()}</span>694 </div>695 </Show>696697 <p class="peach-settings-sub-label">MASTER PASSWORD</p>698 <div class="peach-settings-row peach-settings-row--meta">699 <span>Master password</span>700 <button701 class="peach-btn peach-btn--subtle peach-btn--sm"702 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}703 >704 {showChangePw() ? "cancel" : "change"}705 </button>706 </div>707 <Show when={showChangePw()}>708 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">709 <input710 class="peach-settings-input"711 type="password"712 placeholder="current password"713 value={changePwCurrent()}714 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}715 autocomplete="current-password"716 />717 <input718 class="peach-settings-input"719 type="password"720 placeholder="new password (min 12 chars)"721 value={changePwNew()}722 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}723 autocomplete="new-password"724 />725 <input726 class="peach-settings-input"727 type="password"728 placeholder="confirm new password"729 value={changePwConfirm()}730 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}731 autocomplete="new-password"732 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}733 />734 <Show when={changePwError()}>735 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>736 </Show>737 <Show when={changePwSuccess()}>738 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>739 </Show>740 <button741 class="peach-btn peach-btn--accent peach-btn--sm"742 style="align-self:flex-start"743 onClick={() => void handleChangePassword()}744 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}745 >746 {changePwSaving() ? "saving…" : "save new password"}747 </button>748 </div>749 </Show>750751 <p class="peach-settings-sub-label">DURESS PASSWORD</p>752 <div class="peach-settings-row peach-settings-row--top">753 <div class="peach-settings-row-main">754 <span>Duress password</span>755 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">756 If entered under coercion, shows a decoy empty vault instead of your real one.757 </span>758 </div>759 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>760 {duressEnabled() ? "on" : "off"}761 </div>762 </div>763764 <Show when={duressEnabled() && !showDuressSetup()}>765 <div class="peach-settings-row peach-settings-row--meta">766 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">767 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.768 </span>769 </div>770 </Show>771772 <Show when={duressSuccess()}>773 <div class="peach-settings-row peach-settings-row--meta">774 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>775 </div>776 </Show>777778 <Show when={!showDuressSetup()}>779 <div class="peach-settings-row peach-settings-row--meta">780 <button781 class="peach-btn peach-btn--subtle peach-btn--sm"782 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}783 >784 {duressEnabled() ? "Change / disable" : "Set up"}785 </button>786 </div>787 </Show>788789 <Show when={showDuressSetup()}>790 <div class="peach-duress-form">791 <label class="peach-label">main password (to confirm)</label>792 <input793 class="peach-settings-input"794 type="password"795 placeholder="your current master password"796 autocomplete="current-password"797 value={duressMainPassword()}798 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}799 />800 <Show when={!duressEnabled()}>801 <label class="peach-label">new duress password</label>802 <input803 class="peach-settings-input"804 type="password"805 placeholder="duress password (min 8 chars)"806 autocomplete="new-password"807 value={duressPassword()}808 onInput={(e) => setDuressPassword(e.currentTarget.value)}809 />810 <label class="peach-label">confirm duress password</label>811 <input812 class="peach-settings-input"813 type="password"814 placeholder="confirm duress password"815 autocomplete="new-password"816 value={duressConfirm()}817 onInput={(e) => setDuressConfirm(e.currentTarget.value)}818 />819 </Show>820821 <Show when={duressError()}>822 <p class="peach-hint peach-hint-error">{duressError()}</p>823 </Show>824825 <div class="peach-duress-actions">826 <button827 class="peach-btn peach-btn--subtle peach-btn--sm"828 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}829 >830 cancel831 </button>832 <Show when={duressEnabled()}>833 <button834 class="peach-btn peach-btn--ghost peach-btn--sm"835 style="color:var(--peach-breach)"836 onClick={() => void handleDisableDuress()}837 disabled={duressSaving()}838 >839 {duressSaving() ? "disabling..." : "disable duress"}840 </button>841 </Show>842 <Show when={!duressEnabled()}>843 <button844 class="peach-btn peach-btn--accent peach-btn--sm"845 onClick={() => void handleSetupDuress()}846 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}847 >848 {duressSaving() ? "saving..." : "save"}849 </button>850 </Show>851 </div>852 </div>853 </Show>854855 <p class="peach-settings-sub-label">CANARY ENTRIES</p>856 <div class="peach-settings-row peach-settings-row--top">857 <div class="peach-settings-row-main">858 <span>Canary entries</span>859 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">860 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.861 </span>862 </div>863 <span class="peach-settings-value" style="flex-shrink:0">864 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}865 </span>866 </div>867 <Show when={canaryEntries().length > 0}>868 <For each={canaryEntries()}>869 {(entry) => (870 <div class="peach-settings-row peach-settings-row--meta">871 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>872 <button873 class="peach-btn peach-btn--subtle peach-btn--sm"874 onClick={() => void handleTestCanary(entry)}875 disabled={canaryTestingId() === entry.id}876 style="flex-shrink:0"877 >878 {canaryTestingId() === entry.id879 ? "sending…"880 : canaryTestResult()?.id === entry.id881 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")882 : "test"}883 </button>884 </div>885 )}886 </For>887 </Show>888889 <p class="peach-settings-sub-label">PASSKEYS</p>890 <div class="peach-settings-row peach-settings-row--top">891 <div class="peach-settings-row-main">892 <span>Intercept passkey requests</span>893 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">894 Peach can handle passkey sign-in and creation on supported Chromium browsers.895 </span>896 </div>897 <button898 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}899 onClick={() => void handleTogglePasskeys()}900 aria-label="Toggle passkey interception"901 disabled={!passkeyState()?.supported || passkeySaving()}902 >903 <span class="peach-toggle-knob"></span>904 </button>905 </div>906 <div class="peach-settings-row peach-settings-row--meta">907 <span>Status</span>908 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>909 </div>910 <div class="peach-settings-row peach-settings-row--meta">911 <span>Browser</span>912 <span class="peach-settings-value">913 {passkeyState()?.browserLabel ?? "Unknown"}914 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}915 </span>916 </div>917 <div class="peach-info-notice" style="margin-top:8px">918 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.919 </div>920 <Show when={passkeyState()?.conflict}>921 <div class="peach-settings-row peach-settings-row--meta">922 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>923 </div>924 </Show>925 </div>926 </div>927 );928 }929930 function renderVault() {931 return (932 <div class="peach-settings-body">933 <div class="peach-settings-group">934 <p class="peach-settings-sub-label">ENTRIES</p>935 <div class="peach-settings-row peach-settings-row--meta">936 <span>Entries</span>937 <span class="peach-settings-value">{props.entriesCount}</span>938 </div>939 <div class="peach-settings-row peach-settings-row--meta">940 <span>Duplicates</span>941 <Show when={dedupDone() !== null} fallback={942 <button943 class="peach-btn peach-btn--subtle peach-btn--sm"944 onClick={() => void handleCountDuplicates()}945 disabled={dedupRunning()}946 >947 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}948 </button>949 }>950 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">951 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}952 </span>953 </Show>954 </div>955 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>956 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">957 <div class="peach-restore-warning">958 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.959 </div>960 <div style="display:flex;gap:6px">961 <button962 class="peach-btn peach-btn--subtle peach-btn--sm"963 onClick={() => setDedupCount(null)}964 >965 cancel966 </button>967 <button968 class="peach-btn peach-btn--accent peach-btn--sm"969 onClick={() => void handleDeduplicate()}970 disabled={dedupRunning()}971 >972 {dedupRunning() ? "removing..." : "remove duplicates"}973 </button>974 </div>975 </div>976 </Show>977978 <p class="peach-settings-sub-label">MAINTENANCE</p>979 <div class="peach-settings-row peach-settings-row--meta">980 <span>Trash auto-delete</span>981 <select982 class="peach-settings-select"983 value={String(props.trashRetentionDays)}984 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}985 >986 <option value="7">7 days</option>987 <option value="30">30 days</option>988 <option value="90">90 days</option>989 <option value="0">Never</option>990 </select>991 </div>992 <div class="peach-settings-row peach-settings-row--meta">993 <span>Vault Health</span>994 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>995 open996 </button>997 </div>998 <Show when={declinedCardCount() > 0}>999 <div class="peach-settings-row peach-settings-row--meta">1000 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1001 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1002 Clear1003 </button>1004 </div>1005 </Show>10061007 <p class="peach-settings-sub-label">DATA</p>1008 <div class="peach-settings-row peach-settings-row--meta">1009 <span>Import passwords</span>1010 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1011 open1012 </button>1013 </div>1014 <div class="peach-settings-row peach-settings-row--meta">1015 <span>Export backup</span>1016 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1017 open1018 </button>1019 </div>1020 <div class="peach-danger-card">1021 <div class="peach-danger-card-head">1022 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1023 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1024 </div>1025 <p class="peach-danger-card-copy">1026 Wipes the current vault and restores from a backup file.1027 Recoverable only if your backup is good.1028 </p>1029 <button1030 class="peach-danger-card-button"1031 onClick={() => props.onOpenFileImport?.()}1032 >1033 Choose backup file1034 </button>1035 </div>10361037 <Show when={props.onOpenPanic}>1038 <div class="peach-danger-card peach-danger-card--hazard">1039 <div class="peach-danger-card-head">1040 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1041 <h3 class="peach-danger-card-title">Destroy vault</h3>1042 </div>1043 <p class="peach-danger-card-copy">1044 Permanently wipes every entry, setting, and license on this1045 device. There is no recovery.1046 </p>1047 <button1048 class="peach-danger-card-button"1049 onClick={() => props.onOpenPanic?.()}1050 >1051 Begin destruction1052 </button>1053 </div>1054 </Show>1055 </div>1056 </div>1057 );1058 }10591060 function renderSync() {1061 if (!isPro()) {1062 return (1063 <div class="peach-settings-body">1064 <div class="peach-pro-upgrade">1065 <div class="peach-pro-upgrade-hero">1066 <svg class="peach-sync-hero-icon" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">1067 <circle cx="9" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1068 <circle cx="27" cy="18" r="4.5" stroke="currentColor" stroke-width="1.2" opacity="0.9"/>1069 <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"/>1070 <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"/>1071 <path d="M13.5 18H22.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.35"/>1072 <path d="M20 16L22.5 18L20 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1073 <path d="M16 16L13.5 18L16 20" stroke="var(--peach-accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>1074 </svg>1075 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1076 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1077 </div>10781079 <div class="peach-pro-features">1080 <div class="peach-pro-feature">1081 <span class="peach-pro-feature-mark">✦</span>1082 <div class="peach-pro-feature-text">1083 <span class="peach-pro-feature-name">Always in sync</span>1084 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1085 </div>1086 </div>1087 <div class="peach-pro-feature">1088 <span class="peach-pro-feature-mark">✦</span>1089 <div class="peach-pro-feature-text">1090 <span class="peach-pro-feature-name">No cloud. No relay.</span>1091 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1092 </div>1093 </div>1094 <div class="peach-pro-feature">1095 <span class="peach-pro-feature-mark">✦</span>1096 <div class="peach-pro-feature-text">1097 <span class="peach-pro-feature-name">Tailscale supported</span>1098 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1099 </div>1100 </div>1101 <div class="peach-pro-feature">1102 <span class="peach-pro-feature-mark">✦</span>1103 <div class="peach-pro-feature-text">1104 <span class="peach-pro-feature-name">Zero friction after setup</span>1105 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1106 </div>1107 </div>1108 </div>11091110 <button1111 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1112 onClick={() => setActiveSection("license")}1113 >1114 Upgrade to Pro1115 </button>1116 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1117 Already have a key? Enter it →1118 </button>1119 </div>1120 </div>1121 );1122 }11231124 return (1125 <div class="peach-settings-body">1126 <div class="peach-settings-group">1127 <div class="peach-settings-row">1128 <span>Status</span>1129 <span class="peach-settings-value peach-settings-value--status">1130 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1131 {getStatusLabel(syncStatus())}1132 </span>1133 </div>1134 <div class="peach-settings-row peach-settings-row--meta">1135 <span>Last sync</span>1136 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1137 </div>1138 <div class="peach-settings-row peach-settings-row--meta">1139 <button1140 class="peach-btn peach-btn--subtle peach-btn--sm"1141 onClick={() => void handleSyncNow()}1142 disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}1143 >1144 {syncingNow() ? "Syncing..." : "Sync now"}1145 </button>1146 </div>11471148 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1149 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1150 {pairedDevices().map((device) => (1151 <div class="peach-sync-device-row">1152 <div class="peach-sync-device-info">1153 <span class="peach-sync-device-name">{device.displayName}</span>1154 <span class="peach-sync-device-meta">1155 Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}1156 </span>1157 </div>1158 <button1159 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1160 onClick={() => void handleRemoveDevice(device.deviceId)}1161 >1162 Remove1163 </button>1164 </div>1165 ))}1166 <div class="peach-settings-actions--row">1167 <button1168 class="peach-btn peach-btn--subtle peach-btn--sm"1169 onClick={() => props.onOpenSyncPairing?.("initiate")}1170 >1171 Pair a new device1172 </button>1173 <button1174 class="peach-btn peach-btn--subtle peach-btn--sm"1175 onClick={() => props.onOpenSyncPairing?.("accept")}1176 >1177 Pair with another device1178 </button>1179 </div>1180 </div>11811182 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1183 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1184 {trustedNetworks().filter(n => !n.isTailscale).map((network) => (1185 <div class="peach-sync-device-row">1186 <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>1187 <button1188 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1189 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1190 >1191 Remove1192 </button>1193 </div>1194 ))}1195 <div class="peach-settings-actions--row">1196 <Show when={!isCurrentNetworkTrusted()}>1197 <button1198 class="peach-btn peach-btn--subtle peach-btn--sm"1199 onClick={() => void handleMarkNetworkTrusted()}1200 >1201 Mark current network as trusted1202 </button>1203 </Show>1204 </div>1205 <span class="peach-sync-device-meta">1206 Tailscale is always trusted1207 </span>1208 </div>12091210 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1211 <span class="peach-sync-section-label">Advanced</span>1212 <Show1213 when={resetIdentityConfirm()}1214 fallback={1215 <div class="peach-sync-advanced-row">1216 <div class="peach-sync-advanced-info">1217 <span class="peach-sync-advanced-name">Reset sync identity</span>1218 <span class="peach-sync-device-meta">1219 Generates a new keypair and unpairs every device.1220 </span>1221 </div>1222 <button1223 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1224 onClick={() => setResetIdentityConfirm(true)}1225 >1226 Reset1227 </button>1228 </div>1229 }1230 >1231 <div class="peach-sync-reset-confirm">1232 <p class="peach-sync-reset-confirm-headline">Reset sync identity?</p>1233 <p class="peach-sync-reset-confirm-body">1234 This will unpair all devices. You will need to pair them again from scratch.1235 </p>1236 <div class="peach-settings-actions--row">1237 <button1238 class="peach-btn peach-btn--ghost peach-btn--sm"1239 onClick={() => setResetIdentityConfirm(false)}1240 disabled={resetIdentityRunning()}1241 >1242 Cancel1243 </button>1244 <button1245 class="peach-btn peach-btn--danger peach-btn--sm"1246 onClick={() => void handleResetSyncIdentity()}1247 disabled={resetIdentityRunning()}1248 >1249 {resetIdentityRunning() ? "Resetting..." : "Reset"}1250 </button>1251 </div>1252 </div>1253 </Show>1254 </div>1255 </div>1256 </div>1257 );1258 }12591260 function renderAppearance() {1261 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1262 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1263 { id: "cormorant", label: "Cormorant", preview: "Aa" },1264 { id: "inter", label: "Inter", preview: "Aa" },1265 { id: "system", label: "System", preview: "Aa" },1266 ];1267 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1268 { id: "small", label: "Small" },1269 { id: "medium", label: "Default" },1270 { id: "large", label: "Large" },1271 ];1272 return (1273 <div class="peach-settings-body">1274 <div class="peach-settings-group">1275 <p class="peach-settings-sub-label">THEME</p>1276 <div class="peach-settings-row peach-settings-row--meta">1277 <span>Theme</span>1278 <button1279 class="peach-btn peach-btn--subtle peach-btn--sm"1280 onClick={() =>1281 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1282 }1283 >1284 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1285 </button>1286 </div>12871288 <p class="peach-settings-sub-label">ACCENT</p>1289 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1290 <span>Accent</span>1291 <div class="peach-accent-picker">1292 <For each={ACCENT_COLORS}>1293 {(color) => (1294 <button1295 class="peach-accent-swatch"1296 classList={{ "is-active": props.accentColor === color.name }}1297 style={{ background: color.hex }}1298 onClick={() => void props.onAccentChange(color.name)}1299 aria-label={color.name}1300 title={color.name}1301 />1302 )}1303 </For>1304 </div>1305 </div>13061307 <p class="peach-settings-sub-label">DISPLAY FONT</p>1308 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1309 <span>Font</span>1310 <div class="peach-font-picker">1311 <For each={fontOptions}>1312 {(opt) => (1313 <button1314 class="peach-font-swatch"1315 classList={{ "is-active": props.displayFont === opt.id }}1316 data-font-preview={opt.id}1317 onClick={() => void props.onDisplayFontChange(opt.id)}1318 aria-label={opt.label}1319 title={opt.label}1320 >1321 <span class="peach-font-swatch-glyph">{opt.preview}</span>1322 <span class="peach-font-swatch-name">{opt.label}</span>1323 </button>1324 )}1325 </For>1326 </div>1327 </div>13281329 <p class="peach-settings-sub-label">TEXT SIZE</p>1330 <div class="peach-settings-row peach-settings-row--meta">1331 <span>Size</span>1332 <div class="peach-pill-group">1333 <For each={sizeOptions}>1334 {(opt) => (1335 <button1336 class="peach-btn peach-btn--ghost peach-btn--sm"1337 classList={{ "is-active": props.textSize === opt.id }}1338 onClick={() => void props.onTextSizeChange(opt.id)}1339 >1340 {opt.label}1341 </button>1342 )}1343 </For>1344 </div>1345 </div>13461347 <p class="peach-settings-sub-label">MOTION</p>1348 <div class="peach-settings-row peach-settings-row--meta">1349 <span>Reduce motion</span>1350 <button1351 class="peach-btn peach-btn--subtle peach-btn--sm"1352 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1353 >1354 {props.reduceMotion ? "On" : "Off"}1355 </button>1356 </div>1357 </div>1358 </div>1359 );1360 }13611362 function renderShortcuts() {1363 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1364 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13651366 function startCapture(action: ShortcutAction) {1367 setConflictMsg(null);1368 setCapturingAction(action);1369 }1370 function cancelCapture() {1371 setCapturingAction(null);1372 setConflictMsg(null);1373 }1374 function commitBinding(action: ShortcutAction, binding: Keybinding) {1375 const conflict = findConflict(props.shortcuts, binding, action);1376 if (conflict) {1377 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1378 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1379 return;1380 }1381 const next = { ...props.shortcuts, [action]: binding };1382 setCapturingAction(null);1383 setConflictMsg(null);1384 void props.onShortcutsChange(next);1385 }1386 function resetAction(action: ShortcutAction) {1387 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1388 void props.onShortcutsChange(next);1389 }1390 function resetAll() {1391 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1392 }13931394 1395 1396 function handleCaptureKey(e: KeyboardEvent) {1397 e.preventDefault();1398 e.stopPropagation();1399 if (e.key === "Escape") { cancelCapture(); return; }1400 const binding = keybindingFromEvent(e);1401 if (!binding) return; 1402 const action = capturingAction();1403 if (action) commitBinding(action, binding);1404 }14051406 return (1407 <div class="peach-settings-body">1408 <div class="peach-settings-group peach-shortcuts-group">1409 <p class="peach-settings-sub-label">ENTRY VIEW</p>1410 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1411 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1412 </For>1413 </div>14141415 <div class="peach-settings-group peach-shortcuts-group">1416 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1417 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1418 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1419 </For>1420 </div>14211422 <Show when={conflictMsg()}>1423 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1424 </Show>14251426 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1427 <div class="peach-settings-row peach-settings-row--meta">1428 <span>Inline autofill on page</span>1429 <a1430 class="peach-settings-link"1431 href="chrome://extensions/shortcuts"1432 onClick={(e) => {1433 e.preventDefault();1434 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1435 }}1436 >1437 ⌘⇧P — customize ↗1438 </a>1439 </div>1440 <button1441 class="peach-btn peach-btn--subtle peach-btn--sm"1442 style="align-self:flex-start"1443 onClick={resetAll}1444 >1445 Reset all to defaults1446 </button>1447 </div>1448 </div>1449 );14501451 function renderRow(1452 desc: typeof SHORTCUT_DESCRIPTORS[number],1453 capturing: () => ShortcutAction | null,1454 onStart: (a: ShortcutAction) => void,1455 onCancel: () => void,1456 onReset: (a: ShortcutAction) => void,1457 onKey: (e: KeyboardEvent) => void,1458 ) {1459 const isActive = () => capturing() === desc.action;1460 const binding = () => props.shortcuts[desc.action];1461 const isCustom = () => {1462 const b = binding();1463 const d = DEFAULT_BINDINGS[desc.action];1464 return b.key !== d.key1465 || Boolean(b.ctrl) !== Boolean(d.ctrl)1466 || Boolean(b.meta) !== Boolean(d.meta)1467 || Boolean(b.shift) !== Boolean(d.shift)1468 || Boolean(b.alt) !== Boolean(d.alt);1469 };1470 return (1471 <div class="peach-shortcut-row">1472 <span class="peach-shortcut-row-label">{desc.label}</span>1473 <button1474 class="peach-shortcut-chip"1475 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1476 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1477 onKeyDown={(e) => { if (isActive()) onKey(e); }}1478 ref={(el) => {1479 1480 if (el) {1481 queueMicrotask(() => { if (isActive()) el.focus(); });1482 }1483 }}1484 >1485 {isActive() ? "Press a key…" : formatKeybinding(binding())}1486 </button>1487 <Show when={isCustom() && !isActive()}>1488 <button1489 class="peach-shortcut-reset"1490 title="Reset to default"1491 onClick={() => onReset(desc.action)}1492 >1493 ↺1494 </button>1495 </Show>1496 </div>1497 );1498 }1499 }15001501 const PRO_FEATURES: Array<{1502 feature: ProFeature;1503 name: string;1504 desc: string;1505 }> = [1506 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1507 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1508 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1509 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1510 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1511 ];15121513 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);15141515 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1516 const [deactivating, setDeactivating] = createSignal(false);15171518 async function handleDeactivateLicense() {1519 setDeactivating(true);1520 await sendMessage({ type: "DEACTIVATE_LICENSE" });1521 setLicenseInfo(null);1522 await props.onLicenseChange?.();1523 setDeactivating(false);1524 }15251526 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1527 setCanaryTestingId(entry.id);1528 setCanaryTestResult(null);1529 const res = await sendMessage({1530 type: "FIRE_CANARY_PING",1531 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1532 }) as { ok: boolean };1533 setCanaryTestResult({ id: entry.id, ok: res.ok });1534 setCanaryTestingId(null);1535 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1536 }15371538 function renderLicense() {1539 async function onActivate() {1540 await handleActivateLicense();1541 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1542 await props.onLicenseChange?.();1543 }1544 }15451546 return (1547 <div class="peach-settings-body">1548 <Show1549 when={isPro()}1550 fallback={1551 <Show1552 when={showKeyEntry()}1553 fallback={1554 <div class="peach-pro-upgrade">1555 <div class="peach-pro-upgrade-hero">1556 <div class="peach-pro-upgrade-mark-wrap">1557 <PeachMark class="peach-pro-upgrade-mark" />1558 </div>1559 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1560 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1561 </div>15621563 <div class="peach-pro-features">1564 <For each={PRO_FEATURES}>1565 {(item) => (1566 <div1567 class="peach-pro-feature"1568 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1569 >1570 <span class="peach-pro-feature-mark">✦</span>1571 <div class="peach-pro-feature-text">1572 <span class="peach-pro-feature-name">{item.name}</span>1573 <span class="peach-pro-feature-desc">{item.desc}</span>1574 </div>1575 </div>1576 )}1577 </For>1578 </div>15791580 <div class="peach-pro-upgrade-actions">1581 <p class="peach-pro-pricing">$39 · once · yours forever</p>1582 <a1583 href="https://peachpasswords.com/#pricing"1584 target="_blank"1585 rel="noopener noreferrer"1586 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1587 >1588 Get Peach Pro1589 </a>1590 <div class="peach-pro-key-divider"><span>or</span></div>1591 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1592 Activate a license key1593 </button>1594 </div>1595 </div>1596 }1597 >1598 <div class="peach-pro-key-ceremony">1599 <LockRuneCanvas />1600 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1601 <input1602 class="peach-underline-input peach-pro-key-input"1603 type="text"1604 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1605 value={licenseKeyInput()}1606 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1607 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1608 autocomplete="off"1609 spellcheck={false}1610 />1611 <Show when={licenseError()}>1612 <p class="peach-pro-key-error">That key doesn't look right.</p>1613 </Show>1614 <button1615 class="peach-btn peach-btn--accent peach-pro-key-activate"1616 onClick={() => void onActivate()}1617 disabled={activatingLicense() || !licenseKeyInput().trim()}1618 >1619 {activatingLicense() ? "activating…" : "Activate"}1620 </button>1621 <button1622 class="peach-pro-key-cancel"1623 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1624 >1625 cancel1626 </button>1627 </div>1628 </Show>1629 }1630 >1631 <div class="peach-pro-active">1632 <div class="peach-pro-active-hero">1633 <div class="peach-pro-active-mark-wrap">1634 <PeachMark class="peach-pro-active-mark" />1635 </div>1636 <h2 class="peach-pro-active-title">Peach Pro</h2>1637 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1638 </div>16391640 <Show when={licenseKey()}>1641 <div class="peach-settings-license">1642 <span class="peach-settings-label">LICENSE KEY</span>1643 <div class="peach-settings-license-row">1644 <span class="peach-settings-license-key">{licenseKey()!}</span>1645 <button1646 class="peach-settings-license-copy"1647 onClick={() => copyLicenseKey(licenseKey()!)}1648 >1649 {copied() ? "✓" : "copy"}1650 </button>1651 </div>1652 </div>1653 </Show>16541655 <div class="peach-pro-active-features">1656 <For each={PRO_FEATURES}>1657 {(item) => (1658 <div class="peach-pro-active-feature">1659 <span class="peach-pro-active-feature-mark">✦</span>1660 <span class="peach-pro-active-feature-name">{item.name}</span>1661 </div>1662 )}1663 </For>1664 </div>16651666 <button1667 class="peach-pro-deactivate"1668 onClick={() => void handleDeactivateLicense()}1669 disabled={deactivating()}1670 >1671 {deactivating() ? "Deactivating…" : "Deactivate"}1672 </button>1673 </div>1674 </Show>1675 </div>1676 );1677 }16781679 function renderFeedback() {1680 return (1681 <div class="peach-settings-body">1682 <div class="peach-settings-group peach-feedback-group">1683 <p class="peach-feedback-intro">1684 Help us improve Peach. Every report goes directly to our inbox.1685 </p>16861687 <p class="peach-settings-sub-label">TYPE</p>1688 <div class="peach-feedback-chips">1689 <For each={FEEDBACK_CATEGORIES}>1690 {(cat) => (1691 <button1692 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1693 onClick={() => setFbCategory(cat)}1694 >1695 {cat}1696 </button>1697 )}1698 </For>1699 </div>17001701 <p class="peach-settings-sub-label">MESSAGE</p>1702 <textarea1703 class="peach-feedback-textarea"1704 placeholder="Describe the issue or idea..."1705 value={fbMessage()}1706 onInput={(e) => setFbMessage(e.currentTarget.value)}1707 />17081709 <p class="peach-feedback-meta">1710 Version {version} · will be included automatically1711 </p>17121713 <Show when={fbResult() === "success"}>1714 <p class="peach-feedback-result peach-feedback-result--ok">1715 Sent — thank you1716 </p>1717 </Show>1718 <Show when={fbResult() === "error"}>1719 <p class="peach-feedback-result peach-feedback-result--err">1720 Failed to send — try again1721 </p>1722 </Show>17231724 <button1725 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1726 onClick={() => void handleSubmitFeedback()}1727 disabled={fbSubmitting() || !fbMessage().trim()}1728 >1729 {fbSubmitting() ? "Sending..." : "Send feedback"}1730 </button>17311732 <p class="peach-feedback-anon">1733 Sent anonymously · no account required1734 </p>1735 </div>1736 </div>1737 );1738 }17391740 function renderMobile() {1741 return (1742 <div class="peach-settings-body">1743 <div class="peach-settings-group">1744 <p class="peach-settings-label">Autofill service</p>1745 <div class="peach-mobile-row">1746 <span class="peach-mobile-status">1747 {autofillEnabled() ? "Enabled" : "Disabled"}1748 </span>1749 <button1750 class="peach-btn peach-btn--subtle peach-btn--sm"1751 onClick={() => void AutofillPlugin?.openAutofillSettings()}1752 >1753 {autofillEnabled() ? "Change" : "Enable"}1754 </button>1755 </div>1756 </div>17571758 <div class="peach-settings-group">1759 <p class="peach-settings-label">Biometric unlock</p>1760 <div class="peach-mobile-row">1761 <span class="peach-mobile-status">1762 {biometricEnabled() ? "Available" : "Not available"}1763 </span>1764 </div>1765 </div>1766 </div>1767 );1768 }17691770 return (1771 <div class="peach-overlay-view ov-enter-right" style="display:flex">1772 <div class="peach-overlay-header">1773 <button1774 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1775 onClick={handleHeaderBack}1776 aria-label="Back"1777 >1778 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1779 <path d="M6 1L1 6L6 11" />1780 </svg>1781 </button>1782 <span class="peach-overlay-title">{currentTitle()}</span>1783 <div class="peach-overlay-spacer"></div>1784 </div>17851786 <Switch fallback={renderHome()}>1787 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1788 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1789 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1790 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1791 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1792 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1793 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1794 <Match when={activeSection() === "mobile"}>{renderMobile()}</Match>1795 </Switch>1796 </div>1797 );1798}