Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ SH_SUPABASE_MAIL_PORT=54324
# NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
# NEXT_PUBLIC_PROJECT_NAME=MyProject
# NEXT_PUBLIC_PROJECT_OWNER=MyUsername
# Sentry error monitoring (optional). Public client DSN from sentry.io → your
# project → Client Keys (DSN). Empty = monitoring disabled (no-op). Only sends
# after the user grants analytics consent.
# NEXT_PUBLIC_SENTRY_DSN=https://examplePublicKey@o0.ingest.us.sentry.io/0
# NEXT_PUBLIC_SITE_TWITTER_HANDLE=TwitterHandle
# NEXT_PUBLIC_SITE_URL=https://yourdomain.com
# NEXT_PUBLIC_SOCIAL_PLATFORMS=github,linkedin,twitch,twitter
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jobs:
NEXT_PUBLIC_PAGESPEED_API_KEY: ${{ secrets.NEXT_PUBLIC_PAGESPEED_API_KEY }}
NEXT_PUBLIC_PROJECT_NAME: ${{ vars.NEXT_PUBLIC_PROJECT_NAME }}
NEXT_PUBLIC_PROJECT_OWNER: ${{ vars.NEXT_PUBLIC_PROJECT_OWNER }}
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}
NEXT_PUBLIC_SOCIAL_PLATFORMS: ${{ vars.NEXT_PUBLIC_SOCIAL_PLATFORMS }}
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY: ${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ These customize the site appearance but aren't required for builds:
| `NEXT_PUBLIC_DISQUS_SHORTNAME` | Disqus comments integration |
| `NEXT_PUBLIC_PAGESPEED_API_KEY` | Google PageSpeed API key |
| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics measurement ID |
| `NEXT_PUBLIC_SENTRY_DSN` | Sentry error-monitoring client DSN |

Sentry monitoring is **disabled until `NEXT_PUBLIC_SENTRY_DSN` is set** (empty = no-op) and only sends events **after the user grants analytics consent**. Get the DSN from sentry.io → your project → **Client Keys (DSN)**. All events are PII-scrubbed (emails, tokens, message bodies) before leaving the browser; session replay and tracing are disabled.

### 📝 Optional - Supabase Admin (for migrations)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"@noble/curves": "^2.0.1",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@sentry/react": "9.47.1",
"@stripe/stripe-js": "^8.0.0",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.58.0",
Expand Down
75 changes: 75 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ConsentProvider } from '@/contexts/ConsentContext';
import { CookieConsent } from '@/components/privacy/CookieConsent';
import { ConsentModal } from '@/components/privacy/ConsentModal';
import GoogleAnalytics from '@/lib/analytics/GoogleAnalytics';
import SentryMonitor from '@/lib/monitoring/SentryMonitor';
import ErrorBoundary from '@/components/ErrorBoundary';
import { AuthProvider } from '@/contexts/AuthContext';
import { projectConfig } from '@/config/project.config';
Expand Down Expand Up @@ -101,7 +102,7 @@ export const metadata: Metadata = {
"style-src 'self' 'unsafe-inline' https://unpkg.com",
"img-src 'self' data: https: blob:",
"font-src 'self' data:",
"connect-src 'self' https://www.googleapis.com https://*.google-analytics.com https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.supabase.co wss://*.supabase.co https://*.basemaps.cartocdn.com https://api.web3forms.com",
"connect-src 'self' https://www.googleapis.com https://*.google-analytics.com https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.supabase.co wss://*.supabase.co https://*.basemaps.cartocdn.com https://api.web3forms.com https://*.ingest.sentry.io https://*.ingest.us.sentry.io",
"frame-src 'self' https://www.google.com",
"object-src 'none'",
"base-uri 'self'",
Expand All @@ -127,6 +128,7 @@ export default function RootLayout({
<ColorblindFilters />
<ConsentProvider>
<GoogleAnalytics />
<SentryMonitor />
<AuthProvider>
<AccessibilityProvider>
<GlobalNav />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import SentryMonitor from './SentryMonitor';
import { useConsent } from '@/contexts/ConsentContext';
import {
createMockConsentAllRejected,
createMockConsentWithAnalytics,
} from '@/test-utils/consent-mocks';

vi.mock('@/lib/monitoring/sentry', () => ({
SENTRY_DSN: 'https://k@o1.ingest.us.sentry.io/1',
initSentry: vi.fn(),
closeSentry: vi.fn(() => Promise.resolve()),
}));

vi.mock('@/contexts/ConsentContext', () => ({
useConsent: vi.fn(),
}));

expect.extend(toHaveNoViolations);

describe('SentryMonitor Accessibility', () => {
it('has no accessibility violations when consent is granted', async () => {
vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics());
const { container } = render(<SentryMonitor />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('has no accessibility violations when consent is denied', async () => {
vi.mocked(useConsent).mockReturnValue(createMockConsentAllRejected());
const { container } = render(<SentryMonitor />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('adds no focusable elements', () => {
vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics());
const { container } = render(<SentryMonitor />);
const focusable = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
expect(focusable).toHaveLength(0);
});

it('adds no visible content', () => {
vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics());
const { container } = render(<SentryMonitor />);
expect(container.textContent).toBe('');
});

it('adds no ARIA live regions or roles', () => {
vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics());
const { container } = render(<SentryMonitor />);
expect(container.querySelectorAll('[aria-live]')).toHaveLength(0);
expect(container.querySelectorAll('[role]')).toHaveLength(0);
});
});
86 changes: 86 additions & 0 deletions src/lib/monitoring/SentryMonitor/SentryMonitor.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import React from 'react';

/**
* SentryMonitor renders nothing — it initializes Sentry as a side effect when
* analytics consent is granted and a DSN is configured. These stories document
* that behavior visually rather than rendering the (invisible) component, which
* depends on ConsentProvider + the Sentry SDK at runtime.
*/
const SentryMonitorDoc = ({
analyticsConsent = true,
dsnConfigured = true,
}: {
analyticsConsent?: boolean;
dsnConfigured?: boolean;
}) => {
const active = analyticsConsent && dsnConfigured;
return (
<div className="bg-base-200 rounded-lg p-4">
<h3 className="mb-2 font-bold">Sentry Monitoring</h3>
<p>DSN configured: {dsnConfigured ? '✅ Yes' : '❌ No (inert)'}</p>
<p>Analytics consent: {analyticsConsent ? '✅ Granted' : '❌ Denied'}</p>
<p className="text-base-content/85 mt-2 text-sm">
{active
? 'Sentry is initialized; handled errors report (PII-scrubbed).'
: 'Sentry is NOT initialized — no events are sent.'}
</p>
</div>
);
};

const meta: Meta<typeof SentryMonitorDoc> = {
title: 'Utilities/Monitoring/SentryMonitor',
component: SentryMonitorDoc,
parameters: {
layout: 'padded',
docs: {
description: {
component: `
The SentryMonitor component initializes client-only Sentry error monitoring,
gated on analytics consent (via ConsentContext) and a configured
\`NEXT_PUBLIC_SENTRY_DSN\`. It renders nothing.

## Key Features
- **Privacy-first**: no events until analytics consent is granted
- **Inert without a DSN**: ships disabled until \`NEXT_PUBLIC_SENTRY_DSN\` is set
- **PII scrubbing**: every event is run through a \`beforeSend\` scrubber
- **No session replay / tracing**: never records the encrypted messaging UI
- **Static-export safe**: uses \`@sentry/react\` (client-only), not \`@sentry/nextjs\`

## Usage
\`\`\`tsx
// In app/layout.tsx, beside <GoogleAnalytics />
import SentryMonitor from '@/lib/monitoring/SentryMonitor';

<ConsentProvider>
<GoogleAnalytics />
<SentryMonitor />
{children}
</ConsentProvider>
\`\`\`
`,
},
},
},
tags: ['autodocs'],
argTypes: {
analyticsConsent: { control: 'boolean' },
dsnConfigured: { control: 'boolean' },
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Active: Story = {
args: { analyticsConsent: true, dsnConfigured: true },
};

export const ConsentDenied: Story = {
args: { analyticsConsent: false, dsnConfigured: true },
};

export const NoDsnConfigured: Story = {
args: { analyticsConsent: true, dsnConfigured: false },
};
Loading
Loading