feat: Live cookie banner overlay in SDK — auto-open + FAB reopen button
Build + Deploy / build-admin-compliance (push) Successful in 2m16s
Build + Deploy / build-backend-compliance (push) Failing after 4m47s
Build + Deploy / build-ai-sdk (push) Successful in 51s
Build + Deploy / build-developer-portal (push) Successful in 1m17s
Build + Deploy / build-tts (push) Successful in 2m30s
Build + Deploy / build-document-crawler (push) Successful in 45s
Build + Deploy / build-dsms-gateway (push) Successful in 29s
Build + Deploy / build-dsms-node (push) Successful in 11s
Build + Deploy / trigger-orca (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 28s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m56s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 53s
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Successful in 33s
CI / test-python-dsms-gateway (push) Successful in 26s
CI / validate-canonical-controls (push) Successful in 19s

- CookieBannerOverlay: opens automatically on first visit (localStorage check)
- CookieBannerFAB: shield icon button at right-[10rem] to reopen settings
- 3 consent modes: accept all, reject all (nur notwendige), custom settings
- 4 categories: Notwendig (locked on), Statistik, Marketing, Funktional
- Category toggles with descriptions in settings view
- Datenschutzerklaerung + Impressum links in banner
- Consent persisted to localStorage, custom event fired on change
- Comprehensive Playwright E2E tests (16 tests):
  - First visit auto-open, button visibility, category toggles
  - Accept all / reject all / custom settings persistence
  - FAB reopen behavior, disabled toggle for necessary category

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-02 19:46:22 +02:00
parent 44acd68c96
commit c3db56ddb6
3 changed files with 541 additions and 3 deletions
+8 -3
View File
@@ -7,6 +7,7 @@ import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar'
import { CommandBar } from '@/components/sdk/CommandBar' import { CommandBar } from '@/components/sdk/CommandBar'
import { SDKPipelineSidebar } from '@/components/sdk/SDKPipelineSidebar' import { SDKPipelineSidebar } from '@/components/sdk/SDKPipelineSidebar'
import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget' import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget'
import { CookieBannerOverlay, CookieBannerFAB } from '@/components/sdk/CookieBannerOverlay'
import { useSDK } from '@/lib/sdk' import { useSDK } from '@/lib/sdk'
// ============================================================================= // =============================================================================
@@ -208,10 +209,14 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />} {isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
{/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */} {/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */}
{projectId && <SDKPipelineSidebar />} <SDKPipelineSidebar />
{/* Compliance Advisor Widget */} {/* Compliance Advisor Widget — immer sichtbar, auch ohne Projekt */}
{projectId && <ComplianceAdvisorWidget currentStep={currentStep} />} <ComplianceAdvisorWidget currentStep={currentStep} />
{/* Cookie Banner — opens on first visit, reopenable via FAB */}
<CookieBannerOverlay />
<CookieBannerFAB />
</div> </div>
) )
} }
@@ -0,0 +1,277 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
/**
* CookieBannerOverlay — Live cookie consent banner for the Compliance SDK.
*
* - Opens automatically on first visit (localStorage check)
* - Can be reopened via FAB button (right-[10rem])
* - Records consent choice to localStorage
* - Fires custom event 'sdkCookieConsentUpdated' for other components
*/
const STORAGE_KEY = 'bp-sdk-cookie-consent'
interface ConsentState {
necessary: boolean
statistics: boolean
marketing: boolean
functional: boolean
timestamp: string
}
function getStoredConsent(): ConsentState | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}
export function CookieBannerOverlay() {
const [isOpen, setIsOpen] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [consent, setConsent] = useState<ConsentState>({
necessary: true,
statistics: false,
marketing: false,
functional: false,
timestamp: '',
})
// Check on mount if consent was already given
useEffect(() => {
const stored = getStoredConsent()
if (!stored) {
// First visit — show banner
setIsOpen(true)
} else {
setConsent(stored)
}
}, [])
// Listen for reopen event from FAB button
useEffect(() => {
const handler = () => {
setIsOpen(true)
setShowSettings(true)
}
window.addEventListener('openCookieBanner', handler)
return () => window.removeEventListener('openCookieBanner', handler)
}, [])
const saveConsent = useCallback((state: ConsentState) => {
const withTimestamp = { ...state, timestamp: new Date().toISOString() }
localStorage.setItem(STORAGE_KEY, JSON.stringify(withTimestamp))
setConsent(withTimestamp)
setIsOpen(false)
setShowSettings(false)
window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withTimestamp }))
}, [])
const handleAcceptAll = () => {
saveConsent({ necessary: true, statistics: true, marketing: true, functional: true, timestamp: '' })
}
const handleRejectAll = () => {
saveConsent({ necessary: true, statistics: false, marketing: false, functional: false, timestamp: '' })
}
const handleSaveSettings = () => {
saveConsent(consent)
}
if (!isOpen) return null
return (
<>
{/* Overlay */}
<div
className="fixed inset-0 bg-black/40 z-[9998] transition-opacity duration-300"
onClick={() => {/* Don't close on overlay click — consent is required */}}
/>
{/* Banner */}
<div className="fixed bottom-0 left-0 right-0 z-[9999] animate-in slide-in-from-bottom duration-300">
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden">
{/* Header */}
<div className="px-6 pt-6 pb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Cookie-Einstellungen
</h2>
<p className="text-sm text-gray-600 mt-2 leading-relaxed">
Wir verwenden Cookies und aehnliche Technologien, um Ihnen die bestmoegliche Erfahrung zu bieten.
Sie koennen Ihre Praeferenzen jederzeit aendern.
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<a href="/sdk/einwilligungen/cookie-banner" className="hover:text-purple-600 underline">
Datenschutzerklaerung
</a>
<span>|</span>
<a href="/sdk/einwilligungen" className="hover:text-purple-600 underline">
Impressum
</a>
</div>
</div>
{/* Category Settings (expandable) */}
{showSettings && (
<div className="px-6 pb-4 space-y-3 border-t border-gray-100 pt-4">
{/* Necessary — always on */}
<CategoryToggle
label="Notwendig"
description="Fuer die Grundfunktionen der Website erforderlich."
checked={true}
disabled={true}
onChange={() => {}}
/>
<CategoryToggle
label="Statistik"
description="Helfen uns zu verstehen, wie Besucher mit der Website interagieren."
checked={consent.statistics}
onChange={(v) => setConsent(prev => ({ ...prev, statistics: v }))}
/>
<CategoryToggle
label="Marketing"
description="Werden verwendet, um Besuchern relevante Werbung zu zeigen."
checked={consent.marketing}
onChange={(v) => setConsent(prev => ({ ...prev, marketing: v }))}
/>
<CategoryToggle
label="Funktional"
description="Ermoeglichen erweiterte Funktionen und Personalisierung."
checked={consent.functional}
onChange={(v) => setConsent(prev => ({ ...prev, functional: v }))}
/>
</div>
)}
{/* Buttons */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100 flex flex-wrap items-center gap-3">
{!showSettings ? (
<>
<button
onClick={handleAcceptAll}
className="flex-1 min-w-[140px] px-4 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors text-sm"
>
Alle akzeptieren
</button>
<button
onClick={handleRejectAll}
className="flex-1 min-w-[140px] px-4 py-2.5 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm"
>
Nur notwendige
</button>
<button
onClick={() => setShowSettings(true)}
className="flex-1 min-w-[140px] px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-100 transition-colors text-sm"
>
Einstellungen
</button>
</>
) : (
<>
<button
onClick={handleSaveSettings}
className="flex-1 min-w-[140px] px-4 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors text-sm"
>
Auswahl speichern
</button>
<button
onClick={handleAcceptAll}
className="flex-1 min-w-[140px] px-4 py-2.5 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm"
>
Alle akzeptieren
</button>
<button
onClick={() => setShowSettings(false)}
className="px-4 py-2.5 text-gray-500 hover:text-gray-700 text-sm"
>
Zurueck
</button>
</>
)}
</div>
</div>
</div>
</>
)
}
/**
* FAB button to reopen the cookie banner settings.
* Positioned next to ComplianceAdvisor and PipelineSidebar.
*/
export function CookieBannerFAB() {
const [hasConsent, setHasConsent] = useState(false)
useEffect(() => {
setHasConsent(!!getStoredConsent())
const handler = () => setHasConsent(true)
window.addEventListener('sdkCookieConsentUpdated', handler)
return () => window.removeEventListener('sdkCookieConsentUpdated', handler)
}, [])
// Only show FAB after consent was given (banner is closed)
if (!hasConsent) return null
return (
<button
onClick={() => window.dispatchEvent(new Event('openCookieBanner'))}
className="fixed bottom-6 right-[10rem] w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
aria-label="Cookie-Einstellungen oeffnen"
title="Cookie-Einstellungen"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</button>
)
}
function CategoryToggle({
label,
description,
checked,
disabled,
onChange,
}: {
label: string
description: string
checked: boolean
disabled?: boolean
onChange: (v: boolean) => void
}) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">{label}</div>
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
</div>
<button
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${
checked
? disabled ? 'bg-gray-400' : 'bg-purple-600'
: 'bg-gray-200'
} ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
)
}
@@ -0,0 +1,256 @@
import { test, expect } from '@playwright/test'
/**
* Cookie Banner Overlay E2E Tests
*
* Tests the live cookie consent banner in the Compliance SDK:
* - Auto-opens on first visit
* - Consent choices (accept all, reject all, custom settings)
* - FAB button to reopen after consent
* - Persistence in localStorage
* - Correct toggle behavior for categories
*/
const SDK_URL = '/sdk'
const STORAGE_KEY = 'bp-sdk-cookie-consent'
test.describe('Cookie Banner — First Visit', () => {
test.beforeEach(async ({ page }) => {
// Clear localStorage to simulate first visit
await page.goto(SDK_URL)
await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent'))
await page.reload()
await page.waitForLoadState('networkidle')
})
test('should show banner on first visit', async ({ page }) => {
// Banner should be visible
await expect(page.getByText('Cookie-Einstellungen')).toBeVisible({ timeout: 10000 })
await expect(page.getByText('Wir verwenden Cookies')).toBeVisible()
})
test('should show three buttons: accept all, reject, settings', async ({ page }) => {
await page.waitForTimeout(500)
await expect(page.getByRole('button', { name: 'Alle akzeptieren' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Nur notwendige' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Einstellungen' })).toBeVisible()
})
test('should have Datenschutzerklaerung and Impressum links', async ({ page }) => {
await page.waitForTimeout(500)
await expect(page.getByText('Datenschutzerklaerung')).toBeVisible()
await expect(page.getByText('Impressum')).toBeVisible()
})
test('should show overlay backdrop', async ({ page }) => {
await page.waitForTimeout(500)
// Check for the dark overlay behind the banner
const overlay = page.locator('.bg-black\\/40')
await expect(overlay).toBeVisible()
})
})
test.describe('Cookie Banner — Accept All', () => {
test('should close banner and save consent on "Alle akzeptieren"', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent'))
await page.reload()
await page.waitForLoadState('networkidle')
// Click accept all
await page.getByRole('button', { name: 'Alle akzeptieren' }).click()
// Banner should close
await expect(page.getByText('Cookie-Einstellungen').first()).not.toBeVisible({ timeout: 5000 })
// Verify localStorage
const consent = await page.evaluate(() => {
const raw = localStorage.getItem('bp-sdk-cookie-consent')
return raw ? JSON.parse(raw) : null
})
expect(consent).toBeTruthy()
expect(consent.necessary).toBe(true)
expect(consent.statistics).toBe(true)
expect(consent.marketing).toBe(true)
expect(consent.functional).toBe(true)
expect(consent.timestamp).toBeTruthy()
})
})
test.describe('Cookie Banner — Reject All', () => {
test('should save only necessary on "Nur notwendige"', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent'))
await page.reload()
await page.waitForLoadState('networkidle')
// Click reject all
await page.getByRole('button', { name: 'Nur notwendige' }).click()
// Banner should close
await expect(page.getByText('Cookie-Einstellungen').first()).not.toBeVisible({ timeout: 5000 })
// Verify localStorage — only necessary enabled
const consent = await page.evaluate(() => {
const raw = localStorage.getItem('bp-sdk-cookie-consent')
return raw ? JSON.parse(raw) : null
})
expect(consent).toBeTruthy()
expect(consent.necessary).toBe(true)
expect(consent.statistics).toBe(false)
expect(consent.marketing).toBe(false)
expect(consent.functional).toBe(false)
})
})
test.describe('Cookie Banner — Custom Settings', () => {
test('should expand category toggles on "Einstellungen"', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent'))
await page.reload()
await page.waitForLoadState('networkidle')
// Click settings
await page.getByRole('button', { name: 'Einstellungen' }).click()
// Category toggles should appear
await expect(page.getByText('Notwendig')).toBeVisible()
await expect(page.getByText('Statistik')).toBeVisible()
await expect(page.getByText('Marketing')).toBeVisible()
await expect(page.getByText('Funktional')).toBeVisible()
})
test('should have "Auswahl speichern" button in settings view', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent'))
await page.reload()
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Einstellungen' }).click()
await expect(page.getByRole('button', { name: 'Auswahl speichern' })).toBeVisible()
})
test('should save custom selection', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent'))
await page.reload()
await page.waitForLoadState('networkidle')
// Open settings
await page.getByRole('button', { name: 'Einstellungen' }).click()
await page.waitForTimeout(300)
// Toggle statistics on (click the toggle next to "Statistik")
const statistikRow = page.locator('text=Statistik').locator('..')
const toggle = statistikRow.locator('button').last()
await toggle.click()
// Save
await page.getByRole('button', { name: 'Auswahl speichern' }).click()
// Verify
const consent = await page.evaluate(() => {
const raw = localStorage.getItem('bp-sdk-cookie-consent')
return raw ? JSON.parse(raw) : null
})
expect(consent).toBeTruthy()
expect(consent.necessary).toBe(true)
expect(consent.statistics).toBe(true)
expect(consent.marketing).toBe(false)
})
test('necessary toggle should be disabled', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent'))
await page.reload()
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Einstellungen' }).click()
await page.waitForTimeout(300)
// The "Notwendig" toggle should be disabled (can't be turned off)
const necessaryRow = page.locator('text=Notwendig').locator('..')
const toggle = necessaryRow.locator('button[disabled]')
await expect(toggle).toBeVisible()
})
})
test.describe('Cookie Banner — Persistence', () => {
test('should NOT show banner on subsequent visits', async ({ page }) => {
await page.goto(SDK_URL)
// Set consent in localStorage
await page.evaluate(() => {
localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({
necessary: true, statistics: true, marketing: false, functional: false,
timestamp: new Date().toISOString(),
}))
})
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForTimeout(1000)
// Banner should NOT appear
const bannerVisible = await page.getByText('Wir verwenden Cookies').isVisible().catch(() => false)
expect(bannerVisible).toBe(false)
})
})
test.describe('Cookie Banner — FAB Reopen Button', () => {
test('should show cookie FAB after consent is given', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => {
localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({
necessary: true, statistics: true, marketing: false, functional: false,
timestamp: new Date().toISOString(),
}))
})
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForTimeout(1000)
// FAB button should be visible (shield icon)
const fab = page.locator('button[aria-label="Cookie-Einstellungen oeffnen"]')
await expect(fab).toBeVisible({ timeout: 5000 })
})
test('should reopen banner when FAB is clicked', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => {
localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({
necessary: true, statistics: true, marketing: false, functional: false,
timestamp: new Date().toISOString(),
}))
})
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForTimeout(1000)
// Click FAB
const fab = page.locator('button[aria-label="Cookie-Einstellungen oeffnen"]')
await fab.click()
// Banner should reopen with settings expanded
await expect(page.getByText('Cookie-Einstellungen')).toBeVisible({ timeout: 5000 })
// Settings should be shown (since reopening goes straight to settings)
await expect(page.getByText('Statistik')).toBeVisible()
await expect(page.getByText('Marketing')).toBeVisible()
})
test('should NOT show FAB before consent is given', async ({ page }) => {
await page.goto(SDK_URL)
await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent'))
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForTimeout(1000)
// FAB should NOT be visible (banner is showing instead)
const fab = page.locator('button[aria-label="Cookie-Einstellungen oeffnen"]')
const isVisible = await fab.isVisible().catch(() => false)
expect(isVisible).toBe(false)
})
})