From 0a49baf589236d2dcee4ba90199b89b024203e28 Mon Sep 17 00:00:00 2001 From: Ugooweb Date: Wed, 29 Jul 2026 21:43:22 +0100 Subject: [PATCH] feat(privacy): add explicit consent and data controls for Sentry --- src/components/dashboard/Settings.tsx | 34 +++++++++ .../__tests__/monitoring.consent.test.ts | 74 +++++++++++++++++++ src/utils/monitoring.ts | 17 +++++ src/utils/preferences.js | 1 + 4 files changed, 126 insertions(+) create mode 100644 src/utils/__tests__/monitoring.consent.test.ts diff --git a/src/components/dashboard/Settings.tsx b/src/components/dashboard/Settings.tsx index 07d70bea..9aba375c 100644 --- a/src/components/dashboard/Settings.tsx +++ b/src/components/dashboard/Settings.tsx @@ -9,6 +9,7 @@ import { ALERT_RULE_TYPE, ALERT_CHANNEL } from "../../lib/alerts"; import PluginRegistryView from "./PluginRegistryView"; import DataExport from "./DataExport"; import LanguageSettings from "./LanguageSettings"; +import { revokeSentryConsent } from "../../utils/monitoring"; const SESSION_API_KEY = 'stellar_custom_api_key'; @@ -404,6 +405,39 @@ export default function Settings() { +
+

Privacy & Diagnostics

+
+
+
+

Allow diagnostic data collection

+

+ Share crash reports and performance data to help improve Stellar Dev Dashboard. +

+

+ Data Retention Policy: Diagnostic data is anonymized and retained for a maximum of 30 days. It is used exclusively to improve application reliability. +

+
+
+ { + const consent = e.target.checked; + setPreference('diagnosticsConsent', consent); + if (!consent) { + revokeSentryConsent(); + } + }} + style={{ width: '16px', height: '16px', cursor: 'pointer' }} + aria-label="Toggle diagnostics consent" + /> + {preferences.diagnosticsConsent ? 'Enabled' : 'Disabled'} +
+
+
+
+

Extensions

diff --git a/src/utils/__tests__/monitoring.consent.test.ts b/src/utils/__tests__/monitoring.consent.test.ts new file mode 100644 index 00000000..28df113d --- /dev/null +++ b/src/utils/__tests__/monitoring.consent.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as Sentry from '@sentry/react'; +import * as preferences from '../preferences'; + +vi.mock('@sentry/react', () => ({ + init: vi.fn(), + getClient: vi.fn(() => ({ + close: vi.fn(), + })), + browserTracingIntegration: vi.fn(), + replayIntegration: vi.fn(), + breadcrumbsIntegration: vi.fn(), + withScope: vi.fn(), + captureException: vi.fn(), + setUser: vi.fn(), + startSpan: vi.fn(), + ErrorBoundary: vi.fn(), +})); + +vi.mock('../preferences', () => ({ + loadPreferences: vi.fn(), +})); + +vi.mock('../logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +describe('monitoring Sentry consent', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('initializes Sentry when diagnosticsConsent is true', async () => { + vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: true }); + + const monitoring = await import('../monitoring'); + monitoring.initMonitoring({ sentryDsn: 'http://test-dsn@sentry.io/1' }); + + expect(Sentry.init).toHaveBeenCalled(); + const initArgs = vi.mocked(Sentry.init).mock.calls[0][0]; + expect(initArgs?.dsn).toBe('http://test-dsn@sentry.io/1'); + }); + + it('does not initialize Sentry when diagnosticsConsent is false (defaults to no consent)', async () => { + vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: false }); + + const monitoring = await import('../monitoring'); + monitoring.initMonitoring({ sentryDsn: 'http://test-dsn@sentry.io/1' }); + + expect(Sentry.init).not.toHaveBeenCalled(); + }); + + it('closes Sentry client when consent is revoked', async () => { + vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: true }); + + const monitoring = await import('../monitoring'); + monitoring.revokeSentryConsent(); + + const getClient = vi.mocked(Sentry.getClient); + expect(getClient).toHaveBeenCalled(); + + const client = getClient(); + expect(client?.close).toHaveBeenCalledWith(2000); + }); +}); diff --git a/src/utils/monitoring.ts b/src/utils/monitoring.ts index 570c9b6f..74ac1628 100644 --- a/src/utils/monitoring.ts +++ b/src/utils/monitoring.ts @@ -24,6 +24,7 @@ import { } from '../lib/errorReporting'; import { initPerformanceMonitoring } from '../lib/performance'; import { createLogger } from './logger'; +import { loadPreferences } from './preferences'; export { collectHealthSnapshot, @@ -65,6 +66,12 @@ let _initialised = false; // ─── Sentry init ────────────────────────────────────────────────────────────── function initialiseSentry(cfg: MonitoringConfig): void { + const prefs = loadPreferences(); + if (prefs.diagnosticsConsent !== true) { + logger.info('Sentry initialization skipped: user has not granted diagnostics consent.'); + return; + } + if (!cfg.sentryDsn) { logger.warn('Sentry DSN not set – error tracking disabled.', { env: cfg.environment }); return; @@ -261,6 +268,15 @@ export function initMonitoring(userConfig: Partial = {}): void logger.info('Monitoring stack initialised', { env: cfg.environment }); } + +export function revokeSentryConsent(): void { + logger.info('User revoked diagnostics consent, closing Sentry client.'); + const client = Sentry.getClient(); + if (client) { + client.close(2000); // 2 second flush then close + } +} + // ─── Sentry user context helpers ───────────────────────────────────────────── /** @@ -402,4 +418,5 @@ export default { withSpan, captureError, SentryErrorBoundary, + revokeSentryConsent, }; diff --git a/src/utils/preferences.js b/src/utils/preferences.js index 8d423ef1..d1b5b361 100644 --- a/src/utils/preferences.js +++ b/src/utils/preferences.js @@ -5,6 +5,7 @@ export const DEFAULT_PREFERENCES = { showAdvancedPanels: true, autoRefreshDashboard: true, defaultSearchScope: "all", + diagnosticsConsent: false, }; export function loadPreferences() {