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:
18
src/App.tsx
18
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 <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">
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
30
src/components/TelemetryConsentDialog.test.tsx
Normal file
30
src/components/TelemetryConsentDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
68
src/components/TelemetryConsentDialog.tsx
Normal file
68
src/components/TelemetryConsentDialog.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user