import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; import { sendMessage } from "../App"; import { PeachMark } from "../components/PeachMark"; import { StackDeck } from "../components/settings/StackDeck"; import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types"; import { RecoveryWordDisplayView } from "./RecoveryWordDisplay"; import type { Canary } from "../../shared/types"; import { ACCENT_COLORS } from "../../shared/types"; import type { AccentColorName, PasskeyProviderState, ProFeature, VaultSnapshot, } from "../../shared/types"; import type { PairedDevice } from "../../shared/sync-types"; import { DEFAULT_BINDINGS, findConflict, formatKeybinding, keybindingFromEvent, SHORTCUT_DESCRIPTORS, type Keybinding, type ShortcutAction, } from "../../shared/shortcuts"; import { isNativePlatform, nativePlugin } from "../../shared/native"; import { WEB3FORMS_KEY } from "../../shared/config"; interface LicenseInfo { valid: boolean; plan: "free" | "pro"; issued?: string; } interface SettingsProps { entriesCount: number; breachMonitoring: boolean; lockOnClose: boolean; autoLockMinutes: number; trashRetentionDays: number; theme: "dark" | "light"; accentColor: AccentColorName; textSize: "small" | "medium" | "large"; reduceMotion: boolean; isPro?: boolean; initialSection?: string | null; upgradeHighlight?: ProFeature | null; onBreachMonitoringChange: (enabled: boolean) => void | Promise; onLockOnCloseChange: (enabled: boolean) => void | Promise; onAutoLockMinutesChange: (minutes: number) => void | Promise; onTrashRetentionChange: (days: number) => void | Promise; onThemeChange: (theme: "dark" | "light") => void; onAccentChange: (name: AccentColorName) => void | Promise; onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise; onReduceMotionChange: (enabled: boolean) => void | Promise; shortcuts: Record; onShortcutsChange: (active: Record) => void | Promise; onLicenseChange?: () => void | Promise; isExiting?: boolean; onBack: () => void; onImport: () => void; onExport: () => void; onBreachCheckDone: () => void; onOpenHealth?: () => void; onOpenSyncPairing?: (method?: "code") => void; onOpenFileImport?: () => void; onOpenPanic?: () => void; onOpenCodexGenerate?: () => void; onOpenCodexRestore?: () => void; /** Invoked from within Settings when a Pro-gated control is tapped on * free tier. App owns the upgrade navigation + highlight state, so this * callback is the single way for Settings to request that navigation. */ onProGate?: (feature: ProFeature) => void; onCardChange?: (id: CardId) => void; } const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"]; const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction"; const MAX_FEEDBACK_MESSAGE_LENGTH = 4000; const PRO_CELEBRATION_CARD_STEP_MS = 150; const PRO_CELEBRATION_THANK_YOU_MS = 3200; function sanitizeFeedbackMessage(value: string): string { return value .trim() .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH) .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } type DataCollectionPermissionApi = { permissions?: { getAll?: () => Promise<{ data_collection?: string[] }>; request?: (permissions: { data_collection: string[] }) => Promise; }; }; async function requestFeedbackDataPermission(): Promise { const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ?? (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined; if (!api?.permissions?.getAll) return true; try { const permissions = await api.permissions.getAll(); if (!("data_collection" in permissions)) return true; if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true; return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false)); } catch { return false; } } function formatActivatedSince(issued: string | undefined): string { if (!issued) return "Active"; try { const d = new Date(issued); if (Number.isNaN(d.getTime())) return "Active"; return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`; } catch { return "Active"; } } function initialSectionToCard(value: string | null | undefined): CardId | null { if (!value) return null; if (value === "security") return "protection"; if (value === "license") return "pro"; if ( value === "protection" || value === "vault" || value === "sync" || value === "appearance" || value === "shortcuts" || value === "pro" || value === "feedback" ) { return value; } return null; } export function SettingsView(props: SettingsProps) { const manifest = chrome.runtime.getManifest(); const version = manifest.version; const initial = props.initialSection; const [activeCard, setActiveCard] = createSignal( props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro" ); createEffect(() => { props.onCardChange?.(activeCard()); }); const [justActivated, setJustActivated] = createSignal(false); const [isFanning, setIsFanning] = createSignal(false); function triggerCelebration() { if (isFanning()) return; setJustActivated(false); setIsFanning(true); setActiveCard("pro"); const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"]; fanOrder.forEach((id, i) => { window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS); }); window.setTimeout(() => { setActiveCard("pro"); setIsFanning(false); setJustActivated(true); window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS); }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS); } function handleProCelebrationClick(e: MouseEvent) { e.stopPropagation(); triggerCelebration(); } const [checkingBreaches, setCheckingBreaches] = createSignal(false); const [breachResult, setBreachResult] = createSignal(null); const [licenseInfo, setLicenseInfo] = createSignal(null); const [licenseKeyInput, setLicenseKeyInput] = createSignal(""); const [licenseError, setLicenseError] = createSignal(null); const [, setLicenseSuccess] = createSignal(null); const [activatingLicense, setActivatingLicense] = createSignal(false); const isPro = () => props.isPro ?? false; const cards = createMemo(() => [ { id: "pro", num: "I · FOREVER", title: "Peach Pro", lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever", bgVar: "--stack-pro-bg", fgVar: "--stack-pro-fg", icon: "spark", onIconClick: isPro() ? triggerCelebration : undefined, }, { id: "protection", num: "II · PROTECT", title: "Protection", lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`, bgVar: "--stack-protect-bg", fgVar: "--stack-protect-fg", icon: "shield", }, { id: "vault", num: "III · KEEP", title: "Vault", lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`, bgVar: "--stack-vault-bg", fgVar: "--stack-vault-fg", icon: "lock", }, { id: "sync", num: "IV · ECHO", title: "Sync", lede: isPro() ? "pair with a code, keep in step" : "Pro · encrypted device pairing", bgVar: "--stack-sync-bg", fgVar: "--stack-sync-fg", icon: "sync", }, { id: "appearance", num: "V · DRESS", title: "Appearance", lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`, bgVar: "--stack-appear-bg", fgVar: "--stack-appear-fg", icon: "sun", }, { id: "shortcuts", num: "VI · KEYS", title: "Shortcuts", lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`, bgVar: "--stack-shortcut-bg", fgVar: "--stack-shortcut-fg", icon: "keys", }, { id: "feedback", num: "VII · ECHO", title: "Feedback", lede: `version ${version} · tell us what would make it better`, bgVar: "--stack-feedback-bg", fgVar: "--stack-feedback-fg", icon: "bubble", }, ]); const [biometricSupported, setBiometricSupported] = createSignal(false); const [biometricStored, setBiometricStored] = createSignal(false); const [biometricBusy, setBiometricBusy] = createSignal(false); const [biometricError, setBiometricError] = createSignal(null); const [passkeyState, setPasskeyState] = createSignal(null); const [passkeySaving, setPasskeySaving] = createSignal(false); // UI-3 / MV-7: devices vouched by another of the user's devices, awaiting // explicit confirmation before they become sync targets. interface PendingVouched { deviceId: string; deviceName: string } const [pendingVouched, setPendingVouched] = createSignal([]); const [vouchedBusy, setVouchedBusy] = createSignal(null); // Paired-device management lives here — Settings is the sync hub. const [pairedDevices, setPairedDevices] = createSignal([]); const [confirmUnpair, setConfirmUnpair] = createSignal(null); const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle"); const [syncFlash, setSyncFlash] = createSignal(null); const [relayUrl, setRelayUrl] = createSignal(""); const [lanOnly, setLanOnly] = createSignal(false); const [relaySaving, setRelaySaving] = createSignal(false); const [relayStatus, setRelayStatus] = createSignal(null); const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; function syncRelativeTime(when: number | undefined): string { if (!when) return "Not synced yet"; const elapsed = Date.now() - when; if (elapsed < 60_000) return "Just now"; const minutes = Math.floor(elapsed / 60_000); if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.floor(hours / 24); return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently"; } function isSyncStale(d: PairedDevice): boolean { return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS; } function deviceTypeLabel(t: PairedDevice["deviceType"]): string { switch (t) { case "android": return "Android"; case "ios": return "iPhone"; case "extension": return "Browser"; default: return "Device"; } } async function loadPairedDevices() { if (!isPro()) return; const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as { ok: boolean; data?: { devices: PairedDevice[] }; }; if (r.ok) setPairedDevices(r.data?.devices ?? []); } async function unpairDevice(deviceId: string) { await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } }); setConfirmUnpair(null); await loadPairedDevices(); } async function syncNow() { setSyncStatus("syncing"); setSyncFlash(null); const res = (await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true }, })) as { ok: boolean; error?: string }; setTimeout(async () => { const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as { ok: boolean; data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string }; }; const s = state.data; if (!s) return; setSyncStatus(s.status); if (s.status === "idle") { setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date"); setTimeout(() => setSyncFlash(null), 3000); await loadPairedDevices(); } else if (s.status === "error") { setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`); setTimeout(() => setSyncFlash(null), 4000); } }, 250); if (!res.ok) { setSyncStatus("error"); setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`); setTimeout(() => setSyncFlash(null), 4000); } } async function loadRelaySettings() { const result = (await sendMessage({ type: "GET_RELAY_SETTINGS" })) as { ok: boolean; data?: { relayUrl: string; lanOnly: boolean }; }; if (result.ok && result.data) { setRelayUrl(result.data.relayUrl); setLanOnly(result.data.lanOnly); } } async function saveRelaySettings(nextLanOnly = lanOnly()) { setRelaySaving(true); setRelayStatus(null); const result = (await sendMessage({ type: "SET_RELAY_SETTINGS", payload: { relayUrl: relayUrl(), lanOnly: nextLanOnly }, })) as { ok: boolean; error?: string; data?: { relayUrl: string; lanOnly: boolean }; }; if (result.ok && result.data) { setRelayUrl(result.data.relayUrl); setLanOnly(result.data.lanOnly); setRelayStatus("Saved"); } else { setRelayStatus("Use a valid wss:// relay URL"); } setRelaySaving(false); } // UI-4 / MV-8: rotate sync identity confirmation dialog. const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false); const [rotating, setRotating] = createSignal(false); const [rotateError, setRotateError] = createSignal(null); async function loadPendingVouched() { const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as { ok: boolean; data?: PendingVouched[]; }; if (r.ok) setPendingVouched(r.data ?? []); } async function confirmVouched(deviceId: string) { setVouchedBusy(deviceId); try { await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } }); await loadPendingVouched(); } finally { setVouchedBusy(null); } } async function rejectVouched(deviceId: string) { setVouchedBusy(deviceId); try { await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } }); await loadPendingVouched(); } finally { setVouchedBusy(null); } } async function doRotateSyncIdentity() { setRotateError(null); setRotating(true); try { const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as { ok: boolean; error?: string; }; if (!r.ok) { setRotateError(r.error ?? "Rotation failed."); return; } setRotateConfirmOpen(false); // Backend set peach_recovery_pending; reuse the existing recovery-words // display (its GET_RECOVERY_WORDS fetch is exempted during pending). setShowNewRecoveryWords(true); } finally { setRotating(false); } } const [dedupCount, setDedupCount] = createSignal(null); const [dedupRunning, setDedupRunning] = createSignal(false); const [dedupDone, setDedupDone] = createSignal(null); const [declinedCardCount, setDeclinedCardCount] = createSignal(0); // Vault history (peach_vault_history) — last 10 snapshots, snapshotted // BEFORE every real-mode vault write so the user can roll back if a sync // or import overwrites the wrong state. const [historySnapshots, setHistorySnapshots] = createSignal([]); const [historyLoading, setHistoryLoading] = createSignal(false); const [historyRestoring, setHistoryRestoring] = createSignal(null); const [historyShowAll, setHistoryShowAll] = createSignal(false); async function loadVaultHistory() { setHistoryLoading(true); try { const res = await sendMessage({ type: "GET_VAULT_HISTORY" }); const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? []; setHistorySnapshots(list); } finally { setHistoryLoading(false); } } async function handleRestoreVaultSnapshot(id: string) { if (historyRestoring()) return; setHistoryRestoring(id); const res = await sendMessage({ type: "RESTORE_VAULT_SNAPSHOT", payload: { snapshotId: id }, }); setHistoryRestoring(null); if ((res as { ok: boolean }).ok) { // Vault is now locked — let the parent close Settings; App.tsx's // VAULT_LOCKED listener will route back to the lock screen. props.onBack(); } } function formatSnapshotTime(savedAt: number): string { const diff = Date.now() - savedAt; if (diff < 60_000) return "Just now"; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; const d = new Date(savedAt); const isToday = new Date().toDateString() === d.toDateString(); const isYest = new Date(Date.now() - 86_400_000).toDateString() === d.toDateString(); const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); if (isToday) return `Today ${t}`; if (isYest) return `Yesterday ${t}`; return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`; } function reasonLabel(reason: VaultSnapshot["reason"]): string { return ( { local_save: "saved", sync_received: "synced", import: "imported", restore: "restored", } as const )[reason] ?? reason; } const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false); const [showChangePw, setShowChangePw] = createSignal(false); const [changePwCurrent, setChangePwCurrent] = createSignal(""); const [changePwNew, setChangePwNew] = createSignal(""); const [changePwConfirm, setChangePwConfirm] = createSignal(""); const [changePwSaving, setChangePwSaving] = createSignal(false); const [changePwError, setChangePwError] = createSignal(null); const [changePwSuccess] = createSignal(false); const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false); // UI-5 / LV-4: view existing recovery words behind a fresh password proof. const [showViewWords, setShowViewWords] = createSignal(false); const [viewWordsPassword, setViewWordsPassword] = createSignal(""); const [viewWordsError, setViewWordsError] = createSignal(null); const [viewedWords, setViewedWords] = createSignal(null); const [viewWordsBusy, setViewWordsBusy] = createSignal(false); async function handleViewRecoveryWords() { setViewWordsError(null); if (!viewWordsPassword()) { setViewWordsError("Enter your master password."); return; } setViewWordsBusy(true); try { const r = (await sendMessage({ type: "GET_RECOVERY_WORDS", payload: { password: viewWordsPassword() }, })) as { ok: boolean; data?: string[]; error?: string }; if (r.ok) { setViewedWords(r.data ?? []); setViewWordsPassword(""); return; } switch (r.error) { case "wrong_password": setViewWordsError("Incorrect password"); break; case "password_required": setViewWordsError("Enter your master password."); break; case "recovery_not_setup": setViewWordsError("Recovery words aren't set up for this vault yet."); break; default: setViewWordsError("Couldn't retrieve recovery words."); } } finally { setViewWordsBusy(false); } } function closeViewWords() { setShowViewWords(false); setViewWordsPassword(""); setViewWordsError(null); setViewedWords(null); } const [duressEnabled, setDuressEnabled] = createSignal(false); const [showDuressSetup, setShowDuressSetup] = createSignal(false); const [duressPassword, setDuressPassword] = createSignal(""); const [duressConfirm, setDuressConfirm] = createSignal(""); const [duressMainPassword, setDuressMainPassword] = createSignal(""); const [duressSaving, setDuressSaving] = createSignal(false); const [duressError, setDuressError] = createSignal(null); const [duressSuccess, setDuressSuccess] = createSignal(null); const [canaryEntries, setCanaryEntries] = createSignal>([]); const [canaryTestingId, setCanaryTestingId] = createSignal(null); const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null); const [fbCategory, setFbCategory] = createSignal("Bug report"); const [fbMessage, setFbMessage] = createSignal(""); type FeedbackState = 'idle' | 'sending' | 'sent' | 'error'; const [feedbackState, setFeedbackState] = createSignal('idle'); createEffect(() => { if (props.upgradeHighlight) { setActiveCard("pro"); } }); onMount(async () => { const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([ sendMessage({ type: "GET_LICENSE" }), sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }), sendMessage({ type: "IS_DURESS_ENABLED" }), sendMessage({ type: "GET_SETTINGS" }), sendMessage({ type: "GET_LICENSE_KEY" }), ]); // Vault history loads in the background — the Vault sub-view picks it up // when the user navigates in. Loading it lazily keeps Settings boot fast. void loadVaultHistory(); // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open. void loadPendingVouched(); void loadPairedDevices(); void loadRelaySettings(); const onBroadcast = (msg: unknown) => { if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") { void loadPendingVouched(); } }; chrome.runtime?.onMessage?.addListener(onBroadcast); onCleanup(() => { try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { /* ignore */ } }); if ((licenseRes as { ok: boolean }).ok) { setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null); } if ((passkeyRes as { ok: boolean }).ok) { setPasskeyState( (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null ); } if ((duressRes as { ok: boolean }).ok) { setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false); } if ((settingsRes as { ok: boolean }).ok) { const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data; setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0); } if ((keyRes as { ok: boolean }).ok) { setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null); } const entriesRes = await sendMessage({ type: "GET_ENTRIES" }); if ((entriesRes as { ok: boolean }).ok) { const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? []; setCanaryEntries( entries .filter((e) => e.canary?.webhookUrl) .map((e) => ({ id: e.id, title: e.title, canary: e.canary! })) ); } if (isNativePlatform()) { const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin"); const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin"); try { if (biometric) { const a = await biometric.isAvailable(); setBiometricSupported(a.available); } if (keystore) { const k = await keystore.hasMasterKey(); setBiometricStored(k.exists); } } catch { /* missing plugins — leave defaults */ } } }); async function handleEnableBiometrics() { setBiometricBusy(true); setBiometricError(null); try { const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as { ok: boolean; data?: { masterKeyHex: string }; error?: string; }; if (!keyRes.ok || !keyRes.data?.masterKeyHex) { setBiometricError(keyRes.error === "vault_locked" ? "unlock the vault first" : "couldn't read derived key"); return; } const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise }>("KeystorePlugin"); if (!keystore) { setBiometricError("biometric plugin unavailable"); return; } await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex }); await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } }); setBiometricStored(true); } catch (e) { setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics"); } finally { setBiometricBusy(false); } } async function handleDisableBiometrics() { setBiometricBusy(true); setBiometricError(null); try { const keystore = nativePlugin<{ clearMasterKey: () => Promise }>("KeystorePlugin"); if (keystore) await keystore.clearMasterKey(); await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } }); setBiometricStored(false); } catch (e) { setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics"); } finally { setBiometricBusy(false); } } async function handleChangePassword() { setChangePwError(null); if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; } if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; } if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; } if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; } setChangePwSaving(true); const res = await sendMessage({ type: "CHANGE_PASSWORD", payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() }, }) as { ok: boolean; error?: string }; if (res.ok) { setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm(""); setShowChangePw(false); // LV-4: the user just authenticated (current password), so the recovery // words reveal that follows is exempt from the re-auth gate. Set the // pending flag the same way the create/recovery flow does; the display // clears it on confirm. await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" }); setShowNewRecoveryWords(true); } else { setChangePwError(res.error ?? "failed to change password"); } setChangePwSaving(false); } const [copied, setCopied] = createSignal(false); async function copyLicenseKey(key: string) { await navigator.clipboard.writeText(key); setCopied(true); setTimeout(() => setCopied(false), 2000); } async function handleActivateLicense() { const key = licenseKeyInput().trim(); if (!key) return; setActivatingLicense(true); setLicenseError(null); setLicenseSuccess(null); const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } }); setActivatingLicense(false); if ((res as { ok: boolean }).ok) { const data = (res as { ok: boolean; data?: LicenseInfo }).data; if (data?.valid) { setLicenseInfo(data); setLicenseKey(key); setLicenseSuccess("License activated!"); setLicenseKeyInput(""); } else { setLicenseError("Invalid license key"); } } else { setLicenseError("Activation failed"); } } async function handleTogglePasskeys() { const current = passkeyState(); if (!current || !current.supported) return; setPasskeySaving(true); const res = await sendMessage({ type: "SET_PASSKEY_PROVIDER_ENABLED", payload: { enabled: !current.enabled }, }); setPasskeySaving(false); if ((res as { ok: boolean }).ok) { setPasskeyState( (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null ); } } async function handleCheckAllBreaches() { setCheckingBreaches(true); setBreachResult(null); const res = await sendMessage({ type: "CHECK_ALL_BREACHES" }); setCheckingBreaches(false); if ((res as { ok: boolean }).ok) { const data = (res as { ok: boolean; data?: Record }).data ?? {}; const breached = Object.values(data).filter((count) => count > 0).length; setBreachResult( breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean" ); props.onBreachCheckDone(); } } async function handleSetupDuress() { const mainPw = duressMainPassword().trim(); const duressPw = duressPassword().trim(); const confirmPw = duressConfirm().trim(); if (!mainPw) { setDuressError("enter your main password to confirm"); return; } if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; } if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; } if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; } setDuressSaving(true); setDuressError(null); const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean }; if (!verifyRes.ok || !verifyRes.data) { setDuressError("incorrect main password"); setDuressSaving(false); return; } const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string }; setDuressSaving(false); if (res.ok) { setDuressEnabled(true); setShowDuressSetup(false); setDuressPassword(""); setDuressConfirm(""); setDuressMainPassword(""); setDuressSuccess("Duress password set. If entered, a decoy vault will be shown."); setTimeout(() => setDuressSuccess(null), 5000); } else { setDuressError( res.error === "session_expired" ? "Vault session expired — lock and unlock Peach, then try again" : res.error ?? "failed to set duress password" ); } } async function handleClearDeclinedCards() { await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } }); setDeclinedCardCount(0); } async function handleCountDuplicates() { setDedupRunning(true); setDedupDone(null); const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number }; setDedupRunning(false); if (res.ok) setDedupCount(res.data ?? 0); } async function handleDeduplicate() { setDedupRunning(true); const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number }; setDedupRunning(false); if (res.ok) { setDedupDone(res.data ?? 0); setDedupCount(0); } } async function handleDisableDuress() { const mainPw = duressMainPassword().trim(); if (!mainPw) { setDuressError("enter your main password to confirm"); return; } setDuressSaving(true); setDuressError(null); const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean }; if (!verifyRes.ok || !verifyRes.data) { setDuressError("incorrect main password"); setDuressSaving(false); return; } const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string }; setDuressSaving(false); if (res.ok) { setDuressEnabled(false); setShowDuressSetup(false); setDuressMainPassword(""); setDuressSuccess("Duress password disabled."); setTimeout(() => setDuressSuccess(null), 4000); } else { setDuressError(res.error ?? "failed to disable duress password"); } } async function handleSubmitFeedback() { const message = sanitizeFeedbackMessage(fbMessage()); if (!message) return; setFeedbackState('sending'); try { const hasDataPermission = await requestFeedbackDataPermission(); if (!hasDataPermission) { setFeedbackState('idle'); return; } const category = FEEDBACK_CATEGORIES.includes(fbCategory()) ? fbCategory() : FEEDBACK_CATEGORIES[0]; const res = await fetch('https://api.web3forms.com/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_key: WEB3FORMS_KEY, subject: `Peach Feedback - ${category}`, message, from_name: 'Peach User', // Honeypot — leave blank botcheck: '', }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json() as { success: boolean; message?: string }; if (!data.success) throw new Error(data.message ?? 'unknown error'); setFeedbackState('sent'); setFbMessage(''); setFbCategory(FEEDBACK_CATEGORIES[0]); } catch (err) { console.error('[feedback]', err); setFeedbackState('error'); setTimeout(() => setFeedbackState('idle'), 4000); } } function renderSecurity() { return (

LOCKING

Lock on close
Auto-lock after

BIOMETRICS

Unlock with biometrics
{biometricError()}

BREACH PROTECTION

Breach monitoring
Check all passwords
Last check {breachResult()}

MASTER PASSWORD

Master password
setChangePwCurrent((e.target as HTMLInputElement).value)} autocomplete="current-password" /> setChangePwNew((e.target as HTMLInputElement).value)} autocomplete="new-password" /> setChangePwConfirm((e.target as HTMLInputElement).value)} autocomplete="new-password" onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }} />

{changePwError()}

password changed ✓

{/* UI-5 / LV-4: view existing recovery words behind a password proof. */}
Recovery words

Enter your master password to reveal your recovery words.

setViewWordsPassword((e.target as HTMLInputElement).value)} autocomplete="current-password" onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }} />

{viewWordsError()}

} >
{(word, i) => ( {i() + 1} {word} )}

DURESS PASSWORD ✦

Duress password Pro If entered under coercion, shows a decoy empty vault instead of your real one.
{duressEnabled() ? "on" : "off"}
If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden. The encrypted storage is identical in size whether a hidden vault exists or not, so the hidden vault cannot be proven or disproven without both passwords. This protects against a coercer who demands your password — not against device malware or someone who already has your device unlocked.
{duressSuccess()}
setDuressMainPassword(e.currentTarget.value)} /> setDuressPassword(e.currentTarget.value)} /> setDuressConfirm(e.currentTarget.value)} />

{duressError()}

CANARY ENTRIES ✦

Canary entries Pro Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.
{canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}
0}> {(entry) => (
{entry.title}
)}

PASSKEYS

Intercept passkey requests Peach can handle passkey sign-in and creation on supported Chromium browsers.
Status {passkeyState()?.statusLabel ?? "Unavailable"}
Browser {passkeyState()?.browserLabel ?? "Unknown"} {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}
Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.
{passkeyState()!.conflict}
); } function renderVault() { return (

ENTRIES

Entries {props.entriesCount}
Duplicates void handleCountDuplicates()} disabled={dedupRunning()} > {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`} }> {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}
0 && dedupDone() === null}>
{dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.

MAINTENANCE

Trash auto-delete
Advanced Vault Health
0}>
{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save

DATA

Import passwords
Export backup
Peach Codex Pro
{/* Restore from Codex: intentionally never gated — a printed Codex must remain restorable regardless of license state. */}
Restore from Codex
VAULT HISTORY

Loading…

No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.

0}>

Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.

{(snap) => (
{formatSnapshotTime(snap.savedAt)} {[ snap.entryCount !== null ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}` : null, reasonLabel(snap.reason), ] .filter(Boolean) .join(" · ")}
)}
3}>

Replace vault from backup

Wipes the current vault and restores from a backup file. Recoverable only if your backup is good.

Destroy vault

Permanently wipes every entry, setting, and license on this device. There is no recovery.

); } function renderSync() { return (
Status {isPro() ? "Pro" : "Free"}
{/* Pick a pairing method and begin — no separate hub screen. */}

PAIR A DEVICE

{/* One path in: the phone listens and shows a code, the browser dials it. A browser extension can never accept a connection, so there is no flow where this device is the one being joined. */}

At least one device needs Peach Pro — pair with a Pro device and it carries over to here.

CONNECTION

Use local network only (no relay) Sync will only work when your devices share the same local network. It won't work over VPN or when your devices are in different locations.
setRelayUrl((event.target as HTMLInputElement).value)} onKeyDown={(event) => { if (event.key === "Enter") void saveRelaySettings(); }} />
{relayStatus() ?? "Only secure wss:// relays are accepted."}
{/* Paired devices + manual sync — Settings is the hub. */} 0}>

PAIRED DEVICES

{(d) => (
{d.deviceName ?? "Unnamed device"} {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}
setConfirmUnpair(d.deviceId)} >remove } > {/* HOLE 4: revocation stops this device receiving future changes. It does NOT secure the vault against it — that device may already hold the recovery words, which are master-password-equivalent and restore the whole vault anywhere, forever, with no sync involved. Say so here, rather than leaving the user falsely reassured. */}

Stops this device receiving future changes. It doesn't lock it out of what it already has — if it's lost or stolen, also change your master password and generate new recovery words.

)}
{syncFlash() ?? ""}
{/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */} 0}>

PENDING DEVICES {pendingVouched().length}

{(dev) => (
{dev.deviceName} Was added by another of your devices. Allow it to sync with this vault?
)}
{/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}

RECOVERY

Rotate sync identity Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.
} >
This will disconnect all paired devices — they will need to pair again — and your 12 recovery words will change. You'll be shown the new words next.
{rotateError()}
); } function renderAppearance() { const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [ { id: "medium", label: "Default" }, { id: "large", label: "Large" }, ]; return (

THEME

Theme

ACCENT

Accent
{(color) => (

TEXT SIZE

Size
{(opt) => ( )}

MOTION

Reduce motion
); } function renderShortcuts() { const [capturingAction, setCapturingAction] = createSignal(null); const [conflictMsg, setConflictMsg] = createSignal(null); function startCapture(action: ShortcutAction) { setConflictMsg(null); setCapturingAction(action); } function cancelCapture() { setCapturingAction(null); setConflictMsg(null); } function commitBinding(action: ShortcutAction, binding: Keybinding) { const conflict = findConflict(props.shortcuts, binding, action); if (conflict) { const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict); setConflictMsg(`Already bound to "${other?.label ?? conflict}"`); return; } const next = { ...props.shortcuts, [action]: binding }; setCapturingAction(null); setConflictMsg(null); void props.onShortcutsChange(next); } function resetAction(action: ShortcutAction) { const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] }; void props.onShortcutsChange(next); } function resetAll() { void props.onShortcutsChange({ ...DEFAULT_BINDINGS }); } // Capture-mode key handler — wired via ref onto the chip element so it // catches the next non-modifier keypress and ignores other bindings. function handleCaptureKey(e: KeyboardEvent) { e.preventDefault(); e.stopPropagation(); if (e.key === "Escape") { cancelCapture(); return; } const binding = keybindingFromEvent(e); if (!binding) return; // bare modifier — keep waiting const action = capturingAction(); if (action) commitBinding(action, binding); } return (

ENTRY VIEW

d.scope === "entry")}> {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}

ANYWHERE IN POPUP

d.scope === "global")}> {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}
{conflictMsg()}
); function renderRow( desc: typeof SHORTCUT_DESCRIPTORS[number], capturing: () => ShortcutAction | null, onStart: (a: ShortcutAction) => void, onCancel: () => void, onReset: (a: ShortcutAction) => void, onKey: (e: KeyboardEvent) => void, ) { const isActive = () => capturing() === desc.action; const binding = () => props.shortcuts[desc.action]; const isCustom = () => { const b = binding(); const d = DEFAULT_BINDINGS[desc.action]; return b.key !== d.key || Boolean(b.ctrl) !== Boolean(d.ctrl) || Boolean(b.meta) !== Boolean(d.meta) || Boolean(b.shift) !== Boolean(d.shift) || Boolean(b.alt) !== Boolean(d.alt); }; return (
{desc.label}
); } } const PRO_FEATURES: Array<{ feature: ProFeature; name: string; desc: string; }> = [ { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" }, { feature: "codex", name: "Peach Codex", desc: "Restore from paper" }, { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" }, { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" }, ]; const PRO_SECURITY_FEATURES: Array<{ feature: ProFeature; name: string; desc: string; }> = [ { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" }, { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" }, ]; const [licenseKey, setLicenseKey] = createSignal(null); const [showKey, setShowKey] = createSignal(false); const [deactivating, setDeactivating] = createSignal(false); async function handleDeactivateLicense() { setDeactivating(true); await sendMessage({ type: "DEACTIVATE_LICENSE" }); setLicenseInfo(null); await props.onLicenseChange?.(); setDeactivating(false); } async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) { setCanaryTestingId(entry.id); setCanaryTestResult(null); const res = await sendMessage({ type: "FIRE_CANARY_PING", payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" }, }) as { ok: boolean }; setCanaryTestResult({ id: entry.id, ok: res.ok }); setCanaryTestingId(null); setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000); } function renderLicense() { async function onActivate() { await handleActivateLicense(); if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") { await props.onLicenseChange?.(); } } return (

PEACH PRO · ONE-TIME

Everything,
everywhere.

One key unlocks every Pro feature — for the lifetime of the app.

{(item, i) => (
{String(i() + 1).padStart(2, "0")} {item.name} {item.desc}
)}
{(item, i) => (
{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")} {item.name} {item.desc}
)}

$39 once · yours forever

Get Peach Pro
ALREADY HAVE A KEY
setLicenseKeyInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }} autocomplete="off" spellcheck={false} />

{licenseError()}

} >

Peach Pro

{formatActivatedSince(licenseInfo()?.issued)}

thank you ✦

LICENSE KEY
•••••••••••••••••••••••••••• } > {licenseKey()!}
{(item) => (
✦ {item.name}
)}
); } function renderFeedback() { return (

Help us improve Peach. Every report goes directly to our inbox.

TYPE

{(cat) => { const selected = () => fbCategory() === cat; return ( ); }}

MESSAGE