From 2fd5ce2e94cfcd8c0ea65872cd2c61fb6040e508 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 25 Mar 2026 16:10:39 +0100 Subject: [PATCH] feat: add telemetry consent dialog + Settings privacy toggles First-launch consent dialog asks users to opt-in to anonymous crash reporting. Settings panel gains Privacy & Telemetry section with toggles for crash reporting and usage analytics. Consent gate blocks app shell until answered. UUID generated on accept for anonymous_id. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 18 ++++- src/components/SettingsPanel.test.tsx | 41 +++++++++++ src/components/SettingsPanel.tsx | 40 +++++++++-- .../TelemetryConsentDialog.test.tsx | 30 ++++++++ src/components/TelemetryConsentDialog.tsx | 68 +++++++++++++++++++ 5 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 src/components/TelemetryConsentDialog.test.tsx create mode 100644 src/components/TelemetryConsentDialog.tsx diff --git a/src/App.tsx b/src/App.tsx index 2de46776..4fa0f71d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { StatusBar } from './components/StatusBar' import { SettingsPanel } from './components/SettingsPanel' import { GitHubVaultModal } from './components/GitHubVaultModal' import { WelcomeScreen } from './components/WelcomeScreen' +import { TelemetryConsentDialog } from './components/TelemetryConsentDialog' import { useMcpStatus } from './hooks/useMcpStatus' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' @@ -93,7 +94,7 @@ function App() { const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath const vault = useVaultLoader(resolvedPath) useVaultConfig(resolvedPath) - const { settings, saveSettings } = useSettings() + const { settings, loaded: settingsLoaded, saveSettings } = useSettings() const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) @@ -522,6 +523,21 @@ function App() { return } + // Show telemetry consent dialog on first launch (or first upgrade with telemetry) + if (settingsLoaded && settings.telemetry_consent === null) { + return ( + { + const id = crypto.randomUUID() + saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id }) + }} + onDecline={() => { + saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null }) + }} + /> + ) + } + return (
diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index dcce7839..0e8ec6e1 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -403,4 +403,45 @@ describe('SettingsPanel', () => { }) }) }) + + describe('Privacy & Telemetry section', () => { + it('renders crash reporting and analytics toggles', () => { + render( + + ) + expect(screen.getByTestId('settings-crash-reporting')).toBeInTheDocument() + expect(screen.getByTestId('settings-analytics')).toBeInTheDocument() + }) + + it('toggles reflect initial settings state', () => { + const withTelemetry: Settings = { + ...emptySettings, + telemetry_consent: true, + crash_reporting_enabled: true, + analytics_enabled: false, + anonymous_id: 'test-uuid', + } + render( + + ) + const crashCheckbox = screen.getByTestId('settings-crash-reporting').querySelector('input') as HTMLInputElement + const analyticsCheckbox = screen.getByTestId('settings-analytics').querySelector('input') as HTMLInputElement + expect(crashCheckbox.checked).toBe(true) + expect(analyticsCheckbox.checked).toBe(false) + }) + + it('saves telemetry settings when toggled and saved', () => { + render( + + ) + const crashCheckbox = screen.getByTestId('settings-crash-reporting').querySelector('input')! + fireEvent.click(crashCheckbox) + fireEvent.click(screen.getByTestId('settings-save')) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + crash_reporting_enabled: true, + analytics_enabled: false, + })) + }) + }) }) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 552240e4..69a950ae 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -124,6 +124,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit(null) // Auto-focus first input when settings panel opens @@ -142,11 +144,11 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit { onSave(buildSettings()) @@ -196,6 +198,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit
@@ -228,6 +232,8 @@ interface SettingsBodyProps { onGitHubConnected: (token: string, username: string) => void onGitHubDisconnect: () => void pullInterval: number; setPullInterval: (v: number) => void + crashReporting: boolean; setCrashReporting: (v: boolean) => void + analytics: boolean; setAnalytics: (v: boolean) => void } function SettingsBody(props: SettingsBodyProps) { @@ -285,10 +291,34 @@ function SettingsBody(props: SettingsBodyProps) {
+ +
+ +
+
Privacy & Telemetry
+
+ Anonymous data helps us fix bugs and improve Laputa. No vault content, note titles, or file paths are ever sent. +
+
+ + +
) } +function TelemetryToggle({ label, description, checked, onChange, testId }: { label: string; description: string; checked: boolean; onChange: (v: boolean) => void; testId: string }) { + return ( + + ) +} + function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) { return (
{ + it('renders the consent dialog', () => { + render() + expect(screen.getByText('Help improve Laputa')).toBeDefined() + expect(screen.getByText(/anonymous crash reports/i)).toBeDefined() + }) + + it('calls onAccept when Allow button is clicked', () => { + const onAccept = vi.fn() + render() + fireEvent.click(screen.getByTestId('telemetry-accept')) + expect(onAccept).toHaveBeenCalledOnce() + }) + + it('calls onDecline when No thanks button is clicked', () => { + const onDecline = vi.fn() + render() + fireEvent.click(screen.getByTestId('telemetry-decline')) + expect(onDecline).toHaveBeenCalledOnce() + }) + + it('shows a details section explaining what data is shared', () => { + render() + expect(screen.getByText(/no vault content, note titles/i)).toBeDefined() + }) +}) diff --git a/src/components/TelemetryConsentDialog.tsx b/src/components/TelemetryConsentDialog.tsx new file mode 100644 index 00000000..0a21edbb --- /dev/null +++ b/src/components/TelemetryConsentDialog.tsx @@ -0,0 +1,68 @@ +import { ShieldCheck } from '@phosphor-icons/react' + +interface TelemetryConsentDialogProps { + onAccept: () => void + onDecline: () => void +} + +export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsentDialogProps) { + return ( +
+
+ + +
+

+ Help improve Laputa +

+

+ Send anonymous crash reports to help us fix bugs faster. + No vault content, no personal data, no tracking. +

+
+ +
+

What we collect:

+
    +
  • Stack traces from errors (JS & Rust)
  • +
  • App version, OS, and architecture
  • +
+

What we never collect:

+
    +
  • No vault content, note titles, or file paths
  • +
  • No personal data or IP addresses
  • +
+
+ +
+ + +
+ +

+ You can change this anytime in Settings. +

+
+
+ ) +}