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) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-25 16:10:39 +01:00
parent d7166ea993
commit 2fd5ce2e94
5 changed files with 191 additions and 6 deletions

View File

@@ -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 <LoadingView />
}
// Show telemetry consent dialog on first launch (or first upgrade with telemetry)
if (settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {
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 (
<div className="app-shell">
<div className="app">

View File

@@ -403,4 +403,45 @@ describe('SettingsPanel', () => {
})
})
})
describe('Privacy & Telemetry section', () => {
it('renders crash reporting and analytics toggles', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={withTelemetry} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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,
}))
})
})
})

View File

@@ -124,6 +124,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
const [githubToken, setGithubToken] = useState(settings.github_token)
const [githubUsername, setGithubUsername] = useState(settings.github_username)
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
const [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false)
const [analytics, setAnalytics] = useState(settings.analytics_enabled ?? false)
const panelRef = useRef<HTMLDivElement>(null)
// Auto-focus first input when settings panel opens
@@ -142,11 +144,11 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),
github_username: ghOverride ? ghOverride.username : (githubUsername ?? null),
auto_pull_interval_minutes: pullInterval,
telemetry_consent: settings.telemetry_consent,
crash_reporting_enabled: settings.crash_reporting_enabled,
analytics_enabled: settings.analytics_enabled,
anonymous_id: settings.anonymous_id,
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, settings.telemetry_consent, settings.crash_reporting_enabled, settings.analytics_enabled, settings.anonymous_id])
telemetry_consent: (crashReporting || analytics) ? true : (settings.telemetry_consent === null ? null : false),
crash_reporting_enabled: crashReporting,
analytics_enabled: analytics,
anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id,
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
const handleSave = () => {
onSave(buildSettings())
@@ -196,6 +198,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
pullInterval={pullInterval} setPullInterval={setPullInterval}
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
analytics={analytics} setAnalytics={setAnalytics}
/>
<SettingsFooter onClose={onClose} onSave={handleSave} />
</div>
@@ -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) {
<option value={30}>30</option>
</select>
</div>
<div style={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Privacy &amp; Telemetry</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Anonymous data helps us fix bugs and improve Laputa. No vault content, note titles, or file paths are ever sent.
</div>
</div>
<TelemetryToggle label="Crash reporting" description="Send anonymous error reports" checked={props.crashReporting} onChange={props.setCrashReporting} testId="settings-crash-reporting" />
<TelemetryToggle label="Usage analytics" description="Share anonymous usage patterns" checked={props.analytics} onChange={props.setAnalytics} testId="settings-analytics" />
</div>
)
}
function TelemetryToggle({ label, description, checked, onChange, testId }: { label: string; description: string; checked: boolean; onChange: (v: boolean) => void; testId: string }) {
return (
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }} data-testid={testId}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} style={{ width: 16, height: 16, accentColor: 'var(--primary)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>{label}</div>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{description}</div>
</div>
</label>
)
}
function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
return (
<div

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TelemetryConsentDialog } from './TelemetryConsentDialog'
describe('TelemetryConsentDialog', () => {
it('renders the consent dialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
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(<TelemetryConsentDialog onAccept={onAccept} onDecline={vi.fn()} />)
fireEvent.click(screen.getByTestId('telemetry-accept'))
expect(onAccept).toHaveBeenCalledOnce()
})
it('calls onDecline when No thanks button is clicked', () => {
const onDecline = vi.fn()
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={onDecline} />)
fireEvent.click(screen.getByTestId('telemetry-decline'))
expect(onDecline).toHaveBeenCalledOnce()
})
it('shows a details section explaining what data is shared', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByText(/no vault content, note titles/i)).toBeDefined()
})
})

View File

@@ -0,0 +1,68 @@
import { ShieldCheck } from '@phosphor-icons/react'
interface TelemetryConsentDialogProps {
onAccept: () => void
onDecline: () => void
}
export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsentDialogProps) {
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: 'rgba(0,0,0,0.4)' }}
>
<div
className="bg-background border border-border rounded-lg shadow-xl"
style={{ width: 440, padding: 32, display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'center' }}
>
<ShieldCheck size={40} weight="duotone" style={{ color: 'var(--primary)' }} />
<div style={{ textAlign: 'center' }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: 'var(--foreground)', margin: 0 }}>
Help improve Laputa
</h2>
<p style={{ fontSize: 13, color: 'var(--muted-foreground)', lineHeight: 1.6, marginTop: 8 }}>
Send anonymous crash reports to help us fix bugs faster.
No vault content, no personal data, no tracking.
</p>
</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.6, width: '100%' }}>
<p style={{ margin: '0 0 6px', fontWeight: 500, color: 'var(--foreground)' }}>What we collect:</p>
<ul style={{ margin: 0, paddingLeft: 18 }}>
<li>Stack traces from errors (JS &amp; Rust)</li>
<li>App version, OS, and architecture</li>
</ul>
<p style={{ margin: '10px 0 6px', fontWeight: 500, color: 'var(--foreground)' }}>What we never collect:</p>
<ul style={{ margin: 0, paddingLeft: 18 }}>
<li>No vault content, note titles, or file paths</li>
<li>No personal data or IP addresses</li>
</ul>
</div>
<div style={{ display: 'flex', gap: 12, width: '100%', marginTop: 4 }}>
<button
className="border border-border bg-transparent text-foreground rounded cursor-pointer hover:bg-accent"
style={{ flex: 1, fontSize: 13, padding: '10px 16px' }}
onClick={onDecline}
data-testid="telemetry-decline"
>
No thanks
</button>
<button
className="border-none rounded cursor-pointer"
style={{ flex: 1, fontSize: 13, padding: '10px 16px', background: 'var(--primary)', color: 'white', fontWeight: 500 }}
onClick={onAccept}
data-testid="telemetry-accept"
>
Allow anonymous reporting
</button>
</div>
<p style={{ fontSize: 11, color: 'var(--muted-foreground)', margin: 0, textAlign: 'center' }}>
You can change this anytime in Settings.
</p>
</div>
</div>
)
}