feat(frontend): Cookie consent banner and settings (#11306)

Implements a cookie consent banner and settings modal for GDPR
compliance, allowing users to manage preferences for analytics and
monitoring cookies. Integrates consent checks with Sentry, Vercel
Analytics, and Google Analytics, ensuring tracking is only enabled with
user permission. Refactors dialog components for improved layout and
adds consent management utilities and hooks.

#### For code changes:
- [x] Banner appears at bottom of page on first visit with rounded
corners and proper spacing (40px margins)
- [x] Banner shows three buttons: "Reject All", "Settings", and "Accept
All"
- [x] Clicking "Accept All" hides banner and enables
analytics/monitoring
- [x] Clicking "Reject All" hides banner and keeps analytics/monitoring
disabled
- [x] Banner does not reappear after consent is given (check
localStorage: `autogpt_cookie_consent`)

**Cookie Settings Modal:**
- [x] Clicking "Settings" button opens the Cookie Settings modal
- [x] Modal displays three categories: Essential Cookies (always
active), Analytics & Performance (toggle), Error Monitoring & Session
Replay (toggle)
- [x] Clicking "Save Preferences" saves custom settings and closes modal
- [x] Clicking "Accept All" enables all cookies and closes modal
- [x] Clicking "Reject All" disables optional cookies and closes modal
- [x] Modal can be closed with X button or clicking outside

**Consent Persistence:**
- [x] Refresh page after giving consent - banner should not reappear
- [x] Clear localStorage and refresh - banner should reappear
- [x] Consent choices persist across browser sessions

<img width="1123" height="126" alt="image"
src="https://github.com/user-attachments/assets/7425efab-b5cc-4449-802d-0e12bd65053b"
/>

<img width="1124" height="372" alt="image"
src="https://github.com/user-attachments/assets/2f28919a-97e8-44f5-9021-70d3836bb996"
/>
This commit is contained in:
Bently
2025-11-06 09:25:43 +00:00
committed by GitHub
parent df9850a141
commit 8e83586d13
14 changed files with 593 additions and 19 deletions

View File

@@ -2,6 +2,7 @@
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import { consent } from "@/services/consent/cookies";
import { environment } from "@/services/environment";
import * as Sentry from "@sentry/nextjs";
@@ -11,6 +12,9 @@ const isDisabled = process.env.DISABLE_SENTRY === "true";
const shouldEnable = !isDisabled && isProdOrDev && isCloud;
// Check for monitoring consent (includes session replay)
const hasMonitoringConsent = consent.hasConsentFor("monitoring");
Sentry.init({
dsn: "https://fe4e4aa4a283391808a5da396da20159@o4505260022104064.ingest.us.sentry.io/4507946746380288",
@@ -50,10 +54,12 @@ Sentry.init({
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// GDPR: Only enable if user has consented to monitoring
replaysSessionSampleRate: hasMonitoringConsent ? 0.1 : 0,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// GDPR: Only enable if user has consented to monitoring
replaysOnErrorSampleRate: hasMonitoringConsent ? 1.0 : 0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,

View File

@@ -37,6 +37,27 @@ export default defineConfig({
/* Helps debugging failures */
trace: "retain-on-failure",
video: "retain-on-failure",
/* Auto-accept cookies in all tests to prevent banner interference */
storageState: {
cookies: [],
origins: [
{
origin: "http://localhost:3000",
localStorage: [
{
name: "autogpt_cookie_consent",
value: JSON.stringify({
hasConsented: true,
timestamp: Date.now(),
analytics: true,
monitoring: true,
}),
},
],
},
],
},
},
/* Maximum time one test can run for */
timeout: 25000,

View File

@@ -5,13 +5,13 @@ import React from "react";
import "./globals.css";
import { Providers } from "@/app/providers";
import { CookieConsentBanner } from "@/components/molecules/CookieConsentBanner/CookieConsentBanner";
import TallyPopupSimple from "@/components/molecules/TallyPoup/TallyPopup";
import { Toaster } from "@/components/molecules/Toast/toaster";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Analytics } from "@vercel/analytics/next";
import { headers } from "next/headers";
import { SetupAnalytics } from "@/services/analytics";
import { VercelAnalyticsWrapper } from "@/services/analytics/VercelAnalyticsWrapper";
export const metadata: Metadata = {
title: "AutoGPT Platform",
@@ -51,8 +51,7 @@ export default async function RootLayout({
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
{children}
<TallyPopupSimple />
<SpeedInsights />
<Analytics />
<VercelAnalyticsWrapper />
{/* React Query DevTools is only available in development */}
{process.env.NEXT_PUBLIC_REACT_QUERY_DEVTOOL && (
@@ -63,6 +62,7 @@ export default async function RootLayout({
)}
</div>
<Toaster />
<CookieConsentBanner />
</Providers>
</body>
</html>

View File

@@ -0,0 +1,89 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CookieIcon } from "@phosphor-icons/react/dist/ssr";
import { useCookieConsentBanner } from "./useCookieConsentBanner";
import { CookieSettingsModal } from "./components/CookieSettingsModal/CookieSettingsModal";
export function CookieConsentBanner() {
const {
shouldShowBanner,
showSettings,
handleAcceptAll,
handleRejectAll,
handleOpenSettings,
handleCloseSettings,
} = useCookieConsentBanner();
if (!shouldShowBanner) {
return null;
}
return (
<>
<div className="fixed bottom-0 left-0 right-0 z-50 px-10 pb-4">
<div
className="mx-auto max-w-6xl rounded-lg border border-neutral-200 bg-white p-4 shadow-lg dark:border-neutral-800 dark:bg-neutral-950"
role="dialog"
aria-label="Cookie consent banner"
>
<div className="flex flex-col items-start gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 items-start gap-3">
<CookieIcon className="mt-0.5 h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-300" />
<div className="flex-1">
<Text
variant="body-medium"
className="mb-1 text-neutral-900 dark:text-neutral-100"
>
We use cookies
</Text>
<Text
variant="body"
className="text-neutral-600 dark:text-neutral-400"
>
AutoGPT uses essential cookies for login and optional cookies
for analytics and error tracking to improve our service.
</Text>
</div>
</div>
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
<Button
variant="ghost"
size="small"
onClick={handleRejectAll}
className="w-full md:w-auto"
>
Reject All
</Button>
<Button
variant="ghost"
size="small"
onClick={handleOpenSettings}
className="w-full md:w-auto"
>
Settings
</Button>
<Button
variant="primary"
size="small"
onClick={handleAcceptAll}
className="w-full md:w-auto"
>
Accept All
</Button>
</div>
</div>
</div>
</div>
{showSettings && (
<CookieSettingsModal
isOpen={showSettings}
onClose={handleCloseSettings}
/>
)}
</>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { COOKIE_CATEGORIES } from "@/services/consent/cookies";
import { CheckIcon } from "@phosphor-icons/react/dist/ssr";
import { useCookieSettingsModal } from "./useCookieSettingsModal";
interface Props {
isOpen: boolean;
onClose: () => void;
}
export function CookieSettingsModal({ isOpen, onClose }: Props) {
const {
analytics,
setAnalytics,
monitoring,
setMonitoring,
handleSavePreferences,
handleAcceptAll,
handleRejectAll,
} = useCookieSettingsModal({ onClose });
return (
<Dialog
title="Cookie Settings"
controlled={{
isOpen,
set: (open) => {
if (!open) onClose();
},
}}
>
<Dialog.Content>
<div className="space-y-6 pb-6">
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<Text
variant="body-medium"
className="text-neutral-900 dark:text-neutral-100"
>
{COOKIE_CATEGORIES.essential.name}
</Text>
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">
Always Active
</span>
</div>
<Text
variant="body"
className="text-neutral-600 dark:text-neutral-400"
>
{COOKIE_CATEGORIES.essential.description}
</Text>
</div>
<div className="flex items-center">
<CheckIcon className="h-5 w-5 text-green-600" weight="bold" />
</div>
</div>
</div>
<div className="border-t border-neutral-200 dark:border-neutral-800" />
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<Text
variant="body-medium"
className="text-neutral-900 dark:text-neutral-100"
>
{COOKIE_CATEGORIES.analytics.name}
</Text>
<Text
variant="body"
className="text-neutral-600 dark:text-neutral-400"
>
{COOKIE_CATEGORIES.analytics.description}
</Text>
</div>
<Switch
checked={analytics}
onCheckedChange={setAnalytics}
aria-label="Toggle analytics cookies"
/>
</div>
</div>
<div className="border-t border-neutral-200 dark:border-neutral-800" />
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<Text
variant="body-medium"
className="text-neutral-900 dark:text-neutral-100"
>
{COOKIE_CATEGORIES.monitoring.name}
</Text>
<Text
variant="body"
className="text-neutral-600 dark:text-neutral-400"
>
{COOKIE_CATEGORIES.monitoring.description}
</Text>
</div>
<Switch
checked={monitoring}
onCheckedChange={setMonitoring}
aria-label="Toggle monitoring cookies"
/>
</div>
</div>
</div>
<Dialog.Footer>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="ghost" size="small" onClick={handleRejectAll}>
Reject All
</Button>
<Button variant="ghost" size="small" onClick={handleAcceptAll}>
Accept All
</Button>
<Button
variant="primary"
size="small"
onClick={handleSavePreferences}
>
Save Preferences
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { useState, useEffect } from "react";
import { useCookieConsent } from "../../useCookieConsent";
interface Props {
onClose: () => void;
}
export function useCookieSettingsModal({ onClose }: Props) {
const { consent, handleUpdateConsent } = useCookieConsent();
const [analytics, setAnalytics] = useState(consent.analytics);
const [monitoring, setMonitoring] = useState(consent.monitoring);
useEffect(() => {
setAnalytics(consent.analytics);
setMonitoring(consent.monitoring);
}, [consent.analytics, consent.monitoring]);
function handleSavePreferences() {
handleUpdateConsent({
analytics,
monitoring,
});
onClose();
}
function handleAcceptAll() {
setAnalytics(true);
setMonitoring(true);
handleUpdateConsent({
analytics: true,
monitoring: true,
});
onClose();
}
function handleRejectAll() {
setAnalytics(false);
setMonitoring(false);
handleUpdateConsent({
analytics: false,
monitoring: false,
});
onClose();
}
return {
analytics,
setAnalytics,
monitoring,
setMonitoring,
handleSavePreferences,
handleAcceptAll,
handleRejectAll,
};
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
consent,
ConsentPreferences,
DEFAULT_CONSENT,
} from "@/services/consent/cookies";
export function useCookieConsent() {
const [consentState, setConsentState] =
useState<ConsentPreferences>(DEFAULT_CONSENT);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const stored = consent.load();
setConsentState(stored);
setIsLoaded(true);
}, []);
const handleUpdateConsent = useCallback(
(updates: Partial<ConsentPreferences>) => {
const newConsent: ConsentPreferences = {
...consentState,
...updates,
hasConsented: true,
timestamp: Date.now(),
};
setConsentState(newConsent);
consent.save(newConsent);
if (typeof window !== "undefined") {
window.location.reload();
}
},
[consentState],
);
const handleAcceptAll = useCallback(() => {
handleUpdateConsent({
analytics: true,
monitoring: true,
});
}, [handleUpdateConsent]);
const handleRejectAll = useCallback(() => {
handleUpdateConsent({
analytics: false,
monitoring: false,
});
}, [handleUpdateConsent]);
const handleResetConsent = useCallback(() => {
setConsentState(DEFAULT_CONSENT);
consent.save(DEFAULT_CONSENT);
}, []);
return {
consent: consentState,
isLoaded,
handleUpdateConsent,
handleAcceptAll,
handleRejectAll,
handleResetConsent,
};
}

View File

@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { useCookieConsent } from "./useCookieConsent";
export function useCookieConsentBanner() {
const { consent, isLoaded, handleAcceptAll, handleRejectAll } =
useCookieConsent();
const [showSettings, setShowSettings] = useState(false);
const shouldShowBanner = isLoaded && !consent.hasConsented;
function handleAcceptAllClick() {
handleAcceptAll();
}
function handleRejectAllClick() {
handleRejectAll();
}
function handleOpenSettings() {
setShowSettings(true);
}
function handleCloseSettings() {
setShowSettings(false);
}
return {
shouldShowBanner,
showSettings,
handleAcceptAll: handleAcceptAllClick,
handleRejectAll: handleRejectAllClick,
handleOpenSettings,
handleCloseSettings,
};
}

View File

@@ -84,15 +84,20 @@ export function DialogWrap({
</button>
)}
</div>
<div
ref={scrollRef}
className={cn("overflow-y-auto overflow-x-hidden", scrollbarStyles)}
style={{
scrollbarGutter: "stable",
marginRight: hasVerticalScrollbar ? "-14px" : "0px",
}}
>
{children}
<div className="flex min-h-0 flex-1 flex-col">
<div
ref={scrollRef}
className={cn(
"flex-1 overflow-y-auto overflow-x-hidden",
scrollbarStyles,
)}
style={{
scrollbarGutter: "stable",
marginRight: hasVerticalScrollbar ? "-14px" : "0px",
}}
>
{children}
</div>
</div>
</RXDialog.Content>
</RXDialog.Portal>

View File

@@ -0,0 +1,25 @@
"use client";
import { consent } from "@/services/consent/cookies";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { useEffect, useState } from "react";
export function VercelAnalyticsWrapper() {
const [hasAnalyticsConsent, setHasAnalyticsConsent] = useState(false);
useEffect(() => {
setHasAnalyticsConsent(consent.hasConsentFor("analytics"));
}, []);
if (!hasAnalyticsConsent) {
return null;
}
return (
<>
<SpeedInsights />
<Analytics />
</>
);
}

View File

@@ -6,8 +6,9 @@
"use client";
import type { GAParams } from "@/types/google";
import { consent } from "@/services/consent/cookies";
import Script from "next/script";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { environment } from "../environment";
declare global {
@@ -29,10 +30,20 @@ export function SetupAnalytics(props: SetupProps) {
const { gaId, debugMode, dataLayerName = "dataLayer", nonce } = ga;
const isProductionDomain = host.includes("platform.agpt.co");
// Datafa.st journey analytics only on production
const dataFastEnabled = isProductionDomain;
// Check for user consent
const [hasAnalyticsConsent, setHasAnalyticsConsent] = useState(false);
useEffect(() => {
// Check consent on mount
setHasAnalyticsConsent(consent.hasConsentFor("analytics"));
}, []);
// Datafa.st journey analytics only on production AND with consent
const dataFastEnabled = isProductionDomain && hasAnalyticsConsent;
// We collect analytics too for open source developers running the platform locally
const googleAnalyticsEnabled = environment.isLocal() || isProductionDomain;
// BUT only with consent
const googleAnalyticsEnabled =
(environment.isLocal() || isProductionDomain) && hasAnalyticsConsent;
if (currDataLayerName === undefined) {
currDataLayerName = dataLayerName;

View File

@@ -0,0 +1,103 @@
import * as Sentry from "@sentry/nextjs";
import { Key, storage } from "../storage/local-storage";
export interface ConsentPreferences {
hasConsented: boolean;
timestamp: number;
analytics: boolean;
monitoring: boolean;
}
export const DEFAULT_CONSENT: ConsentPreferences = {
hasConsented: false,
timestamp: Date.now(),
analytics: false,
monitoring: false,
};
export const COOKIE_CATEGORIES = {
essential: {
name: "Essential Cookies",
description: "Required for login, authentication, and core functionality",
alwaysActive: true,
},
analytics: {
name: "Analytics & Performance",
description:
"Help us understand how you use AutoGPT to improve our service (Google Analytics, Vercel Analytics, Datafa.st)",
alwaysActive: false,
},
monitoring: {
name: "Error Monitoring & Session Replay",
description:
"Record errors and user sessions to help us fix bugs faster (Sentry - includes screen recording)",
alwaysActive: false,
},
} as const;
function load(): ConsentPreferences {
try {
const stored = storage.get(Key.COOKIE_CONSENT);
if (!stored) {
return DEFAULT_CONSENT;
}
const parsed = JSON.parse(stored) as ConsentPreferences;
if (
typeof parsed.hasConsented !== "boolean" ||
typeof parsed.timestamp !== "number" ||
typeof parsed.analytics !== "boolean" ||
typeof parsed.monitoring !== "boolean"
) {
console.warn(
"Invalid consent data in localStorage, resetting to defaults",
);
return DEFAULT_CONSENT;
}
return parsed;
} catch (error) {
Sentry.captureException(error);
console.error("Failed to load consent preferences:", error);
return DEFAULT_CONSENT;
}
}
function save(preferences: ConsentPreferences): void {
try {
storage.set(Key.COOKIE_CONSENT, JSON.stringify(preferences));
} catch (error) {
Sentry.captureException(error);
console.error("Failed to save consent preferences:", error);
}
}
function clear(): void {
try {
storage.clean(Key.COOKIE_CONSENT);
} catch (error) {
Sentry.captureException(error);
console.error("Failed to clear consent preferences:", error);
}
}
function hasConsented(): boolean {
const preferences = load();
return preferences.hasConsented;
}
function hasConsentFor(
category: keyof Omit<ConsentPreferences, "hasConsented" | "timestamp">,
): boolean {
const preferences = load();
return preferences.hasConsented && preferences[category];
}
export const consent = {
load,
save,
clear,
hasConsented,
hasConsentFor,
};

View File

@@ -8,6 +8,7 @@ export enum Key {
SHEPHERD_TOUR = "shepherd-tour",
WALLET_LAST_SEEN_CREDITS = "wallet-last-seen-credits",
LIBRARY_AGENTS_CACHE = "library-agents-cache",
COOKIE_CONSENT = "autogpt_cookie_consent",
}
function get(key: Key) {

View File

@@ -30,6 +30,19 @@ export async function createTestUser(
const context = await browser.newContext();
const page = await context.newPage();
// Auto-accept cookies in test environment to prevent banner from appearing
await page.addInitScript(() => {
window.localStorage.setItem(
"autogpt_cookie_consent",
JSON.stringify({
hasConsented: true,
timestamp: Date.now(),
analytics: true,
monitoring: true,
}),
);
});
try {
const testUser = await signupTestUser(
page,