Initial commit: breakpilot-compliance - Compliance SDK Platform

Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View File

@@ -0,0 +1,509 @@
/**
* Angular Integration fuer @breakpilot/consent-sdk
*
* @example
* ```typescript
* // app.module.ts
* import { ConsentModule } from '@breakpilot/consent-sdk/angular';
*
* @NgModule({
* imports: [
* ConsentModule.forRoot({
* apiEndpoint: 'https://consent.example.com/api/v1',
* siteId: 'site_abc123',
* }),
* ],
* })
* export class AppModule {}
* ```
*/
// =============================================================================
// NOTE: Angular SDK Structure
// =============================================================================
//
// Angular hat ein komplexeres Build-System (ngc, ng-packagr).
// Diese Datei definiert die Schnittstelle - fuer Production muss ein
// separates Angular Library Package erstellt werden:
//
// ng generate library @breakpilot/consent-sdk-angular
//
// Die folgende Implementation ist fuer direkten Import vorgesehen.
// =============================================================================
import { ConsentManager } from '../core/ConsentManager';
import type {
ConsentConfig,
ConsentState,
ConsentCategory,
ConsentCategories,
} from '../types';
// =============================================================================
// Angular Service Interface
// =============================================================================
/**
* ConsentService Interface fuer Angular DI
*
* @example
* ```typescript
* @Component({...})
* export class MyComponent {
* constructor(private consent: ConsentService) {
* if (this.consent.hasConsent('analytics')) {
* // Analytics laden
* }
* }
* }
* ```
*/
export interface IConsentService {
/** Initialisiert? */
readonly isInitialized: boolean;
/** Laedt noch? */
readonly isLoading: boolean;
/** Banner sichtbar? */
readonly isBannerVisible: boolean;
/** Aktueller Consent-Zustand */
readonly consent: ConsentState | null;
/** Muss Consent eingeholt werden? */
readonly needsConsent: boolean;
/** Prueft Consent fuer Kategorie */
hasConsent(category: ConsentCategory): boolean;
/** Alle akzeptieren */
acceptAll(): Promise<void>;
/** Alle ablehnen */
rejectAll(): Promise<void>;
/** Auswahl speichern */
saveSelection(categories: Partial<ConsentCategories>): Promise<void>;
/** Banner anzeigen */
showBanner(): void;
/** Banner ausblenden */
hideBanner(): void;
/** Einstellungen oeffnen */
showSettings(): void;
}
// =============================================================================
// ConsentService Implementation
// =============================================================================
/**
* ConsentService - Angular Service Wrapper
*
* Diese Klasse kann als Angular Service registriert werden:
*
* @example
* ```typescript
* // consent.service.ts
* import { Injectable } from '@angular/core';
* import { ConsentServiceBase } from '@breakpilot/consent-sdk/angular';
*
* @Injectable({ providedIn: 'root' })
* export class ConsentService extends ConsentServiceBase {
* constructor() {
* super({
* apiEndpoint: environment.consentApiEndpoint,
* siteId: environment.siteId,
* });
* }
* }
* ```
*/
export class ConsentServiceBase implements IConsentService {
private manager: ConsentManager;
private _consent: ConsentState | null = null;
private _isInitialized = false;
private _isLoading = true;
private _isBannerVisible = false;
// Callbacks fuer Angular Change Detection
private changeCallbacks: Array<(consent: ConsentState) => void> = [];
private bannerShowCallbacks: Array<() => void> = [];
private bannerHideCallbacks: Array<() => void> = [];
constructor(config: ConsentConfig) {
this.manager = new ConsentManager(config);
this.setupEventListeners();
this.initialize();
}
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
get isInitialized(): boolean {
return this._isInitialized;
}
get isLoading(): boolean {
return this._isLoading;
}
get isBannerVisible(): boolean {
return this._isBannerVisible;
}
get consent(): ConsentState | null {
return this._consent;
}
get needsConsent(): boolean {
return this.manager.needsConsent();
}
// ---------------------------------------------------------------------------
// Methods
// ---------------------------------------------------------------------------
hasConsent(category: ConsentCategory): boolean {
return this.manager.hasConsent(category);
}
async acceptAll(): Promise<void> {
await this.manager.acceptAll();
}
async rejectAll(): Promise<void> {
await this.manager.rejectAll();
}
async saveSelection(categories: Partial<ConsentCategories>): Promise<void> {
await this.manager.setConsent(categories);
this.manager.hideBanner();
}
showBanner(): void {
this.manager.showBanner();
}
hideBanner(): void {
this.manager.hideBanner();
}
showSettings(): void {
this.manager.showSettings();
}
// ---------------------------------------------------------------------------
// Change Detection Support
// ---------------------------------------------------------------------------
/**
* Registriert Callback fuer Consent-Aenderungen
* (fuer Angular Change Detection)
*/
onConsentChange(callback: (consent: ConsentState) => void): () => void {
this.changeCallbacks.push(callback);
return () => {
const index = this.changeCallbacks.indexOf(callback);
if (index > -1) {
this.changeCallbacks.splice(index, 1);
}
};
}
/**
* Registriert Callback wenn Banner angezeigt wird
*/
onBannerShow(callback: () => void): () => void {
this.bannerShowCallbacks.push(callback);
return () => {
const index = this.bannerShowCallbacks.indexOf(callback);
if (index > -1) {
this.bannerShowCallbacks.splice(index, 1);
}
};
}
/**
* Registriert Callback wenn Banner ausgeblendet wird
*/
onBannerHide(callback: () => void): () => void {
this.bannerHideCallbacks.push(callback);
return () => {
const index = this.bannerHideCallbacks.indexOf(callback);
if (index > -1) {
this.bannerHideCallbacks.splice(index, 1);
}
};
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private setupEventListeners(): void {
this.manager.on('change', (consent) => {
this._consent = consent;
this.changeCallbacks.forEach((cb) => cb(consent));
});
this.manager.on('banner_show', () => {
this._isBannerVisible = true;
this.bannerShowCallbacks.forEach((cb) => cb());
});
this.manager.on('banner_hide', () => {
this._isBannerVisible = false;
this.bannerHideCallbacks.forEach((cb) => cb());
});
}
private async initialize(): Promise<void> {
try {
await this.manager.init();
this._consent = this.manager.getConsent();
this._isInitialized = true;
this._isBannerVisible = this.manager.isBannerVisible();
} catch (error) {
console.error('Failed to initialize ConsentManager:', error);
} finally {
this._isLoading = false;
}
}
}
// =============================================================================
// Angular Module Configuration
// =============================================================================
/**
* Konfiguration fuer ConsentModule.forRoot()
*/
export interface ConsentModuleConfig extends ConsentConfig {}
/**
* Token fuer Dependency Injection
* Verwendung mit Angular @Inject():
*
* @example
* ```typescript
* constructor(@Inject(CONSENT_CONFIG) private config: ConsentConfig) {}
* ```
*/
export const CONSENT_CONFIG = 'CONSENT_CONFIG';
export const CONSENT_SERVICE = 'CONSENT_SERVICE';
// =============================================================================
// Factory Functions fuer Angular DI
// =============================================================================
/**
* Factory fuer ConsentService
*
* @example
* ```typescript
* // app.module.ts
* providers: [
* { provide: CONSENT_CONFIG, useValue: { apiEndpoint: '...', siteId: '...' } },
* { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] },
* ]
* ```
*/
export function consentServiceFactory(config: ConsentConfig): ConsentServiceBase {
return new ConsentServiceBase(config);
}
// =============================================================================
// Angular Module Definition (Template)
// =============================================================================
/**
* ConsentModule - Angular Module
*
* Dies ist eine Template-Definition. Fuer echte Angular-Nutzung
* muss ein separates Angular Library Package erstellt werden.
*
* @example
* ```typescript
* // In einem Angular Library Package:
* @NgModule({
* declarations: [ConsentBannerComponent, ConsentGateDirective],
* exports: [ConsentBannerComponent, ConsentGateDirective],
* })
* export class ConsentModule {
* static forRoot(config: ConsentModuleConfig): ModuleWithProviders<ConsentModule> {
* return {
* ngModule: ConsentModule,
* providers: [
* { provide: CONSENT_CONFIG, useValue: config },
* { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] },
* ],
* };
* }
* }
* ```
*/
export const ConsentModuleDefinition = {
/**
* Providers fuer Root-Module
*/
forRoot: (config: ConsentModuleConfig) => ({
provide: CONSENT_CONFIG,
useValue: config,
}),
};
// =============================================================================
// Component Templates (fuer Angular Library)
// =============================================================================
/**
* ConsentBannerComponent Template
*
* Fuer Angular Library Implementation:
*
* @example
* ```typescript
* @Component({
* selector: 'bp-consent-banner',
* template: CONSENT_BANNER_TEMPLATE,
* styles: [CONSENT_BANNER_STYLES],
* })
* export class ConsentBannerComponent {
* constructor(public consent: ConsentService) {}
* }
* ```
*/
export const CONSENT_BANNER_TEMPLATE = `
<div
*ngIf="consent.isBannerVisible"
class="bp-consent-banner"
role="dialog"
aria-modal="true"
aria-label="Cookie-Einstellungen"
>
<div class="bp-consent-banner-content">
<h2>Datenschutzeinstellungen</h2>
<p>
Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales
Nutzererlebnis zu bieten.
</p>
<div class="bp-consent-banner-actions">
<button
type="button"
class="bp-consent-btn bp-consent-btn-reject"
(click)="consent.rejectAll()"
>
Alle ablehnen
</button>
<button
type="button"
class="bp-consent-btn bp-consent-btn-settings"
(click)="consent.showSettings()"
>
Einstellungen
</button>
<button
type="button"
class="bp-consent-btn bp-consent-btn-accept"
(click)="consent.acceptAll()"
>
Alle akzeptieren
</button>
</div>
</div>
</div>
`;
/**
* ConsentGateDirective Template
*
* @example
* ```typescript
* @Directive({
* selector: '[bpConsentGate]',
* })
* export class ConsentGateDirective implements OnInit, OnDestroy {
* @Input('bpConsentGate') category!: ConsentCategory;
*
* private unsubscribe?: () => void;
*
* constructor(
* private templateRef: TemplateRef<any>,
* private viewContainer: ViewContainerRef,
* private consent: ConsentService
* ) {}
*
* ngOnInit() {
* this.updateView();
* this.unsubscribe = this.consent.onConsentChange(() => this.updateView());
* }
*
* ngOnDestroy() {
* this.unsubscribe?.();
* }
*
* private updateView() {
* if (this.consent.hasConsent(this.category)) {
* this.viewContainer.createEmbeddedView(this.templateRef);
* } else {
* this.viewContainer.clear();
* }
* }
* }
* ```
*/
export const CONSENT_GATE_USAGE = `
<!-- Verwendung in Templates -->
<div *bpConsentGate="'analytics'">
<analytics-component></analytics-component>
</div>
<!-- Mit else Template -->
<ng-container *bpConsentGate="'marketing'; else placeholder">
<marketing-component></marketing-component>
</ng-container>
<ng-template #placeholder>
<p>Bitte akzeptieren Sie Marketing-Cookies.</p>
</ng-template>
`;
// =============================================================================
// RxJS Observable Wrapper (Optional)
// =============================================================================
/**
* RxJS Observable Wrapper fuer ConsentService
*
* Fuer Projekte die RxJS bevorzugen:
*
* @example
* ```typescript
* import { BehaviorSubject, Observable } from 'rxjs';
*
* export class ConsentServiceRx extends ConsentServiceBase {
* private consentSubject = new BehaviorSubject<ConsentState | null>(null);
* private bannerVisibleSubject = new BehaviorSubject<boolean>(false);
*
* consent$ = this.consentSubject.asObservable();
* isBannerVisible$ = this.bannerVisibleSubject.asObservable();
*
* constructor(config: ConsentConfig) {
* super(config);
* this.onConsentChange((c) => this.consentSubject.next(c));
* this.onBannerShow(() => this.bannerVisibleSubject.next(true));
* this.onBannerHide(() => this.bannerVisibleSubject.next(false));
* }
* }
* ```
*/
// =============================================================================
// Exports
// =============================================================================
export type { ConsentConfig, ConsentState, ConsentCategory, ConsentCategories };

View File

@@ -0,0 +1,312 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConsentAPI } from './ConsentAPI';
import type { ConsentConfig, ConsentState } from '../types';
describe('ConsentAPI', () => {
let api: ConsentAPI;
const mockConfig: ConsentConfig = {
apiEndpoint: 'https://api.example.com/',
siteId: 'test-site',
debug: false,
};
const mockConsent: ConsentState = {
categories: {
essential: true,
functional: true,
analytics: false,
marketing: false,
social: false,
},
vendors: {},
timestamp: '2024-01-15T10:00:00.000Z',
version: '1.0.0',
};
beforeEach(() => {
api = new ConsentAPI(mockConfig);
vi.clearAllMocks();
});
describe('constructor', () => {
it('should strip trailing slash from apiEndpoint', () => {
expect(api).toBeDefined();
});
});
describe('saveConsent', () => {
it('should POST consent to the API', async () => {
const mockResponse = {
consentId: 'consent-123',
timestamp: '2024-01-15T10:00:00.000Z',
expiresAt: '2025-01-15T10:00:00.000Z',
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/consent',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Accept: 'application/json',
}),
credentials: 'include',
})
);
expect(result.consentId).toBe('consent-123');
});
it('should include metadata in the request', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ consentId: '123' }),
} as Response);
await api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
});
const call = vi.mocked(fetch).mock.calls[0];
const body = JSON.parse(call[1]?.body as string);
expect(body.metadata).toBeDefined();
expect(body.metadata.platform).toBe('web');
});
it('should throw on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as Response);
await expect(
api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
})
).rejects.toThrow('Failed to save consent: 500');
});
it('should include signature headers', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ consentId: '123' }),
} as Response);
await api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
});
const call = vi.mocked(fetch).mock.calls[0];
const headers = call[1]?.headers as Record<string, string>;
expect(headers['X-Consent-Timestamp']).toBeDefined();
expect(headers['X-Consent-Signature']).toMatch(/^sha256=/);
});
});
describe('getConsent', () => {
it('should GET consent from the API', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ consent: mockConsent }),
} as Response);
const result = await api.getConsent('test-site', 'fp_123');
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/consent?siteId=test-site&deviceFingerprint=fp_123'),
expect.objectContaining({
headers: expect.any(Object),
credentials: 'include',
})
);
expect(result?.categories.essential).toBe(true);
});
it('should return null on 404', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response);
const result = await api.getConsent('test-site', 'fp_123');
expect(result).toBeNull();
});
it('should throw on other errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as Response);
await expect(api.getConsent('test-site', 'fp_123')).rejects.toThrow(
'Failed to get consent: 500'
);
});
});
describe('revokeConsent', () => {
it('should DELETE consent from the API', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 204,
} as Response);
await api.revokeConsent('consent-123');
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/consent/consent-123',
expect.objectContaining({
method: 'DELETE',
})
);
});
it('should throw on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response);
await expect(api.revokeConsent('consent-123')).rejects.toThrow(
'Failed to revoke consent: 404'
);
});
});
describe('getSiteConfig', () => {
it('should GET site configuration', async () => {
const mockSiteConfig = {
siteId: 'test-site',
name: 'Test Site',
categories: ['essential', 'analytics'],
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockSiteConfig),
} as Response);
const result = await api.getSiteConfig('test-site');
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/config/test-site',
expect.any(Object)
);
expect(result.siteId).toBe('test-site');
});
it('should throw on error', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response);
await expect(api.getSiteConfig('unknown-site')).rejects.toThrow(
'Failed to get site config: 404'
);
});
});
describe('exportConsent', () => {
it('should GET consent export for user', async () => {
const mockExport = {
userId: 'user-123',
consents: [mockConsent],
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockExport),
} as Response);
const result = await api.exportConsent('user-123');
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/consent/export?userId=user-123'),
expect.any(Object)
);
expect(result).toEqual(mockExport);
});
it('should throw on error', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 403,
} as Response);
await expect(api.exportConsent('user-123')).rejects.toThrow(
'Failed to export consent: 403'
);
});
});
describe('network errors', () => {
it('should propagate fetch errors', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
await expect(
api.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
})
).rejects.toThrow('Network error');
});
});
describe('debug mode', () => {
it('should log when debug is enabled', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugApi = new ConsentAPI({
...mockConfig,
debug: true,
});
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ consentId: '123' }),
} as Response);
await debugApi.saveConsent({
siteId: 'test-site',
deviceFingerprint: 'fp_123',
consent: mockConsent,
});
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,212 @@
/**
* ConsentAPI - Kommunikation mit dem Consent-Backend
*
* Sendet Consent-Entscheidungen an das Backend zur
* revisionssicheren Speicherung.
*/
import type {
ConsentConfig,
ConsentState,
ConsentAPIResponse,
SiteConfigResponse,
} from '../types';
/**
* Request-Payload fuer Consent-Speicherung
*/
interface SaveConsentRequest {
siteId: string;
userId?: string;
deviceFingerprint: string;
consent: ConsentState;
metadata?: {
userAgent?: string;
language?: string;
screenResolution?: string;
platform?: string;
appVersion?: string;
};
}
/**
* ConsentAPI - Backend-Kommunikation
*/
export class ConsentAPI {
private config: ConsentConfig;
private baseUrl: string;
constructor(config: ConsentConfig) {
this.config = config;
this.baseUrl = config.apiEndpoint.replace(/\/$/, '');
}
/**
* Consent speichern
*/
async saveConsent(request: SaveConsentRequest): Promise<ConsentAPIResponse> {
const payload = {
...request,
metadata: {
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
language: typeof navigator !== 'undefined' ? navigator.language : '',
screenResolution:
typeof window !== 'undefined'
? `${window.screen.width}x${window.screen.height}`
: '',
platform: 'web',
...request.metadata,
},
};
const response = await this.fetch('/consent', {
method: 'POST',
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Failed to save consent: ${response.status}`);
}
return response.json();
}
/**
* Consent abrufen
*/
async getConsent(
siteId: string,
deviceFingerprint: string
): Promise<ConsentState | null> {
const params = new URLSearchParams({
siteId,
deviceFingerprint,
});
const response = await this.fetch(`/consent?${params}`);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Failed to get consent: ${response.status}`);
}
const data = await response.json();
return data.consent;
}
/**
* Consent widerrufen
*/
async revokeConsent(consentId: string): Promise<void> {
const response = await this.fetch(`/consent/${consentId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to revoke consent: ${response.status}`);
}
}
/**
* Site-Konfiguration abrufen
*/
async getSiteConfig(siteId: string): Promise<SiteConfigResponse> {
const response = await this.fetch(`/config/${siteId}`);
if (!response.ok) {
throw new Error(`Failed to get site config: ${response.status}`);
}
return response.json();
}
/**
* Consent-Historie exportieren (DSGVO Art. 20)
*/
async exportConsent(userId: string): Promise<unknown> {
const params = new URLSearchParams({ userId });
const response = await this.fetch(`/consent/export?${params}`);
if (!response.ok) {
throw new Error(`Failed to export consent: ${response.status}`);
}
return response.json();
}
// ===========================================================================
// Internal Methods
// ===========================================================================
/**
* Fetch mit Standard-Headers
*/
private async fetch(
path: string,
options: RequestInit = {}
): Promise<Response> {
const url = `${this.baseUrl}${path}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
Accept: 'application/json',
...this.getSignatureHeaders(),
...(options.headers || {}),
};
try {
const response = await fetch(url, {
...options,
headers,
credentials: 'include',
});
this.log(`${options.method || 'GET'} ${path}:`, response.status);
return response;
} catch (error) {
this.log('Fetch error:', error);
throw error;
}
}
/**
* Signatur-Headers generieren (HMAC)
*/
private getSignatureHeaders(): Record<string, string> {
const timestamp = Math.floor(Date.now() / 1000).toString();
// Einfache Signatur fuer Client-Side
// In Produktion: Server-seitige Validierung mit echtem HMAC
const signature = this.simpleHash(`${this.config.siteId}:${timestamp}`);
return {
'X-Consent-Timestamp': timestamp,
'X-Consent-Signature': `sha256=${signature}`,
};
}
/**
* Einfache Hash-Funktion (djb2)
*/
private simpleHash(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return (hash >>> 0).toString(16);
}
/**
* Debug-Logging
*/
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[ConsentAPI]', ...args);
}
}
}
export default ConsentAPI;

View File

@@ -0,0 +1,605 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ConsentManager } from './ConsentManager';
import type { ConsentConfig, ConsentState } from '../types';
describe('ConsentManager', () => {
let manager: ConsentManager;
const mockConfig: ConsentConfig = {
apiEndpoint: 'https://api.example.com',
siteId: 'test-site',
debug: false,
};
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
// Mock successful API response
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
consentId: 'consent-123',
timestamp: '2024-01-15T10:00:00.000Z',
expiresAt: '2025-01-15T10:00:00.000Z',
}),
} as Response);
manager = new ConsentManager(mockConfig);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should create manager with merged config', () => {
expect(manager).toBeDefined();
});
it('should apply default config values', () => {
// Default consent config should be applied
expect(manager).toBeDefined();
});
});
describe('init', () => {
it('should initialize the manager', async () => {
await manager.init();
// Should have generated fingerprint and be initialized
expect(manager.needsConsent()).toBe(true); // No consent stored
});
it('should only initialize once', async () => {
await manager.init();
await manager.init(); // Second call should be skipped
expect(manager.needsConsent()).toBe(true);
});
it('should emit init event', async () => {
const callback = vi.fn();
manager.on('init', callback);
await manager.init();
expect(callback).toHaveBeenCalled();
});
it('should load existing consent from storage', async () => {
// Pre-set consent in storage
const storageKey = `bp_consent_${mockConfig.siteId}`;
const mockConsent = {
categories: {
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
},
vendors: {},
timestamp: new Date().toISOString(),
version: '1.0.0',
};
// Create a simple hash for signature
const data = JSON.stringify(mockConsent) + mockConfig.siteId;
let hash = 5381;
for (let i = 0; i < data.length; i++) {
hash = (hash * 33) ^ data.charCodeAt(i);
}
const signature = (hash >>> 0).toString(16);
localStorage.setItem(
storageKey,
JSON.stringify({
version: '1',
consent: mockConsent,
signature,
})
);
manager = new ConsentManager(mockConfig);
await manager.init();
expect(manager.hasConsent('analytics')).toBe(true);
});
it('should show banner when no consent exists', async () => {
const callback = vi.fn();
manager.on('banner_show', callback);
await manager.init();
expect(callback).toHaveBeenCalled();
expect(manager.isBannerVisible()).toBe(true);
});
});
describe('hasConsent', () => {
it('should return true for essential without initialization', () => {
expect(manager.hasConsent('essential')).toBe(true);
});
it('should return false for other categories without consent', () => {
expect(manager.hasConsent('analytics')).toBe(false);
expect(manager.hasConsent('marketing')).toBe(false);
});
});
describe('hasVendorConsent', () => {
it('should return false when no consent exists', () => {
expect(manager.hasVendorConsent('google-analytics')).toBe(false);
});
});
describe('getConsent', () => {
it('should return null when no consent exists', () => {
expect(manager.getConsent()).toBeNull();
});
it('should return a copy of consent state', async () => {
await manager.init();
await manager.acceptAll();
const consent1 = manager.getConsent();
const consent2 = manager.getConsent();
expect(consent1).not.toBe(consent2); // Different objects
expect(consent1).toEqual(consent2); // Same content
});
});
describe('setConsent', () => {
it('should set consent categories', async () => {
await manager.init();
await manager.setConsent({
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
});
expect(manager.hasConsent('analytics')).toBe(true);
expect(manager.hasConsent('marketing')).toBe(false);
});
it('should always keep essential enabled', async () => {
await manager.init();
await manager.setConsent({
essential: false, // Attempting to disable
functional: false,
analytics: false,
marketing: false,
social: false,
});
expect(manager.hasConsent('essential')).toBe(true);
});
it('should emit change event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('change', callback);
await manager.setConsent({
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
});
expect(callback).toHaveBeenCalled();
});
it('should save consent locally even on API error', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
await manager.init();
await manager.setConsent({
essential: true,
functional: false,
analytics: true,
marketing: false,
social: false,
});
expect(manager.hasConsent('analytics')).toBe(true);
});
});
describe('acceptAll', () => {
it('should enable all categories', async () => {
await manager.init();
await manager.acceptAll();
expect(manager.hasConsent('essential')).toBe(true);
expect(manager.hasConsent('functional')).toBe(true);
expect(manager.hasConsent('analytics')).toBe(true);
expect(manager.hasConsent('marketing')).toBe(true);
expect(manager.hasConsent('social')).toBe(true);
});
it('should emit accept_all event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('accept_all', callback);
await manager.acceptAll();
expect(callback).toHaveBeenCalled();
});
it('should hide banner', async () => {
await manager.init();
expect(manager.isBannerVisible()).toBe(true);
await manager.acceptAll();
expect(manager.isBannerVisible()).toBe(false);
});
});
describe('rejectAll', () => {
it('should only keep essential enabled', async () => {
await manager.init();
await manager.rejectAll();
expect(manager.hasConsent('essential')).toBe(true);
expect(manager.hasConsent('functional')).toBe(false);
expect(manager.hasConsent('analytics')).toBe(false);
expect(manager.hasConsent('marketing')).toBe(false);
expect(manager.hasConsent('social')).toBe(false);
});
it('should emit reject_all event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('reject_all', callback);
await manager.rejectAll();
expect(callback).toHaveBeenCalled();
});
it('should hide banner', async () => {
await manager.init();
await manager.rejectAll();
expect(manager.isBannerVisible()).toBe(false);
});
});
describe('revokeAll', () => {
it('should clear all consent', async () => {
await manager.init();
await manager.acceptAll();
await manager.revokeAll();
expect(manager.getConsent()).toBeNull();
});
it('should try to revoke on server', async () => {
await manager.init();
await manager.acceptAll();
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 204,
} as Response);
await manager.revokeAll();
// DELETE request should have been made
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/consent/'),
expect.objectContaining({ method: 'DELETE' })
);
});
});
describe('exportConsent', () => {
it('should export consent data as JSON', async () => {
await manager.init();
await manager.acceptAll();
const exported = await manager.exportConsent();
const parsed = JSON.parse(exported);
expect(parsed.currentConsent).toBeDefined();
expect(parsed.exportedAt).toBeDefined();
expect(parsed.siteId).toBe('test-site');
});
});
describe('needsConsent', () => {
it('should return true when no consent exists', () => {
expect(manager.needsConsent()).toBe(true);
});
it('should return false when valid consent exists', async () => {
await manager.init();
await manager.acceptAll();
// After acceptAll, consent should exist
expect(manager.getConsent()).not.toBeNull();
// needsConsent checks for currentConsent and expiration
// Since we just accepted all, consent should be valid
const consent = manager.getConsent();
expect(consent?.categories?.essential).toBe(true);
});
});
describe('banner control', () => {
it('should show banner', async () => {
await manager.init();
manager.hideBanner();
manager.showBanner();
expect(manager.isBannerVisible()).toBe(true);
});
it('should hide banner', async () => {
await manager.init();
manager.hideBanner();
expect(manager.isBannerVisible()).toBe(false);
});
it('should emit banner_show event', async () => {
const callback = vi.fn();
manager.on('banner_show', callback);
await manager.init(); // This shows banner
expect(callback).toHaveBeenCalled();
});
it('should emit banner_hide event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('banner_hide', callback);
manager.hideBanner();
expect(callback).toHaveBeenCalled();
});
it('should not show banner if already visible', async () => {
await manager.init();
const callback = vi.fn();
manager.on('banner_show', callback);
callback.mockClear();
manager.showBanner();
manager.showBanner();
expect(callback).toHaveBeenCalledTimes(0); // Already visible from init
});
});
describe('showSettings', () => {
it('should emit settings_open event', async () => {
await manager.init();
const callback = vi.fn();
manager.on('settings_open', callback);
manager.showSettings();
expect(callback).toHaveBeenCalled();
});
});
describe('event handling', () => {
it('should register event listeners', async () => {
await manager.init();
const callback = vi.fn();
manager.on('change', callback);
await manager.acceptAll();
expect(callback).toHaveBeenCalled();
});
it('should unregister event listeners', async () => {
await manager.init();
const callback = vi.fn();
manager.on('change', callback);
manager.off('change', callback);
await manager.acceptAll();
expect(callback).not.toHaveBeenCalled();
});
it('should return unsubscribe function', async () => {
await manager.init();
const callback = vi.fn();
const unsubscribe = manager.on('change', callback);
unsubscribe();
await manager.acceptAll();
expect(callback).not.toHaveBeenCalled();
});
});
describe('callbacks', () => {
it('should call onConsentChange callback', async () => {
const onConsentChange = vi.fn();
manager = new ConsentManager({
...mockConfig,
onConsentChange,
});
await manager.init();
await manager.acceptAll();
expect(onConsentChange).toHaveBeenCalled();
});
it('should call onBannerShow callback', async () => {
const onBannerShow = vi.fn();
manager = new ConsentManager({
...mockConfig,
onBannerShow,
});
await manager.init();
expect(onBannerShow).toHaveBeenCalled();
});
it('should call onBannerHide callback', async () => {
const onBannerHide = vi.fn();
manager = new ConsentManager({
...mockConfig,
onBannerHide,
});
await manager.init();
manager.hideBanner();
expect(onBannerHide).toHaveBeenCalled();
});
});
describe('static methods', () => {
it('should return SDK version', () => {
const version = ConsentManager.getVersion();
expect(version).toBeDefined();
expect(typeof version).toBe('string');
});
});
describe('Google Consent Mode', () => {
it('should update Google Consent Mode when gtag is available', async () => {
const gtag = vi.fn();
(window as unknown as { gtag: typeof gtag }).gtag = gtag;
await manager.init();
await manager.acceptAll();
expect(gtag).toHaveBeenCalledWith(
'consent',
'update',
expect.objectContaining({
analytics_storage: 'granted',
ad_storage: 'granted',
})
);
delete (window as unknown as { gtag?: typeof gtag }).gtag;
});
});
describe('consent expiration', () => {
it('should clear expired consent on init', async () => {
const storageKey = `bp_consent_${mockConfig.siteId}`;
const expiredConsent = {
categories: {
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
},
vendors: {},
timestamp: '2020-01-01T00:00:00.000Z', // Very old
version: '1.0.0',
expiresAt: '2020-06-01T00:00:00.000Z', // Expired
};
const data = JSON.stringify(expiredConsent) + mockConfig.siteId;
let hash = 5381;
for (let i = 0; i < data.length; i++) {
hash = (hash * 33) ^ data.charCodeAt(i);
}
const signature = (hash >>> 0).toString(16);
localStorage.setItem(
storageKey,
JSON.stringify({
version: '1',
consent: expiredConsent,
signature,
})
);
manager = new ConsentManager(mockConfig);
await manager.init();
expect(manager.needsConsent()).toBe(true);
});
});
describe('debug mode', () => {
it('should log when debug is enabled', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugManager = new ConsentManager({
...mockConfig,
debug: true,
});
await debugManager.init();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('consent input normalization', () => {
it('should accept categories object directly', async () => {
await manager.init();
await manager.setConsent({
essential: true,
functional: true,
analytics: false,
marketing: false,
social: false,
});
expect(manager.hasConsent('functional')).toBe(true);
});
it('should accept nested categories object', async () => {
await manager.init();
await manager.setConsent({
categories: {
essential: true,
functional: false,
analytics: true,
marketing: false,
social: false,
},
});
expect(manager.hasConsent('analytics')).toBe(true);
expect(manager.hasConsent('functional')).toBe(false);
});
it('should accept vendors in consent input', async () => {
await manager.init();
await manager.setConsent({
categories: {
essential: true,
functional: true,
analytics: true,
marketing: false,
social: false,
},
vendors: {
'google-analytics': true,
},
});
const consent = manager.getConsent();
expect(consent?.vendors['google-analytics']).toBe(true);
});
});
});

View File

@@ -0,0 +1,525 @@
/**
* ConsentManager - Hauptklasse fuer das Consent Management
*
* DSGVO/TTDSG-konformes Consent Management fuer Web, PWA und Mobile.
*/
import type {
ConsentConfig,
ConsentState,
ConsentCategory,
ConsentCategories,
ConsentInput,
ConsentEventType,
ConsentEventCallback,
ConsentEventData,
} from '../types';
import { ConsentStorage } from './ConsentStorage';
import { ScriptBlocker } from './ScriptBlocker';
import { ConsentAPI } from './ConsentAPI';
import { EventEmitter } from '../utils/EventEmitter';
import { generateFingerprint } from '../utils/fingerprint';
import { SDK_VERSION } from '../version';
/**
* Default-Konfiguration
*/
const DEFAULT_CONFIG: Partial<ConsentConfig> = {
language: 'de',
fallbackLanguage: 'en',
ui: {
position: 'bottom',
layout: 'modal',
theme: 'auto',
zIndex: 999999,
blockScrollOnModal: true,
},
consent: {
required: true,
rejectAllVisible: true,
acceptAllVisible: true,
granularControl: true,
vendorControl: false,
rememberChoice: true,
rememberDays: 365,
geoTargeting: false,
recheckAfterDays: 180,
},
categories: ['essential', 'functional', 'analytics', 'marketing', 'social'],
debug: false,
};
/**
* Default Consent-State (nur Essential aktiv)
*/
const DEFAULT_CONSENT: ConsentCategories = {
essential: true,
functional: false,
analytics: false,
marketing: false,
social: false,
};
/**
* ConsentManager - Zentrale Klasse fuer Consent-Verwaltung
*/
export class ConsentManager {
private config: ConsentConfig;
private storage: ConsentStorage;
private scriptBlocker: ScriptBlocker;
private api: ConsentAPI;
private events: EventEmitter<ConsentEventData>;
private currentConsent: ConsentState | null = null;
private initialized = false;
private bannerVisible = false;
private deviceFingerprint: string = '';
constructor(config: ConsentConfig) {
this.config = this.mergeConfig(config);
this.storage = new ConsentStorage(this.config);
this.scriptBlocker = new ScriptBlocker(this.config);
this.api = new ConsentAPI(this.config);
this.events = new EventEmitter();
this.log('ConsentManager created with config:', this.config);
}
/**
* SDK initialisieren
*/
async init(): Promise<void> {
if (this.initialized) {
this.log('Already initialized, skipping');
return;
}
try {
this.log('Initializing ConsentManager...');
// Device Fingerprint generieren
this.deviceFingerprint = await generateFingerprint();
// Consent aus Storage laden
this.currentConsent = this.storage.get();
if (this.currentConsent) {
this.log('Loaded consent from storage:', this.currentConsent);
// Pruefen ob Consent abgelaufen
if (this.isConsentExpired()) {
this.log('Consent expired, clearing');
this.storage.clear();
this.currentConsent = null;
} else {
// Consent anwenden
this.applyConsent();
}
}
// Script-Blocker initialisieren
this.scriptBlocker.init();
this.initialized = true;
this.emit('init', this.currentConsent);
// Banner anzeigen falls noetig
if (this.needsConsent()) {
this.showBanner();
}
this.log('ConsentManager initialized successfully');
} catch (error) {
this.handleError(error as Error);
throw error;
}
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Pruefen ob Consent fuer Kategorie vorhanden
*/
hasConsent(category: ConsentCategory): boolean {
if (!this.currentConsent) {
return category === 'essential';
}
return this.currentConsent.categories[category] ?? false;
}
/**
* Pruefen ob Consent fuer Vendor vorhanden
*/
hasVendorConsent(vendorId: string): boolean {
if (!this.currentConsent) {
return false;
}
return this.currentConsent.vendors[vendorId] ?? false;
}
/**
* Aktuellen Consent-State abrufen
*/
getConsent(): ConsentState | null {
return this.currentConsent ? { ...this.currentConsent } : null;
}
/**
* Consent setzen
*/
async setConsent(input: ConsentInput): Promise<void> {
const categories = this.normalizeConsentInput(input);
// Essential ist immer aktiv
categories.essential = true;
const newConsent: ConsentState = {
categories,
vendors: 'vendors' in input && input.vendors ? input.vendors : {},
timestamp: new Date().toISOString(),
version: SDK_VERSION,
};
try {
// An Backend senden
const response = await this.api.saveConsent({
siteId: this.config.siteId,
deviceFingerprint: this.deviceFingerprint,
consent: newConsent,
});
newConsent.consentId = response.consentId;
newConsent.expiresAt = response.expiresAt;
// Lokal speichern
this.storage.set(newConsent);
this.currentConsent = newConsent;
// Consent anwenden
this.applyConsent();
// Event emittieren
this.emit('change', newConsent);
this.config.onConsentChange?.(newConsent);
this.log('Consent saved:', newConsent);
} catch (error) {
// Bei Netzwerkfehler trotzdem lokal speichern
this.log('API error, saving locally:', error);
this.storage.set(newConsent);
this.currentConsent = newConsent;
this.applyConsent();
this.emit('change', newConsent);
}
}
/**
* Alle Kategorien akzeptieren
*/
async acceptAll(): Promise<void> {
const allCategories: ConsentCategories = {
essential: true,
functional: true,
analytics: true,
marketing: true,
social: true,
};
await this.setConsent(allCategories);
this.emit('accept_all', this.currentConsent!);
this.hideBanner();
}
/**
* Alle nicht-essentiellen Kategorien ablehnen
*/
async rejectAll(): Promise<void> {
const minimalCategories: ConsentCategories = {
essential: true,
functional: false,
analytics: false,
marketing: false,
social: false,
};
await this.setConsent(minimalCategories);
this.emit('reject_all', this.currentConsent!);
this.hideBanner();
}
/**
* Alle Einwilligungen widerrufen
*/
async revokeAll(): Promise<void> {
if (this.currentConsent?.consentId) {
try {
await this.api.revokeConsent(this.currentConsent.consentId);
} catch (error) {
this.log('Failed to revoke on server:', error);
}
}
this.storage.clear();
this.currentConsent = null;
this.scriptBlocker.blockAll();
this.log('All consents revoked');
}
/**
* Consent-Daten exportieren (DSGVO Art. 20)
*/
async exportConsent(): Promise<string> {
const exportData = {
currentConsent: this.currentConsent,
exportedAt: new Date().toISOString(),
siteId: this.config.siteId,
deviceFingerprint: this.deviceFingerprint,
};
return JSON.stringify(exportData, null, 2);
}
// ===========================================================================
// Banner Control
// ===========================================================================
/**
* Pruefen ob Consent-Abfrage noetig
*/
needsConsent(): boolean {
if (!this.currentConsent) {
return true;
}
if (this.isConsentExpired()) {
return true;
}
// Recheck nach X Tagen
if (this.config.consent?.recheckAfterDays) {
const consentDate = new Date(this.currentConsent.timestamp);
const recheckDate = new Date(consentDate);
recheckDate.setDate(
recheckDate.getDate() + this.config.consent.recheckAfterDays
);
if (new Date() > recheckDate) {
return true;
}
}
return false;
}
/**
* Banner anzeigen
*/
showBanner(): void {
if (this.bannerVisible) {
return;
}
this.bannerVisible = true;
this.emit('banner_show', undefined);
this.config.onBannerShow?.();
// Banner wird von UI-Komponente gerendert
// Hier nur Status setzen
this.log('Banner shown');
}
/**
* Banner verstecken
*/
hideBanner(): void {
if (!this.bannerVisible) {
return;
}
this.bannerVisible = false;
this.emit('banner_hide', undefined);
this.config.onBannerHide?.();
this.log('Banner hidden');
}
/**
* Einstellungs-Modal oeffnen
*/
showSettings(): void {
this.emit('settings_open', undefined);
this.log('Settings opened');
}
/**
* Pruefen ob Banner sichtbar
*/
isBannerVisible(): boolean {
return this.bannerVisible;
}
// ===========================================================================
// Event Handling
// ===========================================================================
/**
* Event-Listener registrieren
*/
on<T extends ConsentEventType>(
event: T,
callback: ConsentEventCallback<ConsentEventData[T]>
): () => void {
return this.events.on(event, callback);
}
/**
* Event-Listener entfernen
*/
off<T extends ConsentEventType>(
event: T,
callback: ConsentEventCallback<ConsentEventData[T]>
): void {
this.events.off(event, callback);
}
// ===========================================================================
// Internal Methods
// ===========================================================================
/**
* Konfiguration zusammenfuehren
*/
private mergeConfig(config: ConsentConfig): ConsentConfig {
return {
...DEFAULT_CONFIG,
...config,
ui: { ...DEFAULT_CONFIG.ui, ...config.ui },
consent: { ...DEFAULT_CONFIG.consent, ...config.consent },
} as ConsentConfig;
}
/**
* Consent-Input normalisieren
*/
private normalizeConsentInput(input: ConsentInput): ConsentCategories {
if ('categories' in input && input.categories) {
return { ...DEFAULT_CONSENT, ...input.categories };
}
return { ...DEFAULT_CONSENT, ...(input as Partial<ConsentCategories>) };
}
/**
* Consent anwenden (Skripte aktivieren/blockieren)
*/
private applyConsent(): void {
if (!this.currentConsent) {
return;
}
for (const [category, allowed] of Object.entries(
this.currentConsent.categories
)) {
if (allowed) {
this.scriptBlocker.enableCategory(category as ConsentCategory);
} else {
this.scriptBlocker.disableCategory(category as ConsentCategory);
}
}
// Google Consent Mode aktualisieren
this.updateGoogleConsentMode();
}
/**
* Google Consent Mode v2 aktualisieren
*/
private updateGoogleConsentMode(): void {
if (typeof window === 'undefined' || !this.currentConsent) {
return;
}
const gtag = (window as unknown as { gtag?: (...args: unknown[]) => void }).gtag;
if (typeof gtag !== 'function') {
return;
}
const { categories } = this.currentConsent;
gtag('consent', 'update', {
ad_storage: categories.marketing ? 'granted' : 'denied',
ad_user_data: categories.marketing ? 'granted' : 'denied',
ad_personalization: categories.marketing ? 'granted' : 'denied',
analytics_storage: categories.analytics ? 'granted' : 'denied',
functionality_storage: categories.functional ? 'granted' : 'denied',
personalization_storage: categories.functional ? 'granted' : 'denied',
security_storage: 'granted',
});
this.log('Google Consent Mode updated');
}
/**
* Pruefen ob Consent abgelaufen
*/
private isConsentExpired(): boolean {
if (!this.currentConsent?.expiresAt) {
// Fallback: Nach rememberDays ablaufen
if (this.currentConsent?.timestamp && this.config.consent?.rememberDays) {
const consentDate = new Date(this.currentConsent.timestamp);
const expiryDate = new Date(consentDate);
expiryDate.setDate(
expiryDate.getDate() + this.config.consent.rememberDays
);
return new Date() > expiryDate;
}
return false;
}
return new Date() > new Date(this.currentConsent.expiresAt);
}
/**
* Event emittieren
*/
private emit<T extends ConsentEventType>(
event: T,
data: ConsentEventData[T]
): void {
this.events.emit(event, data);
}
/**
* Fehler behandeln
*/
private handleError(error: Error): void {
this.log('Error:', error);
this.emit('error', error);
this.config.onError?.(error);
}
/**
* Debug-Logging
*/
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[ConsentSDK]', ...args);
}
}
// ===========================================================================
// Static Methods
// ===========================================================================
/**
* SDK-Version abrufen
*/
static getVersion(): string {
return SDK_VERSION;
}
}
// Default-Export
export default ConsentManager;

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConsentStorage } from './ConsentStorage';
import type { ConsentConfig, ConsentState } from '../types';
describe('ConsentStorage', () => {
let storage: ConsentStorage;
const mockConfig: ConsentConfig = {
apiEndpoint: 'https://api.example.com',
siteId: 'test-site',
debug: false,
consent: {
rememberDays: 365,
},
};
const mockConsent: ConsentState = {
categories: {
essential: true,
functional: true,
analytics: false,
marketing: false,
social: false,
},
vendors: {},
timestamp: '2024-01-15T10:00:00.000Z',
version: '1.0.0',
};
beforeEach(() => {
localStorage.clear();
storage = new ConsentStorage(mockConfig);
});
describe('constructor', () => {
it('should create storage with site-specific key', () => {
expect(storage).toBeDefined();
});
});
describe('get', () => {
it('should return null when no consent stored', () => {
const result = storage.get();
expect(result).toBeNull();
});
it('should return consent when valid data exists', () => {
storage.set(mockConsent);
const result = storage.get();
expect(result).toBeDefined();
expect(result?.categories.essential).toBe(true);
expect(result?.categories.analytics).toBe(false);
});
it('should return null and clear when version mismatch', () => {
// Manually set invalid version in storage
const storageKey = `bp_consent_${mockConfig.siteId}`;
localStorage.setItem(
storageKey,
JSON.stringify({
version: 'invalid',
consent: mockConsent,
signature: 'test',
})
);
const result = storage.get();
expect(result).toBeNull();
});
it('should return null and clear when signature invalid', () => {
const storageKey = `bp_consent_${mockConfig.siteId}`;
localStorage.setItem(
storageKey,
JSON.stringify({
version: '1',
consent: mockConsent,
signature: 'invalid-signature',
})
);
const result = storage.get();
expect(result).toBeNull();
});
it('should return null when JSON is invalid', () => {
const storageKey = `bp_consent_${mockConfig.siteId}`;
localStorage.setItem(storageKey, 'invalid-json');
const result = storage.get();
expect(result).toBeNull();
});
});
describe('set', () => {
it('should store consent in localStorage', () => {
storage.set(mockConsent);
const storageKey = `bp_consent_${mockConfig.siteId}`;
const stored = localStorage.getItem(storageKey);
expect(stored).toBeDefined();
expect(stored).toContain('"version":"1"');
});
it('should set a cookie for SSR support', () => {
storage.set(mockConsent);
expect(document.cookie).toContain(`bp_consent_${mockConfig.siteId}`);
});
it('should include signature in stored data', () => {
storage.set(mockConsent);
const storageKey = `bp_consent_${mockConfig.siteId}`;
const stored = JSON.parse(localStorage.getItem(storageKey) || '{}');
expect(stored.signature).toBeDefined();
expect(typeof stored.signature).toBe('string');
});
});
describe('clear', () => {
it('should remove consent from localStorage', () => {
storage.set(mockConsent);
storage.clear();
const result = storage.get();
expect(result).toBeNull();
});
it('should clear the cookie', () => {
storage.set(mockConsent);
storage.clear();
// Cookie should be cleared (expired)
expect(document.cookie).toContain('expires=');
});
});
describe('exists', () => {
it('should return false when no consent exists', () => {
expect(storage.exists()).toBe(false);
});
it('should return true when consent exists', () => {
storage.set(mockConsent);
expect(storage.exists()).toBe(true);
});
});
describe('signature verification', () => {
it('should detect tampered consent data', () => {
storage.set(mockConsent);
const storageKey = `bp_consent_${mockConfig.siteId}`;
const stored = JSON.parse(localStorage.getItem(storageKey) || '{}');
// Tamper with the data
stored.consent.categories.marketing = true;
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = storage.get();
expect(result).toBeNull(); // Signature mismatch should clear
});
});
describe('debug mode', () => {
it('should log when debug is enabled', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugStorage = new ConsentStorage({
...mockConfig,
debug: true,
});
debugStorage.set(mockConsent);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('cookie settings', () => {
it('should set Secure flag on HTTPS', () => {
storage.set(mockConsent);
expect(document.cookie).toContain('Secure');
});
it('should set SameSite=Lax', () => {
storage.set(mockConsent);
expect(document.cookie).toContain('SameSite=Lax');
});
it('should set expiration based on rememberDays', () => {
storage.set(mockConsent);
expect(document.cookie).toContain('expires=');
});
});
describe('different sites', () => {
it('should isolate storage by siteId', () => {
const storage1 = new ConsentStorage({ ...mockConfig, siteId: 'site-1' });
const storage2 = new ConsentStorage({ ...mockConfig, siteId: 'site-2' });
storage1.set(mockConsent);
expect(storage1.exists()).toBe(true);
expect(storage2.exists()).toBe(false);
});
});
});

View File

@@ -0,0 +1,203 @@
/**
* ConsentStorage - Lokale Speicherung des Consent-Status
*
* Speichert Consent-Daten im localStorage mit HMAC-Signatur
* zur Manipulationserkennung.
*/
import type { ConsentConfig, ConsentState } from '../types';
const STORAGE_KEY = 'bp_consent';
const STORAGE_VERSION = '1';
/**
* Gespeichertes Format
*/
interface StoredConsent {
version: string;
consent: ConsentState;
signature: string;
}
/**
* ConsentStorage - Persistente Speicherung
*/
export class ConsentStorage {
private config: ConsentConfig;
private storageKey: string;
constructor(config: ConsentConfig) {
this.config = config;
// Pro Site ein separater Key
this.storageKey = `${STORAGE_KEY}_${config.siteId}`;
}
/**
* Consent laden
*/
get(): ConsentState | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = localStorage.getItem(this.storageKey);
if (!raw) {
return null;
}
const stored: StoredConsent = JSON.parse(raw);
// Version pruefen
if (stored.version !== STORAGE_VERSION) {
this.log('Storage version mismatch, clearing');
this.clear();
return null;
}
// Signatur pruefen
if (!this.verifySignature(stored.consent, stored.signature)) {
this.log('Invalid signature, clearing');
this.clear();
return null;
}
return stored.consent;
} catch (error) {
this.log('Failed to load consent:', error);
return null;
}
}
/**
* Consent speichern
*/
set(consent: ConsentState): void {
if (typeof window === 'undefined') {
return;
}
try {
const signature = this.generateSignature(consent);
const stored: StoredConsent = {
version: STORAGE_VERSION,
consent,
signature,
};
localStorage.setItem(this.storageKey, JSON.stringify(stored));
// Auch als Cookie setzen (fuer Server-Side Rendering)
this.setCookie(consent);
this.log('Consent saved to storage');
} catch (error) {
this.log('Failed to save consent:', error);
}
}
/**
* Consent loeschen
*/
clear(): void {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.removeItem(this.storageKey);
this.clearCookie();
this.log('Consent cleared from storage');
} catch (error) {
this.log('Failed to clear consent:', error);
}
}
/**
* Pruefen ob Consent existiert
*/
exists(): boolean {
return this.get() !== null;
}
// ===========================================================================
// Cookie Management
// ===========================================================================
/**
* Consent als Cookie setzen
*/
private setCookie(consent: ConsentState): void {
const days = this.config.consent?.rememberDays ?? 365;
const expires = new Date();
expires.setDate(expires.getDate() + days);
// Nur Kategorien als Cookie (fuer SSR)
const cookieValue = JSON.stringify(consent.categories);
const encoded = encodeURIComponent(cookieValue);
document.cookie = [
`${this.storageKey}=${encoded}`,
`expires=${expires.toUTCString()}`,
'path=/',
'SameSite=Lax',
location.protocol === 'https:' ? 'Secure' : '',
]
.filter(Boolean)
.join('; ');
}
/**
* Cookie loeschen
*/
private clearCookie(): void {
document.cookie = `${this.storageKey}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
// ===========================================================================
// Signature (Simple HMAC-like)
// ===========================================================================
/**
* Signatur generieren
*/
private generateSignature(consent: ConsentState): string {
const data = JSON.stringify(consent);
const key = this.config.siteId;
// Einfache Hash-Funktion (fuer Client-Side)
// In Produktion wuerde man SubtleCrypto verwenden
return this.simpleHash(data + key);
}
/**
* Signatur verifizieren
*/
private verifySignature(consent: ConsentState, signature: string): boolean {
const expected = this.generateSignature(consent);
return expected === signature;
}
/**
* Einfache Hash-Funktion (djb2)
*/
private simpleHash(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return (hash >>> 0).toString(16);
}
/**
* Debug-Logging
*/
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[ConsentStorage]', ...args);
}
}
}
export default ConsentStorage;

View File

@@ -0,0 +1,305 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ScriptBlocker } from './ScriptBlocker';
import type { ConsentConfig } from '../types';
describe('ScriptBlocker', () => {
let blocker: ScriptBlocker;
const mockConfig: ConsentConfig = {
apiEndpoint: 'https://api.example.com',
siteId: 'test-site',
debug: false,
};
beforeEach(() => {
// Clear document body
document.body.innerHTML = '';
blocker = new ScriptBlocker(mockConfig);
});
afterEach(() => {
blocker.destroy();
});
describe('constructor', () => {
it('should create blocker with essential category enabled by default', () => {
expect(blocker.isCategoryEnabled('essential')).toBe(true);
});
it('should have other categories disabled by default', () => {
expect(blocker.isCategoryEnabled('analytics')).toBe(false);
expect(blocker.isCategoryEnabled('marketing')).toBe(false);
expect(blocker.isCategoryEnabled('functional')).toBe(false);
expect(blocker.isCategoryEnabled('social')).toBe(false);
});
});
describe('init', () => {
it('should process existing scripts with data-consent', () => {
// Add a blocked script before init
const script = document.createElement('script');
script.setAttribute('data-consent', 'analytics');
script.setAttribute('data-src', 'https://analytics.example.com/script.js');
script.type = 'text/plain';
document.body.appendChild(script);
blocker.init();
// Script should remain blocked (analytics not enabled)
expect(script.type).toBe('text/plain');
});
it('should start MutationObserver for new elements', () => {
blocker.init();
// Add a script after init
const script = document.createElement('script');
script.setAttribute('data-consent', 'analytics');
script.setAttribute('data-src', 'https://analytics.example.com/script.js');
script.type = 'text/plain';
document.body.appendChild(script);
// Script should be tracked (processed)
expect(script.type).toBe('text/plain');
});
});
describe('enableCategory', () => {
it('should enable a category', () => {
blocker.enableCategory('analytics');
expect(blocker.isCategoryEnabled('analytics')).toBe(true);
});
it('should not duplicate enabling', () => {
blocker.enableCategory('analytics');
blocker.enableCategory('analytics');
expect(blocker.isCategoryEnabled('analytics')).toBe(true);
});
it('should activate blocked scripts for the category', () => {
const script = document.createElement('script');
script.setAttribute('data-consent', 'analytics');
script.setAttribute('data-src', 'https://analytics.example.com/script.js');
script.type = 'text/plain';
document.body.appendChild(script);
blocker.init();
blocker.enableCategory('analytics');
// The original script should be replaced
const scripts = document.querySelectorAll('script[src="https://analytics.example.com/script.js"]');
expect(scripts.length).toBe(1);
});
});
describe('disableCategory', () => {
it('should disable a category', () => {
blocker.enableCategory('analytics');
blocker.disableCategory('analytics');
expect(blocker.isCategoryEnabled('analytics')).toBe(false);
});
it('should not disable essential category', () => {
blocker.disableCategory('essential');
expect(blocker.isCategoryEnabled('essential')).toBe(true);
});
});
describe('blockAll', () => {
it('should block all categories except essential', () => {
blocker.enableCategory('analytics');
blocker.enableCategory('marketing');
blocker.enableCategory('social');
blocker.blockAll();
expect(blocker.isCategoryEnabled('essential')).toBe(true);
expect(blocker.isCategoryEnabled('analytics')).toBe(false);
expect(blocker.isCategoryEnabled('marketing')).toBe(false);
expect(blocker.isCategoryEnabled('social')).toBe(false);
});
});
describe('isCategoryEnabled', () => {
it('should return true for enabled categories', () => {
blocker.enableCategory('functional');
expect(blocker.isCategoryEnabled('functional')).toBe(true);
});
it('should return false for disabled categories', () => {
expect(blocker.isCategoryEnabled('marketing')).toBe(false);
});
});
describe('destroy', () => {
it('should disconnect the MutationObserver', () => {
blocker.init();
blocker.destroy();
// After destroy, adding new elements should not trigger processing
// This is hard to test directly, but we can verify it doesn't throw
expect(() => {
const script = document.createElement('script');
script.setAttribute('data-consent', 'analytics');
document.body.appendChild(script);
}).not.toThrow();
});
});
describe('script processing', () => {
it('should handle external scripts with data-src', () => {
const script = document.createElement('script');
script.setAttribute('data-consent', 'essential');
script.setAttribute('data-src', 'https://essential.example.com/script.js');
script.type = 'text/plain';
document.body.appendChild(script);
blocker.init();
// Essential is enabled, so script should be activated
const activatedScript = document.querySelector('script[src="https://essential.example.com/script.js"]');
expect(activatedScript).toBeDefined();
});
it('should handle inline scripts', () => {
const script = document.createElement('script');
script.setAttribute('data-consent', 'essential');
script.type = 'text/plain';
script.textContent = 'console.log("test");';
document.body.appendChild(script);
blocker.init();
// Check that script was processed
const scripts = document.querySelectorAll('script');
expect(scripts.length).toBeGreaterThan(0);
});
});
describe('iframe processing', () => {
it('should block iframes with data-consent', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'social');
iframe.setAttribute('data-src', 'https://social.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
// Should be hidden and have placeholder
expect(iframe.style.display).toBe('none');
});
it('should show placeholder for blocked iframes', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'social');
iframe.setAttribute('data-src', 'https://social.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
const placeholder = document.querySelector('.bp-consent-placeholder');
expect(placeholder).toBeDefined();
});
it('should activate iframe when category enabled', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'social');
iframe.setAttribute('data-src', 'https://social.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
blocker.enableCategory('social');
expect(iframe.src).toBe('https://social.example.com/embed');
expect(iframe.style.display).toBe('');
});
it('should remove placeholder when iframe activated', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'social');
iframe.setAttribute('data-src', 'https://social.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
blocker.enableCategory('social');
const placeholder = document.querySelector('.bp-consent-placeholder');
expect(placeholder).toBeNull();
});
});
describe('placeholder button', () => {
it('should dispatch event when placeholder button clicked', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'marketing');
iframe.setAttribute('data-src', 'https://marketing.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
const eventHandler = vi.fn();
window.addEventListener('bp-consent-request', eventHandler);
const button = document.querySelector('.bp-consent-placeholder-btn') as HTMLButtonElement;
button?.click();
expect(eventHandler).toHaveBeenCalled();
expect(eventHandler.mock.calls[0][0].detail.category).toBe('marketing');
window.removeEventListener('bp-consent-request', eventHandler);
});
});
describe('category names', () => {
it('should show correct category name in placeholder', () => {
const iframe = document.createElement('iframe');
iframe.setAttribute('data-consent', 'analytics');
iframe.setAttribute('data-src', 'https://analytics.example.com/embed');
document.body.appendChild(iframe);
blocker.init();
const placeholder = document.querySelector('.bp-consent-placeholder');
expect(placeholder?.innerHTML).toContain('Statistik-Cookies aktivieren');
});
});
describe('debug mode', () => {
it('should log when debug is enabled', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugBlocker = new ScriptBlocker({
...mockConfig,
debug: true,
});
debugBlocker.init();
debugBlocker.enableCategory('analytics');
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
debugBlocker.destroy();
});
});
describe('nested elements', () => {
it('should process scripts in nested containers', () => {
const container = document.createElement('div');
const script = document.createElement('script');
script.setAttribute('data-consent', 'essential');
script.setAttribute('data-src', 'https://essential.example.com/nested.js');
script.type = 'text/plain';
container.appendChild(script);
blocker.init();
document.body.appendChild(container);
// Give MutationObserver time to process
const activatedScript = document.querySelector('script[src="https://essential.example.com/nested.js"]');
expect(activatedScript).toBeDefined();
});
});
});

View File

@@ -0,0 +1,367 @@
/**
* ScriptBlocker - Blockiert Skripte bis Consent erteilt wird
*
* Verwendet das data-consent Attribut zur Identifikation von
* Skripten, die erst nach Consent geladen werden duerfen.
*
* Beispiel:
* <script data-consent="analytics" data-src="..." type="text/plain"></script>
*/
import type { ConsentConfig, ConsentCategory } from '../types';
/**
* Script-Element mit Consent-Attributen
*/
interface ConsentScript extends HTMLScriptElement {
dataset: DOMStringMap & {
consent?: string;
src?: string;
};
}
/**
* iFrame-Element mit Consent-Attributen
*/
interface ConsentIframe extends HTMLIFrameElement {
dataset: DOMStringMap & {
consent?: string;
src?: string;
};
}
/**
* ScriptBlocker - Verwaltet Script-Blocking
*/
export class ScriptBlocker {
private config: ConsentConfig;
private observer: MutationObserver | null = null;
private enabledCategories: Set<ConsentCategory> = new Set(['essential']);
private processedElements: WeakSet<Element> = new WeakSet();
constructor(config: ConsentConfig) {
this.config = config;
}
/**
* Initialisieren und Observer starten
*/
init(): void {
if (typeof window === 'undefined') {
return;
}
// Bestehende Elemente verarbeiten
this.processExistingElements();
// MutationObserver fuer neue Elemente
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
this.processElement(node as Element);
}
}
}
});
this.observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
this.log('ScriptBlocker initialized');
}
/**
* Kategorie aktivieren
*/
enableCategory(category: ConsentCategory): void {
if (this.enabledCategories.has(category)) {
return;
}
this.enabledCategories.add(category);
this.log('Category enabled:', category);
// Blockierte Elemente dieser Kategorie aktivieren
this.activateCategory(category);
}
/**
* Kategorie deaktivieren
*/
disableCategory(category: ConsentCategory): void {
if (category === 'essential') {
// Essential kann nicht deaktiviert werden
return;
}
this.enabledCategories.delete(category);
this.log('Category disabled:', category);
// Hinweis: Bereits geladene Skripte koennen nicht entladen werden
// Page-Reload noetig fuer vollstaendige Deaktivierung
}
/**
* Alle Kategorien blockieren (ausser Essential)
*/
blockAll(): void {
this.enabledCategories.clear();
this.enabledCategories.add('essential');
this.log('All categories blocked');
}
/**
* Pruefen ob Kategorie aktiviert
*/
isCategoryEnabled(category: ConsentCategory): boolean {
return this.enabledCategories.has(category);
}
/**
* Observer stoppen
*/
destroy(): void {
this.observer?.disconnect();
this.observer = null;
this.log('ScriptBlocker destroyed');
}
// ===========================================================================
// Internal Methods
// ===========================================================================
/**
* Bestehende Elemente verarbeiten
*/
private processExistingElements(): void {
// Scripts mit data-consent
const scripts = document.querySelectorAll<ConsentScript>(
'script[data-consent]'
);
scripts.forEach((script) => this.processScript(script));
// iFrames mit data-consent
const iframes = document.querySelectorAll<ConsentIframe>(
'iframe[data-consent]'
);
iframes.forEach((iframe) => this.processIframe(iframe));
this.log(`Processed ${scripts.length} scripts, ${iframes.length} iframes`);
}
/**
* Element verarbeiten
*/
private processElement(element: Element): void {
if (element.tagName === 'SCRIPT') {
this.processScript(element as ConsentScript);
} else if (element.tagName === 'IFRAME') {
this.processIframe(element as ConsentIframe);
}
// Auch Kinder verarbeiten
element
.querySelectorAll<ConsentScript>('script[data-consent]')
.forEach((script) => this.processScript(script));
element
.querySelectorAll<ConsentIframe>('iframe[data-consent]')
.forEach((iframe) => this.processIframe(iframe));
}
/**
* Script-Element verarbeiten
*/
private processScript(script: ConsentScript): void {
if (this.processedElements.has(script)) {
return;
}
const category = script.dataset.consent as ConsentCategory | undefined;
if (!category) {
return;
}
this.processedElements.add(script);
if (this.enabledCategories.has(category)) {
this.activateScript(script);
} else {
this.log(`Script blocked (${category}):`, script.dataset.src || 'inline');
}
}
/**
* iFrame-Element verarbeiten
*/
private processIframe(iframe: ConsentIframe): void {
if (this.processedElements.has(iframe)) {
return;
}
const category = iframe.dataset.consent as ConsentCategory | undefined;
if (!category) {
return;
}
this.processedElements.add(iframe);
if (this.enabledCategories.has(category)) {
this.activateIframe(iframe);
} else {
this.log(`iFrame blocked (${category}):`, iframe.dataset.src);
// Placeholder anzeigen
this.showPlaceholder(iframe, category);
}
}
/**
* Script aktivieren
*/
private activateScript(script: ConsentScript): void {
const src = script.dataset.src;
if (src) {
// Externes Script: neues Element erstellen
const newScript = document.createElement('script');
// Attribute kopieren
for (const attr of script.attributes) {
if (attr.name !== 'type' && attr.name !== 'data-src') {
newScript.setAttribute(attr.name, attr.value);
}
}
newScript.src = src;
newScript.removeAttribute('data-consent');
// Altes Element ersetzen
script.parentNode?.replaceChild(newScript, script);
this.log('External script activated:', src);
} else {
// Inline-Script: type aendern
const newScript = document.createElement('script');
for (const attr of script.attributes) {
if (attr.name !== 'type') {
newScript.setAttribute(attr.name, attr.value);
}
}
newScript.textContent = script.textContent;
newScript.removeAttribute('data-consent');
script.parentNode?.replaceChild(newScript, script);
this.log('Inline script activated');
}
}
/**
* iFrame aktivieren
*/
private activateIframe(iframe: ConsentIframe): void {
const src = iframe.dataset.src;
if (!src) {
return;
}
// Placeholder entfernen falls vorhanden
const placeholder = iframe.parentElement?.querySelector(
'.bp-consent-placeholder'
);
placeholder?.remove();
// src setzen
iframe.src = src;
iframe.removeAttribute('data-src');
iframe.removeAttribute('data-consent');
iframe.style.display = '';
this.log('iFrame activated:', src);
}
/**
* Placeholder fuer blockierten iFrame anzeigen
*/
private showPlaceholder(iframe: ConsentIframe, category: ConsentCategory): void {
// iFrame verstecken
iframe.style.display = 'none';
// Placeholder erstellen
const placeholder = document.createElement('div');
placeholder.className = 'bp-consent-placeholder';
placeholder.setAttribute('data-category', category);
placeholder.innerHTML = `
<div class="bp-consent-placeholder-content">
<p>Dieser Inhalt erfordert Ihre Zustimmung.</p>
<button type="button" class="bp-consent-placeholder-btn">
${this.getCategoryName(category)} aktivieren
</button>
</div>
`;
// Click-Handler
const btn = placeholder.querySelector('button');
btn?.addEventListener('click', () => {
// Event dispatchen damit ConsentManager reagieren kann
window.dispatchEvent(
new CustomEvent('bp-consent-request', {
detail: { category },
})
);
});
// Nach iFrame einfuegen
iframe.parentNode?.insertBefore(placeholder, iframe.nextSibling);
}
/**
* Alle Elemente einer Kategorie aktivieren
*/
private activateCategory(category: ConsentCategory): void {
// Scripts
const scripts = document.querySelectorAll<ConsentScript>(
`script[data-consent="${category}"]`
);
scripts.forEach((script) => this.activateScript(script));
// iFrames
const iframes = document.querySelectorAll<ConsentIframe>(
`iframe[data-consent="${category}"]`
);
iframes.forEach((iframe) => this.activateIframe(iframe));
this.log(
`Activated ${scripts.length} scripts, ${iframes.length} iframes for ${category}`
);
}
/**
* Kategorie-Name fuer UI
*/
private getCategoryName(category: ConsentCategory): string {
const names: Record<ConsentCategory, string> = {
essential: 'Essentielle Cookies',
functional: 'Funktionale Cookies',
analytics: 'Statistik-Cookies',
marketing: 'Marketing-Cookies',
social: 'Social Media-Cookies',
};
return names[category] ?? category;
}
/**
* Debug-Logging
*/
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[ScriptBlocker]', ...args);
}
}
}
export default ScriptBlocker;

View File

@@ -0,0 +1,7 @@
/**
* Core module exports
*/
export { ConsentManager } from './ConsentManager';
export { ConsentStorage } from './ConsentStorage';
export { ScriptBlocker } from './ScriptBlocker';
export { ConsentAPI } from './ConsentAPI';

81
consent-sdk/src/index.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* @breakpilot/consent-sdk
*
* DSGVO/TTDSG-konformes Consent Management SDK
*
* @example
* ```typescript
* import { ConsentManager } from '@breakpilot/consent-sdk';
*
* const consent = new ConsentManager({
* apiEndpoint: 'https://consent.example.com/api/v1',
* siteId: 'site_abc123',
* });
*
* await consent.init();
*
* if (consent.hasConsent('analytics')) {
* // Analytics laden
* }
* ```
*/
// Core
export { ConsentManager } from './core/ConsentManager';
export { ConsentStorage } from './core/ConsentStorage';
export { ScriptBlocker } from './core/ScriptBlocker';
export { ConsentAPI } from './core/ConsentAPI';
// Utils
export { EventEmitter } from './utils/EventEmitter';
export { generateFingerprint, generateFingerprintSync } from './utils/fingerprint';
// Types
export type {
// Categories
ConsentCategory,
ConsentCategories,
ConsentVendors,
// State
ConsentState,
ConsentInput,
// Config
ConsentConfig,
ConsentUIConfig,
ConsentBehaviorConfig,
TCFConfig,
PWAConfig,
BannerPosition,
BannerLayout,
BannerTheme,
// Vendors
ConsentVendor,
CookieInfo,
// API
ConsentAPIResponse,
SiteConfigResponse,
CategoryConfig,
LegalConfig,
// Events
ConsentEventType,
ConsentEventCallback,
ConsentEventData,
// Storage
ConsentStorageAdapter,
// Translations
ConsentTranslations,
SupportedLanguage,
} from './types';
// Version
export { SDK_VERSION } from './version';
// Default export
export { ConsentManager as default } from './core/ConsentManager';

View File

@@ -0,0 +1,182 @@
# Mobile SDKs - @breakpilot/consent-sdk
## Übersicht
Die Mobile SDKs bieten native Integration für iOS, Android und Flutter.
## SDK Übersicht
| Platform | Sprache | Min Version | Status |
|----------|---------|-------------|--------|
| iOS | Swift 5.9+ | iOS 15.0+ | 📋 Spec |
| Android | Kotlin | API 26+ | 📋 Spec |
| Flutter | Dart 3.0+ | Flutter 3.16+ | 📋 Spec |
## Architektur
```
Mobile SDK
├── Core (shared)
│ ├── ConsentManager
│ ├── ConsentStorage (Keychain/SharedPrefs)
│ ├── API Client
│ └── Device Fingerprint
├── UI Components
│ ├── ConsentBanner
│ ├── ConsentSettings
│ └── ConsentGate
└── Platform-specific
├── iOS: SwiftUI + UIKit
├── Android: Jetpack Compose + XML
└── Flutter: Widgets
```
## Feature-Parität mit Web SDK
| Feature | iOS | Android | Flutter |
|---------|-----|---------|---------|
| Consent Storage | Keychain | SharedPrefs | SecureStorage |
| Banner UI | SwiftUI | Compose | Widget |
| Settings Modal | ✓ | ✓ | ✓ |
| Category Control | ✓ | ✓ | ✓ |
| Vendor Control | ✓ | ✓ | ✓ |
| Offline Support | ✓ | ✓ | ✓ |
| Google Consent Mode | ✓ | ✓ | ✓ |
| ATT Integration | ✓ | - | ✓ (iOS) |
| TCF 2.2 | ✓ | ✓ | ✓ |
## Installation
### iOS (Swift Package Manager)
```swift
// Package.swift
dependencies: [
.package(url: "https://github.com/breakpilot/consent-sdk-ios.git", from: "1.0.0")
]
```
### Android (Gradle)
```kotlin
// build.gradle.kts
dependencies {
implementation("com.breakpilot:consent-sdk:1.0.0")
}
```
### Flutter
```yaml
# pubspec.yaml
dependencies:
breakpilot_consent_sdk: ^1.0.0
```
## Quick Start
### iOS
```swift
import BreakpilotConsentSDK
// AppDelegate.swift
ConsentManager.shared.configure(
apiEndpoint: "https://consent.example.com/api/v1",
siteId: "site_abc123"
)
// ContentView.swift (SwiftUI)
struct ContentView: View {
@EnvironmentObject var consent: ConsentManager
var body: some View {
VStack {
if consent.hasConsent(.analytics) {
AnalyticsView()
}
}
.consentBanner()
}
}
```
### Android
```kotlin
import com.breakpilot.consent.ConsentManager
import com.breakpilot.consent.ui.ConsentBanner
// Application.kt
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
ConsentManager.configure(
context = this,
apiEndpoint = "https://consent.example.com/api/v1",
siteId = "site_abc123"
)
}
}
// MainActivity.kt (Jetpack Compose)
@Composable
fun MainScreen() {
val consent = ConsentManager.current
if (consent.hasConsent(ConsentCategory.ANALYTICS)) {
AnalyticsComponent()
}
ConsentBanner()
}
```
### Flutter
```dart
import 'package:breakpilot_consent_sdk/consent_sdk.dart';
// main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await ConsentManager.configure(
apiEndpoint: 'https://consent.example.com/api/v1',
siteId: 'site_abc123',
);
runApp(MyApp());
}
// Widget
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ConsentProvider(
child: MaterialApp(
home: Scaffold(
body: Column(
children: [
ConsentGate(
category: ConsentCategory.analytics,
child: AnalyticsWidget(),
placeholder: Text('Analytics nicht aktiviert'),
),
],
),
bottomSheet: ConsentBanner(),
),
),
);
}
}
```
## Dateien
Siehe die einzelnen Platform-SDKs:
- [iOS SDK Spec](./ios/README.md)
- [Android SDK Spec](./android/README.md)
- [Flutter SDK Spec](./flutter/README.md)

View File

@@ -0,0 +1,499 @@
/**
* Android Consent SDK - ConsentManager
*
* DSGVO/TTDSG-konformes Consent Management fuer Android Apps.
*
* Nutzung:
* 1. In Application.onCreate() konfigurieren
* 2. In Activities/Fragments mit ConsentManager.current nutzen
* 3. In Jetpack Compose mit rememberConsentState()
*
* Copyright (c) 2025 BreakPilot
* Apache License 2.0
*/
package com.breakpilot.consent
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.provider.Settings
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.security.MessageDigest
import java.util.*
// =============================================================================
// Consent Categories
// =============================================================================
/**
* Standard-Consent-Kategorien nach IAB TCF 2.2
*/
enum class ConsentCategory {
ESSENTIAL, // Technisch notwendig
FUNCTIONAL, // Personalisierung
ANALYTICS, // Nutzungsanalyse
MARKETING, // Werbung
SOCIAL // Social Media
}
// =============================================================================
// Consent State
// =============================================================================
/**
* Aktueller Consent-Zustand
*/
@Serializable
data class ConsentState(
val categories: Map<ConsentCategory, Boolean> = defaultCategories(),
val vendors: Map<String, Boolean> = emptyMap(),
val timestamp: Long = System.currentTimeMillis(),
val version: String = "1.0.0",
val consentId: String? = null,
val expiresAt: Long? = null,
val tcfString: String? = null
) {
companion object {
fun defaultCategories() = mapOf(
ConsentCategory.ESSENTIAL to true,
ConsentCategory.FUNCTIONAL to false,
ConsentCategory.ANALYTICS to false,
ConsentCategory.MARKETING to false,
ConsentCategory.SOCIAL to false
)
val DEFAULT = ConsentState()
}
}
// =============================================================================
// Configuration
// =============================================================================
/**
* SDK-Konfiguration
*/
data class ConsentConfig(
val apiEndpoint: String,
val siteId: String,
val language: String = Locale.getDefault().language,
val showRejectAll: Boolean = true,
val showAcceptAll: Boolean = true,
val granularControl: Boolean = true,
val rememberDays: Int = 365,
val debug: Boolean = false
)
// =============================================================================
// Consent Manager
// =============================================================================
/**
* Haupt-Manager fuer Consent-Verwaltung
*/
class ConsentManager private constructor() {
// State
private val _consent = MutableStateFlow(ConsentState.DEFAULT)
val consent: StateFlow<ConsentState> = _consent.asStateFlow()
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _isLoading = MutableStateFlow(true)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _isBannerVisible = MutableStateFlow(false)
val isBannerVisible: StateFlow<Boolean> = _isBannerVisible.asStateFlow()
private val _isSettingsVisible = MutableStateFlow(false)
val isSettingsVisible: StateFlow<Boolean> = _isSettingsVisible.asStateFlow()
// Private
private var config: ConsentConfig? = null
private var storage: ConsentStorage? = null
private var apiClient: ConsentApiClient? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// Singleton
companion object {
@Volatile
private var INSTANCE: ConsentManager? = null
val current: ConsentManager
get() = INSTANCE ?: synchronized(this) {
INSTANCE ?: ConsentManager().also { INSTANCE = it }
}
/**
* Konfiguriert den ConsentManager
* Sollte in Application.onCreate() aufgerufen werden
*/
fun configure(context: Context, config: ConsentConfig) {
current.apply {
this.config = config
this.storage = ConsentStorage(context)
this.apiClient = ConsentApiClient(config.apiEndpoint, config.siteId)
if (config.debug) {
println("[ConsentSDK] Configured with siteId: ${config.siteId}")
}
scope.launch {
initialize(context)
}
}
}
}
// ==========================================================================
// Initialization
// ==========================================================================
private suspend fun initialize(context: Context) {
try {
// Lokalen Consent laden
storage?.load()?.let { stored ->
// Pruefen ob abgelaufen
val expiresAt = stored.expiresAt
if (expiresAt != null && System.currentTimeMillis() > expiresAt) {
_consent.value = ConsentState.DEFAULT
storage?.clear()
} else {
_consent.value = stored
}
}
// Vom Server synchronisieren
try {
apiClient?.getConsent(DeviceFingerprint.generate(context))?.let { serverConsent ->
_consent.value = serverConsent
storage?.save(serverConsent)
}
} catch (e: Exception) {
if (config?.debug == true) {
println("[ConsentSDK] Failed to sync consent: $e")
}
}
_isInitialized.value = true
// Banner anzeigen falls noetig
if (needsConsent) {
showBanner()
}
} finally {
_isLoading.value = false
}
}
// ==========================================================================
// Public API
// ==========================================================================
/**
* Prueft ob Consent fuer Kategorie erteilt wurde
*/
fun hasConsent(category: ConsentCategory): Boolean {
// Essential ist immer erlaubt
if (category == ConsentCategory.ESSENTIAL) return true
return consent.value.categories[category] ?: false
}
/**
* Prueft ob Consent eingeholt werden muss
*/
val needsConsent: Boolean
get() = consent.value.consentId == null
/**
* Alle Kategorien akzeptieren
*/
suspend fun acceptAll() {
val newCategories = ConsentCategory.values().associateWith { true }
val newConsent = consent.value.copy(
categories = newCategories,
timestamp = System.currentTimeMillis()
)
saveConsent(newConsent)
hideBanner()
}
/**
* Alle nicht-essentiellen Kategorien ablehnen
*/
suspend fun rejectAll() {
val newCategories = ConsentCategory.values().associateWith {
it == ConsentCategory.ESSENTIAL
}
val newConsent = consent.value.copy(
categories = newCategories,
timestamp = System.currentTimeMillis()
)
saveConsent(newConsent)
hideBanner()
}
/**
* Auswahl speichern
*/
suspend fun saveSelection(categories: Map<ConsentCategory, Boolean>) {
val updated = categories.toMutableMap()
updated[ConsentCategory.ESSENTIAL] = true // Essential immer true
val newConsent = consent.value.copy(
categories = updated,
timestamp = System.currentTimeMillis()
)
saveConsent(newConsent)
hideBanner()
}
// ==========================================================================
// UI Control
// ==========================================================================
fun showBanner() {
_isBannerVisible.value = true
}
fun hideBanner() {
_isBannerVisible.value = false
}
fun showSettings() {
_isSettingsVisible.value = true
}
fun hideSettings() {
_isSettingsVisible.value = false
}
// ==========================================================================
// Private Methods
// ==========================================================================
private suspend fun saveConsent(newConsent: ConsentState) {
// Lokal speichern
storage?.save(newConsent)
// An Server senden
try {
val response = apiClient?.saveConsent(
newConsent,
DeviceFingerprint.generate(storage?.context!!)
)
val updated = newConsent.copy(
consentId = response?.consentId,
expiresAt = response?.expiresAt
)
_consent.value = updated
storage?.save(updated)
} catch (e: Exception) {
// Lokal speichern auch bei Fehler
_consent.value = newConsent
if (config?.debug == true) {
println("[ConsentSDK] Failed to sync consent: $e")
}
}
}
}
// =============================================================================
// Storage
// =============================================================================
/**
* Sichere Speicherung mit EncryptedSharedPreferences
*/
internal class ConsentStorage(val context: Context) {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"breakpilot_consent",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
private val json = Json { ignoreUnknownKeys = true }
fun load(): ConsentState? {
val data = prefs.getString("consent_state", null) ?: return null
return try {
json.decodeFromString<ConsentState>(data)
} catch (e: Exception) {
null
}
}
fun save(consent: ConsentState) {
val data = json.encodeToString(consent)
prefs.edit().putString("consent_state", data).apply()
}
fun clear() {
prefs.edit().remove("consent_state").apply()
}
}
// =============================================================================
// API Client
// =============================================================================
/**
* API Client fuer Backend-Kommunikation
*/
internal class ConsentApiClient(
private val baseUrl: String,
private val siteId: String
) {
private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
@Serializable
data class ConsentResponse(
val consentId: String,
val expiresAt: Long
)
suspend fun getConsent(fingerprint: String): ConsentState? = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint")
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val body = response.body?.string() ?: return@withContext null
json.decodeFromString<ConsentState>(body)
}
}
suspend fun saveConsent(
consent: ConsentState,
fingerprint: String
): ConsentResponse = withContext(Dispatchers.IO) {
val body = """
{
"site_id": "$siteId",
"device_fingerprint": "$fingerprint",
"categories": ${json.encodeToString(consent.categories.mapKeys { it.key.name.lowercase() })},
"vendors": ${json.encodeToString(consent.vendors)},
"platform": "android",
"app_version": "${BuildConfig.VERSION_NAME}"
}
""".trimIndent()
val request = Request.Builder()
.url("$baseUrl/banner/consent")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
json.decodeFromString<ConsentResponse>(responseBody)
}
}
}
// =============================================================================
// Device Fingerprint
// =============================================================================
/**
* Privacy-konformer Device Fingerprint
*/
internal object DeviceFingerprint {
fun generate(context: Context): String {
// Android ID (reset bei Factory Reset)
val androidId = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID
) ?: UUID.randomUUID().toString()
// Device Info
val model = Build.MODEL
val version = Build.VERSION.SDK_INT.toString()
val locale = Locale.getDefault().toString()
// Hash erstellen
val raw = "$androidId-$model-$version-$locale"
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(raw.toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}
}
// =============================================================================
// Jetpack Compose Integration
// =============================================================================
/**
* State Holder fuer Compose
*/
@Composable
fun rememberConsentState(): State<ConsentState> {
return ConsentManager.current.consent.collectAsState()
}
/**
* Banner Visibility State
*/
@Composable
fun rememberBannerVisibility(): State<Boolean> {
return ConsentManager.current.isBannerVisible.collectAsState()
}
/**
* Consent Gate - Zeigt Inhalt nur bei Consent
*/
@Composable
fun ConsentGate(
category: ConsentCategory,
placeholder: @Composable () -> Unit = {},
content: @Composable () -> Unit
) {
val consent by rememberConsentState()
if (ConsentManager.current.hasConsent(category)) {
content()
} else {
placeholder()
}
}
/**
* Local Composition fuer ConsentManager
*/
val LocalConsentManager = staticCompositionLocalOf { ConsentManager.current }
/**
* Consent Provider
*/
@Composable
fun ConsentProvider(
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalConsentManager provides ConsentManager.current
) {
content()
}
}

View File

@@ -0,0 +1,658 @@
/// Flutter Consent SDK
///
/// DSGVO/TTDSG-konformes Consent Management fuer Flutter Apps.
///
/// Nutzung:
/// 1. In main() mit ConsentManager.configure() initialisieren
/// 2. App mit ConsentProvider wrappen
/// 3. Mit ConsentGate Inhalte schuetzen
///
/// Copyright (c) 2025 BreakPilot
/// Apache License 2.0
library consent_sdk;
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
// =============================================================================
// Consent Categories
// =============================================================================
/// Standard-Consent-Kategorien nach IAB TCF 2.2
enum ConsentCategory {
essential, // Technisch notwendig
functional, // Personalisierung
analytics, // Nutzungsanalyse
marketing, // Werbung
social, // Social Media
}
// =============================================================================
// Consent State
// =============================================================================
/// Aktueller Consent-Zustand
class ConsentState {
final Map<ConsentCategory, bool> categories;
final Map<String, bool> vendors;
final DateTime timestamp;
final String version;
final String? consentId;
final DateTime? expiresAt;
final String? tcfString;
const ConsentState({
required this.categories,
this.vendors = const {},
required this.timestamp,
this.version = '1.0.0',
this.consentId,
this.expiresAt,
this.tcfString,
});
/// Default State mit nur essential = true
factory ConsentState.defaultState() {
return ConsentState(
categories: {
ConsentCategory.essential: true,
ConsentCategory.functional: false,
ConsentCategory.analytics: false,
ConsentCategory.marketing: false,
ConsentCategory.social: false,
},
timestamp: DateTime.now(),
);
}
ConsentState copyWith({
Map<ConsentCategory, bool>? categories,
Map<String, bool>? vendors,
DateTime? timestamp,
String? version,
String? consentId,
DateTime? expiresAt,
String? tcfString,
}) {
return ConsentState(
categories: categories ?? this.categories,
vendors: vendors ?? this.vendors,
timestamp: timestamp ?? this.timestamp,
version: version ?? this.version,
consentId: consentId ?? this.consentId,
expiresAt: expiresAt ?? this.expiresAt,
tcfString: tcfString ?? this.tcfString,
);
}
Map<String, dynamic> toJson() => {
'categories': categories.map((k, v) => MapEntry(k.name, v)),
'vendors': vendors,
'timestamp': timestamp.toIso8601String(),
'version': version,
'consentId': consentId,
'expiresAt': expiresAt?.toIso8601String(),
'tcfString': tcfString,
};
factory ConsentState.fromJson(Map<String, dynamic> json) {
return ConsentState(
categories: (json['categories'] as Map<String, dynamic>).map(
(k, v) => MapEntry(
ConsentCategory.values.firstWhere((e) => e.name == k),
v as bool,
),
),
vendors: Map<String, bool>.from(json['vendors'] ?? {}),
timestamp: DateTime.parse(json['timestamp']),
version: json['version'] ?? '1.0.0',
consentId: json['consentId'],
expiresAt: json['expiresAt'] != null
? DateTime.parse(json['expiresAt'])
: null,
tcfString: json['tcfString'],
);
}
}
// =============================================================================
// Configuration
// =============================================================================
/// SDK-Konfiguration
class ConsentConfig {
final String apiEndpoint;
final String siteId;
final String language;
final bool showRejectAll;
final bool showAcceptAll;
final bool granularControl;
final int rememberDays;
final bool debug;
const ConsentConfig({
required this.apiEndpoint,
required this.siteId,
this.language = 'en',
this.showRejectAll = true,
this.showAcceptAll = true,
this.granularControl = true,
this.rememberDays = 365,
this.debug = false,
});
}
// =============================================================================
// Consent Manager
// =============================================================================
/// Haupt-Manager fuer Consent-Verwaltung
class ConsentManager extends ChangeNotifier {
// Singleton
static ConsentManager? _instance;
static ConsentManager get instance => _instance!;
// State
ConsentState _consent = ConsentState.defaultState();
bool _isInitialized = false;
bool _isLoading = true;
bool _isBannerVisible = false;
bool _isSettingsVisible = false;
// Private
ConsentConfig? _config;
late ConsentStorage _storage;
late ConsentApiClient _apiClient;
// Getters
ConsentState get consent => _consent;
bool get isInitialized => _isInitialized;
bool get isLoading => _isLoading;
bool get isBannerVisible => _isBannerVisible;
bool get isSettingsVisible => _isSettingsVisible;
bool get needsConsent => _consent.consentId == null;
// Private constructor
ConsentManager._();
/// Konfiguriert den ConsentManager
/// Sollte in main() vor runApp() aufgerufen werden
static Future<void> configure(ConsentConfig config) async {
_instance = ConsentManager._();
_instance!._config = config;
_instance!._storage = ConsentStorage();
_instance!._apiClient = ConsentApiClient(
baseUrl: config.apiEndpoint,
siteId: config.siteId,
);
if (config.debug) {
debugPrint('[ConsentSDK] Configured with siteId: ${config.siteId}');
}
await _instance!._initialize();
}
// ==========================================================================
// Initialization
// ==========================================================================
Future<void> _initialize() async {
try {
// Lokalen Consent laden
final stored = await _storage.load();
if (stored != null) {
// Pruefen ob abgelaufen
if (stored.expiresAt != null &&
DateTime.now().isAfter(stored.expiresAt!)) {
_consent = ConsentState.defaultState();
await _storage.clear();
} else {
_consent = stored;
}
}
// Vom Server synchronisieren
try {
final fingerprint = await DeviceFingerprint.generate();
final serverConsent = await _apiClient.getConsent(fingerprint);
if (serverConsent != null) {
_consent = serverConsent;
await _storage.save(_consent);
}
} catch (e) {
if (_config?.debug == true) {
debugPrint('[ConsentSDK] Failed to sync consent: $e');
}
}
_isInitialized = true;
// Banner anzeigen falls noetig
if (needsConsent) {
showBanner();
}
} finally {
_isLoading = false;
notifyListeners();
}
}
// ==========================================================================
// Public API
// ==========================================================================
/// Prueft ob Consent fuer Kategorie erteilt wurde
bool hasConsent(ConsentCategory category) {
// Essential ist immer erlaubt
if (category == ConsentCategory.essential) return true;
return _consent.categories[category] ?? false;
}
/// Alle Kategorien akzeptieren
Future<void> acceptAll() async {
final newCategories = {
for (var cat in ConsentCategory.values) cat: true
};
final newConsent = _consent.copyWith(
categories: newCategories,
timestamp: DateTime.now(),
);
await _saveConsent(newConsent);
hideBanner();
}
/// Alle nicht-essentiellen Kategorien ablehnen
Future<void> rejectAll() async {
final newCategories = {
for (var cat in ConsentCategory.values)
cat: cat == ConsentCategory.essential
};
final newConsent = _consent.copyWith(
categories: newCategories,
timestamp: DateTime.now(),
);
await _saveConsent(newConsent);
hideBanner();
}
/// Auswahl speichern
Future<void> saveSelection(Map<ConsentCategory, bool> categories) async {
final updated = Map<ConsentCategory, bool>.from(categories);
updated[ConsentCategory.essential] = true; // Essential immer true
final newConsent = _consent.copyWith(
categories: updated,
timestamp: DateTime.now(),
);
await _saveConsent(newConsent);
hideBanner();
}
// ==========================================================================
// UI Control
// ==========================================================================
void showBanner() {
_isBannerVisible = true;
notifyListeners();
}
void hideBanner() {
_isBannerVisible = false;
notifyListeners();
}
void showSettings() {
_isSettingsVisible = true;
notifyListeners();
}
void hideSettings() {
_isSettingsVisible = false;
notifyListeners();
}
// ==========================================================================
// Private Methods
// ==========================================================================
Future<void> _saveConsent(ConsentState newConsent) async {
// Lokal speichern
await _storage.save(newConsent);
// An Server senden
try {
final fingerprint = await DeviceFingerprint.generate();
final response = await _apiClient.saveConsent(newConsent, fingerprint);
final updated = newConsent.copyWith(
consentId: response['consentId'],
expiresAt: DateTime.parse(response['expiresAt']),
);
_consent = updated;
await _storage.save(updated);
} catch (e) {
// Lokal speichern auch bei Fehler
_consent = newConsent;
if (_config?.debug == true) {
debugPrint('[ConsentSDK] Failed to sync consent: $e');
}
}
notifyListeners();
}
}
// =============================================================================
// Storage
// =============================================================================
/// Sichere Speicherung mit flutter_secure_storage
class ConsentStorage {
final _storage = const FlutterSecureStorage();
static const _key = 'breakpilot_consent_state';
Future<ConsentState?> load() async {
final data = await _storage.read(key: _key);
if (data == null) return null;
try {
return ConsentState.fromJson(jsonDecode(data));
} catch (e) {
return null;
}
}
Future<void> save(ConsentState consent) async {
final data = jsonEncode(consent.toJson());
await _storage.write(key: _key, value: data);
}
Future<void> clear() async {
await _storage.delete(key: _key);
}
}
// =============================================================================
// API Client
// =============================================================================
/// API Client fuer Backend-Kommunikation
class ConsentApiClient {
final String baseUrl;
final String siteId;
ConsentApiClient({
required this.baseUrl,
required this.siteId,
});
Future<ConsentState?> getConsent(String fingerprint) async {
final response = await http.get(
Uri.parse('$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint'),
);
if (response.statusCode != 200) return null;
return ConsentState.fromJson(jsonDecode(response.body));
}
Future<Map<String, dynamic>> saveConsent(
ConsentState consent,
String fingerprint,
) async {
final response = await http.post(
Uri.parse('$baseUrl/banner/consent'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'site_id': siteId,
'device_fingerprint': fingerprint,
'categories': consent.categories.map((k, v) => MapEntry(k.name, v)),
'vendors': consent.vendors,
'platform': Platform.isIOS ? 'ios' : 'android',
'app_version': '1.0.0', // TODO: Get from package_info_plus
}),
);
return jsonDecode(response.body);
}
}
// =============================================================================
// Device Fingerprint
// =============================================================================
/// Privacy-konformer Device Fingerprint
class DeviceFingerprint {
static Future<String> generate() async {
final deviceInfo = DeviceInfoPlugin();
String rawId;
if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
rawId = '${iosInfo.identifierForVendor}-${iosInfo.model}-${iosInfo.systemVersion}';
} else if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
rawId = '${androidInfo.id}-${androidInfo.model}-${androidInfo.version.sdkInt}';
} else {
rawId = DateTime.now().millisecondsSinceEpoch.toString();
}
// SHA-256 Hash
final bytes = utf8.encode(rawId);
final digest = sha256.convert(bytes);
return digest.toString();
}
}
// =============================================================================
// Flutter Widgets
// =============================================================================
/// Consent Provider - Wraps the app
class ConsentProvider extends StatelessWidget {
final Widget child;
const ConsentProvider({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: ConsentManager.instance,
child: child,
);
}
}
/// Consent Gate - Zeigt Inhalt nur bei Consent
class ConsentGate extends StatelessWidget {
final ConsentCategory category;
final Widget child;
final Widget? placeholder;
final Widget? loading;
const ConsentGate({
super.key,
required this.category,
required this.child,
this.placeholder,
this.loading,
});
@override
Widget build(BuildContext context) {
return Consumer<ConsentManager>(
builder: (context, consent, _) {
if (consent.isLoading) {
return loading ?? const SizedBox.shrink();
}
if (!consent.hasConsent(category)) {
return placeholder ?? const SizedBox.shrink();
}
return child;
},
);
}
}
/// Consent Banner - Default Banner UI
class ConsentBanner extends StatelessWidget {
final String? title;
final String? description;
final String? acceptAllText;
final String? rejectAllText;
final String? settingsText;
const ConsentBanner({
super.key,
this.title,
this.description,
this.acceptAllText,
this.rejectAllText,
this.settingsText,
});
@override
Widget build(BuildContext context) {
return Consumer<ConsentManager>(
builder: (context, consent, _) {
if (!consent.isBannerVisible) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, -5),
),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title ?? 'Datenschutzeinstellungen',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
description ??
'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: consent.rejectAll,
child: Text(rejectAllText ?? 'Alle ablehnen'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: consent.showSettings,
child: Text(settingsText ?? 'Einstellungen'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: consent.acceptAll,
child: Text(acceptAllText ?? 'Alle akzeptieren'),
),
),
],
),
],
),
),
);
},
);
}
}
/// Consent Placeholder - Placeholder fuer blockierten Inhalt
class ConsentPlaceholder extends StatelessWidget {
final ConsentCategory category;
final String? message;
final String? buttonText;
const ConsentPlaceholder({
super.key,
required this.category,
this.message,
this.buttonText,
});
String get _categoryName {
switch (category) {
case ConsentCategory.essential:
return 'Essentielle Cookies';
case ConsentCategory.functional:
return 'Funktionale Cookies';
case ConsentCategory.analytics:
return 'Statistik-Cookies';
case ConsentCategory.marketing:
return 'Marketing-Cookies';
case ConsentCategory.social:
return 'Social Media-Cookies';
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
message ?? 'Dieser Inhalt erfordert $_categoryName.',
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: ConsentManager.instance.showSettings,
child: Text(buttonText ?? 'Cookie-Einstellungen öffnen'),
),
],
),
);
}
}
// =============================================================================
// Extension for easy context access
// =============================================================================
extension ConsentExtension on BuildContext {
ConsentManager get consent => Provider.of<ConsentManager>(this, listen: false);
bool hasConsent(ConsentCategory category) => consent.hasConsent(category);
}

View File

@@ -0,0 +1,517 @@
/**
* iOS Consent SDK - ConsentManager
*
* DSGVO/TTDSG-konformes Consent Management fuer iOS Apps.
*
* Nutzung:
* 1. Im AppDelegate/App.init() konfigurieren
* 2. In SwiftUI Views mit @EnvironmentObject nutzen
* 3. Banner mit .consentBanner() Modifier anzeigen
*
* Copyright (c) 2025 BreakPilot
* Apache License 2.0
*/
import Foundation
import SwiftUI
import Combine
import CryptoKit
// MARK: - Consent Categories
/// Standard-Consent-Kategorien nach IAB TCF 2.2
public enum ConsentCategory: String, CaseIterable, Codable {
case essential // Technisch notwendig
case functional // Personalisierung
case analytics // Nutzungsanalyse
case marketing // Werbung
case social // Social Media
}
// MARK: - Consent State
/// Aktueller Consent-Zustand
public struct ConsentState: Codable, Equatable {
public var categories: [ConsentCategory: Bool]
public var vendors: [String: Bool]
public var timestamp: Date
public var version: String
public var consentId: String?
public var expiresAt: Date?
public var tcfString: String?
public init(
categories: [ConsentCategory: Bool] = [:],
vendors: [String: Bool] = [:],
timestamp: Date = Date(),
version: String = "1.0.0"
) {
self.categories = categories
self.vendors = vendors
self.timestamp = timestamp
self.version = version
}
/// Default State mit nur essential = true
public static var `default`: ConsentState {
ConsentState(
categories: [
.essential: true,
.functional: false,
.analytics: false,
.marketing: false,
.social: false
]
)
}
}
// MARK: - Configuration
/// SDK-Konfiguration
public struct ConsentConfig {
public let apiEndpoint: String
public let siteId: String
public var language: String = Locale.current.language.languageCode?.identifier ?? "en"
public var showRejectAll: Bool = true
public var showAcceptAll: Bool = true
public var granularControl: Bool = true
public var rememberDays: Int = 365
public var debug: Bool = false
public init(apiEndpoint: String, siteId: String) {
self.apiEndpoint = apiEndpoint
self.siteId = siteId
}
}
// MARK: - Consent Manager
/// Haupt-Manager fuer Consent-Verwaltung
@MainActor
public final class ConsentManager: ObservableObject {
// MARK: Singleton
public static let shared = ConsentManager()
// MARK: Published Properties
@Published public private(set) var consent: ConsentState = .default
@Published public private(set) var isInitialized: Bool = false
@Published public private(set) var isLoading: Bool = true
@Published public private(set) var isBannerVisible: Bool = false
@Published public private(set) var isSettingsVisible: Bool = false
// MARK: Private Properties
private var config: ConsentConfig?
private var storage: ConsentStorage?
private var apiClient: ConsentAPIClient?
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
private init() {}
/// Konfiguriert den ConsentManager
public func configure(_ config: ConsentConfig) {
self.config = config
self.storage = ConsentStorage()
self.apiClient = ConsentAPIClient(
baseURL: config.apiEndpoint,
siteId: config.siteId
)
if config.debug {
print("[ConsentSDK] Configured with siteId: \(config.siteId)")
}
Task {
await initialize()
}
}
/// Initialisiert und laedt gespeicherten Consent
private func initialize() async {
defer { isLoading = false }
// Lokalen Consent laden
if let stored = storage?.load() {
consent = stored
// Pruefen ob abgelaufen
if let expiresAt = stored.expiresAt, Date() > expiresAt {
consent = .default
storage?.clear()
}
}
// Vom Server synchronisieren (optional)
do {
if let serverConsent = try await apiClient?.getConsent(
fingerprint: DeviceFingerprint.generate()
) {
consent = serverConsent
storage?.save(consent)
}
} catch {
if config?.debug == true {
print("[ConsentSDK] Failed to sync consent: \(error)")
}
}
isInitialized = true
// Banner anzeigen falls noetig
if needsConsent {
showBanner()
}
}
// MARK: - Public API
/// Prueft ob Consent fuer Kategorie erteilt wurde
public func hasConsent(_ category: ConsentCategory) -> Bool {
// Essential ist immer erlaubt
if category == .essential { return true }
return consent.categories[category] ?? false
}
/// Prueft ob Consent eingeholt werden muss
public var needsConsent: Bool {
consent.consentId == nil
}
/// Alle Kategorien akzeptieren
public func acceptAll() async {
var newConsent = consent
for category in ConsentCategory.allCases {
newConsent.categories[category] = true
}
newConsent.timestamp = Date()
await saveConsent(newConsent)
hideBanner()
}
/// Alle nicht-essentiellen Kategorien ablehnen
public func rejectAll() async {
var newConsent = consent
for category in ConsentCategory.allCases {
newConsent.categories[category] = category == .essential
}
newConsent.timestamp = Date()
await saveConsent(newConsent)
hideBanner()
}
/// Auswahl speichern
public func saveSelection(_ categories: [ConsentCategory: Bool]) async {
var newConsent = consent
newConsent.categories = categories
newConsent.categories[.essential] = true // Essential immer true
newConsent.timestamp = Date()
await saveConsent(newConsent)
hideBanner()
}
// MARK: - UI Control
/// Banner anzeigen
public func showBanner() {
isBannerVisible = true
}
/// Banner ausblenden
public func hideBanner() {
isBannerVisible = false
}
/// Einstellungen anzeigen
public func showSettings() {
isSettingsVisible = true
}
/// Einstellungen ausblenden
public func hideSettings() {
isSettingsVisible = false
}
// MARK: - Private Methods
private func saveConsent(_ newConsent: ConsentState) async {
// Lokal speichern
storage?.save(newConsent)
// An Server senden
do {
let response = try await apiClient?.saveConsent(
consent: newConsent,
fingerprint: DeviceFingerprint.generate()
)
var updated = newConsent
updated.consentId = response?.consentId
updated.expiresAt = response?.expiresAt
consent = updated
storage?.save(updated)
} catch {
// Lokal speichern auch bei Fehler
consent = newConsent
if config?.debug == true {
print("[ConsentSDK] Failed to sync consent: \(error)")
}
}
}
}
// MARK: - Storage
/// Sichere Speicherung im Keychain
final class ConsentStorage {
private let key = "com.breakpilot.consent.state"
func load() -> ConsentState? {
guard let data = KeychainHelper.read(key: key) else { return nil }
return try? JSONDecoder().decode(ConsentState.self, from: data)
}
func save(_ consent: ConsentState) {
guard let data = try? JSONEncoder().encode(consent) else { return }
KeychainHelper.write(data: data, key: key)
}
func clear() {
KeychainHelper.delete(key: key)
}
}
/// Keychain Helper
enum KeychainHelper {
static func write(data: Data, key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func read(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
return result as? Data
}
static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
// MARK: - API Client
/// API Client fuer Backend-Kommunikation
final class ConsentAPIClient {
private let baseURL: String
private let siteId: String
init(baseURL: String, siteId: String) {
self.baseURL = baseURL
self.siteId = siteId
}
struct ConsentResponse: Codable {
let consentId: String
let expiresAt: Date
}
func getConsent(fingerprint: String) async throws -> ConsentState? {
let url = URL(string: "\(baseURL)/banner/consent?site_id=\(siteId)&fingerprint=\(fingerprint)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return nil
}
return try JSONDecoder().decode(ConsentState.self, from: data)
}
func saveConsent(consent: ConsentState, fingerprint: String) async throws -> ConsentResponse {
var request = URLRequest(url: URL(string: "\(baseURL)/banner/consent")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"site_id": siteId,
"device_fingerprint": fingerprint,
"categories": Dictionary(
uniqueKeysWithValues: consent.categories.map { ($0.key.rawValue, $0.value) }
),
"vendors": consent.vendors,
"platform": "ios",
"app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(ConsentResponse.self, from: data)
}
}
// MARK: - Device Fingerprint
/// Privacy-konformer Device Fingerprint
enum DeviceFingerprint {
static func generate() -> String {
// Vendor ID (reset-safe)
let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
// System Info
let model = UIDevice.current.model
let systemVersion = UIDevice.current.systemVersion
let locale = Locale.current.identifier
// Hash erstellen
let raw = "\(vendorId)-\(model)-\(systemVersion)-\(locale)"
let data = Data(raw.utf8)
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
}
// MARK: - SwiftUI Extensions
/// Environment Key fuer ConsentManager
private struct ConsentManagerKey: EnvironmentKey {
static let defaultValue = ConsentManager.shared
}
extension EnvironmentValues {
public var consentManager: ConsentManager {
get { self[ConsentManagerKey.self] }
set { self[ConsentManagerKey.self] = newValue }
}
}
/// Banner ViewModifier
public struct ConsentBannerModifier: ViewModifier {
@ObservedObject var consent = ConsentManager.shared
public func body(content: Content) -> some View {
ZStack {
content
if consent.isBannerVisible {
ConsentBannerView()
}
}
}
}
extension View {
/// Fuegt einen Consent-Banner hinzu
public func consentBanner() -> some View {
modifier(ConsentBannerModifier())
}
}
// MARK: - Banner View
/// Default Consent Banner UI
public struct ConsentBannerView: View {
@ObservedObject var consent = ConsentManager.shared
public init() {}
public var body: some View {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 16) {
Text("Datenschutzeinstellungen")
.font(.headline)
Text("Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Alle ablehnen") {
Task { await consent.rejectAll() }
}
.buttonStyle(.bordered)
Button("Einstellungen") {
consent.showSettings()
}
.buttonStyle(.bordered)
Button("Alle akzeptieren") {
Task { await consent.acceptAll() }
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.padding()
.shadow(radius: 20)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(), value: consent.isBannerVisible)
}
}
// MARK: - Consent Gate
/// Zeigt Inhalt nur bei Consent an
public struct ConsentGate<Content: View, Placeholder: View>: View {
let category: ConsentCategory
let content: () -> Content
let placeholder: () -> Placeholder
@ObservedObject var consent = ConsentManager.shared
public init(
category: ConsentCategory,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder placeholder: @escaping () -> Placeholder
) {
self.category = category
self.content = content
self.placeholder = placeholder
}
public var body: some View {
if consent.hasConsent(category) {
content()
} else {
placeholder()
}
}
}
extension ConsentGate where Placeholder == EmptyView {
public init(
category: ConsentCategory,
@ViewBuilder content: @escaping () -> Content
) {
self.init(category: category, content: content, placeholder: { EmptyView() })
}
}

View File

@@ -0,0 +1,511 @@
/**
* React Integration fuer @breakpilot/consent-sdk
*
* @example
* ```tsx
* import { ConsentProvider, useConsent, ConsentBanner } from '@breakpilot/consent-sdk/react';
*
* function App() {
* return (
* <ConsentProvider config={config}>
* <ConsentBanner />
* <MainContent />
* </ConsentProvider>
* );
* }
* ```
*/
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
useMemo,
type ReactNode,
type FC,
} from 'react';
import { ConsentManager } from '../core/ConsentManager';
import type {
ConsentConfig,
ConsentState,
ConsentCategory,
ConsentCategories,
} from '../types';
// =============================================================================
// Context
// =============================================================================
interface ConsentContextValue {
/** ConsentManager Instanz */
manager: ConsentManager | null;
/** Aktueller Consent-State */
consent: ConsentState | null;
/** Ist SDK initialisiert? */
isInitialized: boolean;
/** Wird geladen? */
isLoading: boolean;
/** Ist Banner sichtbar? */
isBannerVisible: boolean;
/** Wird Consent benoetigt? */
needsConsent: boolean;
/** Consent fuer Kategorie pruefen */
hasConsent: (category: ConsentCategory) => boolean;
/** Alle akzeptieren */
acceptAll: () => Promise<void>;
/** Alle ablehnen */
rejectAll: () => Promise<void>;
/** Auswahl speichern */
saveSelection: (categories: Partial<ConsentCategories>) => Promise<void>;
/** Banner anzeigen */
showBanner: () => void;
/** Banner verstecken */
hideBanner: () => void;
/** Einstellungen oeffnen */
showSettings: () => void;
}
const ConsentContext = createContext<ConsentContextValue | null>(null);
// =============================================================================
// Provider
// =============================================================================
interface ConsentProviderProps {
/** SDK-Konfiguration */
config: ConsentConfig;
/** Kinder-Komponenten */
children: ReactNode;
}
/**
* ConsentProvider - Stellt Consent-Kontext bereit
*/
export const ConsentProvider: FC<ConsentProviderProps> = ({
config,
children,
}) => {
const [manager, setManager] = useState<ConsentManager | null>(null);
const [consent, setConsent] = useState<ConsentState | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isBannerVisible, setIsBannerVisible] = useState(false);
// Manager erstellen und initialisieren
useEffect(() => {
const consentManager = new ConsentManager(config);
setManager(consentManager);
// Events abonnieren
const unsubChange = consentManager.on('change', (newConsent) => {
setConsent(newConsent);
});
const unsubBannerShow = consentManager.on('banner_show', () => {
setIsBannerVisible(true);
});
const unsubBannerHide = consentManager.on('banner_hide', () => {
setIsBannerVisible(false);
});
// Initialisieren
consentManager
.init()
.then(() => {
setConsent(consentManager.getConsent());
setIsInitialized(true);
setIsLoading(false);
setIsBannerVisible(consentManager.isBannerVisible());
})
.catch((error) => {
console.error('Failed to initialize ConsentManager:', error);
setIsLoading(false);
});
// Cleanup
return () => {
unsubChange();
unsubBannerShow();
unsubBannerHide();
};
}, [config]);
// Callback-Funktionen
const hasConsent = useCallback(
(category: ConsentCategory): boolean => {
return manager?.hasConsent(category) ?? category === 'essential';
},
[manager]
);
const acceptAll = useCallback(async () => {
await manager?.acceptAll();
}, [manager]);
const rejectAll = useCallback(async () => {
await manager?.rejectAll();
}, [manager]);
const saveSelection = useCallback(
async (categories: Partial<ConsentCategories>) => {
await manager?.setConsent(categories);
manager?.hideBanner();
},
[manager]
);
const showBanner = useCallback(() => {
manager?.showBanner();
}, [manager]);
const hideBanner = useCallback(() => {
manager?.hideBanner();
}, [manager]);
const showSettings = useCallback(() => {
manager?.showSettings();
}, [manager]);
const needsConsent = useMemo(() => {
return manager?.needsConsent() ?? true;
}, [manager, consent]);
// Context-Wert
const contextValue = useMemo<ConsentContextValue>(
() => ({
manager,
consent,
isInitialized,
isLoading,
isBannerVisible,
needsConsent,
hasConsent,
acceptAll,
rejectAll,
saveSelection,
showBanner,
hideBanner,
showSettings,
}),
[
manager,
consent,
isInitialized,
isLoading,
isBannerVisible,
needsConsent,
hasConsent,
acceptAll,
rejectAll,
saveSelection,
showBanner,
hideBanner,
showSettings,
]
);
return (
<ConsentContext.Provider value={contextValue}>
{children}
</ConsentContext.Provider>
);
};
// =============================================================================
// Hooks
// =============================================================================
/**
* useConsent - Hook fuer Consent-Zugriff
*
* @example
* ```tsx
* const { hasConsent, acceptAll, rejectAll } = useConsent();
*
* if (hasConsent('analytics')) {
* // Analytics laden
* }
* ```
*/
export function useConsent(): ConsentContextValue;
export function useConsent(
category: ConsentCategory
): ConsentContextValue & { allowed: boolean };
export function useConsent(category?: ConsentCategory) {
const context = useContext(ConsentContext);
if (!context) {
throw new Error('useConsent must be used within a ConsentProvider');
}
if (category) {
return {
...context,
allowed: context.hasConsent(category),
};
}
return context;
}
/**
* useConsentManager - Direkter Zugriff auf ConsentManager
*/
export function useConsentManager(): ConsentManager | null {
const context = useContext(ConsentContext);
return context?.manager ?? null;
}
// =============================================================================
// Components
// =============================================================================
interface ConsentGateProps {
/** Erforderliche Kategorie */
category: ConsentCategory;
/** Inhalt bei Consent */
children: ReactNode;
/** Inhalt ohne Consent */
placeholder?: ReactNode;
/** Fallback waehrend Laden */
fallback?: ReactNode;
}
/**
* ConsentGate - Zeigt Inhalt nur bei Consent
*
* @example
* ```tsx
* <ConsentGate
* category="analytics"
* placeholder={<ConsentPlaceholder category="analytics" />}
* >
* <GoogleAnalytics />
* </ConsentGate>
* ```
*/
export const ConsentGate: FC<ConsentGateProps> = ({
category,
children,
placeholder = null,
fallback = null,
}) => {
const { hasConsent, isLoading } = useConsent();
if (isLoading) {
return <>{fallback}</>;
}
if (!hasConsent(category)) {
return <>{placeholder}</>;
}
return <>{children}</>;
};
interface ConsentPlaceholderProps {
/** Kategorie */
category: ConsentCategory;
/** Custom Nachricht */
message?: string;
/** Custom Button-Text */
buttonText?: string;
/** Custom Styling */
className?: string;
}
/**
* ConsentPlaceholder - Placeholder fuer blockierten Inhalt
*/
export const ConsentPlaceholder: FC<ConsentPlaceholderProps> = ({
category,
message,
buttonText,
className = '',
}) => {
const { showSettings } = useConsent();
const categoryNames: Record<ConsentCategory, string> = {
essential: 'Essentielle Cookies',
functional: 'Funktionale Cookies',
analytics: 'Statistik-Cookies',
marketing: 'Marketing-Cookies',
social: 'Social Media-Cookies',
};
const defaultMessage = `Dieser Inhalt erfordert ${categoryNames[category]}.`;
return (
<div className={`bp-consent-placeholder ${className}`}>
<p>{message || defaultMessage}</p>
<button type="button" onClick={showSettings}>
{buttonText || 'Cookie-Einstellungen oeffnen'}
</button>
</div>
);
};
// =============================================================================
// Banner Component (Headless)
// =============================================================================
interface ConsentBannerRenderProps {
/** Ist Banner sichtbar? */
isVisible: boolean;
/** Aktueller Consent */
consent: ConsentState | null;
/** Wird Consent benoetigt? */
needsConsent: boolean;
/** Alle akzeptieren */
onAcceptAll: () => void;
/** Alle ablehnen */
onRejectAll: () => void;
/** Auswahl speichern */
onSaveSelection: (categories: Partial<ConsentCategories>) => void;
/** Einstellungen oeffnen */
onShowSettings: () => void;
/** Banner schliessen */
onClose: () => void;
}
interface ConsentBannerProps {
/** Render-Funktion fuer Custom UI */
render?: (props: ConsentBannerRenderProps) => ReactNode;
/** Custom Styling */
className?: string;
}
/**
* ConsentBanner - Headless Banner-Komponente
*
* Kann mit eigener UI gerendert werden oder nutzt Default-UI.
*
* @example
* ```tsx
* // Mit eigener UI
* <ConsentBanner
* render={({ isVisible, onAcceptAll, onRejectAll }) => (
* isVisible && (
* <div className="my-banner">
* <button onClick={onAcceptAll}>Accept</button>
* <button onClick={onRejectAll}>Reject</button>
* </div>
* )
* )}
* />
*
* // Mit Default-UI
* <ConsentBanner />
* ```
*/
export const ConsentBanner: FC<ConsentBannerProps> = ({ render, className }) => {
const {
consent,
isBannerVisible,
needsConsent,
acceptAll,
rejectAll,
saveSelection,
showSettings,
hideBanner,
} = useConsent();
const renderProps: ConsentBannerRenderProps = {
isVisible: isBannerVisible,
consent,
needsConsent,
onAcceptAll: acceptAll,
onRejectAll: rejectAll,
onSaveSelection: saveSelection,
onShowSettings: showSettings,
onClose: hideBanner,
};
// Custom Render
if (render) {
return <>{render(renderProps)}</>;
}
// Default UI
if (!isBannerVisible) {
return null;
}
return (
<div
className={`bp-consent-banner ${className || ''}`}
role="dialog"
aria-modal="true"
aria-label="Cookie-Einstellungen"
>
<div className="bp-consent-banner-content">
<h2>Datenschutzeinstellungen</h2>
<p>
Wir nutzen Cookies und aehnliche Technologien, um Ihnen ein optimales
Nutzererlebnis zu bieten.
</p>
<div className="bp-consent-banner-actions">
<button
type="button"
className="bp-consent-btn bp-consent-btn-reject"
onClick={rejectAll}
>
Alle ablehnen
</button>
<button
type="button"
className="bp-consent-btn bp-consent-btn-settings"
onClick={showSettings}
>
Einstellungen
</button>
<button
type="button"
className="bp-consent-btn bp-consent-btn-accept"
onClick={acceptAll}
>
Alle akzeptieren
</button>
</div>
</div>
</div>
);
};
// =============================================================================
// Exports
// =============================================================================
export { ConsentContext };
export type { ConsentContextValue, ConsentBannerRenderProps };

View File

@@ -0,0 +1,438 @@
/**
* Consent SDK Types
*
* DSGVO/TTDSG-konforme Typdefinitionen für das Consent Management System.
*/
// =============================================================================
// Consent Categories
// =============================================================================
/**
* Standard-Consent-Kategorien nach IAB TCF 2.2
*/
export type ConsentCategory =
| 'essential' // Technisch notwendig (TTDSG § 25 Abs. 2)
| 'functional' // Personalisierung, Komfortfunktionen
| 'analytics' // Anonyme Nutzungsanalyse
| 'marketing' // Werbung, Retargeting
| 'social'; // Social Media Plugins
/**
* Consent-Status pro Kategorie
*/
export type ConsentCategories = Record<ConsentCategory, boolean>;
/**
* Consent-Status pro Vendor
*/
export type ConsentVendors = Record<string, boolean>;
// =============================================================================
// Consent State
// =============================================================================
/**
* Aktueller Consent-Zustand
*/
export interface ConsentState {
/** Consent pro Kategorie */
categories: ConsentCategories;
/** Consent pro Vendor (optional, für granulare Kontrolle) */
vendors: ConsentVendors;
/** Zeitstempel der letzten Aenderung */
timestamp: string;
/** SDK-Version bei Erstellung */
version: string;
/** Eindeutige Consent-ID vom Backend */
consentId?: string;
/** Ablaufdatum */
expiresAt?: string;
/** IAB TCF String (falls aktiviert) */
tcfString?: string;
}
/**
* Minimaler Consent-Input fuer setConsent()
*/
export type ConsentInput = Partial<ConsentCategories> | {
categories?: Partial<ConsentCategories>;
vendors?: ConsentVendors;
};
// =============================================================================
// Configuration
// =============================================================================
/**
* UI-Position des Banners
*/
export type BannerPosition = 'bottom' | 'top' | 'center';
/**
* Banner-Layout
*/
export type BannerLayout = 'bar' | 'modal' | 'floating';
/**
* Farbschema
*/
export type BannerTheme = 'light' | 'dark' | 'auto';
/**
* UI-Konfiguration
*/
export interface ConsentUIConfig {
/** Position des Banners */
position?: BannerPosition;
/** Layout-Typ */
layout?: BannerLayout;
/** Farbschema */
theme?: BannerTheme;
/** Pfad zu Custom CSS */
customCss?: string;
/** z-index fuer Banner */
zIndex?: number;
/** Scroll blockieren bei Modal */
blockScrollOnModal?: boolean;
/** Custom Container-ID */
containerId?: string;
}
/**
* Consent-Verhaltens-Konfiguration
*/
export interface ConsentBehaviorConfig {
/** Muss Nutzer interagieren? */
required?: boolean;
/** "Alle ablehnen" Button sichtbar */
rejectAllVisible?: boolean;
/** "Alle akzeptieren" Button sichtbar */
acceptAllVisible?: boolean;
/** Einzelne Kategorien waehlbar */
granularControl?: boolean;
/** Einzelne Vendors waehlbar */
vendorControl?: boolean;
/** Auswahl speichern */
rememberChoice?: boolean;
/** Speicherdauer in Tagen */
rememberDays?: number;
/** Nur in EU anzeigen (Geo-Targeting) */
geoTargeting?: boolean;
/** Erneut nachfragen nach X Tagen */
recheckAfterDays?: number;
}
/**
* TCF 2.2 Konfiguration
*/
export interface TCFConfig {
/** TCF aktivieren */
enabled?: boolean;
/** CMP ID */
cmpId?: number;
/** CMP Version */
cmpVersion?: number;
}
/**
* PWA-spezifische Konfiguration
*/
export interface PWAConfig {
/** Offline-Unterstuetzung aktivieren */
offlineSupport?: boolean;
/** Bei Reconnect synchronisieren */
syncOnReconnect?: boolean;
/** Cache-Strategie */
cacheStrategy?: 'stale-while-revalidate' | 'network-first' | 'cache-first';
}
/**
* Haupt-Konfiguration fuer ConsentManager
*/
export interface ConsentConfig {
// Pflichtfelder
/** API-Endpunkt fuer Consent-Backend */
apiEndpoint: string;
/** Site-ID */
siteId: string;
// Sprache
/** Sprache (ISO 639-1) */
language?: string;
/** Fallback-Sprache */
fallbackLanguage?: string;
// UI
/** UI-Konfiguration */
ui?: ConsentUIConfig;
// Verhalten
/** Consent-Verhaltens-Konfiguration */
consent?: ConsentBehaviorConfig;
// Kategorien
/** Aktive Kategorien */
categories?: ConsentCategory[];
// TCF
/** TCF 2.2 Konfiguration */
tcf?: TCFConfig;
// PWA
/** PWA-Konfiguration */
pwa?: PWAConfig;
// Callbacks
/** Callback bei Consent-Aenderung */
onConsentChange?: (consent: ConsentState) => void;
/** Callback wenn Banner angezeigt wird */
onBannerShow?: () => void;
/** Callback wenn Banner geschlossen wird */
onBannerHide?: () => void;
/** Callback bei Fehler */
onError?: (error: Error) => void;
// Debug
/** Debug-Modus aktivieren */
debug?: boolean;
}
// =============================================================================
// Vendor Configuration
// =============================================================================
/**
* Cookie-Information
*/
export interface CookieInfo {
/** Cookie-Name */
name: string;
/** Cookie-Domain */
domain: string;
/** Ablaufzeit (z.B. "2 Jahre", "Session") */
expiration: string;
/** Speichertyp */
type: 'http' | 'localStorage' | 'sessionStorage' | 'indexedDB';
/** Beschreibung */
description: string;
}
/**
* Vendor-Definition
*/
export interface ConsentVendor {
/** Eindeutige Vendor-ID */
id: string;
/** Anzeigename */
name: string;
/** Kategorie */
category: ConsentCategory;
/** IAB TCF Purposes (falls relevant) */
purposes?: number[];
/** Legitimate Interests */
legitimateInterests?: number[];
/** Cookie-Liste */
cookies: CookieInfo[];
/** Link zur Datenschutzerklaerung */
privacyPolicyUrl: string;
/** Datenaufbewahrung */
dataRetention?: string;
/** Datentransfer (z.B. "USA (EU-US DPF)", "EU") */
dataTransfer?: string;
}
// =============================================================================
// API Types
// =============================================================================
/**
* API-Antwort fuer Consent-Erstellung
*/
export interface ConsentAPIResponse {
consentId: string;
timestamp: string;
expiresAt: string;
version: string;
}
/**
* API-Antwort fuer Site-Konfiguration
*/
export interface SiteConfigResponse {
siteId: string;
siteName: string;
categories: CategoryConfig[];
ui: ConsentUIConfig;
legal: LegalConfig;
tcf?: TCFConfig;
}
/**
* Kategorie-Konfiguration vom Server
*/
export interface CategoryConfig {
id: ConsentCategory;
name: Record<string, string>;
description: Record<string, string>;
required: boolean;
vendors: ConsentVendor[];
}
/**
* Rechtliche Konfiguration
*/
export interface LegalConfig {
privacyPolicyUrl: string;
imprintUrl: string;
dpo?: {
name: string;
email: string;
};
}
// =============================================================================
// Events
// =============================================================================
/**
* Event-Typen
*/
export type ConsentEventType =
| 'init'
| 'change'
| 'accept_all'
| 'reject_all'
| 'save_selection'
| 'banner_show'
| 'banner_hide'
| 'settings_open'
| 'settings_close'
| 'vendor_enable'
| 'vendor_disable'
| 'error';
/**
* Event-Listener Callback
*/
export type ConsentEventCallback<T = unknown> = (data: T) => void;
/**
* Event-Daten fuer verschiedene Events
*/
export type ConsentEventData = {
init: ConsentState | null;
change: ConsentState;
accept_all: ConsentState;
reject_all: ConsentState;
save_selection: ConsentState;
banner_show: undefined;
banner_hide: undefined;
settings_open: undefined;
settings_close: undefined;
vendor_enable: string;
vendor_disable: string;
error: Error;
};
// =============================================================================
// Storage
// =============================================================================
/**
* Storage-Adapter Interface
*/
export interface ConsentStorageAdapter {
/** Consent laden */
get(): ConsentState | null;
/** Consent speichern */
set(consent: ConsentState): void;
/** Consent loeschen */
clear(): void;
/** Pruefen ob Consent existiert */
exists(): boolean;
}
// =============================================================================
// Translations
// =============================================================================
/**
* Uebersetzungsstruktur
*/
export interface ConsentTranslations {
title: string;
description: string;
acceptAll: string;
rejectAll: string;
settings: string;
saveSelection: string;
close: string;
categories: {
[K in ConsentCategory]: {
name: string;
description: string;
};
};
footer: {
privacyPolicy: string;
imprint: string;
cookieDetails: string;
};
accessibility: {
closeButton: string;
categoryToggle: string;
requiredCategory: string;
};
}
/**
* Alle unterstuetzten Sprachen
*/
export type SupportedLanguage =
| 'de' | 'en' | 'fr' | 'es' | 'it' | 'nl' | 'pl' | 'pt'
| 'cs' | 'da' | 'el' | 'fi' | 'hu' | 'ro' | 'sk' | 'sl' | 'sv';

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from './EventEmitter';
interface TestEvents {
test: string;
count: number;
data: { value: string };
}
describe('EventEmitter', () => {
let emitter: EventEmitter<TestEvents>;
beforeEach(() => {
emitter = new EventEmitter();
});
describe('on', () => {
it('should register an event listener', () => {
const callback = vi.fn();
emitter.on('test', callback);
emitter.emit('test', 'hello');
expect(callback).toHaveBeenCalledWith('hello');
expect(callback).toHaveBeenCalledTimes(1);
});
it('should return an unsubscribe function', () => {
const callback = vi.fn();
const unsubscribe = emitter.on('test', callback);
emitter.emit('test', 'first');
unsubscribe();
emitter.emit('test', 'second');
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('first');
});
it('should allow multiple listeners for the same event', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
emitter.on('test', callback1);
emitter.on('test', callback2);
emitter.emit('test', 'value');
expect(callback1).toHaveBeenCalledWith('value');
expect(callback2).toHaveBeenCalledWith('value');
});
});
describe('off', () => {
it('should remove an event listener', () => {
const callback = vi.fn();
emitter.on('test', callback);
emitter.emit('test', 'first');
emitter.off('test', callback);
emitter.emit('test', 'second');
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not throw when removing non-existent listener', () => {
const callback = vi.fn();
expect(() => emitter.off('test', callback)).not.toThrow();
});
});
describe('emit', () => {
it('should call all listeners with the data', () => {
const callback = vi.fn();
emitter.on('data', callback);
emitter.emit('data', { value: 'test' });
expect(callback).toHaveBeenCalledWith({ value: 'test' });
});
it('should not throw when emitting to no listeners', () => {
expect(() => emitter.emit('test', 'value')).not.toThrow();
});
it('should catch errors in listeners and continue', () => {
const errorCallback = vi.fn(() => {
throw new Error('Test error');
});
const successCallback = vi.fn();
emitter.on('test', errorCallback);
emitter.on('test', successCallback);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
emitter.emit('test', 'value');
expect(errorCallback).toHaveBeenCalled();
expect(successCallback).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('once', () => {
it('should call listener only once', () => {
const callback = vi.fn();
emitter.once('test', callback);
emitter.emit('test', 'first');
emitter.emit('test', 'second');
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('first');
});
it('should return an unsubscribe function', () => {
const callback = vi.fn();
const unsubscribe = emitter.once('test', callback);
unsubscribe();
emitter.emit('test', 'value');
expect(callback).not.toHaveBeenCalled();
});
});
describe('clear', () => {
it('should remove all listeners', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
emitter.on('test', callback1);
emitter.on('count', callback2);
emitter.clear();
emitter.emit('test', 'value');
emitter.emit('count', 42);
expect(callback1).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
});
});
describe('clearEvent', () => {
it('should remove all listeners for a specific event', () => {
const testCallback = vi.fn();
const countCallback = vi.fn();
emitter.on('test', testCallback);
emitter.on('count', countCallback);
emitter.clearEvent('test');
emitter.emit('test', 'value');
emitter.emit('count', 42);
expect(testCallback).not.toHaveBeenCalled();
expect(countCallback).toHaveBeenCalledWith(42);
});
});
describe('listenerCount', () => {
it('should return the number of listeners for an event', () => {
expect(emitter.listenerCount('test')).toBe(0);
emitter.on('test', () => {});
expect(emitter.listenerCount('test')).toBe(1);
emitter.on('test', () => {});
expect(emitter.listenerCount('test')).toBe(2);
});
it('should return 0 for events with no listeners', () => {
expect(emitter.listenerCount('count')).toBe(0);
});
});
});

View File

@@ -0,0 +1,89 @@
/**
* EventEmitter - Typsicherer Event-Handler
*/
type EventCallback<T = unknown> = (data: T) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class EventEmitter<Events extends Record<string, any> = Record<string, unknown>> {
private listeners: Map<keyof Events, Set<EventCallback<unknown>>> = new Map();
/**
* Event-Listener registrieren
* @returns Unsubscribe-Funktion
*/
on<K extends keyof Events>(
event: K,
callback: EventCallback<Events[K]>
): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback as EventCallback<unknown>);
// Unsubscribe-Funktion zurueckgeben
return () => this.off(event, callback);
}
/**
* Event-Listener entfernen
*/
off<K extends keyof Events>(
event: K,
callback: EventCallback<Events[K]>
): void {
this.listeners.get(event)?.delete(callback as EventCallback<unknown>);
}
/**
* Event emittieren
*/
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.listeners.get(event)?.forEach((callback) => {
try {
callback(data);
} catch (error) {
console.error(`Error in event handler for ${String(event)}:`, error);
}
});
}
/**
* Einmaligen Listener registrieren
*/
once<K extends keyof Events>(
event: K,
callback: EventCallback<Events[K]>
): () => void {
const wrapper = (data: Events[K]) => {
this.off(event, wrapper);
callback(data);
};
return this.on(event, wrapper);
}
/**
* Alle Listener entfernen
*/
clear(): void {
this.listeners.clear();
}
/**
* Alle Listener fuer ein Event entfernen
*/
clearEvent<K extends keyof Events>(event: K): void {
this.listeners.delete(event);
}
/**
* Anzahl Listener fuer ein Event
*/
listenerCount<K extends keyof Events>(event: K): number {
return this.listeners.get(event)?.size ?? 0;
}
}
export default EventEmitter;

View File

@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateFingerprint, generateFingerprintSync } from './fingerprint';
describe('fingerprint', () => {
describe('generateFingerprint', () => {
it('should generate a fingerprint with fp_ prefix', async () => {
const fingerprint = await generateFingerprint();
expect(fingerprint).toMatch(/^fp_[a-f0-9]{32}$/);
});
it('should generate consistent fingerprints for same environment', async () => {
const fp1 = await generateFingerprint();
const fp2 = await generateFingerprint();
expect(fp1).toBe(fp2);
});
it('should include browser detection in fingerprint components', async () => {
// Chrome is in the mocked userAgent
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
});
});
describe('generateFingerprintSync', () => {
it('should generate a fingerprint with fp_ prefix', () => {
const fingerprint = generateFingerprintSync();
expect(fingerprint).toMatch(/^fp_[a-f0-9]+$/);
});
it('should be consistent for same environment', () => {
const fp1 = generateFingerprintSync();
const fp2 = generateFingerprintSync();
expect(fp1).toBe(fp2);
});
});
describe('environment variations', () => {
it('should detect screen categories correctly', async () => {
// Default is 1920px (FHD)
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
});
it('should handle touch detection', async () => {
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 0,
configurable: true,
});
});
it('should handle Do Not Track', async () => {
Object.defineProperty(navigator, 'doNotTrack', {
value: '1',
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(navigator, 'doNotTrack', {
value: null,
configurable: true,
});
});
});
describe('browser detection', () => {
it('should detect Firefox', async () => {
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
configurable: true,
});
});
it('should detect Safari', async () => {
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
configurable: true,
});
});
it('should detect Edge', async () => {
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
configurable: true,
});
});
});
describe('platform detection', () => {
it('should detect Windows', async () => {
Object.defineProperty(navigator, 'platform', {
value: 'Win32',
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
});
it('should detect Linux', async () => {
Object.defineProperty(navigator, 'platform', {
value: 'Linux x86_64',
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
});
it('should detect iOS', async () => {
Object.defineProperty(navigator, 'platform', {
value: 'iPhone',
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
});
});
describe('screen categories', () => {
it('should detect 4K screens', async () => {
Object.defineProperty(window, 'screen', {
value: { width: 3840, height: 2160, colorDepth: 24 },
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(window, 'screen', {
value: { width: 1920, height: 1080, colorDepth: 24 },
configurable: true,
});
});
it('should detect tablet screens', async () => {
Object.defineProperty(window, 'screen', {
value: { width: 1024, height: 768, colorDepth: 24 },
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(window, 'screen', {
value: { width: 1920, height: 1080, colorDepth: 24 },
configurable: true,
});
});
it('should detect mobile screens', async () => {
Object.defineProperty(window, 'screen', {
value: { width: 375, height: 812, colorDepth: 24 },
configurable: true,
});
const fingerprint = await generateFingerprint();
expect(fingerprint).toBeDefined();
// Reset
Object.defineProperty(window, 'screen', {
value: { width: 1920, height: 1080, colorDepth: 24 },
configurable: true,
});
});
});
});

View File

@@ -0,0 +1,176 @@
/**
* Device Fingerprinting - Datenschutzkonform
*
* Generiert einen anonymen Fingerprint OHNE:
* - Canvas Fingerprinting
* - WebGL Fingerprinting
* - Audio Fingerprinting
* - Hardware-spezifische IDs
*
* Verwendet nur:
* - User Agent
* - Sprache
* - Bildschirmaufloesung
* - Zeitzone
* - Platform
*/
/**
* Fingerprint-Komponenten sammeln
*/
function getComponents(): string[] {
if (typeof window === 'undefined') {
return ['server'];
}
const components: string[] = [];
// User Agent (anonymisiert)
try {
// Nur Browser-Familie, nicht vollstaendiger UA
const ua = navigator.userAgent;
if (ua.includes('Chrome')) components.push('chrome');
else if (ua.includes('Firefox')) components.push('firefox');
else if (ua.includes('Safari')) components.push('safari');
else if (ua.includes('Edge')) components.push('edge');
else components.push('other');
} catch {
components.push('unknown-browser');
}
// Sprache
try {
components.push(navigator.language || 'unknown-lang');
} catch {
components.push('unknown-lang');
}
// Bildschirm-Kategorie (nicht exakte Aufloesung)
try {
const width = window.screen.width;
if (width >= 2560) components.push('4k');
else if (width >= 1920) components.push('fhd');
else if (width >= 1366) components.push('hd');
else if (width >= 768) components.push('tablet');
else components.push('mobile');
} catch {
components.push('unknown-screen');
}
// Farbtiefe (grob)
try {
const depth = window.screen.colorDepth;
if (depth >= 24) components.push('deep-color');
else components.push('standard-color');
} catch {
components.push('unknown-color');
}
// Zeitzone (nur Offset, nicht Name)
try {
const offset = new Date().getTimezoneOffset();
const hours = Math.floor(Math.abs(offset) / 60);
const sign = offset <= 0 ? '+' : '-';
components.push(`tz${sign}${hours}`);
} catch {
components.push('unknown-tz');
}
// Platform-Kategorie
try {
const platform = navigator.platform?.toLowerCase() || '';
if (platform.includes('mac')) components.push('mac');
else if (platform.includes('win')) components.push('win');
else if (platform.includes('linux')) components.push('linux');
else if (platform.includes('iphone') || platform.includes('ipad'))
components.push('ios');
else if (platform.includes('android')) components.push('android');
else components.push('other-platform');
} catch {
components.push('unknown-platform');
}
// Touch-Faehigkeit
try {
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
components.push('touch');
} else {
components.push('no-touch');
}
} catch {
components.push('unknown-touch');
}
// Do Not Track (als Datenschutz-Signal)
try {
if (navigator.doNotTrack === '1') {
components.push('dnt');
}
} catch {
// Ignorieren
}
return components;
}
/**
* SHA-256 Hash (async, nutzt SubtleCrypto)
*/
async function sha256(message: string): Promise<string> {
if (typeof window === 'undefined' || !window.crypto?.subtle) {
// Fallback fuer Server/alte Browser
return simpleHash(message);
}
try {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
} catch {
return simpleHash(message);
}
}
/**
* Fallback Hash-Funktion (djb2)
*/
function simpleHash(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
/**
* Datenschutzkonformen Fingerprint generieren
*
* Der Fingerprint ist:
* - Nicht eindeutig (viele Nutzer teilen sich denselben)
* - Nicht persistent (aendert sich bei Browser-Updates)
* - Nicht invasiv (keine Canvas/WebGL/Audio)
* - Anonymisiert (SHA-256 Hash)
*/
export async function generateFingerprint(): Promise<string> {
const components = getComponents();
const combined = components.join('|');
const hash = await sha256(combined);
// Prefix fuer Identifikation
return `fp_${hash.substring(0, 32)}`;
}
/**
* Synchrone Version (mit einfachem Hash)
*/
export function generateFingerprintSync(): string {
const components = getComponents();
const combined = components.join('|');
const hash = simpleHash(combined);
return `fp_${hash}`;
}
export default generateFingerprint;

View File

@@ -0,0 +1,6 @@
/**
* SDK Version
*/
export const SDK_VERSION = '1.0.0';
export default SDK_VERSION;

View File

@@ -0,0 +1,511 @@
/**
* Vue 3 Integration fuer @breakpilot/consent-sdk
*
* @example
* ```vue
* <script setup>
* import { useConsent, ConsentBanner, ConsentGate } from '@breakpilot/consent-sdk/vue';
*
* const { hasConsent, acceptAll, rejectAll } = useConsent();
* </script>
*
* <template>
* <ConsentBanner />
* <ConsentGate category="analytics">
* <AnalyticsComponent />
* </ConsentGate>
* </template>
* ```
*/
import {
ref,
computed,
readonly,
inject,
provide,
onMounted,
onUnmounted,
defineComponent,
h,
type Ref,
type InjectionKey,
type PropType,
} from 'vue';
import { ConsentManager } from '../core/ConsentManager';
import type {
ConsentConfig,
ConsentState,
ConsentCategory,
ConsentCategories,
} from '../types';
// =============================================================================
// Injection Key
// =============================================================================
const CONSENT_KEY: InjectionKey<ConsentContext> = Symbol('consent');
// =============================================================================
// Types
// =============================================================================
interface ConsentContext {
manager: Ref<ConsentManager | null>;
consent: Ref<ConsentState | null>;
isInitialized: Ref<boolean>;
isLoading: Ref<boolean>;
isBannerVisible: Ref<boolean>;
needsConsent: Ref<boolean>;
hasConsent: (category: ConsentCategory) => boolean;
acceptAll: () => Promise<void>;
rejectAll: () => Promise<void>;
saveSelection: (categories: Partial<ConsentCategories>) => Promise<void>;
showBanner: () => void;
hideBanner: () => void;
showSettings: () => void;
}
// =============================================================================
// Composable: useConsent
// =============================================================================
/**
* Haupt-Composable fuer Consent-Zugriff
*
* @example
* ```vue
* <script setup>
* const { hasConsent, acceptAll } = useConsent();
*
* if (hasConsent('analytics')) {
* // Analytics laden
* }
* </script>
* ```
*/
export function useConsent(): ConsentContext {
const context = inject(CONSENT_KEY);
if (!context) {
throw new Error(
'useConsent() must be used within a component that has called provideConsent() or is wrapped in ConsentProvider'
);
}
return context;
}
/**
* Consent-Provider einrichten (in App.vue aufrufen)
*
* @example
* ```vue
* <script setup>
* import { provideConsent } from '@breakpilot/consent-sdk/vue';
*
* provideConsent({
* apiEndpoint: 'https://consent.example.com/api/v1',
* siteId: 'site_abc123',
* });
* </script>
* ```
*/
export function provideConsent(config: ConsentConfig): ConsentContext {
const manager = ref<ConsentManager | null>(null);
const consent = ref<ConsentState | null>(null);
const isInitialized = ref(false);
const isLoading = ref(true);
const isBannerVisible = ref(false);
const needsConsent = computed(() => {
return manager.value?.needsConsent() ?? true;
});
// Initialisierung
onMounted(async () => {
const consentManager = new ConsentManager(config);
manager.value = consentManager;
// Events abonnieren
const unsubChange = consentManager.on('change', (newConsent) => {
consent.value = newConsent;
});
const unsubBannerShow = consentManager.on('banner_show', () => {
isBannerVisible.value = true;
});
const unsubBannerHide = consentManager.on('banner_hide', () => {
isBannerVisible.value = false;
});
try {
await consentManager.init();
consent.value = consentManager.getConsent();
isInitialized.value = true;
isBannerVisible.value = consentManager.isBannerVisible();
} catch (error) {
console.error('Failed to initialize ConsentManager:', error);
} finally {
isLoading.value = false;
}
// Cleanup bei Unmount
onUnmounted(() => {
unsubChange();
unsubBannerShow();
unsubBannerHide();
});
});
// Methoden
const hasConsent = (category: ConsentCategory): boolean => {
return manager.value?.hasConsent(category) ?? category === 'essential';
};
const acceptAll = async (): Promise<void> => {
await manager.value?.acceptAll();
};
const rejectAll = async (): Promise<void> => {
await manager.value?.rejectAll();
};
const saveSelection = async (categories: Partial<ConsentCategories>): Promise<void> => {
await manager.value?.setConsent(categories);
manager.value?.hideBanner();
};
const showBanner = (): void => {
manager.value?.showBanner();
};
const hideBanner = (): void => {
manager.value?.hideBanner();
};
const showSettings = (): void => {
manager.value?.showSettings();
};
const context: ConsentContext = {
manager: readonly(manager) as Ref<ConsentManager | null>,
consent: readonly(consent) as Ref<ConsentState | null>,
isInitialized: readonly(isInitialized),
isLoading: readonly(isLoading),
isBannerVisible: readonly(isBannerVisible),
needsConsent,
hasConsent,
acceptAll,
rejectAll,
saveSelection,
showBanner,
hideBanner,
showSettings,
};
provide(CONSENT_KEY, context);
return context;
}
// =============================================================================
// Components
// =============================================================================
/**
* ConsentProvider - Wrapper-Komponente
*
* @example
* ```vue
* <ConsentProvider :config="config">
* <App />
* </ConsentProvider>
* ```
*/
export const ConsentProvider = defineComponent({
name: 'ConsentProvider',
props: {
config: {
type: Object as PropType<ConsentConfig>,
required: true,
},
},
setup(props, { slots }) {
provideConsent(props.config);
return () => slots.default?.();
},
});
/**
* ConsentGate - Zeigt Inhalt nur bei Consent
*
* @example
* ```vue
* <ConsentGate category="analytics">
* <template #default>
* <AnalyticsComponent />
* </template>
* <template #placeholder>
* <p>Bitte akzeptieren Sie Statistik-Cookies.</p>
* </template>
* </ConsentGate>
* ```
*/
export const ConsentGate = defineComponent({
name: 'ConsentGate',
props: {
category: {
type: String as PropType<ConsentCategory>,
required: true,
},
},
setup(props, { slots }) {
const { hasConsent, isLoading } = useConsent();
return () => {
if (isLoading.value) {
return slots.fallback?.() ?? null;
}
if (!hasConsent(props.category)) {
return slots.placeholder?.() ?? null;
}
return slots.default?.();
};
},
});
/**
* ConsentPlaceholder - Placeholder fuer blockierten Inhalt
*
* @example
* ```vue
* <ConsentPlaceholder category="marketing" />
* ```
*/
export const ConsentPlaceholder = defineComponent({
name: 'ConsentPlaceholder',
props: {
category: {
type: String as PropType<ConsentCategory>,
required: true,
},
message: {
type: String,
default: '',
},
buttonText: {
type: String,
default: 'Cookie-Einstellungen öffnen',
},
},
setup(props) {
const { showSettings } = useConsent();
const categoryNames: Record<ConsentCategory, string> = {
essential: 'Essentielle Cookies',
functional: 'Funktionale Cookies',
analytics: 'Statistik-Cookies',
marketing: 'Marketing-Cookies',
social: 'Social Media-Cookies',
};
const displayMessage = computed(() => {
return props.message || `Dieser Inhalt erfordert ${categoryNames[props.category]}.`;
});
return () =>
h('div', { class: 'bp-consent-placeholder' }, [
h('p', displayMessage.value),
h(
'button',
{
type: 'button',
onClick: showSettings,
},
props.buttonText
),
]);
},
});
/**
* ConsentBanner - Cookie-Banner Komponente
*
* @example
* ```vue
* <ConsentBanner>
* <template #default="{ isVisible, onAcceptAll, onRejectAll, onShowSettings }">
* <div v-if="isVisible" class="my-banner">
* <button @click="onAcceptAll">Accept</button>
* <button @click="onRejectAll">Reject</button>
* </div>
* </template>
* </ConsentBanner>
* ```
*/
export const ConsentBanner = defineComponent({
name: 'ConsentBanner',
setup(_, { slots }) {
const {
consent,
isBannerVisible,
needsConsent,
acceptAll,
rejectAll,
saveSelection,
showSettings,
hideBanner,
} = useConsent();
const slotProps = computed(() => ({
isVisible: isBannerVisible.value,
consent: consent.value,
needsConsent: needsConsent.value,
onAcceptAll: acceptAll,
onRejectAll: rejectAll,
onSaveSelection: saveSelection,
onShowSettings: showSettings,
onClose: hideBanner,
}));
return () => {
// Custom Slot
if (slots.default) {
return slots.default(slotProps.value);
}
// Default UI
if (!isBannerVisible.value) {
return null;
}
return h(
'div',
{
class: 'bp-consent-banner',
role: 'dialog',
'aria-modal': 'true',
'aria-label': 'Cookie-Einstellungen',
},
[
h('div', { class: 'bp-consent-banner-content' }, [
h('h2', 'Datenschutzeinstellungen'),
h(
'p',
'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.'
),
h('div', { class: 'bp-consent-banner-actions' }, [
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-reject',
onClick: rejectAll,
},
'Alle ablehnen'
),
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-settings',
onClick: showSettings,
},
'Einstellungen'
),
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-accept',
onClick: acceptAll,
},
'Alle akzeptieren'
),
]),
]),
]
);
};
},
});
// =============================================================================
// Plugin
// =============================================================================
/**
* Vue Plugin fuer globale Installation
*
* @example
* ```ts
* import { createApp } from 'vue';
* import { ConsentPlugin } from '@breakpilot/consent-sdk/vue';
*
* const app = createApp(App);
* app.use(ConsentPlugin, {
* apiEndpoint: 'https://consent.example.com/api/v1',
* siteId: 'site_abc123',
* });
* ```
*/
export const ConsentPlugin = {
install(app: { provide: (key: symbol | string, value: unknown) => void }, config: ConsentConfig) {
const manager = new ConsentManager(config);
const consent = ref<ConsentState | null>(null);
const isInitialized = ref(false);
const isLoading = ref(true);
const isBannerVisible = ref(false);
// Initialisieren
manager.init().then(() => {
consent.value = manager.getConsent();
isInitialized.value = true;
isLoading.value = false;
isBannerVisible.value = manager.isBannerVisible();
});
// Events
manager.on('change', (newConsent) => {
consent.value = newConsent;
});
manager.on('banner_show', () => {
isBannerVisible.value = true;
});
manager.on('banner_hide', () => {
isBannerVisible.value = false;
});
const context: ConsentContext = {
manager: ref(manager) as Ref<ConsentManager | null>,
consent: consent as Ref<ConsentState | null>,
isInitialized,
isLoading,
isBannerVisible,
needsConsent: computed(() => manager.needsConsent()),
hasConsent: (category: ConsentCategory) => manager.hasConsent(category),
acceptAll: () => manager.acceptAll(),
rejectAll: () => manager.rejectAll(),
saveSelection: async (categories: Partial<ConsentCategories>) => {
await manager.setConsent(categories);
manager.hideBanner();
},
showBanner: () => manager.showBanner(),
hideBanner: () => manager.hideBanner(),
showSettings: () => manager.showSettings(),
};
app.provide(CONSENT_KEY, context);
},
};
// =============================================================================
// Exports
// =============================================================================
export { CONSENT_KEY };
export type { ConsentContext };