feat(cmp): Phase 2 complete — self-hosted fonts, ScriptManager, GeoIP, vendor UI

- Session ID via sessionStorage UUID
- Self-host Google Fonts (Inter, Plus Jakarta Sans, JetBrains Mono) — eliminates
  third-party transfer to Google, no more DSGVO violation
- ScriptManager component: consent-change listener for future analytics/marketing scripts
- GeoIP via browser timezone (Intl.DateTimeFormat) + IP injection in proxy
- Vendor-level consent UI: loads vendor config from backend, shows per-vendor
  toggles under each category, sends vendor_consents dict
- DSE updated: Google Fonts section now says "lokal gehostet"
- Config proxy route: GET /api/consent/config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-12 14:42:55 +02:00
parent f6489e7748
commit 0c09b960b9
10 changed files with 232 additions and 43 deletions
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.CONSENT_BACKEND_URL || 'https://macmini:3007/api/sdk/v1/banner'
const TENANT_ID = process.env.CONSENT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const SITE_ID = process.env.NEXT_PUBLIC_CONSENT_SITE_ID || 'breakpilot-marketing'
export async function GET(req: NextRequest) {
try {
const siteId = req.nextUrl.searchParams.get('site_id') || SITE_ID
const res = await fetch(`${BACKEND_URL}/config/${siteId}`, {
headers: { 'X-Tenant-ID': TENANT_ID },
})
const data = await res.text()
return new NextResponse(data, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
})
} catch {
return NextResponse.json({ categories: [], vendors: [] }, { status: 200 })
}
}
+10 -6
View File
@@ -5,7 +5,13 @@ const TENANT_ID = process.env.CONSENT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc
export async function POST(req: NextRequest) {
try {
const body = await req.text()
const data = await req.json()
// Inject client IP for backend GeoIP resolution
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| req.headers.get('x-real-ip')
|| null
if (ip) data.ip_address = ip
const res = await fetch(`${BACKEND_URL}/consent`, {
method: 'POST',
@@ -13,13 +19,11 @@ export async function POST(req: NextRequest) {
'Content-Type': 'application/json',
'X-Tenant-ID': TENANT_ID,
},
body,
// Accept self-signed certs on internal network
...(process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' ? {} : {}),
body: JSON.stringify(data),
})
const data = await res.text()
return new NextResponse(data, {
const resBody = await res.text()
return new NextResponse(resBody, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
})
+6 -13
View File
@@ -87,19 +87,12 @@ export default function DatenschutzPage() {
</div>
<div>
<h2 className="text-lg font-semibold text-white mb-2">6. Externe Schriften</h2>
<h2 className="text-lg font-semibold text-white mb-2">6. Schriften</h2>
<p>
Diese Website laedt Schriftarten von Google Fonts. Dabei wird Ihre IP-Adresse an Google LLC,
1600 Amphitheatre Parkway, Mountain View, CA 94043, USA uebermittelt.
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.
Google ist unter dem EU-US Data Privacy Framework (DPF) zertifiziert (Angemessenheitsbeschluss
der EU-Kommission vom 10. Juli 2023).
</p>
<p className="mt-1">
Weitere Informationen:{' '}
<a href="https://policies.google.com/privacy" className="text-accent-electric hover:underline" target="_blank" rel="noopener noreferrer">
policies.google.com/privacy
</a>
Diese Website verwendet die Schriftarten Inter, Plus Jakarta Sans und JetBrains Mono.
Die Schriften werden lokal auf unserem Server gehostet es findet kein Abruf von
externen Servern (z.B. Google Fonts) statt. Es werden keine personenbezogenen Daten
an Dritte uebermittelt.
</p>
</div>
@@ -116,8 +109,8 @@ export default function DatenschutzPage() {
<h2 className="text-lg font-semibold text-white mb-2">8. Empfaenger und Auftragsverarbeiter</h2>
<ul className="list-disc list-inside space-y-1">
<li>Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen Hosting (AVV nach Art. 28 DSGVO)</li>
<li>Google LLC Schriftarten (Google Fonts CDN, EU-US DPF)</li>
</ul>
<p className="mt-1">Schriftarten werden lokal gehostet kein Drittanbieter-Transfer.</p>
</div>
<div>
+25 -3
View File
@@ -1,6 +1,28 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
/* Self-hosted fonts — kein Drittlandtransfer zu Google */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300 900;
font-display: swap;
src: url('/fonts/Inter-Latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url('/fonts/PlusJakartaSans-Latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url('/fonts/JetBrainsMono-Latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@tailwind base;
@tailwind components;
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { AppProvider } from '@/lib/context'
import ConsentBanner from '@/components/layout/ConsentBanner'
import ScriptManager from '@/components/layout/ScriptManager'
import './globals.css'
export const metadata: Metadata = {
@@ -27,6 +28,7 @@ export default function RootLayout({
<AppProvider>
{children}
<ConsentBanner />
<ScriptManager />
</AppProvider>
</body>
</html>
@@ -51,6 +51,18 @@ const texts = {
},
}
function getSessionId(): string {
if (typeof window === 'undefined') return ''
try {
let sid = sessionStorage.getItem('bp_session_id')
if (!sid) {
sid = crypto.randomUUID()
sessionStorage.setItem('bp_session_id', sid)
}
return sid
} catch { return '' }
}
function getFingerprint(): string {
const nav = typeof navigator !== 'undefined' ? navigator : null
const raw = [nav?.language, nav?.platform, screen?.width, screen?.height, new Date().getTimezoneOffset()].join('|')
@@ -115,7 +127,7 @@ function detectCookies(): CookieEntry[] {
return cookies
}
async function sendConsent(consent: ConsentState, method: ConsentMethod) {
async function sendConsent(consent: ConsentState, method: ConsentMethod, vendorConsents?: Record<string, boolean>) {
try {
const { device_type, browser, os } = detectDevice()
const { blocked, released } = detectScripts()
@@ -131,7 +143,8 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
...(consent.functional ? ['functional'] : []),
...(consent.analytics ? ['analytics'] : []),
],
vendors: [],
vendors: Object.keys(vendorConsents || {}).filter(k => vendorConsents?.[k]),
vendor_consents: vendorConsents || {},
user_agent: navigator.userAgent,
consent_method: method,
page_url: window.location.href,
@@ -141,6 +154,8 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
os,
screen_resolution: `${screen.width}x${screen.height}`,
consent_scope: 'domain',
session_id: getSessionId(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
scripts_blocked: blocked,
scripts_released: released,
cookies_set,
@@ -151,26 +166,49 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
}
}
interface VendorConfig {
vendor_name: string
category_key: string
description_de?: string
description_en?: string
cookie_names?: string[]
retention_days?: number
}
export default function ConsentBanner() {
const { lang } = useApp()
const t = texts[lang]
const [visible, setVisible] = useState(false)
const [showDetails, setShowDetails] = useState(false)
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
const [vendors, setVendors] = useState<VendorConfig[]>([])
const [vendorConsents, setVendorConsents] = useState<Record<string, boolean>>({})
useEffect(() => {
const saved = getSavedConsent()
if (!saved) {
setVisible(true)
}
// Load vendor config from backend
fetch('/api/consent/config')
.then(r => r.json())
.then(data => {
const v = data?.vendors || []
setVendors(v)
// Default all vendors to true
const defaults: Record<string, boolean> = {}
for (const vendor of v) defaults[vendor.vendor_name] = true
setVendorConsents(defaults)
})
.catch(() => {})
}, [])
const save = useCallback((state: ConsentState, method: ConsentMethod) => {
localStorage.setItem(COOKIE_NAME, JSON.stringify(state))
sendConsent(state, method)
sendConsent(state, method, vendorConsents)
setVisible(false)
window.dispatchEvent(new CustomEvent('consent-change', { detail: state }))
}, [])
}, [vendorConsents])
const acceptAll = () => save({ essential: true, functional: true, analytics: true }, 'accept_all')
const rejectAll = () => save({ essential: true, functional: false, analytics: false }, 'reject_all')
@@ -215,24 +253,49 @@ export default function ConsentBanner() {
className="overflow-hidden mb-4"
>
<div className="space-y-2 pt-2 border-t border-white/[0.06]">
{categories.map(([key, cat]) => (
<label
key={key}
className="flex items-center justify-between p-3 rounded-xl bg-white/[0.03] border border-white/[0.06] cursor-pointer hover:bg-white/[0.05] transition-colors"
>
<div className="flex-1 mr-4">
<span className="text-xs font-semibold text-white">{cat.name}</span>
<p className="text-xs text-white/40 mt-0.5">{cat.description}</p>
{categories.map(([key, cat]) => {
const catVendors = vendors.filter(v => v.category_key === key)
return (
<div key={key} className="rounded-xl bg-white/[0.03] border border-white/[0.06]">
<label className="flex items-center justify-between p-3 cursor-pointer hover:bg-white/[0.05] transition-colors">
<div className="flex-1 mr-4">
<span className="text-xs font-semibold text-white">{cat.name}</span>
<p className="text-xs text-white/40 mt-0.5">{cat.description}</p>
</div>
<input
type="checkbox"
checked={cat.required || consent[key as keyof ConsentState]}
disabled={cat.required}
onChange={(e) => {
setConsent(prev => ({ ...prev, [key]: e.target.checked }))
for (const v of catVendors) {
setVendorConsents(prev => ({ ...prev, [v.vendor_name]: e.target.checked }))
}
}}
className="w-4 h-4 rounded accent-accent-electric"
/>
</label>
{catVendors.length > 0 && consent[key as keyof ConsentState] && (
<div className="px-3 pb-3 space-y-1">
{catVendors.map(v => (
<label key={v.vendor_name} className="flex items-center justify-between pl-4 py-1 text-xs cursor-pointer">
<div className="flex-1 mr-2">
<span className="text-white/60">{v.vendor_name}</span>
{v.retention_days && <span className="text-white/30 ml-1">({v.retention_days}d)</span>}
</div>
<input
type="checkbox"
checked={vendorConsents[v.vendor_name] ?? true}
onChange={(e) => setVendorConsents(prev => ({ ...prev, [v.vendor_name]: e.target.checked }))}
className="w-3 h-3 rounded accent-accent-electric"
/>
</label>
))}
</div>
)}
</div>
<input
type="checkbox"
checked={cat.required || consent[key as keyof ConsentState]}
disabled={cat.required}
onChange={(e) => setConsent(prev => ({ ...prev, [key]: e.target.checked }))}
className="w-4 h-4 rounded accent-accent-electric"
/>
</label>
))}
)
})}
</div>
</motion.div>
)}
@@ -0,0 +1,84 @@
'use client'
import { useEffect, useRef } from 'react'
/**
* ScriptManager — consent-aware script injection.
*
* Listens to the `consent-change` CustomEvent dispatched by ConsentBanner
* and injects/skips third-party scripts based on accepted categories.
*
* Add scripts to CONSENT_SCRIPTS when the site starts using analytics,
* marketing, or functional third-party tools.
*/
interface ConsentScript {
src: string
async?: boolean
}
const CONSENT_SCRIPTS: Record<string, ConsentScript[]> = {
analytics: [
// Example: { src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXX', async: true },
],
marketing: [
// Example: { src: 'https://connect.facebook.net/en_US/fbevents.js', async: true },
],
functional: [
// Example: { src: 'https://widget.example.com/chat.js', async: true },
],
}
interface ConsentState {
essential: boolean
functional: boolean
analytics: boolean
}
function getStoredConsent(): ConsentState | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem('bp_consent')
return raw ? JSON.parse(raw) : null
} catch { return null }
}
export default function ScriptManager() {
const injected = useRef(new Set<string>())
function injectScripts(consent: ConsentState) {
const categories: (keyof typeof CONSENT_SCRIPTS)[] = []
if (consent.analytics) categories.push('analytics')
if (consent.functional) categories.push('functional')
// marketing would need its own toggle in ConsentState
for (const cat of categories) {
for (const script of CONSENT_SCRIPTS[cat] ?? []) {
if (injected.current.has(script.src)) continue
const el = document.createElement('script')
el.src = script.src
if (script.async) el.async = true
el.dataset.consent = cat
document.head.appendChild(el)
injected.current.add(script.src)
}
}
}
useEffect(() => {
// On mount: apply saved consent (return visitors)
const saved = getStoredConsent()
if (saved) injectScripts(saved)
// Listen for new consent decisions
function onConsentChange(e: Event) {
const detail = (e as CustomEvent<ConsentState>).detail
if (detail) injectScripts(detail)
}
window.addEventListener('consent-change', onConsentChange)
return () => window.removeEventListener('consent-change', onConsentChange)
}, [])
return null // Invisible component
}
Binary file not shown.