Skip to content

Commit 0661e2c

Browse files
Merge pull request #1301 from dev-susa/feat/1164-cookie-consent-banner
feat(frontend): complete cookie audit and GDPR compliance verification
2 parents fd8713d + e717776 commit 0661e2c

4 files changed

Lines changed: 883 additions & 0 deletions

File tree

frontend/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ The web interface for interacting with the Sanctifier suite.
2323
- Upload WASM files for analysis.
2424
- View real-time security reports.
2525
- Dashboard for tracked contracts.
26+
- **Cookie-free by default** - No tracking, analytics, or non-essential cookies (see [Cookie Audit](docs/COOKIE_AUDIT.md))
2627

2728
## Behavior Notes
2829
- [Report export (PDF/CSV/JSON)](docs/report-export.md)
2930
- [Offline and dev mode](docs/offline-dev-mode.md)
3031
- [Self-hosting guide](docs/self-hosting.md)
32+
- [Cookie & tracking audit](docs/COOKIE_AUDIT.md) - GDPR/ePrivacy compliance documentation
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
5+
/**
6+
* GDPR/ePrivacy-compliant cookie consent banner
7+
*
8+
* This component is currently NOT rendered in the app because no
9+
* non-essential cookies or tracking scripts are used.
10+
*
11+
* **When to enable**: If analytics/error tracking (like Sentry) is added,
12+
* import this component in `app/layout.tsx` and render it:
13+
*
14+
* ```tsx
15+
* import { CookieConsentBanner } from './components/CookieConsentBanner';
16+
*
17+
* export default function RootLayout() {
18+
* return (
19+
* <html>
20+
* <body>
21+
* {children}
22+
* <CookieConsentBanner />
23+
* </body>
24+
* </html>
25+
* );
26+
* }
27+
* ```
28+
*
29+
* **Gating analytics initialization**:
30+
* ```tsx
31+
* import { hasConsent } from './components/CookieConsentBanner';
32+
*
33+
* if (hasConsent('analytics')) {
34+
* Sentry.init({ ... });
35+
* }
36+
* ```
37+
*
38+
* @see frontend/docs/COOKIE_AUDIT.md for compliance details
39+
* @see #1164 for implementation rationale
40+
*/
41+
42+
const CONSENT_STORAGE_KEY = "sanctifier-cookie-consent";
43+
44+
type ConsentPreferences = {
45+
necessary: boolean; // Always true
46+
analytics: boolean;
47+
timestamp: string;
48+
};
49+
50+
const defaultPreferences: ConsentPreferences = {
51+
necessary: true,
52+
analytics: false,
53+
timestamp: new Date().toISOString(),
54+
};
55+
56+
/**
57+
* Check if user has given consent for a specific category
58+
* @param category - 'necessary' | 'analytics'
59+
* @returns boolean
60+
*/
61+
export function hasConsent(category: keyof ConsentPreferences): boolean {
62+
if (typeof window === "undefined") return false;
63+
if (category === "necessary") return true; // Always allowed
64+
65+
try {
66+
const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
67+
if (!stored) return false;
68+
69+
const prefs: ConsentPreferences = JSON.parse(stored);
70+
return prefs[category] || false;
71+
} catch {
72+
return false;
73+
}
74+
}
75+
76+
/**
77+
* Save user consent preferences
78+
*/
79+
function saveConsent(prefs: ConsentPreferences): void {
80+
try {
81+
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(prefs));
82+
} catch (error) {
83+
console.error("Failed to save cookie consent:", error);
84+
}
85+
}
86+
87+
/**
88+
* Get saved consent preferences
89+
*/
90+
function getConsent(): ConsentPreferences | null {
91+
if (typeof window === "undefined") return null;
92+
93+
try {
94+
const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
95+
if (!stored) return null;
96+
97+
return JSON.parse(stored);
98+
} catch {
99+
return null;
100+
}
101+
}
102+
103+
export function CookieConsentBanner() {
104+
const [showBanner, setShowBanner] = useState(false);
105+
const [showSettings, setShowSettings] = useState(false);
106+
const [preferences, setPreferences] = useState<ConsentPreferences>(defaultPreferences);
107+
108+
useEffect(() => {
109+
const saved = getConsent();
110+
if (saved) {
111+
setPreferences(saved);
112+
setShowBanner(false);
113+
} else {
114+
setShowBanner(true);
115+
}
116+
}, []);
117+
118+
const handleAcceptAll = () => {
119+
const prefs: ConsentPreferences = {
120+
necessary: true,
121+
analytics: true,
122+
timestamp: new Date().toISOString(),
123+
};
124+
saveConsent(prefs);
125+
setPreferences(prefs);
126+
setShowBanner(false);
127+
window.location.reload(); // Reload to initialize analytics
128+
};
129+
130+
const handleRejectAll = () => {
131+
const prefs: ConsentPreferences = {
132+
necessary: true,
133+
analytics: false,
134+
timestamp: new Date().toISOString(),
135+
};
136+
saveConsent(prefs);
137+
setPreferences(prefs);
138+
setShowBanner(false);
139+
};
140+
141+
const handleSavePreferences = () => {
142+
const prefs: ConsentPreferences = {
143+
...preferences,
144+
timestamp: new Date().toISOString(),
145+
};
146+
saveConsent(prefs);
147+
setShowBanner(false);
148+
setShowSettings(false);
149+
window.location.reload(); // Reload to apply new preferences
150+
};
151+
152+
const handleToggleAnalytics = () => {
153+
setPreferences((prev) => ({
154+
...prev,
155+
analytics: !prev.analytics,
156+
}));
157+
};
158+
159+
if (!showBanner) return null;
160+
161+
return (
162+
<>
163+
{/* Banner Overlay */}
164+
<div
165+
className="fixed inset-0 bg-black/50 z-40"
166+
aria-hidden="true"
167+
onClick={() => setShowSettings(false)}
168+
/>
169+
170+
{/* Banner Content */}
171+
<div
172+
className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 shadow-lg z-50 p-6"
173+
role="dialog"
174+
aria-labelledby="cookie-banner-title"
175+
aria-describedby="cookie-banner-description"
176+
>
177+
<div className="max-w-7xl mx-auto">
178+
{!showSettings ? (
179+
// Simple Banner View
180+
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
181+
<div className="flex-1">
182+
<h2
183+
id="cookie-banner-title"
184+
className="text-lg font-semibold text-gray-900 dark:text-white mb-2"
185+
>
186+
🍪 Cookie Preferences
187+
</h2>
188+
<p
189+
id="cookie-banner-description"
190+
className="text-sm text-gray-600 dark:text-gray-300"
191+
>
192+
We use cookies to improve your experience and analyze usage. You can choose
193+
which cookies to accept. See our{" "}
194+
<a
195+
href="/privacy"
196+
className="underline hover:text-blue-600 dark:hover:text-blue-400"
197+
target="_blank"
198+
rel="noopener noreferrer"
199+
>
200+
Privacy Policy
201+
</a>{" "}
202+
for more details.
203+
</p>
204+
</div>
205+
206+
<div className="flex flex-wrap gap-3">
207+
<button
208+
onClick={() => setShowSettings(true)}
209+
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition"
210+
aria-label="Customize cookie preferences"
211+
>
212+
Customize
213+
</button>
214+
<button
215+
onClick={handleRejectAll}
216+
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition"
217+
aria-label="Reject all non-essential cookies"
218+
>
219+
Reject All
220+
</button>
221+
<button
222+
onClick={handleAcceptAll}
223+
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition"
224+
aria-label="Accept all cookies"
225+
>
226+
Accept All
227+
</button>
228+
</div>
229+
</div>
230+
) : (
231+
// Settings View
232+
<div className="space-y-6">
233+
<div>
234+
<h2
235+
id="cookie-settings-title"
236+
className="text-lg font-semibold text-gray-900 dark:text-white mb-2"
237+
>
238+
Cookie Preferences
239+
</h2>
240+
<p className="text-sm text-gray-600 dark:text-gray-300">
241+
Manage your cookie preferences below. Essential cookies cannot be disabled as
242+
they are required for the site to function.
243+
</p>
244+
</div>
245+
246+
<div className="space-y-4">
247+
{/* Necessary Cookies */}
248+
<div className="flex items-start justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800">
249+
<div className="flex-1">
250+
<div className="flex items-center gap-2 mb-1">
251+
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
252+
Essential Cookies
253+
</h3>
254+
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
255+
Always Active
256+
</span>
257+
</div>
258+
<p className="text-xs text-gray-600 dark:text-gray-400">
259+
Required for basic site functionality like theme preferences and session
260+
management. Cannot be disabled.
261+
</p>
262+
</div>
263+
<div className="ml-4">
264+
<input
265+
type="checkbox"
266+
checked={true}
267+
disabled
268+
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
269+
aria-label="Essential cookies (always active)"
270+
/>
271+
</div>
272+
</div>
273+
274+
{/* Analytics Cookies */}
275+
<div className="flex items-start justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-gray-300 dark:hover:border-gray-600 transition">
276+
<div className="flex-1">
277+
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
278+
Analytics & Performance
279+
</h3>
280+
<p className="text-xs text-gray-600 dark:text-gray-400">
281+
Help us understand how visitors use the site so we can improve performance
282+
and fix bugs (Sentry error tracking, usage analytics).
283+
</p>
284+
</div>
285+
<div className="ml-4">
286+
<input
287+
type="checkbox"
288+
checked={preferences.analytics}
289+
onChange={handleToggleAnalytics}
290+
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
291+
aria-label="Toggle analytics cookies"
292+
/>
293+
</div>
294+
</div>
295+
</div>
296+
297+
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
298+
<button
299+
onClick={() => setShowSettings(false)}
300+
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition"
301+
aria-label="Cancel and close settings"
302+
>
303+
Cancel
304+
</button>
305+
<button
306+
onClick={handleSavePreferences}
307+
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition"
308+
aria-label="Save cookie preferences"
309+
>
310+
Save Preferences
311+
</button>
312+
</div>
313+
</div>
314+
)}
315+
</div>
316+
</div>
317+
</>
318+
);
319+
}
320+
321+
/**
322+
* Optional: Preference center link component for footer
323+
* Allows users to change their consent after initial decision
324+
*/
325+
export function CookiePreferenceLink() {
326+
const handleClick = () => {
327+
localStorage.removeItem(CONSENT_STORAGE_KEY);
328+
window.location.reload();
329+
};
330+
331+
return (
332+
<button
333+
onClick={handleClick}
334+
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white underline"
335+
aria-label="Change cookie preferences"
336+
>
337+
Cookie Preferences
338+
</button>
339+
);
340+
}

0 commit comments

Comments
 (0)