1import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import type { Canary } from "../../shared/types";6import { ACCENT_COLORS } from "../../shared/types";7import type {8 AccentColorName,9 PasskeyProviderState,10 ProFeature,11 VaultSnapshot,12} from "../../shared/types";13import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";14import {15 DEFAULT_BINDINGS,16 findConflict,17 formatKeybinding,18 keybindingFromEvent,19 SHORTCUT_DESCRIPTORS,20 type Keybinding,21 type ShortcutAction,22} from "../../shared/shortcuts";2324interface LicenseInfo {25 valid: boolean;26 plan: "free" | "pro";27 issued?: string;28}2930interface SettingsProps {31 entriesCount: number;32 breachMonitoring: boolean;33 lockOnClose: boolean;34 autoLockMinutes: number;35 trashRetentionDays: number;36 theme: "dark" | "light";37 accentColor: AccentColorName;38 displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";39 textSize: "small" | "medium" | "large";40 reduceMotion: boolean;41 isPro?: boolean;42 initialSection?: string | null;43 upgradeHighlight?: ProFeature | null;44 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;45 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;46 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;47 onTrashRetentionChange: (days: number) => void | Promise<void>;48 onThemeChange: (theme: "dark" | "light") => void;49 onAccentChange: (name: AccentColorName) => void | Promise<void>;50 onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;51 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;52 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;53 shortcuts: Record<ShortcutAction, Keybinding>;54 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;55 onLicenseChange?: () => void | Promise<void>;56 onBack: () => void;57 onImport: () => void;58 onExport: () => void;59 onBreachCheckDone: () => void;60 onOpenHealth?: () => void;61 onOpenSyncPairing?: () => void;62 onOpenFileImport?: () => void;63 onOpenPanic?: () => void;64}6566type SectionName =67 | "security"68 | "vault"69 | "sync"70 | "appearance"71 | "shortcuts"72 | "license"73 | "feedback";7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7677function formatActivatedSince(issued: string | undefined): string {78 if (!issued) return "Active";79 try {80 const d = new Date(issued);81 if (Number.isNaN(d.getTime())) return "Active";82 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;83 } catch {84 return "Active";85 }86}8788function isSectionName(value: string): value is SectionName {89 return (90 value === "security" ||91 value === "vault" ||92 value === "sync" ||93 value === "appearance" ||94 value === "shortcuts" ||95 value === "license" ||96 value === "feedback"97 );98}99100export function SettingsView(props: SettingsProps) {101 const version = chrome.runtime.getManifest().version;102 const initial = props.initialSection;103 const [activeSection, setActiveSection] = createSignal<SectionName | null>(104 initial && isSectionName(initial) ? (initial as SectionName) : null105 );106107 const [checkingBreaches, setCheckingBreaches] = createSignal(false);108 const [breachResult, setBreachResult] = createSignal<string | null>(null);109 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);110 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");111 const [licenseError, setLicenseError] = createSignal<string | null>(null);112 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);113 const [activatingLicense, setActivatingLicense] = createSignal(false);114115 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);116 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);117 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");118 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);119 120 121 122 const [syncProStatus, setSyncProStatus] = createSignal(false);123 const isPro = () => props.isPro ?? syncProStatus();124 const [syncingNow, setSyncingNow] = createSignal(false);125 const [syncFeedback, setSyncFeedback] = createSignal<126 { kind: "ok" | "err"; text: string } | null127 >(null);128 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);129 const [passkeySaving, setPasskeySaving] = createSignal(false);130131 const [dedupCount, setDedupCount] = createSignal<number | null>(null);132 const [dedupRunning, setDedupRunning] = createSignal(false);133 const [dedupDone, setDedupDone] = createSignal<number | null>(null);134135 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);136137 138 139 140 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);141 const [historyLoading, setHistoryLoading] = createSignal(false);142 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);143144 async function loadVaultHistory() {145 setHistoryLoading(true);146 try {147 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });148 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];149 setHistorySnapshots(list);150 } finally {151 setHistoryLoading(false);152 }153 }154155 async function handleRestoreVaultSnapshot(id: string) {156 if (historyRestoring()) return;157 setHistoryRestoring(id);158 const res = await sendMessage({159 type: "RESTORE_VAULT_SNAPSHOT",160 payload: { snapshotId: id },161 });162 setHistoryRestoring(null);163 if ((res as { ok: boolean }).ok) {164 165 166 props.onBack();167 }168 }169170 function formatSnapshotTime(savedAt: number): string {171 const diff = Date.now() - savedAt;172 if (diff < 60_000) return "Just now";173 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;174 const d = new Date(savedAt);175 const isToday = new Date().toDateString() === d.toDateString();176 const isYest =177 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();178 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });179 if (isToday) return `Today ${t}`;180 if (isYest) return `Yesterday ${t}`;181 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;182 }183184 function reasonLabel(reason: VaultSnapshot["reason"]): string {185 return (186 {187 local_save: "saved",188 sync_received: "synced",189 import: "imported",190 restore: "restored",191 } as const192 )[reason] ?? reason;193 }194195 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);196197 const [showChangePw, setShowChangePw] = createSignal(false);198 const [changePwCurrent, setChangePwCurrent] = createSignal("");199 const [changePwNew, setChangePwNew] = createSignal("");200 const [changePwConfirm, setChangePwConfirm] = createSignal("");201 const [changePwSaving, setChangePwSaving] = createSignal(false);202 const [changePwError, setChangePwError] = createSignal<string | null>(null);203 const [changePwSuccess, setChangePwSuccess] = createSignal(false);204205 const [duressEnabled, setDuressEnabled] = createSignal(false);206 const [showDuressSetup, setShowDuressSetup] = createSignal(false);207 const [duressPassword, setDuressPassword] = createSignal("");208 const [duressConfirm, setDuressConfirm] = createSignal("");209 const [duressMainPassword, setDuressMainPassword] = createSignal("");210 const [duressSaving, setDuressSaving] = createSignal(false);211 const [duressError, setDuressError] = createSignal<string | null>(null);212 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);213214 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);215216 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);217 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);218219 const [fbCategory, setFbCategory] = createSignal("Bug report");220 const [fbMessage, setFbMessage] = createSignal("");221 const [fbSubmitting, setFbSubmitting] = createSignal(false);222 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);223224 onMount(async () => {225 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([226 sendMessage({ type: "GET_LICENSE" }),227 sendMessage({ type: "GET_SYNC_STATE" }),228 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),229 sendMessage({ type: "IS_DURESS_ENABLED" }),230 sendMessage({ type: "GET_SETTINGS" }),231 sendMessage({ type: "GET_LICENSE_KEY" }),232 ]);233234 235 236 void loadVaultHistory();237238 if ((licenseRes as { ok: boolean }).ok) {239 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);240 }241242 if ((syncRes as { ok: boolean }).ok) {243 const data = syncRes as { ok: boolean; data?: {244 pairedDevices?: PairedDevice[];245 trustedNetworks?: TrustedNetwork[];246 status?: SyncStatus;247 lastSyncAt?: number;248 isPro?: boolean;249 }};250 if (data.data) {251 setPairedDevices(data.data.pairedDevices ?? []);252 setTrustedNetworks(data.data.trustedNetworks ?? []);253 setSyncStatus(data.data.status ?? "offline");254 setLastSyncAt(data.data.lastSyncAt);255 setSyncProStatus(data.data.isPro ?? false);256 }257 }258259 if ((passkeyRes as { ok: boolean }).ok) {260 setPasskeyState(261 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null262 );263 }264265 if ((duressRes as { ok: boolean }).ok) {266 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);267 }268269 if ((settingsRes as { ok: boolean }).ok) {270 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;271 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);272 }273274 if ((keyRes as { ok: boolean }).ok) {275 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);276 }277278 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });279 if ((entriesRes as { ok: boolean }).ok) {280 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];281 setCanaryEntries(282 entries283 .filter((e) => e.canary?.webhookUrl)284 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))285 );286 }287288 chrome.runtime.onMessage.addListener((msg) => {289 if (msg.type === "SYNC_STATE_UPDATE") {290 setPairedDevices(msg.pairedDevices ?? []);291 setTrustedNetworks(msg.trustedNetworks ?? []);292 setSyncStatus(msg.status ?? "offline");293 setLastSyncAt(msg.lastSyncAt);294 setSyncProStatus(msg.isPro ?? false);295 }296 });297 });298299 async function handleChangePassword() {300 setChangePwError(null);301 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }302 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }303 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }304 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }305 setChangePwSaving(true);306 const res = await sendMessage({307 type: "CHANGE_PASSWORD",308 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },309 }) as { ok: boolean; error?: string };310 if (res.ok) {311 setChangePwSuccess(true);312 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");313 setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);314 } else {315 setChangePwError(res.error ?? "failed to change password");316 }317 setChangePwSaving(false);318 }319320 const [copied, setCopied] = createSignal(false);321322 async function copyLicenseKey(key: string) {323 await navigator.clipboard.writeText(key);324 setCopied(true);325 setTimeout(() => setCopied(false), 2000);326 }327328 async function handleActivateLicense() {329 const key = licenseKeyInput().trim();330 if (!key) return;331 setActivatingLicense(true);332 setLicenseError(null);333 setLicenseSuccess(null);334 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });335 setActivatingLicense(false);336 if ((res as { ok: boolean }).ok) {337 const data = (res as { ok: boolean; data?: LicenseInfo }).data;338 if (data?.valid) {339 setLicenseInfo(data);340 setLicenseKey(key);341 setLicenseSuccess("License activated!");342 setLicenseKeyInput("");343 } else {344 setLicenseError("Invalid license key");345 }346 } else {347 setLicenseError("Activation failed");348 }349 }350351 async function handleTogglePasskeys() {352 const current = passkeyState();353 if (!current || !current.supported) return;354 setPasskeySaving(true);355 const res = await sendMessage({356 type: "SET_PASSKEY_PROVIDER_ENABLED",357 payload: { enabled: !current.enabled },358 });359 setPasskeySaving(false);360 if ((res as { ok: boolean }).ok) {361 setPasskeyState(362 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null363 );364 }365 }366367 async function handleCheckAllBreaches() {368 setCheckingBreaches(true);369 setBreachResult(null);370 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });371 setCheckingBreaches(false);372 if ((res as { ok: boolean }).ok) {373 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};374 const breached = Object.values(data).filter((count) => count > 0).length;375 setBreachResult(376 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"377 );378 props.onBreachCheckDone();379 }380 }381382 async function handleSyncNow() {383 if (syncStatus() === "not_trusted") return;384 setSyncingNow(true);385 setSyncFeedback(null);386 const res = await sendMessage({ type: "TRIGGER_SYNC" });387 const ok = (res as { ok: boolean }).ok;388 if (ok) {389 const data = (res as { data?: { syncedCount?: number; totalReachable?: number } }).data;390 const count = data?.syncedCount ?? 0;391 setSyncFeedback({ kind: "ok", text: count > 0 ? `Synced with ${count} ${count === 1 ? "device" : "devices"}` : "Up to date" });392 setLastSyncAt(Date.now());393 void refreshSyncState();394 } else {395 const error = (res as { error?: string }).error;396 const map: Record<string, string> = {397 no_paired_devices: "No paired devices",398 no_devices_reachable: "No devices found",399 vault_locked: "Vault is locked",400 pro_required: "Sync requires Pro",401 };402 setSyncFeedback({ kind: "err", text: map[error ?? ""] ?? "Sync failed" });403 }404 setTimeout(() => {405 setSyncingNow(false);406 setSyncFeedback(null);407 }, 3500);408 }409410 async function refreshSyncState() {411 const res = await sendMessage({ type: "GET_SYNC_STATE" });412 const data = (res as { ok: boolean; data?: {413 pairedDevices?: PairedDevice[];414 trustedNetworks?: TrustedNetwork[];415 status?: SyncStatus;416 lastSyncAt?: number;417 }}).data;418 if (!data) return;419 setPairedDevices(data.pairedDevices ?? []);420 setTrustedNetworks(data.trustedNetworks ?? []);421 setSyncStatus(data.status ?? "offline");422 setLastSyncAt(data.lastSyncAt);423 }424425 async function handleRemoveDevice(deviceId: string) {426 setPairedDevices((prev) => prev.filter((d) => d.deviceId !== deviceId));427 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });428 void refreshSyncState();429 }430431 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {432 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });433 void refreshSyncState();434 }435436 const [markingNetwork, setMarkingNetwork] = createSignal(false);437438 async function handleMarkNetworkTrusted() {439 if (markingNetwork()) return;440 setMarkingNetwork(true);441 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });442 await refreshSyncState();443 setMarkingNetwork(false);444 }445446 const [resetIdentityConfirm, setResetIdentityConfirm] = createSignal(false);447 const [resetIdentityRunning, setResetIdentityRunning] = createSignal(false);448449 async function handleResetSyncIdentity() {450 setResetIdentityRunning(true);451 const res = await sendMessage({ type: "RESET_SYNC_IDENTITY" });452 setResetIdentityRunning(false);453 if ((res as { ok: boolean }).ok) {454 setPairedDevices([]);455 setResetIdentityConfirm(false);456 }457 }458459 function formatRelativeTime(timestamp?: number): string {460 if (!timestamp) return "Never";461 const diff = Date.now() - timestamp;462 const minutes = Math.floor(diff / 60000);463 const hours = Math.floor(diff / 3600000);464 const days = Math.floor(diff / 86400000);465 if (minutes < 1) return "Just now";466 if (minutes < 60) return `${minutes}m ago`;467 if (hours < 24) return `${hours}h ago`;468 return `${days}d ago`;469 }470471 function getStatusColor(status: SyncStatus): string {472 switch (status) {473 case "synced": return "var(--peach-sync-ok)";474 case "syncing": return "var(--peach-sync-busy)";475 case "no_peers": return "var(--peach-sync-off)";476 case "not_trusted": return "var(--peach-sync-err)";477 case "offline": return "var(--peach-sync-off)";478 default: return "var(--peach-sync-off)";479 }480 }481482 function getStatusLabel(status: SyncStatus): string {483 switch (status) {484 case "synced": return "Synced";485 case "syncing": return "Syncing";486 case "no_peers": return "No peers visible";487 case "not_trusted": return "Not on a trusted network";488 case "offline": return "Offline";489 default: return "Unknown";490 }491 }492493 function isCurrentNetworkTrusted(): boolean {494 return trustedNetworks().length > 0;495 }496497 async function handleSetupDuress() {498 const mainPw = duressMainPassword().trim();499 const duressPw = duressPassword().trim();500 const confirmPw = duressConfirm().trim();501502 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }503 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }504 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }505 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }506507 setDuressSaving(true);508 setDuressError(null);509510 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };511 if (!verifyRes.ok || !verifyRes.data) {512 setDuressError("incorrect main password");513 setDuressSaving(false);514 return;515 }516517 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };518 setDuressSaving(false);519 if (res.ok) {520 setDuressEnabled(true);521 setShowDuressSetup(false);522 setDuressPassword("");523 setDuressConfirm("");524 setDuressMainPassword("");525 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");526 setTimeout(() => setDuressSuccess(null), 5000);527 } else {528 setDuressError(res.error ?? "failed to set duress password");529 }530 }531532 async function handleClearDeclinedCards() {533 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });534 setDeclinedCardCount(0);535 }536537 async function handleCountDuplicates() {538 setDedupRunning(true);539 setDedupDone(null);540 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };541 setDedupRunning(false);542 if (res.ok) setDedupCount(res.data ?? 0);543 }544545 async function handleDeduplicate() {546 setDedupRunning(true);547 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };548 setDedupRunning(false);549 if (res.ok) {550 setDedupDone(res.data ?? 0);551 setDedupCount(0);552 }553 }554555 async function handleDisableDuress() {556 const mainPw = duressMainPassword().trim();557 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }558559 setDuressSaving(true);560 setDuressError(null);561562 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };563 if (!verifyRes.ok || !verifyRes.data) {564 setDuressError("incorrect main password");565 setDuressSaving(false);566 return;567 }568569 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };570 setDuressSaving(false);571 if (res.ok) {572 setDuressEnabled(false);573 setShowDuressSetup(false);574 setDuressMainPassword("");575 setDuressSuccess("Duress password disabled.");576 setTimeout(() => setDuressSuccess(null), 4000);577 } else {578 setDuressError(res.error ?? "failed to disable duress password");579 }580 }581582 async function handleSubmitFeedback() {583 if (!fbMessage().trim() || fbSubmitting()) return;584 setFbSubmitting(true);585 setFbResult(null);586 const res = await sendMessage({587 type: "SUBMIT_FEEDBACK",588 payload: {589 category: fbCategory(),590 message: fbMessage().trim(),591 version,592 browser: navigator.userAgent,593 },594 }) as { ok: boolean; error?: string };595 setFbSubmitting(false);596 if (res.ok) {597 setFbResult("success");598 setFbMessage("");599 setTimeout(() => setFbResult(null), 4000);600 } else {601 setFbResult("error");602 }603 }604605 function handleHeaderBack() {606 if (activeSection() !== null) {607 setActiveSection(null);608 } else {609 props.onBack();610 }611 }612613 function currentTitle(): string {614 const s = activeSection();615 if (s === null) return "settings";616 if (s === "license") return "account";617 return s;618 }619620 function renderCatRow(opts: {621 section: SectionName;622 name: string;623 desc: string;624 icon: () => any;625 pro?: boolean;626 }) {627 return (628 <button629 class="peach-settings-cat-row"630 onClick={() => setActiveSection(opts.section)}631 >632 <div class="peach-settings-cat-icon">{opts.icon()}</div>633 <div class="peach-settings-cat-content">634 <span class="peach-settings-cat-name">635 {opts.name}636 <Show when={opts.pro && !isPro()}>637 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>638 </Show>639 </span>640 <span class="peach-settings-cat-desc">{opts.desc}</span>641 </div>642 <svg643 class="peach-settings-cat-chevron"644 width="6" height="10" viewBox="0 0 6 10" fill="none"645 stroke="currentColor" stroke-width="1.1"646 stroke-linecap="round" stroke-linejoin="round"647 >648 <path d="M1 1l4 4-4 4" />649 </svg>650 </button>651 );652 }653654 const iconShield = () => (655 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">656 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>657 </svg>658 );659 const iconKey = () => (660 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">661 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>662 </svg>663 );664 const iconLock = () => (665 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">666 <rect x="3" y="7" width="10" height="7" rx="1.5"/>667 <path d="M6 7V5a3 3 0 016 0v2"/>668 </svg>669 );670 const iconSync = () => (671 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">672 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>673 <path d="M14 6l-2 2-2-2"/>674 <path d="M2 10l2-2 2 2"/>675 </svg>676 );677 const iconSun = () => (678 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">679 <circle cx="8" cy="8" r="3"/>680 <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"/>681 </svg>682 );683 const iconKeys = () => (684 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">685 <rect x="2" y="5" width="5" height="4" rx="1"/>686 <rect x="9" y="7" width="5" height="4" rx="1"/>687 <path d="M7 7h2m-2 2h2"/>688 </svg>689 );690 const iconBubble = () => (691 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">692 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>693 </svg>694 );695696 function renderHome() {697 return (698 <div class="peach-settings-body">699 <p class="peach-settings-nav-label">PROTECTION</p>700 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}701 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}702703 <p class="peach-settings-nav-label">VAULT</p>704 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}705 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}706707 <p class="peach-settings-nav-label">PREFERENCES</p>708 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}709 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}710711 <p class="peach-settings-nav-label">SUPPORT</p>712 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}713714 <div class="peach-settings-footer">715 <span>Peach {version}</span>716 <span class="peach-divider"></span>717 <span>Browser extension</span>718 </div>719 </div>720 );721 }722723 function renderSecurity() {724 return (725 <div class="peach-settings-body">726 <div class="peach-settings-group">727 <p class="peach-settings-sub-label">LOCKING</p>728 <div class="peach-settings-row">729 <span>Lock on close</span>730 <button731 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}732 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}733 aria-label="Toggle lock on close"734 >735 <span class="peach-toggle-knob"></span>736 </button>737 </div>738 <div class="peach-settings-row">739 <span>Auto-lock after</span>740 <select741 class="peach-settings-select"742 value={String(props.autoLockMinutes)}743 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}744 >745 <option value="1">1 minute</option>746 <option value="5">5 minutes</option>747 <option value="15">15 minutes</option>748 <option value="30">30 minutes</option>749 <option value="0">Never</option>750 </select>751 </div>752753 <p class="peach-settings-sub-label">BREACH PROTECTION</p>754 <div class="peach-settings-row">755 <span>Breach monitoring</span>756 <button757 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}758 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}759 aria-label="Toggle breach monitoring"760 >761 <span class="peach-toggle-knob"></span>762 </button>763 </div>764 <div class="peach-settings-row peach-settings-row--meta">765 <span>Check all passwords</span>766 <button767 class="peach-btn peach-btn--subtle peach-btn--sm"768 onClick={() => void handleCheckAllBreaches()}769 disabled={checkingBreaches()}770 >771 {checkingBreaches() ? "checking..." : "run"}772 </button>773 </div>774 <Show when={breachResult()}>775 <div class="peach-settings-row peach-settings-row--meta">776 <span>Last check</span>777 <span class="peach-settings-value">{breachResult()}</span>778 </div>779 </Show>780781 <p class="peach-settings-sub-label">MASTER PASSWORD</p>782 <div class="peach-settings-row peach-settings-row--meta">783 <span>Master password</span>784 <button785 class="peach-btn peach-btn--subtle peach-btn--sm"786 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}787 >788 {showChangePw() ? "cancel" : "change"}789 </button>790 </div>791 <Show when={showChangePw()}>792 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">793 <input794 class="peach-settings-input"795 type="password"796 placeholder="current password"797 value={changePwCurrent()}798 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}799 autocomplete="current-password"800 />801 <input802 class="peach-settings-input"803 type="password"804 placeholder="new password (min 12 chars)"805 value={changePwNew()}806 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}807 autocomplete="new-password"808 />809 <input810 class="peach-settings-input"811 type="password"812 placeholder="confirm new password"813 value={changePwConfirm()}814 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}815 autocomplete="new-password"816 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}817 />818 <Show when={changePwError()}>819 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>820 </Show>821 <Show when={changePwSuccess()}>822 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>823 </Show>824 <button825 class="peach-btn peach-btn--accent peach-btn--sm"826 style="align-self:flex-start"827 onClick={() => void handleChangePassword()}828 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}829 >830 {changePwSaving() ? "saving…" : "save new password"}831 </button>832 </div>833 </Show>834835 <p class="peach-settings-sub-label">DURESS PASSWORD</p>836 <div class="peach-settings-row peach-settings-row--top">837 <div class="peach-settings-row-main">838 <span>Duress password</span>839 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">840 If entered under coercion, shows a decoy empty vault instead of your real one.841 </span>842 </div>843 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>844 {duressEnabled() ? "on" : "off"}845 </div>846 </div>847848 <Show when={duressEnabled() && !showDuressSetup()}>849 <div class="peach-settings-row peach-settings-row--meta">850 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">851 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.852 </span>853 </div>854 </Show>855856 <Show when={duressSuccess()}>857 <div class="peach-settings-row peach-settings-row--meta">858 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>859 </div>860 </Show>861862 <Show when={!showDuressSetup()}>863 <div class="peach-settings-row peach-settings-row--meta">864 <button865 class="peach-btn peach-btn--subtle peach-btn--sm"866 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}867 >868 {duressEnabled() ? "Change / disable" : "Set up"}869 </button>870 </div>871 </Show>872873 <Show when={showDuressSetup()}>874 <div class="peach-duress-form">875 <label class="peach-label">main password (to confirm)</label>876 <input877 class="peach-settings-input"878 type="password"879 placeholder="your current master password"880 autocomplete="current-password"881 value={duressMainPassword()}882 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}883 />884 <Show when={!duressEnabled()}>885 <label class="peach-label">new duress password</label>886 <input887 class="peach-settings-input"888 type="password"889 placeholder="duress password (min 8 chars)"890 autocomplete="new-password"891 value={duressPassword()}892 onInput={(e) => setDuressPassword(e.currentTarget.value)}893 />894 <label class="peach-label">confirm duress password</label>895 <input896 class="peach-settings-input"897 type="password"898 placeholder="confirm duress password"899 autocomplete="new-password"900 value={duressConfirm()}901 onInput={(e) => setDuressConfirm(e.currentTarget.value)}902 />903 </Show>904905 <Show when={duressError()}>906 <p class="peach-hint peach-hint-error">{duressError()}</p>907 </Show>908909 <div class="peach-duress-actions">910 <button911 class="peach-btn peach-btn--subtle peach-btn--sm"912 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}913 >914 cancel915 </button>916 <Show when={duressEnabled()}>917 <button918 class="peach-btn peach-btn--ghost peach-btn--sm"919 style="color:var(--peach-breach)"920 onClick={() => void handleDisableDuress()}921 disabled={duressSaving()}922 >923 {duressSaving() ? "disabling..." : "disable duress"}924 </button>925 </Show>926 <Show when={!duressEnabled()}>927 <button928 class="peach-btn peach-btn--accent peach-btn--sm"929 onClick={() => void handleSetupDuress()}930 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}931 >932 {duressSaving() ? "saving..." : "save"}933 </button>934 </Show>935 </div>936 </div>937 </Show>938939 <p class="peach-settings-sub-label">CANARY ENTRIES</p>940 <div class="peach-settings-row peach-settings-row--top">941 <div class="peach-settings-row-main">942 <span>Canary entries</span>943 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">944 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.945 </span>946 </div>947 <span class="peach-settings-value" style="flex-shrink:0">948 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}949 </span>950 </div>951 <Show when={canaryEntries().length > 0}>952 <For each={canaryEntries()}>953 {(entry) => (954 <div class="peach-settings-row peach-settings-row--meta">955 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>956 <button957 class="peach-btn peach-btn--subtle peach-btn--sm"958 onClick={() => void handleTestCanary(entry)}959 disabled={canaryTestingId() === entry.id}960 style="flex-shrink:0"961 >962 {canaryTestingId() === entry.id963 ? "sending…"964 : canaryTestResult()?.id === entry.id965 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")966 : "test"}967 </button>968 </div>969 )}970 </For>971 </Show>972973 <p class="peach-settings-sub-label">PASSKEYS</p>974 <div class="peach-settings-row peach-settings-row--top">975 <div class="peach-settings-row-main">976 <span>Intercept passkey requests</span>977 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">978 Peach can handle passkey sign-in and creation on supported Chromium browsers.979 </span>980 </div>981 <button982 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}983 onClick={() => void handleTogglePasskeys()}984 aria-label="Toggle passkey interception"985 disabled={!passkeyState()?.supported || passkeySaving()}986 >987 <span class="peach-toggle-knob"></span>988 </button>989 </div>990 <div class="peach-settings-row peach-settings-row--meta">991 <span>Status</span>992 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>993 </div>994 <div class="peach-settings-row peach-settings-row--meta">995 <span>Browser</span>996 <span class="peach-settings-value">997 {passkeyState()?.browserLabel ?? "Unknown"}998 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}999 </span>1000 </div>1001 <div class="peach-info-notice" style="margin-top:8px">1002 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1003 </div>1004 <Show when={passkeyState()?.conflict}>1005 <div class="peach-settings-row peach-settings-row--meta">1006 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1007 </div>1008 </Show>1009 </div>1010 </div>1011 );1012 }10131014 function renderVault() {1015 return (1016 <div class="peach-settings-body">1017 <div class="peach-settings-group">1018 <p class="peach-settings-sub-label">ENTRIES</p>1019 <div class="peach-settings-row peach-settings-row--meta">1020 <span>Entries</span>1021 <span class="peach-settings-value">{props.entriesCount}</span>1022 </div>1023 <div class="peach-settings-row peach-settings-row--meta">1024 <span>Duplicates</span>1025 <Show when={dedupDone() !== null} fallback={1026 <button1027 class="peach-btn peach-btn--subtle peach-btn--sm"1028 onClick={() => void handleCountDuplicates()}1029 disabled={dedupRunning()}1030 >1031 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1032 </button>1033 }>1034 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1035 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1036 </span>1037 </Show>1038 </div>1039 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1040 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1041 <div class="peach-restore-warning">1042 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1043 </div>1044 <div style="display:flex;gap:6px">1045 <button1046 class="peach-btn peach-btn--subtle peach-btn--sm"1047 onClick={() => setDedupCount(null)}1048 >1049 cancel1050 </button>1051 <button1052 class="peach-btn peach-btn--accent peach-btn--sm"1053 onClick={() => void handleDeduplicate()}1054 disabled={dedupRunning()}1055 >1056 {dedupRunning() ? "removing..." : "remove duplicates"}1057 </button>1058 </div>1059 </div>1060 </Show>10611062 <p class="peach-settings-sub-label">MAINTENANCE</p>1063 <div class="peach-settings-row peach-settings-row--meta">1064 <span>Trash auto-delete</span>1065 <select1066 class="peach-settings-select"1067 value={String(props.trashRetentionDays)}1068 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1069 >1070 <option value="7">7 days</option>1071 <option value="30">30 days</option>1072 <option value="90">90 days</option>1073 <option value="0">Never</option>1074 </select>1075 </div>1076 <div class="peach-settings-row peach-settings-row--meta">1077 <span>Vault Health</span>1078 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1079 open1080 </button>1081 </div>1082 <Show when={declinedCardCount() > 0}>1083 <div class="peach-settings-row peach-settings-row--meta">1084 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1085 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1086 Clear1087 </button>1088 </div>1089 </Show>10901091 <p class="peach-settings-sub-label">DATA</p>1092 <div class="peach-settings-row peach-settings-row--meta">1093 <span>Import passwords</span>1094 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1095 open1096 </button>1097 </div>1098 <div class="peach-settings-row peach-settings-row--meta">1099 <span>Export backup</span>1100 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1101 open1102 </button>1103 </div>11041105 <p class="peach-settings-sub-label">VAULT HISTORY</p>1106 <Show when={historyLoading()}>1107 <p class="peach-settings-hint">Loading…</p>1108 </Show>1109 <Show when={!historyLoading() && historySnapshots().length === 0}>1110 <p class="peach-settings-hint">1111 No snapshots yet. Peach saves automatically before every change —1112 saves, syncs, and imports.1113 </p>1114 </Show>1115 <Show when={!historyLoading() && historySnapshots().length > 0}>1116 <p class="peach-settings-hint">1117 Restoring locks your vault. Unlock with the password that1118 encrypted that snapshot to confirm.1119 </p>1120 <div class="peach-history-list">1121 <For each={historySnapshots()}>1122 {(snap) => (1123 <div class="peach-history-row">1124 <div class="peach-history-meta">1125 <span class="peach-history-time">1126 {formatSnapshotTime(snap.savedAt)}1127 </span>1128 <span class="peach-history-sub">1129 {[1130 snap.entryCount !== null1131 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1132 : null,1133 reasonLabel(snap.reason),1134 ]1135 .filter(Boolean)1136 .join(" · ")}1137 </span>1138 </div>1139 <button1140 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1141 historyRestoring() === snap.id ? " is-busy" : ""1142 }`}1143 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1144 disabled={historyRestoring() !== null}1145 >1146 <Show when={historyRestoring() === snap.id} fallback="Restore">1147 …1148 </Show>1149 </button>1150 </div>1151 )}1152 </For>1153 </div>1154 <p class="peach-history-footer">1155 {historySnapshots().length} of 10 snapshots1156 </p>1157 </Show>11581159 <button1160 class="peach-settings-advanced-toggle"1161 onClick={() => setShowVaultAdvanced((v) => !v)}1162 aria-expanded={showVaultAdvanced()}1163 >1164 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1165 <span class="peach-settings-advanced-chevron">1166 {showVaultAdvanced() ? "▲" : "▼"}1167 </span>1168 </button>11691170 <Show when={showVaultAdvanced()}>1171 <div class="peach-danger-card">1172 <div class="peach-danger-card-head">1173 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1174 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1175 </div>1176 <p class="peach-danger-card-copy">1177 Wipes the current vault and restores from a backup file.1178 Recoverable only if your backup is good.1179 </p>1180 <button1181 class="peach-danger-card-button"1182 onClick={() => props.onOpenFileImport?.()}1183 >1184 Choose backup file1185 </button>1186 </div>11871188 <Show when={props.onOpenPanic}>1189 <div class="peach-danger-card peach-danger-card--hazard">1190 <div class="peach-danger-card-head">1191 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1192 <h3 class="peach-danger-card-title">Destroy vault</h3>1193 </div>1194 <p class="peach-danger-card-copy">1195 Permanently wipes every entry, setting, and license on this1196 device. There is no recovery.1197 </p>1198 <button1199 class="peach-danger-card-button"1200 onClick={() => props.onOpenPanic?.()}1201 >1202 Begin destruction1203 </button>1204 </div>1205 </Show>1206 </Show>1207 </div>1208 </div>1209 );1210 }12111212 function renderSync() {1213 if (!isPro()) {1214 return (1215 <div class="peach-settings-body">1216 <div class="peach-pro-upgrade">1217 <div class="peach-pro-upgrade-hero">1218 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1219 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1220 </div>12211222 <div class="peach-pro-features">1223 <div class="peach-pro-feature">1224 <span class="peach-pro-feature-mark">✦</span>1225 <div class="peach-pro-feature-text">1226 <span class="peach-pro-feature-name">Always in sync</span>1227 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1228 </div>1229 </div>1230 <div class="peach-pro-feature">1231 <span class="peach-pro-feature-mark">✦</span>1232 <div class="peach-pro-feature-text">1233 <span class="peach-pro-feature-name">No cloud. No relay.</span>1234 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1235 </div>1236 </div>1237 <div class="peach-pro-feature">1238 <span class="peach-pro-feature-mark">✦</span>1239 <div class="peach-pro-feature-text">1240 <span class="peach-pro-feature-name">Tailscale supported</span>1241 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1242 </div>1243 </div>1244 <div class="peach-pro-feature">1245 <span class="peach-pro-feature-mark">✦</span>1246 <div class="peach-pro-feature-text">1247 <span class="peach-pro-feature-name">Zero friction after setup</span>1248 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1249 </div>1250 </div>1251 </div>12521253 <button1254 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1255 onClick={() => setActiveSection("license")}1256 >1257 Upgrade to Pro1258 </button>1259 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1260 Already have a key? Enter it →1261 </button>1262 </div>1263 </div>1264 );1265 }12661267 return (1268 <div class="peach-settings-body">1269 <div class="peach-settings-group">1270 <div class="peach-settings-row">1271 <span>Status</span>1272 <span class="peach-settings-value peach-settings-value--status">1273 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1274 {getStatusLabel(syncStatus())}1275 </span>1276 </div>1277 <div class="peach-settings-row peach-settings-row--meta">1278 <span>Last sync</span>1279 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1280 </div>1281 <div class="peach-settings-row peach-settings-row--meta">1282 <button1283 class="peach-btn peach-btn--subtle peach-btn--sm"1284 onClick={() => void handleSyncNow()}1285 disabled={syncStatus() === "not_trusted" || syncingNow()}1286 >1287 <Switch>1288 <Match when={syncingNow()}>Syncing…</Match>1289 <Match when={syncFeedback()?.kind === "ok"}>✓ {syncFeedback()!.text}</Match>1290 <Match when={syncFeedback()?.kind === "err"}>{syncFeedback()!.text}</Match>1291 <Match when={true}>Sync now</Match>1292 </Switch>1293 </button>1294 </div>1295 <p class="peach-settings-hint">1296 If you edit on multiple devices before syncing, the most recently1297 changed device wins. Use Vault History above to recover older1298 versions if needed.1299 </p>13001301 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1302 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1303 <Show when={pairedDevices().length === 0}>1304 <p class="peach-settings-hint">1305 No paired devices yet. Add a device to start syncing.1306 </p>1307 </Show>1308 <Show when={pairedDevices().length > 0}>1309 <div class="peach-history-list">1310 <For each={pairedDevices()}>1311 {(device) => (1312 <div class="peach-history-row">1313 <div class="peach-history-meta">1314 <span class="peach-history-time">{device.displayName}</span>1315 <span class="peach-history-sub">1316 {device.lastSyncAt1317 ? `Last synced: ${formatSnapshotTime(device.lastSyncAt)}`1318 : "Never synced"}1319 </span>1320 </div>1321 <button1322 class="peach-btn peach-btn--subtle peach-btn--sm peach-history-btn"1323 onClick={() => void handleRemoveDevice(device.deviceId)}1324 >1325 Remove1326 </button>1327 </div>1328 )}1329 </For>1330 </div>1331 </Show>1332 <div class="peach-settings-actions--row">1333 <button1334 class="peach-btn peach-btn--subtle peach-btn--sm"1335 onClick={() => props.onOpenSyncPairing?.()}1336 >1337 Add a device1338 </button>1339 </div>1340 </div>13411342 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1343 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1344 <For each={trustedNetworks()}>1345 {(network) => (1346 <div class="peach-sync-device-row">1347 <span class="peach-sync-device-name">1348 {network.isTailscale ? "Tailscale" : (network.ssid ?? "Unknown network")}1349 </span>1350 <Show1351 when={!network.isTailscale}1352 fallback={<span class="peach-sync-device-meta">Always trusted</span>}1353 >1354 <button1355 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1356 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1357 >1358 Remove1359 </button>1360 </Show>1361 </div>1362 )}1363 </For>1364 <div class="peach-settings-actions--row">1365 <Show when={!isCurrentNetworkTrusted()}>1366 <button1367 class="peach-btn peach-btn--subtle peach-btn--sm"1368 onClick={() => void handleMarkNetworkTrusted()}1369 disabled={markingNetwork()}1370 >1371 {markingNetwork() ? "Marking…" : "Mark current network as trusted"}1372 </button>1373 </Show>1374 </div>1375 </div>13761377 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1378 <span class="peach-sync-section-label">Advanced</span>1379 <Show1380 when={resetIdentityConfirm()}1381 fallback={1382 <div class="peach-sync-advanced-row">1383 <div class="peach-sync-advanced-info">1384 <span class="peach-sync-advanced-name">Reset sync identity</span>1385 <span class="peach-sync-device-meta">1386 Generates a new keypair and unpairs every device.1387 </span>1388 </div>1389 <button1390 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1391 onClick={() => setResetIdentityConfirm(true)}1392 >1393 Reset1394 </button>1395 </div>1396 }1397 >1398 <div class="peach-sync-reset-confirm">1399 <p class="peach-sync-reset-confirm-headline">Reset sync identity?</p>1400 <p class="peach-sync-reset-confirm-body">1401 This will unpair all devices. You will need to pair them again from scratch.1402 </p>1403 <div class="peach-settings-actions--row">1404 <button1405 class="peach-btn peach-btn--ghost peach-btn--sm"1406 onClick={() => setResetIdentityConfirm(false)}1407 disabled={resetIdentityRunning()}1408 >1409 Cancel1410 </button>1411 <button1412 class="peach-btn peach-btn--danger peach-btn--sm"1413 onClick={() => void handleResetSyncIdentity()}1414 disabled={resetIdentityRunning()}1415 >1416 {resetIdentityRunning() ? "Resetting..." : "Reset"}1417 </button>1418 </div>1419 </div>1420 </Show>1421 </div>1422 </div>1423 </div>1424 );1425 }14261427 function renderAppearance() {1428 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1429 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1430 { id: "cormorant", label: "Cormorant", preview: "Aa" },1431 { id: "inter", label: "Inter", preview: "Aa" },1432 { id: "system", label: "System", preview: "Aa" },1433 ];1434 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1435 { id: "small", label: "Small" },1436 { id: "medium", label: "Default" },1437 { id: "large", label: "Large" },1438 ];1439 return (1440 <div class="peach-settings-body">1441 <div class="peach-settings-group">1442 <p class="peach-settings-sub-label">THEME</p>1443 <div class="peach-settings-row peach-settings-row--meta">1444 <span>Theme</span>1445 <button1446 class="peach-btn peach-btn--subtle peach-btn--sm"1447 onClick={() =>1448 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1449 }1450 >1451 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1452 </button>1453 </div>14541455 <p class="peach-settings-sub-label">ACCENT</p>1456 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1457 <span>Accent</span>1458 <div class="peach-accent-picker">1459 <For each={ACCENT_COLORS}>1460 {(color) => (1461 <button1462 class="peach-accent-swatch"1463 classList={{ "is-active": props.accentColor === color.name }}1464 style={{ background: color.hex }}1465 onClick={() => void props.onAccentChange(color.name)}1466 aria-label={color.name}1467 title={color.name}1468 />1469 )}1470 </For>1471 </div>1472 </div>14731474 <p class="peach-settings-sub-label">DISPLAY FONT</p>1475 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1476 <span>Font</span>1477 <div class="peach-font-picker">1478 <For each={fontOptions}>1479 {(opt) => (1480 <button1481 class="peach-font-swatch"1482 classList={{ "is-active": props.displayFont === opt.id }}1483 data-font-preview={opt.id}1484 onClick={() => void props.onDisplayFontChange(opt.id)}1485 aria-label={opt.label}1486 title={opt.label}1487 >1488 <span class="peach-font-swatch-glyph">{opt.preview}</span>1489 <span class="peach-font-swatch-name">{opt.label}</span>1490 </button>1491 )}1492 </For>1493 </div>1494 </div>14951496 <p class="peach-settings-sub-label">TEXT SIZE</p>1497 <div class="peach-settings-row peach-settings-row--meta">1498 <span>Size</span>1499 <div class="peach-pill-group">1500 <For each={sizeOptions}>1501 {(opt) => (1502 <button1503 class="peach-btn peach-btn--ghost peach-btn--sm"1504 classList={{ "is-active": props.textSize === opt.id }}1505 onClick={() => void props.onTextSizeChange(opt.id)}1506 >1507 {opt.label}1508 </button>1509 )}1510 </For>1511 </div>1512 </div>15131514 <p class="peach-settings-sub-label">MOTION</p>1515 <div class="peach-settings-row peach-settings-row--meta">1516 <span>Reduce motion</span>1517 <button1518 class="peach-btn peach-btn--subtle peach-btn--sm"1519 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1520 >1521 {props.reduceMotion ? "On" : "Off"}1522 </button>1523 </div>1524 </div>1525 </div>1526 );1527 }15281529 function renderShortcuts() {1530 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1531 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);15321533 function startCapture(action: ShortcutAction) {1534 setConflictMsg(null);1535 setCapturingAction(action);1536 }1537 function cancelCapture() {1538 setCapturingAction(null);1539 setConflictMsg(null);1540 }1541 function commitBinding(action: ShortcutAction, binding: Keybinding) {1542 const conflict = findConflict(props.shortcuts, binding, action);1543 if (conflict) {1544 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1545 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1546 return;1547 }1548 const next = { ...props.shortcuts, [action]: binding };1549 setCapturingAction(null);1550 setConflictMsg(null);1551 void props.onShortcutsChange(next);1552 }1553 function resetAction(action: ShortcutAction) {1554 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1555 void props.onShortcutsChange(next);1556 }1557 function resetAll() {1558 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1559 }15601561 1562 1563 function handleCaptureKey(e: KeyboardEvent) {1564 e.preventDefault();1565 e.stopPropagation();1566 if (e.key === "Escape") { cancelCapture(); return; }1567 const binding = keybindingFromEvent(e);1568 if (!binding) return; 1569 const action = capturingAction();1570 if (action) commitBinding(action, binding);1571 }15721573 return (1574 <div class="peach-settings-body">1575 <div class="peach-settings-group peach-shortcuts-group">1576 <p class="peach-settings-sub-label">ENTRY VIEW</p>1577 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1578 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1579 </For>1580 </div>15811582 <div class="peach-settings-group peach-shortcuts-group">1583 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1584 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1585 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1586 </For>1587 </div>15881589 <Show when={conflictMsg()}>1590 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1591 </Show>15921593 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1594 <div class="peach-settings-row peach-settings-row--meta">1595 <span>Inline autofill on page</span>1596 <a1597 class="peach-settings-link"1598 href="chrome://extensions/shortcuts"1599 onClick={(e) => {1600 e.preventDefault();1601 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1602 }}1603 >1604 ⌘⇧P — customize ↗1605 </a>1606 </div>1607 <button1608 class="peach-btn peach-btn--subtle peach-btn--sm"1609 style="align-self:flex-start"1610 onClick={resetAll}1611 >1612 Reset all to defaults1613 </button>1614 </div>1615 </div>1616 );16171618 function renderRow(1619 desc: typeof SHORTCUT_DESCRIPTORS[number],1620 capturing: () => ShortcutAction | null,1621 onStart: (a: ShortcutAction) => void,1622 onCancel: () => void,1623 onReset: (a: ShortcutAction) => void,1624 onKey: (e: KeyboardEvent) => void,1625 ) {1626 const isActive = () => capturing() === desc.action;1627 const binding = () => props.shortcuts[desc.action];1628 const isCustom = () => {1629 const b = binding();1630 const d = DEFAULT_BINDINGS[desc.action];1631 return b.key !== d.key1632 || Boolean(b.ctrl) !== Boolean(d.ctrl)1633 || Boolean(b.meta) !== Boolean(d.meta)1634 || Boolean(b.shift) !== Boolean(d.shift)1635 || Boolean(b.alt) !== Boolean(d.alt);1636 };1637 return (1638 <div class="peach-shortcut-row">1639 <span class="peach-shortcut-row-label">{desc.label}</span>1640 <button1641 class="peach-shortcut-chip"1642 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1643 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1644 onKeyDown={(e) => { if (isActive()) onKey(e); }}1645 ref={(el) => {1646 1647 if (el) {1648 queueMicrotask(() => { if (isActive()) el.focus(); });1649 }1650 }}1651 >1652 {isActive() ? "Press a key…" : formatKeybinding(binding())}1653 </button>1654 <Show when={isCustom() && !isActive()}>1655 <button1656 class="peach-shortcut-reset"1657 title="Reset to default"1658 onClick={() => onReset(desc.action)}1659 >1660 ↺1661 </button>1662 </Show>1663 </div>1664 );1665 }1666 }16671668 const PRO_FEATURES: Array<{1669 feature: ProFeature;1670 name: string;1671 desc: string;1672 }> = [1673 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1674 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1675 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1676 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1677 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1678 ];16791680 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);16811682 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1683 const [deactivating, setDeactivating] = createSignal(false);16841685 async function handleDeactivateLicense() {1686 setDeactivating(true);1687 await sendMessage({ type: "DEACTIVATE_LICENSE" });1688 setLicenseInfo(null);1689 await props.onLicenseChange?.();1690 setDeactivating(false);1691 }16921693 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1694 setCanaryTestingId(entry.id);1695 setCanaryTestResult(null);1696 const res = await sendMessage({1697 type: "FIRE_CANARY_PING",1698 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1699 }) as { ok: boolean };1700 setCanaryTestResult({ id: entry.id, ok: res.ok });1701 setCanaryTestingId(null);1702 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1703 }17041705 function renderLicense() {1706 async function onActivate() {1707 await handleActivateLicense();1708 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1709 await props.onLicenseChange?.();1710 }1711 }17121713 return (1714 <div class="peach-settings-body">1715 <Show1716 when={isPro()}1717 fallback={1718 <Show1719 when={showKeyEntry()}1720 fallback={1721 <div class="peach-pro-upgrade">1722 <div class="peach-pro-upgrade-hero">1723 <div class="peach-pro-upgrade-mark-wrap">1724 <PeachMark class="peach-pro-upgrade-mark" />1725 </div>1726 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1727 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1728 </div>17291730 <div class="peach-pro-features">1731 <For each={PRO_FEATURES}>1732 {(item) => (1733 <div1734 class="peach-pro-feature"1735 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1736 >1737 <span class="peach-pro-feature-mark">✦</span>1738 <div class="peach-pro-feature-text">1739 <span class="peach-pro-feature-name">{item.name}</span>1740 <span class="peach-pro-feature-desc">{item.desc}</span>1741 </div>1742 </div>1743 )}1744 </For>1745 </div>17461747 <div class="peach-pro-upgrade-actions">1748 <p class="peach-pro-pricing">$39 · once · yours forever</p>1749 <a1750 href="https://peachpasswords.com/#pricing"1751 target="_blank"1752 rel="noopener noreferrer"1753 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1754 >1755 Get Peach Pro1756 </a>1757 <div class="peach-pro-key-divider"><span>or</span></div>1758 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1759 Activate a license key1760 </button>1761 </div>1762 </div>1763 }1764 >1765 <div class="peach-pro-key-ceremony">1766 <LockRuneCanvas />1767 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1768 <input1769 class="peach-underline-input peach-pro-key-input"1770 type="text"1771 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1772 value={licenseKeyInput()}1773 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1774 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1775 autocomplete="off"1776 spellcheck={false}1777 />1778 <Show when={licenseError()}>1779 <p class="peach-pro-key-error">That key doesn't look right.</p>1780 </Show>1781 <button1782 class="peach-btn peach-btn--accent peach-pro-key-activate"1783 onClick={() => void onActivate()}1784 disabled={activatingLicense() || !licenseKeyInput().trim()}1785 >1786 {activatingLicense() ? "activating…" : "Activate"}1787 </button>1788 <button1789 class="peach-pro-key-cancel"1790 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1791 >1792 cancel1793 </button>1794 </div>1795 </Show>1796 }1797 >1798 <div class="peach-pro-active">1799 <div class="peach-pro-active-hero">1800 <div class="peach-pro-active-mark-wrap">1801 <PeachMark class="peach-pro-active-mark" />1802 </div>1803 <h2 class="peach-pro-active-title">Peach Pro</h2>1804 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1805 </div>18061807 <Show when={licenseKey()}>1808 <div class="peach-settings-license">1809 <span class="peach-settings-label">LICENSE KEY</span>1810 <div class="peach-settings-license-row">1811 <span class="peach-settings-license-key">{licenseKey()!}</span>1812 <button1813 class="peach-settings-license-copy"1814 onClick={() => copyLicenseKey(licenseKey()!)}1815 >1816 {copied() ? "✓" : "copy"}1817 </button>1818 </div>1819 </div>1820 </Show>18211822 <div class="peach-pro-active-features">1823 <For each={PRO_FEATURES}>1824 {(item) => (1825 <div class="peach-pro-active-feature">1826 <span class="peach-pro-active-feature-mark">✦</span>1827 <span class="peach-pro-active-feature-name">{item.name}</span>1828 </div>1829 )}1830 </For>1831 </div>18321833 <button1834 class="peach-pro-deactivate"1835 onClick={() => void handleDeactivateLicense()}1836 disabled={deactivating()}1837 >1838 {deactivating() ? "Deactivating…" : "Deactivate"}1839 </button>1840 </div>1841 </Show>1842 </div>1843 );1844 }18451846 function renderFeedback() {1847 return (1848 <div class="peach-settings-body">1849 <div class="peach-settings-group peach-feedback-group">1850 <p class="peach-feedback-intro">1851 Help us improve Peach. Every report goes directly to our inbox.1852 </p>18531854 <p class="peach-settings-sub-label">TYPE</p>1855 <div class="peach-feedback-chips">1856 <For each={FEEDBACK_CATEGORIES}>1857 {(cat) => (1858 <button1859 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1860 onClick={() => setFbCategory(cat)}1861 >1862 {cat}1863 </button>1864 )}1865 </For>1866 </div>18671868 <p class="peach-settings-sub-label">MESSAGE</p>1869 <textarea1870 class="peach-feedback-textarea"1871 placeholder="Describe the issue or idea..."1872 value={fbMessage()}1873 onInput={(e) => setFbMessage(e.currentTarget.value)}1874 />18751876 <p class="peach-feedback-meta">1877 Version {version} · will be included automatically1878 </p>18791880 <Show when={fbResult() === "success"}>1881 <p class="peach-feedback-result peach-feedback-result--ok">1882 Sent — thank you1883 </p>1884 </Show>1885 <Show when={fbResult() === "error"}>1886 <p class="peach-feedback-result peach-feedback-result--err">1887 Failed to send — try again1888 </p>1889 </Show>18901891 <button1892 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1893 onClick={() => void handleSubmitFeedback()}1894 disabled={fbSubmitting() || !fbMessage().trim()}1895 >1896 {fbSubmitting() ? "Sending..." : "Send feedback"}1897 </button>18981899 <p class="peach-feedback-anon">1900 Sent anonymously · no account required1901 </p>1902 </div>1903 </div>1904 );1905 }19061907 return (1908 <div class="peach-overlay-view ov-enter-right" style="display:flex">1909 <div class="peach-overlay-header">1910 <button1911 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1912 onClick={handleHeaderBack}1913 aria-label="Back"1914 >1915 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1916 <path d="M6 1L1 6L6 11" />1917 </svg>1918 </button>1919 <span class="peach-overlay-title">{currentTitle()}</span>1920 <div class="peach-overlay-spacer"></div>1921 </div>19221923 <Switch fallback={renderHome()}>1924 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1925 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1926 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1927 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1928 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1929 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1930 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1931 </Switch>1932 </div>1933 );1934}