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 { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import type { Canary } from "../../shared/types";7import { ACCENT_COLORS } from "../../shared/types";8import type {9 AccentColorName,10 PasskeyProviderState,11 ProFeature,12 VaultSnapshot,13} from "../../shared/types";14import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";15import {16 DEFAULT_BINDINGS,17 findConflict,18 formatKeybinding,19 keybindingFromEvent,20 SHORTCUT_DESCRIPTORS,21 type Keybinding,22 type ShortcutAction,23} from "../../shared/shortcuts";2425interface LicenseInfo {26 valid: boolean;27 plan: "free" | "pro";28 issued?: string;29}3031interface SettingsProps {32 entriesCount: number;33 breachMonitoring: boolean;34 lockOnClose: boolean;35 autoLockMinutes: number;36 trashRetentionDays: number;37 theme: "dark" | "light";38 accentColor: AccentColorName;39 displayFont: "cormorant-italic" | "cormorant" | "inter" | "system";40 textSize: "small" | "medium" | "large";41 reduceMotion: boolean;42 isPro?: boolean;43 initialSection?: string | null;44 upgradeHighlight?: ProFeature | null;45 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;46 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;47 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;48 onTrashRetentionChange: (days: number) => void | Promise<void>;49 onThemeChange: (theme: "dark" | "light") => void;50 onAccentChange: (name: AccentColorName) => void | Promise<void>;51 onDisplayFontChange: (font: "cormorant-italic" | "cormorant" | "inter" | "system") => void | Promise<void>;52 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54 shortcuts: Record<ShortcutAction, Keybinding>;55 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56 onLicenseChange?: () => void | Promise<void>;57 onBack: () => void;58 onImport: () => void;59 onExport: () => void;60 onBreachCheckDone: () => void;61 onOpenHealth?: () => void;62 onOpenSyncPairing?: () => void;63 onOpenFileImport?: () => void;64 onOpenPanic?: () => void;65}6667type SectionName =68 | "security"69 | "vault"70 | "sync"71 | "appearance"72 | "shortcuts"73 | "license"74 | "feedback";7576const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7778function formatActivatedSince(issued: string | undefined): string {79 if (!issued) return "Active";80 try {81 const d = new Date(issued);82 if (Number.isNaN(d.getTime())) return "Active";83 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;84 } catch {85 return "Active";86 }87}8889function isSectionName(value: string): value is SectionName {90 return (91 value === "security" ||92 value === "vault" ||93 value === "sync" ||94 value === "appearance" ||95 value === "shortcuts" ||96 value === "license" ||97 value === "feedback"98 );99}100101export function SettingsView(props: SettingsProps) {102 const version = chrome.runtime.getManifest().version;103 const initial = props.initialSection;104 const [activeSection, setActiveSection] = createSignal<SectionName | null>(105 initial && isSectionName(initial) ? (initial as SectionName) : null106 );107108 const [checkingBreaches, setCheckingBreaches] = createSignal(false);109 const [breachResult, setBreachResult] = createSignal<string | null>(null);110 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);111 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");112 const [licenseError, setLicenseError] = createSignal<string | null>(null);113 const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);114 const [activatingLicense, setActivatingLicense] = createSignal(false);115116 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);117 const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);118 const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");119 const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);120 121 122 123 const [syncProStatus, setSyncProStatus] = createSignal(false);124 const isPro = () => props.isPro ?? syncProStatus();125 const [syncingNow, setSyncingNow] = createSignal(false);126 const [syncFeedback, setSyncFeedback] = createSignal<127 { kind: "ok" | "err"; text: string } | null128 >(null);129 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);130 const [passkeySaving, setPasskeySaving] = createSignal(false);131132 const [dedupCount, setDedupCount] = createSignal<number | null>(null);133 const [dedupRunning, setDedupRunning] = createSignal(false);134 const [dedupDone, setDedupDone] = createSignal<number | null>(null);135136 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);137138 139 140 141 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);142 const [historyLoading, setHistoryLoading] = createSignal(false);143 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);144145 async function loadVaultHistory() {146 setHistoryLoading(true);147 try {148 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });149 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];150 setHistorySnapshots(list);151 } finally {152 setHistoryLoading(false);153 }154 }155156 async function handleRestoreVaultSnapshot(id: string) {157 if (historyRestoring()) return;158 setHistoryRestoring(id);159 const res = await sendMessage({160 type: "RESTORE_VAULT_SNAPSHOT",161 payload: { snapshotId: id },162 });163 setHistoryRestoring(null);164 if ((res as { ok: boolean }).ok) {165 166 167 props.onBack();168 }169 }170171 function formatSnapshotTime(savedAt: number): string {172 const diff = Date.now() - savedAt;173 if (diff < 60_000) return "Just now";174 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;175 const d = new Date(savedAt);176 const isToday = new Date().toDateString() === d.toDateString();177 const isYest =178 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();179 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });180 if (isToday) return `Today ${t}`;181 if (isYest) return `Yesterday ${t}`;182 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;183 }184185 function reasonLabel(reason: VaultSnapshot["reason"]): string {186 return (187 {188 local_save: "saved",189 sync_received: "synced",190 import: "imported",191 restore: "restored",192 } as const193 )[reason] ?? reason;194 }195196 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);197198 const [showChangePw, setShowChangePw] = createSignal(false);199 const [changePwCurrent, setChangePwCurrent] = createSignal("");200 const [changePwNew, setChangePwNew] = createSignal("");201 const [changePwConfirm, setChangePwConfirm] = createSignal("");202 const [changePwSaving, setChangePwSaving] = createSignal(false);203 const [changePwError, setChangePwError] = createSignal<string | null>(null);204 const [changePwSuccess, setChangePwSuccess] = createSignal(false);205 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);206207 const [duressEnabled, setDuressEnabled] = createSignal(false);208 const [showDuressSetup, setShowDuressSetup] = createSignal(false);209 const [duressPassword, setDuressPassword] = createSignal("");210 const [duressConfirm, setDuressConfirm] = createSignal("");211 const [duressMainPassword, setDuressMainPassword] = createSignal("");212 const [duressSaving, setDuressSaving] = createSignal(false);213 const [duressError, setDuressError] = createSignal<string | null>(null);214 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);215216 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);217218 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);219 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);220221 const [fbCategory, setFbCategory] = createSignal("Bug report");222 const [fbMessage, setFbMessage] = createSignal("");223 const [fbSubmitting, setFbSubmitting] = createSignal(false);224 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);225226 onMount(async () => {227 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([228 sendMessage({ type: "GET_LICENSE" }),229 sendMessage({ type: "GET_SYNC_STATE" }),230 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),231 sendMessage({ type: "IS_DURESS_ENABLED" }),232 sendMessage({ type: "GET_SETTINGS" }),233 sendMessage({ type: "GET_LICENSE_KEY" }),234 ]);235236 237 238 void loadVaultHistory();239240 if ((licenseRes as { ok: boolean }).ok) {241 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);242 }243244 if ((syncRes as { ok: boolean }).ok) {245 const data = syncRes as { ok: boolean; data?: {246 pairedDevices?: PairedDevice[];247 trustedNetworks?: TrustedNetwork[];248 status?: SyncStatus;249 lastSyncAt?: number;250 isPro?: boolean;251 }};252 if (data.data) {253 setPairedDevices(data.data.pairedDevices ?? []);254 setTrustedNetworks(data.data.trustedNetworks ?? []);255 setSyncStatus(data.data.status ?? "offline");256 setLastSyncAt(data.data.lastSyncAt);257 setSyncProStatus(data.data.isPro ?? false);258 }259 }260261 if ((passkeyRes as { ok: boolean }).ok) {262 setPasskeyState(263 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null264 );265 }266267 if ((duressRes as { ok: boolean }).ok) {268 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);269 }270271 if ((settingsRes as { ok: boolean }).ok) {272 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;273 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);274 }275276 if ((keyRes as { ok: boolean }).ok) {277 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);278 }279280 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });281 if ((entriesRes as { ok: boolean }).ok) {282 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];283 setCanaryEntries(284 entries285 .filter((e) => e.canary?.webhookUrl)286 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))287 );288 }289290 chrome.runtime.onMessage.addListener((msg) => {291 if (msg.type === "SYNC_STATE_UPDATE") {292 setPairedDevices(msg.pairedDevices ?? []);293 setTrustedNetworks(msg.trustedNetworks ?? []);294 setSyncStatus(msg.status ?? "offline");295 setLastSyncAt(msg.lastSyncAt);296 setSyncProStatus(msg.isPro ?? false);297 }298 });299 });300301 async function handleChangePassword() {302 setChangePwError(null);303 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }304 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }305 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }306 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }307 setChangePwSaving(true);308 const res = await sendMessage({309 type: "CHANGE_PASSWORD",310 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },311 }) as { ok: boolean; error?: string };312 if (res.ok) {313 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");314 setShowChangePw(false);315 setShowNewRecoveryWords(true);316 } else {317 setChangePwError(res.error ?? "failed to change password");318 }319 setChangePwSaving(false);320 }321322 const [copied, setCopied] = createSignal(false);323324 async function copyLicenseKey(key: string) {325 await navigator.clipboard.writeText(key);326 setCopied(true);327 setTimeout(() => setCopied(false), 2000);328 }329330 async function handleActivateLicense() {331 const key = licenseKeyInput().trim();332 if (!key) return;333 setActivatingLicense(true);334 setLicenseError(null);335 setLicenseSuccess(null);336 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });337 setActivatingLicense(false);338 if ((res as { ok: boolean }).ok) {339 const data = (res as { ok: boolean; data?: LicenseInfo }).data;340 if (data?.valid) {341 setLicenseInfo(data);342 setLicenseKey(key);343 setLicenseSuccess("License activated!");344 setLicenseKeyInput("");345 } else {346 setLicenseError("Invalid license key");347 }348 } else {349 setLicenseError("Activation failed");350 }351 }352353 async function handleTogglePasskeys() {354 const current = passkeyState();355 if (!current || !current.supported) return;356 setPasskeySaving(true);357 const res = await sendMessage({358 type: "SET_PASSKEY_PROVIDER_ENABLED",359 payload: { enabled: !current.enabled },360 });361 setPasskeySaving(false);362 if ((res as { ok: boolean }).ok) {363 setPasskeyState(364 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null365 );366 }367 }368369 async function handleCheckAllBreaches() {370 setCheckingBreaches(true);371 setBreachResult(null);372 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });373 setCheckingBreaches(false);374 if ((res as { ok: boolean }).ok) {375 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};376 const breached = Object.values(data).filter((count) => count > 0).length;377 setBreachResult(378 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"379 );380 props.onBreachCheckDone();381 }382 }383384 async function handleSyncNow() {385 if (syncStatus() === "not_trusted") return;386 setSyncingNow(true);387 setSyncFeedback(null);388 const res = await sendMessage({ type: "TRIGGER_SYNC" });389 const ok = (res as { ok: boolean }).ok;390 if (ok) {391 const data = (res as { data?: { syncedCount?: number; totalReachable?: number } }).data;392 const count = data?.syncedCount ?? 0;393 setSyncFeedback({ kind: "ok", text: count > 0 ? `Synced with ${count} ${count === 1 ? "device" : "devices"}` : "Up to date" });394 setLastSyncAt(Date.now());395 void refreshSyncState();396 } else {397 const error = (res as { error?: string }).error;398 const map: Record<string, string> = {399 no_paired_devices: "No paired devices",400 no_devices_reachable: "No devices found",401 vault_locked: "Vault is locked",402 pro_required: "Sync requires Pro",403 };404 setSyncFeedback({ kind: "err", text: map[error ?? ""] ?? "Sync failed" });405 }406 setTimeout(() => {407 setSyncingNow(false);408 setSyncFeedback(null);409 }, 3500);410 }411412 async function refreshSyncState() {413 const res = await sendMessage({ type: "GET_SYNC_STATE" });414 const data = (res as { ok: boolean; data?: {415 pairedDevices?: PairedDevice[];416 trustedNetworks?: TrustedNetwork[];417 status?: SyncStatus;418 lastSyncAt?: number;419 }}).data;420 if (!data) return;421 setPairedDevices(data.pairedDevices ?? []);422 setTrustedNetworks(data.trustedNetworks ?? []);423 setSyncStatus(data.status ?? "offline");424 setLastSyncAt(data.lastSyncAt);425 }426427 async function handleRemoveDevice(deviceId: string) {428 setPairedDevices((prev) => prev.filter((d) => d.deviceId !== deviceId));429 await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });430 void refreshSyncState();431 }432433 async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {434 await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });435 void refreshSyncState();436 }437438 const [markingNetwork, setMarkingNetwork] = createSignal(false);439440 async function handleMarkNetworkTrusted() {441 if (markingNetwork()) return;442 setMarkingNetwork(true);443 await sendMessage({ type: "MARK_NETWORK_TRUSTED" });444 await refreshSyncState();445 setMarkingNetwork(false);446 }447448 const [resetIdentityConfirm, setResetIdentityConfirm] = createSignal(false);449 const [resetIdentityRunning, setResetIdentityRunning] = createSignal(false);450451 async function handleResetSyncIdentity() {452 setResetIdentityRunning(true);453 const res = await sendMessage({ type: "RESET_SYNC_IDENTITY" });454 setResetIdentityRunning(false);455 if ((res as { ok: boolean }).ok) {456 setPairedDevices([]);457 setResetIdentityConfirm(false);458 }459 }460461 function formatRelativeTime(timestamp?: number): string {462 if (!timestamp) return "Never";463 const diff = Date.now() - timestamp;464 const minutes = Math.floor(diff / 60000);465 const hours = Math.floor(diff / 3600000);466 const days = Math.floor(diff / 86400000);467 if (minutes < 1) return "Just now";468 if (minutes < 60) return `${minutes}m ago`;469 if (hours < 24) return `${hours}h ago`;470 return `${days}d ago`;471 }472473 function getStatusColor(status: SyncStatus): string {474 switch (status) {475 case "synced": return "var(--peach-sync-ok)";476 case "syncing": return "var(--peach-sync-busy)";477 case "no_peers": return "var(--peach-sync-off)";478 case "not_trusted": return "var(--peach-sync-err)";479 case "offline": return "var(--peach-sync-off)";480 default: return "var(--peach-sync-off)";481 }482 }483484 function getStatusLabel(status: SyncStatus): string {485 switch (status) {486 case "synced": return "Synced";487 case "syncing": return "Syncing";488 case "no_peers": return "No peers visible";489 case "not_trusted": return "Not on a trusted network";490 case "offline": return "Offline";491 default: return "Unknown";492 }493 }494495 function isCurrentNetworkTrusted(): boolean {496 return trustedNetworks().length > 0;497 }498499 async function handleSetupDuress() {500 const mainPw = duressMainPassword().trim();501 const duressPw = duressPassword().trim();502 const confirmPw = duressConfirm().trim();503504 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }505 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }506 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }507 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }508509 setDuressSaving(true);510 setDuressError(null);511512 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };513 if (!verifyRes.ok || !verifyRes.data) {514 setDuressError("incorrect main password");515 setDuressSaving(false);516 return;517 }518519 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };520 setDuressSaving(false);521 if (res.ok) {522 setDuressEnabled(true);523 setShowDuressSetup(false);524 setDuressPassword("");525 setDuressConfirm("");526 setDuressMainPassword("");527 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");528 setTimeout(() => setDuressSuccess(null), 5000);529 } else {530 setDuressError(res.error ?? "failed to set duress password");531 }532 }533534 async function handleClearDeclinedCards() {535 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });536 setDeclinedCardCount(0);537 }538539 async function handleCountDuplicates() {540 setDedupRunning(true);541 setDedupDone(null);542 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };543 setDedupRunning(false);544 if (res.ok) setDedupCount(res.data ?? 0);545 }546547 async function handleDeduplicate() {548 setDedupRunning(true);549 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };550 setDedupRunning(false);551 if (res.ok) {552 setDedupDone(res.data ?? 0);553 setDedupCount(0);554 }555 }556557 async function handleDisableDuress() {558 const mainPw = duressMainPassword().trim();559 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }560561 setDuressSaving(true);562 setDuressError(null);563564 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };565 if (!verifyRes.ok || !verifyRes.data) {566 setDuressError("incorrect main password");567 setDuressSaving(false);568 return;569 }570571 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };572 setDuressSaving(false);573 if (res.ok) {574 setDuressEnabled(false);575 setShowDuressSetup(false);576 setDuressMainPassword("");577 setDuressSuccess("Duress password disabled.");578 setTimeout(() => setDuressSuccess(null), 4000);579 } else {580 setDuressError(res.error ?? "failed to disable duress password");581 }582 }583584 async function handleSubmitFeedback() {585 if (!fbMessage().trim() || fbSubmitting()) return;586 setFbSubmitting(true);587 setFbResult(null);588 const res = await sendMessage({589 type: "SUBMIT_FEEDBACK",590 payload: {591 category: fbCategory(),592 message: fbMessage().trim(),593 version,594 browser: navigator.userAgent,595 },596 }) as { ok: boolean; error?: string };597 setFbSubmitting(false);598 if (res.ok) {599 setFbResult("success");600 setFbMessage("");601 setTimeout(() => setFbResult(null), 4000);602 } else {603 setFbResult("error");604 }605 }606607 function handleHeaderBack() {608 if (activeSection() !== null) {609 setActiveSection(null);610 } else {611 props.onBack();612 }613 }614615 function currentTitle(): string {616 const s = activeSection();617 if (s === null) return "settings";618 if (s === "license") return "account";619 return s;620 }621622 function renderCatRow(opts: {623 section: SectionName;624 name: string;625 desc: string;626 icon: () => any;627 pro?: boolean;628 }) {629 return (630 <button631 class="peach-settings-cat-row"632 onClick={() => setActiveSection(opts.section)}633 >634 <div class="peach-settings-cat-icon">{opts.icon()}</div>635 <div class="peach-settings-cat-content">636 <span class="peach-settings-cat-name">637 {opts.name}638 <Show when={opts.pro && !isPro()}>639 <span class="peach-pro-mark" aria-label="Pro feature">✦</span>640 </Show>641 </span>642 <span class="peach-settings-cat-desc">{opts.desc}</span>643 </div>644 <svg645 class="peach-settings-cat-chevron"646 width="6" height="10" viewBox="0 0 6 10" fill="none"647 stroke="currentColor" stroke-width="1.1"648 stroke-linecap="round" stroke-linejoin="round"649 >650 <path d="M1 1l4 4-4 4" />651 </svg>652 </button>653 );654 }655656 const iconShield = () => (657 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">658 <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>659 </svg>660 );661 const iconKey = () => (662 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">663 <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>664 </svg>665 );666 const iconLock = () => (667 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">668 <rect x="3" y="7" width="10" height="7" rx="1.5"/>669 <path d="M6 7V5a3 3 0 016 0v2"/>670 </svg>671 );672 const iconSync = () => (673 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">674 <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>675 <path d="M14 6l-2 2-2-2"/>676 <path d="M2 10l2-2 2 2"/>677 </svg>678 );679 const iconSun = () => (680 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">681 <circle cx="8" cy="8" r="3"/>682 <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"/>683 </svg>684 );685 const iconKeys = () => (686 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">687 <rect x="2" y="5" width="5" height="4" rx="1"/>688 <rect x="9" y="7" width="5" height="4" rx="1"/>689 <path d="M7 7h2m-2 2h2"/>690 </svg>691 );692 const iconBubble = () => (693 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">694 <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>695 </svg>696 );697698 function renderHome() {699 return (700 <div class="peach-settings-body">701 <p class="peach-settings-nav-label">PROTECTION</p>702 {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}703 {renderCatRow({ section: "license", name: "Peach Pro", desc: isPro() ? "Active · manage your license" : "Upgrade · $39 · yours forever", icon: iconKey })}704705 <p class="peach-settings-nav-label">VAULT</p>706 {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}707 {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}708709 <p class="peach-settings-nav-label">PREFERENCES</p>710 {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}711 {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}712713 <p class="peach-settings-nav-label">SUPPORT</p>714 {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}715716 <div class="peach-settings-footer">717 <span>Peach {version}</span>718 <span class="peach-divider"></span>719 <span>Browser extension</span>720 </div>721 </div>722 );723 }724725 function renderSecurity() {726 return (727 <div class="peach-settings-body">728 <div class="peach-settings-group">729 <p class="peach-settings-sub-label">LOCKING</p>730 <div class="peach-settings-row">731 <span>Lock on close</span>732 <button733 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}734 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}735 aria-label="Toggle lock on close"736 >737 <span class="peach-toggle-knob"></span>738 </button>739 </div>740 <div class="peach-settings-row">741 <span>Auto-lock after</span>742 <select743 class="peach-settings-select"744 value={String(props.autoLockMinutes)}745 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}746 >747 <option value="1">1 minute</option>748 <option value="5">5 minutes</option>749 <option value="15">15 minutes</option>750 <option value="30">30 minutes</option>751 <option value="0">Never</option>752 </select>753 </div>754755 <p class="peach-settings-sub-label">BREACH PROTECTION</p>756 <div class="peach-settings-row">757 <span>Breach monitoring</span>758 <button759 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}760 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}761 aria-label="Toggle breach monitoring"762 >763 <span class="peach-toggle-knob"></span>764 </button>765 </div>766 <div class="peach-settings-row peach-settings-row--meta">767 <span>Check all passwords</span>768 <button769 class="peach-btn peach-btn--subtle peach-btn--sm"770 onClick={() => void handleCheckAllBreaches()}771 disabled={checkingBreaches()}772 >773 {checkingBreaches() ? "checking..." : "run"}774 </button>775 </div>776 <Show when={breachResult()}>777 <div class="peach-settings-row peach-settings-row--meta">778 <span>Last check</span>779 <span class="peach-settings-value">{breachResult()}</span>780 </div>781 </Show>782783 <p class="peach-settings-sub-label">MASTER PASSWORD</p>784 <div class="peach-settings-row peach-settings-row--meta">785 <span>Master password</span>786 <button787 class="peach-btn peach-btn--subtle peach-btn--sm"788 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}789 >790 {showChangePw() ? "cancel" : "change"}791 </button>792 </div>793 <Show when={showChangePw()}>794 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">795 <input796 class="peach-settings-input"797 type="password"798 placeholder="current password"799 value={changePwCurrent()}800 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}801 autocomplete="current-password"802 />803 <input804 class="peach-settings-input"805 type="password"806 placeholder="new password (min 12 chars)"807 value={changePwNew()}808 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}809 autocomplete="new-password"810 />811 <input812 class="peach-settings-input"813 type="password"814 placeholder="confirm new password"815 value={changePwConfirm()}816 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}817 autocomplete="new-password"818 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}819 />820 <Show when={changePwError()}>821 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>822 </Show>823 <Show when={changePwSuccess()}>824 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>825 </Show>826 <button827 class="peach-btn peach-btn--accent peach-btn--sm"828 style="align-self:flex-start"829 onClick={() => void handleChangePassword()}830 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}831 >832 {changePwSaving() ? "saving…" : "save new password"}833 </button>834 </div>835 </Show>836837 <p class="peach-settings-sub-label">DURESS PASSWORD</p>838 <div class="peach-settings-row peach-settings-row--top">839 <div class="peach-settings-row-main">840 <span>Duress password</span>841 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">842 If entered under coercion, shows a decoy empty vault instead of your real one.843 </span>844 </div>845 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>846 {duressEnabled() ? "on" : "off"}847 </div>848 </div>849850 <Show when={duressEnabled() && !showDuressSetup()}>851 <div class="peach-settings-row peach-settings-row--meta">852 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">853 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden.854 </span>855 </div>856 </Show>857858 <Show when={duressSuccess()}>859 <div class="peach-settings-row peach-settings-row--meta">860 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>861 </div>862 </Show>863864 <Show when={!showDuressSetup()}>865 <div class="peach-settings-row peach-settings-row--meta">866 <button867 class="peach-btn peach-btn--subtle peach-btn--sm"868 onClick={() => { setShowDuressSetup(true); setDuressError(null); setDuressSuccess(null); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); }}869 >870 {duressEnabled() ? "Change / disable" : "Set up"}871 </button>872 </div>873 </Show>874875 <Show when={showDuressSetup()}>876 <div class="peach-duress-form">877 <label class="peach-label">main password (to confirm)</label>878 <input879 class="peach-settings-input"880 type="password"881 placeholder="your current master password"882 autocomplete="current-password"883 value={duressMainPassword()}884 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}885 />886 <Show when={!duressEnabled()}>887 <label class="peach-label">new duress password</label>888 <input889 class="peach-settings-input"890 type="password"891 placeholder="duress password (min 8 chars)"892 autocomplete="new-password"893 value={duressPassword()}894 onInput={(e) => setDuressPassword(e.currentTarget.value)}895 />896 <label class="peach-label">confirm duress password</label>897 <input898 class="peach-settings-input"899 type="password"900 placeholder="confirm duress password"901 autocomplete="new-password"902 value={duressConfirm()}903 onInput={(e) => setDuressConfirm(e.currentTarget.value)}904 />905 </Show>906907 <Show when={duressError()}>908 <p class="peach-hint peach-hint-error">{duressError()}</p>909 </Show>910911 <div class="peach-duress-actions">912 <button913 class="peach-btn peach-btn--subtle peach-btn--sm"914 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}915 >916 cancel917 </button>918 <Show when={duressEnabled()}>919 <button920 class="peach-btn peach-btn--ghost peach-btn--sm"921 style="color:var(--peach-breach)"922 onClick={() => void handleDisableDuress()}923 disabled={duressSaving()}924 >925 {duressSaving() ? "disabling..." : "disable duress"}926 </button>927 </Show>928 <Show when={!duressEnabled()}>929 <button930 class="peach-btn peach-btn--accent peach-btn--sm"931 onClick={() => void handleSetupDuress()}932 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}933 >934 {duressSaving() ? "saving..." : "save"}935 </button>936 </Show>937 </div>938 </div>939 </Show>940941 <p class="peach-settings-sub-label">CANARY ENTRIES</p>942 <div class="peach-settings-row peach-settings-row--top">943 <div class="peach-settings-row-main">944 <span>Canary entries</span>945 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">946 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.947 </span>948 </div>949 <span class="peach-settings-value" style="flex-shrink:0">950 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}951 </span>952 </div>953 <Show when={canaryEntries().length > 0}>954 <For each={canaryEntries()}>955 {(entry) => (956 <div class="peach-settings-row peach-settings-row--meta">957 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>958 <button959 class="peach-btn peach-btn--subtle peach-btn--sm"960 onClick={() => void handleTestCanary(entry)}961 disabled={canaryTestingId() === entry.id}962 style="flex-shrink:0"963 >964 {canaryTestingId() === entry.id965 ? "sending…"966 : canaryTestResult()?.id === entry.id967 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")968 : "test"}969 </button>970 </div>971 )}972 </For>973 </Show>974975 <p class="peach-settings-sub-label">PASSKEYS</p>976 <div class="peach-settings-row peach-settings-row--top">977 <div class="peach-settings-row-main">978 <span>Intercept passkey requests</span>979 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">980 Peach can handle passkey sign-in and creation on supported Chromium browsers.981 </span>982 </div>983 <button984 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}985 onClick={() => void handleTogglePasskeys()}986 aria-label="Toggle passkey interception"987 disabled={!passkeyState()?.supported || passkeySaving()}988 >989 <span class="peach-toggle-knob"></span>990 </button>991 </div>992 <div class="peach-settings-row peach-settings-row--meta">993 <span>Status</span>994 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>995 </div>996 <div class="peach-settings-row peach-settings-row--meta">997 <span>Browser</span>998 <span class="peach-settings-value">999 {passkeyState()?.browserLabel ?? "Unknown"}1000 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1001 </span>1002 </div>1003 <div class="peach-info-notice" style="margin-top:8px">1004 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1005 </div>1006 <Show when={passkeyState()?.conflict}>1007 <div class="peach-settings-row peach-settings-row--meta">1008 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1009 </div>1010 </Show>1011 </div>1012 </div>1013 );1014 }10151016 function renderVault() {1017 return (1018 <div class="peach-settings-body">1019 <div class="peach-settings-group">1020 <p class="peach-settings-sub-label">ENTRIES</p>1021 <div class="peach-settings-row peach-settings-row--meta">1022 <span>Entries</span>1023 <span class="peach-settings-value">{props.entriesCount}</span>1024 </div>1025 <div class="peach-settings-row peach-settings-row--meta">1026 <span>Duplicates</span>1027 <Show when={dedupDone() !== null} fallback={1028 <button1029 class="peach-btn peach-btn--subtle peach-btn--sm"1030 onClick={() => void handleCountDuplicates()}1031 disabled={dedupRunning()}1032 >1033 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1034 </button>1035 }>1036 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1037 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1038 </span>1039 </Show>1040 </div>1041 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1042 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1043 <div class="peach-restore-warning">1044 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1045 </div>1046 <div style="display:flex;gap:6px">1047 <button1048 class="peach-btn peach-btn--subtle peach-btn--sm"1049 onClick={() => setDedupCount(null)}1050 >1051 cancel1052 </button>1053 <button1054 class="peach-btn peach-btn--accent peach-btn--sm"1055 onClick={() => void handleDeduplicate()}1056 disabled={dedupRunning()}1057 >1058 {dedupRunning() ? "removing..." : "remove duplicates"}1059 </button>1060 </div>1061 </div>1062 </Show>10631064 <p class="peach-settings-sub-label">MAINTENANCE</p>1065 <div class="peach-settings-row peach-settings-row--meta">1066 <span>Trash auto-delete</span>1067 <select1068 class="peach-settings-select"1069 value={String(props.trashRetentionDays)}1070 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1071 >1072 <option value="7">7 days</option>1073 <option value="30">30 days</option>1074 <option value="90">90 days</option>1075 <option value="0">Never</option>1076 </select>1077 </div>1078 <div class="peach-settings-row peach-settings-row--meta">1079 <span>Vault Health</span>1080 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1081 open1082 </button>1083 </div>1084 <Show when={declinedCardCount() > 0}>1085 <div class="peach-settings-row peach-settings-row--meta">1086 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1087 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1088 Clear1089 </button>1090 </div>1091 </Show>10921093 <p class="peach-settings-sub-label">DATA</p>1094 <div class="peach-settings-row peach-settings-row--meta">1095 <span>Import passwords</span>1096 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1097 open1098 </button>1099 </div>1100 <div class="peach-settings-row peach-settings-row--meta">1101 <span>Export backup</span>1102 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1103 open1104 </button>1105 </div>11061107 <p class="peach-settings-sub-label">VAULT HISTORY</p>1108 <Show when={historyLoading()}>1109 <p class="peach-settings-hint">Loading…</p>1110 </Show>1111 <Show when={!historyLoading() && historySnapshots().length === 0}>1112 <p class="peach-settings-hint">1113 No snapshots yet. Peach saves automatically before every change —1114 saves, syncs, and imports.1115 </p>1116 </Show>1117 <Show when={!historyLoading() && historySnapshots().length > 0}>1118 <p class="peach-settings-hint">1119 Restoring locks your vault. Unlock with the password that1120 encrypted that snapshot to confirm.1121 </p>1122 <div class="peach-history-list">1123 <For each={historySnapshots()}>1124 {(snap) => (1125 <div class="peach-history-row">1126 <div class="peach-history-meta">1127 <span class="peach-history-time">1128 {formatSnapshotTime(snap.savedAt)}1129 </span>1130 <span class="peach-history-sub">1131 {[1132 snap.entryCount !== null1133 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1134 : null,1135 reasonLabel(snap.reason),1136 ]1137 .filter(Boolean)1138 .join(" · ")}1139 </span>1140 </div>1141 <button1142 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1143 historyRestoring() === snap.id ? " is-busy" : ""1144 }`}1145 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1146 disabled={historyRestoring() !== null}1147 >1148 <Show when={historyRestoring() === snap.id} fallback="Restore">1149 …1150 </Show>1151 </button>1152 </div>1153 )}1154 </For>1155 </div>1156 <p class="peach-history-footer">1157 {historySnapshots().length} of 10 snapshots1158 </p>1159 </Show>11601161 <button1162 class="peach-settings-advanced-toggle"1163 onClick={() => setShowVaultAdvanced((v) => !v)}1164 aria-expanded={showVaultAdvanced()}1165 >1166 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1167 <span class="peach-settings-advanced-chevron">1168 {showVaultAdvanced() ? "▲" : "▼"}1169 </span>1170 </button>11711172 <Show when={showVaultAdvanced()}>1173 <div class="peach-danger-card">1174 <div class="peach-danger-card-head">1175 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1176 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1177 </div>1178 <p class="peach-danger-card-copy">1179 Wipes the current vault and restores from a backup file.1180 Recoverable only if your backup is good.1181 </p>1182 <button1183 class="peach-danger-card-button"1184 onClick={() => props.onOpenFileImport?.()}1185 >1186 Choose backup file1187 </button>1188 </div>11891190 <Show when={props.onOpenPanic}>1191 <div class="peach-danger-card peach-danger-card--hazard">1192 <div class="peach-danger-card-head">1193 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1194 <h3 class="peach-danger-card-title">Destroy vault</h3>1195 </div>1196 <p class="peach-danger-card-copy">1197 Permanently wipes every entry, setting, and license on this1198 device. There is no recovery.1199 </p>1200 <button1201 class="peach-danger-card-button"1202 onClick={() => props.onOpenPanic?.()}1203 >1204 Begin destruction1205 </button>1206 </div>1207 </Show>1208 </Show>1209 </div>1210 </div>1211 );1212 }12131214 function renderSync() {1215 if (!isPro()) {1216 return (1217 <div class="peach-settings-body">1218 <div class="peach-pro-upgrade">1219 <div class="peach-pro-upgrade-hero">1220 <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1221 <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1222 </div>12231224 <div class="peach-pro-features">1225 <div class="peach-pro-feature">1226 <span class="peach-pro-feature-mark">✦</span>1227 <div class="peach-pro-feature-text">1228 <span class="peach-pro-feature-name">Always in sync</span>1229 <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1230 </div>1231 </div>1232 <div class="peach-pro-feature">1233 <span class="peach-pro-feature-mark">✦</span>1234 <div class="peach-pro-feature-text">1235 <span class="peach-pro-feature-name">No cloud. No relay.</span>1236 <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1237 </div>1238 </div>1239 <div class="peach-pro-feature">1240 <span class="peach-pro-feature-mark">✦</span>1241 <div class="peach-pro-feature-text">1242 <span class="peach-pro-feature-name">Tailscale supported</span>1243 <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1244 </div>1245 </div>1246 <div class="peach-pro-feature">1247 <span class="peach-pro-feature-mark">✦</span>1248 <div class="peach-pro-feature-text">1249 <span class="peach-pro-feature-name">Zero friction after setup</span>1250 <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1251 </div>1252 </div>1253 </div>12541255 <button1256 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1257 onClick={() => setActiveSection("license")}1258 >1259 Upgrade to Pro1260 </button>1261 <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1262 Already have a key? Enter it →1263 </button>1264 </div>1265 </div>1266 );1267 }12681269 return (1270 <div class="peach-settings-body">1271 <div class="peach-settings-group">1272 <div class="peach-settings-row">1273 <span>Status</span>1274 <span class="peach-settings-value peach-settings-value--status">1275 <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1276 {getStatusLabel(syncStatus())}1277 </span>1278 </div>1279 <div class="peach-settings-row peach-settings-row--meta">1280 <span>Last sync</span>1281 <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1282 </div>1283 <div class="peach-settings-row peach-settings-row--meta">1284 <button1285 class="peach-btn peach-btn--subtle peach-btn--sm"1286 onClick={() => void handleSyncNow()}1287 disabled={syncStatus() === "not_trusted" || syncingNow()}1288 >1289 <Switch>1290 <Match when={syncingNow()}>Syncing…</Match>1291 <Match when={syncFeedback()?.kind === "ok"}>✓ {syncFeedback()!.text}</Match>1292 <Match when={syncFeedback()?.kind === "err"}>{syncFeedback()!.text}</Match>1293 <Match when={true}>Sync now</Match>1294 </Switch>1295 </button>1296 </div>1297 <p class="peach-settings-hint">1298 If you edit on multiple devices before syncing, the most recently1299 changed device wins. Use Vault History above to recover older1300 versions if needed.1301 </p>13021303 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1304 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>1305 <Show when={pairedDevices().length === 0}>1306 <p class="peach-settings-hint">1307 No paired devices yet. Add a device to start syncing.1308 </p>1309 </Show>1310 <Show when={pairedDevices().length > 0}>1311 <div class="peach-history-list">1312 <For each={pairedDevices()}>1313 {(device) => (1314 <div class="peach-history-row">1315 <div class="peach-history-meta">1316 <span class="peach-history-time">{device.displayName}</span>1317 <span class="peach-history-sub">1318 {device.lastSyncAt1319 ? `Last synced: ${formatSnapshotTime(device.lastSyncAt)}`1320 : "Never synced"}1321 </span>1322 </div>1323 <button1324 class="peach-btn peach-btn--subtle peach-btn--sm peach-history-btn"1325 onClick={() => void handleRemoveDevice(device.deviceId)}1326 >1327 Remove1328 </button>1329 </div>1330 )}1331 </For>1332 </div>1333 </Show>1334 <div class="peach-settings-actions--row">1335 <button1336 class="peach-btn peach-btn--subtle peach-btn--sm"1337 onClick={() => props.onOpenSyncPairing?.()}1338 >1339 Add a device1340 </button>1341 </div>1342 </div>13431344 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1345 <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1346 <For each={trustedNetworks()}>1347 {(network) => (1348 <div class="peach-sync-device-row">1349 <span class="peach-sync-device-name">1350 {network.isTailscale ? "Tailscale" : (network.ssid ?? "Unknown network")}1351 </span>1352 <Show1353 when={!network.isTailscale}1354 fallback={<span class="peach-sync-device-meta">Always trusted</span>}1355 >1356 <button1357 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1358 onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1359 >1360 Remove1361 </button>1362 </Show>1363 </div>1364 )}1365 </For>1366 <div class="peach-settings-actions--row">1367 <Show when={!isCurrentNetworkTrusted()}>1368 <button1369 class="peach-btn peach-btn--subtle peach-btn--sm"1370 onClick={() => void handleMarkNetworkTrusted()}1371 disabled={markingNetwork()}1372 >1373 {markingNetwork() ? "Marking…" : "Mark current network as trusted"}1374 </button>1375 </Show>1376 </div>1377 </div>13781379 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1380 <span class="peach-sync-section-label">Advanced</span>1381 <Show1382 when={resetIdentityConfirm()}1383 fallback={1384 <div class="peach-sync-advanced-row">1385 <div class="peach-sync-advanced-info">1386 <span class="peach-sync-advanced-name">Reset sync identity</span>1387 <span class="peach-sync-device-meta">1388 Generates a new keypair and unpairs every device.1389 </span>1390 </div>1391 <button1392 class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1393 onClick={() => setResetIdentityConfirm(true)}1394 >1395 Reset1396 </button>1397 </div>1398 }1399 >1400 <div class="peach-sync-reset-confirm">1401 <p class="peach-sync-reset-confirm-headline">Reset sync identity?</p>1402 <p class="peach-sync-reset-confirm-body">1403 This will unpair all devices. You will need to pair them again from scratch.1404 </p>1405 <div class="peach-settings-actions--row">1406 <button1407 class="peach-btn peach-btn--ghost peach-btn--sm"1408 onClick={() => setResetIdentityConfirm(false)}1409 disabled={resetIdentityRunning()}1410 >1411 Cancel1412 </button>1413 <button1414 class="peach-btn peach-btn--danger peach-btn--sm"1415 onClick={() => void handleResetSyncIdentity()}1416 disabled={resetIdentityRunning()}1417 >1418 {resetIdentityRunning() ? "Resetting..." : "Reset"}1419 </button>1420 </div>1421 </div>1422 </Show>1423 </div>1424 </div>1425 </div>1426 );1427 }14281429 function renderAppearance() {1430 const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [1431 { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },1432 { id: "cormorant", label: "Cormorant", preview: "Aa" },1433 { id: "inter", label: "Inter", preview: "Aa" },1434 { id: "system", label: "System", preview: "Aa" },1435 ];1436 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1437 { id: "small", label: "Small" },1438 { id: "medium", label: "Default" },1439 { id: "large", label: "Large" },1440 ];1441 return (1442 <div class="peach-settings-body">1443 <div class="peach-settings-group">1444 <p class="peach-settings-sub-label">THEME</p>1445 <div class="peach-settings-row peach-settings-row--meta">1446 <span>Theme</span>1447 <button1448 class="peach-btn peach-btn--subtle peach-btn--sm"1449 onClick={() =>1450 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1451 }1452 >1453 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1454 </button>1455 </div>14561457 <p class="peach-settings-sub-label">ACCENT</p>1458 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1459 <span>Accent</span>1460 <div class="peach-accent-picker">1461 <For each={ACCENT_COLORS}>1462 {(color) => (1463 <button1464 class="peach-accent-swatch"1465 classList={{ "is-active": props.accentColor === color.name }}1466 style={{ background: color.hex }}1467 onClick={() => void props.onAccentChange(color.name)}1468 aria-label={color.name}1469 title={color.name}1470 />1471 )}1472 </For>1473 </div>1474 </div>14751476 <p class="peach-settings-sub-label">DISPLAY FONT</p>1477 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--font">1478 <span>Font</span>1479 <div class="peach-font-picker">1480 <For each={fontOptions}>1481 {(opt) => (1482 <button1483 class="peach-font-swatch"1484 classList={{ "is-active": props.displayFont === opt.id }}1485 data-font-preview={opt.id}1486 onClick={() => void props.onDisplayFontChange(opt.id)}1487 aria-label={opt.label}1488 title={opt.label}1489 >1490 <span class="peach-font-swatch-glyph">{opt.preview}</span>1491 <span class="peach-font-swatch-name">{opt.label}</span>1492 </button>1493 )}1494 </For>1495 </div>1496 </div>14971498 <p class="peach-settings-sub-label">TEXT SIZE</p>1499 <div class="peach-settings-row peach-settings-row--meta">1500 <span>Size</span>1501 <div class="peach-pill-group">1502 <For each={sizeOptions}>1503 {(opt) => (1504 <button1505 class="peach-btn peach-btn--ghost peach-btn--sm"1506 classList={{ "is-active": props.textSize === opt.id }}1507 onClick={() => void props.onTextSizeChange(opt.id)}1508 >1509 {opt.label}1510 </button>1511 )}1512 </For>1513 </div>1514 </div>15151516 <p class="peach-settings-sub-label">MOTION</p>1517 <div class="peach-settings-row peach-settings-row--meta">1518 <span>Reduce motion</span>1519 <button1520 class="peach-btn peach-btn--subtle peach-btn--sm"1521 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1522 >1523 {props.reduceMotion ? "On" : "Off"}1524 </button>1525 </div>1526 </div>1527 </div>1528 );1529 }15301531 function renderShortcuts() {1532 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1533 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);15341535 function startCapture(action: ShortcutAction) {1536 setConflictMsg(null);1537 setCapturingAction(action);1538 }1539 function cancelCapture() {1540 setCapturingAction(null);1541 setConflictMsg(null);1542 }1543 function commitBinding(action: ShortcutAction, binding: Keybinding) {1544 const conflict = findConflict(props.shortcuts, binding, action);1545 if (conflict) {1546 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1547 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1548 return;1549 }1550 const next = { ...props.shortcuts, [action]: binding };1551 setCapturingAction(null);1552 setConflictMsg(null);1553 void props.onShortcutsChange(next);1554 }1555 function resetAction(action: ShortcutAction) {1556 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1557 void props.onShortcutsChange(next);1558 }1559 function resetAll() {1560 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1561 }15621563 1564 1565 function handleCaptureKey(e: KeyboardEvent) {1566 e.preventDefault();1567 e.stopPropagation();1568 if (e.key === "Escape") { cancelCapture(); return; }1569 const binding = keybindingFromEvent(e);1570 if (!binding) return; 1571 const action = capturingAction();1572 if (action) commitBinding(action, binding);1573 }15741575 return (1576 <div class="peach-settings-body">1577 <div class="peach-settings-group peach-shortcuts-group">1578 <p class="peach-settings-sub-label">ENTRY VIEW</p>1579 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1580 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1581 </For>1582 </div>15831584 <div class="peach-settings-group peach-shortcuts-group">1585 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1586 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1587 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1588 </For>1589 </div>15901591 <Show when={conflictMsg()}>1592 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1593 </Show>15941595 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1596 <div class="peach-settings-row peach-settings-row--meta">1597 <span>Inline autofill on page</span>1598 <a1599 class="peach-settings-link"1600 href="chrome://extensions/shortcuts"1601 onClick={(e) => {1602 e.preventDefault();1603 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1604 }}1605 >1606 ⌘⇧P — customize ↗1607 </a>1608 </div>1609 <button1610 class="peach-btn peach-btn--subtle peach-btn--sm"1611 style="align-self:flex-start"1612 onClick={resetAll}1613 >1614 Reset all to defaults1615 </button>1616 </div>1617 </div>1618 );16191620 function renderRow(1621 desc: typeof SHORTCUT_DESCRIPTORS[number],1622 capturing: () => ShortcutAction | null,1623 onStart: (a: ShortcutAction) => void,1624 onCancel: () => void,1625 onReset: (a: ShortcutAction) => void,1626 onKey: (e: KeyboardEvent) => void,1627 ) {1628 const isActive = () => capturing() === desc.action;1629 const binding = () => props.shortcuts[desc.action];1630 const isCustom = () => {1631 const b = binding();1632 const d = DEFAULT_BINDINGS[desc.action];1633 return b.key !== d.key1634 || Boolean(b.ctrl) !== Boolean(d.ctrl)1635 || Boolean(b.meta) !== Boolean(d.meta)1636 || Boolean(b.shift) !== Boolean(d.shift)1637 || Boolean(b.alt) !== Boolean(d.alt);1638 };1639 return (1640 <div class="peach-shortcut-row">1641 <span class="peach-shortcut-row-label">{desc.label}</span>1642 <button1643 class="peach-shortcut-chip"1644 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1645 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1646 onKeyDown={(e) => { if (isActive()) onKey(e); }}1647 ref={(el) => {1648 1649 if (el) {1650 queueMicrotask(() => { if (isActive()) el.focus(); });1651 }1652 }}1653 >1654 {isActive() ? "Press a key…" : formatKeybinding(binding())}1655 </button>1656 <Show when={isCustom() && !isActive()}>1657 <button1658 class="peach-shortcut-reset"1659 title="Reset to default"1660 onClick={() => onReset(desc.action)}1661 >1662 ↺1663 </button>1664 </Show>1665 </div>1666 );1667 }1668 }16691670 const PRO_FEATURES: Array<{1671 feature: ProFeature;1672 name: string;1673 desc: string;1674 }> = [1675 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1676 { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1677 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1678 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1679 { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1680 ];16811682 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);16831684 const [showKeyEntry, setShowKeyEntry] = createSignal(false);1685 const [deactivating, setDeactivating] = createSignal(false);16861687 async function handleDeactivateLicense() {1688 setDeactivating(true);1689 await sendMessage({ type: "DEACTIVATE_LICENSE" });1690 setLicenseInfo(null);1691 await props.onLicenseChange?.();1692 setDeactivating(false);1693 }16941695 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1696 setCanaryTestingId(entry.id);1697 setCanaryTestResult(null);1698 const res = await sendMessage({1699 type: "FIRE_CANARY_PING",1700 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1701 }) as { ok: boolean };1702 setCanaryTestResult({ id: entry.id, ok: res.ok });1703 setCanaryTestingId(null);1704 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1705 }17061707 function renderLicense() {1708 async function onActivate() {1709 await handleActivateLicense();1710 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1711 await props.onLicenseChange?.();1712 }1713 }17141715 return (1716 <div class="peach-settings-body">1717 <Show1718 when={isPro()}1719 fallback={1720 <Show1721 when={showKeyEntry()}1722 fallback={1723 <div class="peach-pro-upgrade">1724 <div class="peach-pro-upgrade-hero">1725 <div class="peach-pro-upgrade-mark-wrap">1726 <PeachMark class="peach-pro-upgrade-mark" />1727 </div>1728 <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1729 <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1730 </div>17311732 <div class="peach-pro-features">1733 <For each={PRO_FEATURES}>1734 {(item) => (1735 <div1736 class="peach-pro-feature"1737 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1738 >1739 <span class="peach-pro-feature-mark">✦</span>1740 <div class="peach-pro-feature-text">1741 <span class="peach-pro-feature-name">{item.name}</span>1742 <span class="peach-pro-feature-desc">{item.desc}</span>1743 </div>1744 </div>1745 )}1746 </For>1747 </div>17481749 <div class="peach-pro-upgrade-actions">1750 <p class="peach-pro-pricing">$39 · once · yours forever</p>1751 <a1752 href="https://peachpasswords.com/#pricing"1753 target="_blank"1754 rel="noopener noreferrer"1755 class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1756 >1757 Get Peach Pro1758 </a>1759 <div class="peach-pro-key-divider"><span>or</span></div>1760 <button class="peach-pro-key-link" onClick={() => setShowKeyEntry(true)}>1761 Activate a license key1762 </button>1763 </div>1764 </div>1765 }1766 >1767 <div class="peach-pro-key-ceremony">1768 <LockRuneCanvas />1769 <p class="peach-pro-key-ceremony-label">Enter your license key</p>1770 <input1771 class="peach-underline-input peach-pro-key-input"1772 type="text"1773 placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1774 value={licenseKeyInput()}1775 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1776 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1777 autocomplete="off"1778 spellcheck={false}1779 />1780 <Show when={licenseError()}>1781 <p class="peach-pro-key-error">That key doesn't look right.</p>1782 </Show>1783 <button1784 class="peach-btn peach-btn--accent peach-pro-key-activate"1785 onClick={() => void onActivate()}1786 disabled={activatingLicense() || !licenseKeyInput().trim()}1787 >1788 {activatingLicense() ? "activating…" : "Activate"}1789 </button>1790 <button1791 class="peach-pro-key-cancel"1792 onClick={() => { setShowKeyEntry(false); setLicenseError(null); setLicenseKeyInput(""); }}1793 >1794 cancel1795 </button>1796 </div>1797 </Show>1798 }1799 >1800 <div class="peach-pro-active">1801 <div class="peach-pro-active-hero">1802 <div class="peach-pro-active-mark-wrap">1803 <PeachMark class="peach-pro-active-mark" />1804 </div>1805 <h2 class="peach-pro-active-title">Peach Pro</h2>1806 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1807 </div>18081809 <Show when={licenseKey()}>1810 <div class="peach-settings-license">1811 <span class="peach-settings-label">LICENSE KEY</span>1812 <div class="peach-settings-license-row">1813 <span class="peach-settings-license-key">{licenseKey()!}</span>1814 <button1815 class="peach-settings-license-copy"1816 onClick={() => copyLicenseKey(licenseKey()!)}1817 >1818 {copied() ? "✓" : "copy"}1819 </button>1820 </div>1821 </div>1822 </Show>18231824 <div class="peach-pro-active-features">1825 <For each={PRO_FEATURES}>1826 {(item) => (1827 <div class="peach-pro-active-feature">1828 <span class="peach-pro-active-feature-mark">✦</span>1829 <span class="peach-pro-active-feature-name">{item.name}</span>1830 </div>1831 )}1832 </For>1833 </div>18341835 <button1836 class="peach-pro-deactivate"1837 onClick={() => void handleDeactivateLicense()}1838 disabled={deactivating()}1839 >1840 {deactivating() ? "Deactivating…" : "Deactivate"}1841 </button>1842 </div>1843 </Show>1844 </div>1845 );1846 }18471848 function renderFeedback() {1849 return (1850 <div class="peach-settings-body">1851 <div class="peach-settings-group peach-feedback-group">1852 <p class="peach-feedback-intro">1853 Help us improve Peach. Every report goes directly to our inbox.1854 </p>18551856 <p class="peach-settings-sub-label">TYPE</p>1857 <div class="peach-feedback-chips">1858 <For each={FEEDBACK_CATEGORIES}>1859 {(cat) => (1860 <button1861 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1862 onClick={() => setFbCategory(cat)}1863 >1864 {cat}1865 </button>1866 )}1867 </For>1868 </div>18691870 <p class="peach-settings-sub-label">MESSAGE</p>1871 <textarea1872 class="peach-feedback-textarea"1873 placeholder="Describe the issue or idea..."1874 value={fbMessage()}1875 onInput={(e) => setFbMessage(e.currentTarget.value)}1876 />18771878 <p class="peach-feedback-meta">1879 Version {version} · will be included automatically1880 </p>18811882 <Show when={fbResult() === "success"}>1883 <p class="peach-feedback-result peach-feedback-result--ok">1884 Sent — thank you1885 </p>1886 </Show>1887 <Show when={fbResult() === "error"}>1888 <p class="peach-feedback-result peach-feedback-result--err">1889 Failed to send — try again1890 </p>1891 </Show>18921893 <button1894 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1895 onClick={() => void handleSubmitFeedback()}1896 disabled={fbSubmitting() || !fbMessage().trim()}1897 >1898 {fbSubmitting() ? "Sending..." : "Send feedback"}1899 </button>19001901 <p class="peach-feedback-anon">1902 Sent anonymously · no account required1903 </p>1904 </div>1905 </div>1906 );1907 }19081909 return (1910 <Show1911 when={showNewRecoveryWords()}1912 fallback={1913 <div class="peach-overlay-view ov-enter-right" style="display:flex">1914 <div class="peach-overlay-header">1915 <button1916 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1917 onClick={handleHeaderBack}1918 aria-label="Back"1919 >1920 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1921 <path d="M6 1L1 6L6 11" />1922 </svg>1923 </button>1924 <span class="peach-overlay-title">{currentTitle()}</span>1925 <div class="peach-overlay-spacer"></div>1926 </div>19271928 <Switch fallback={renderHome()}>1929 <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1930 <Match when={activeSection() === "vault"}>{renderVault()}</Match>1931 <Match when={activeSection() === "sync"}>{renderSync()}</Match>1932 <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1933 <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1934 <Match when={activeSection() === "license"}>{renderLicense()}</Match>1935 <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1936 </Switch>1937 </div>1938 }1939 >1940 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1941 </Show>1942 );1943}