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:
509
consent-sdk/src/angular/index.ts
Normal file
509
consent-sdk/src/angular/index.ts
Normal 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 };
|
||||
312
consent-sdk/src/core/ConsentAPI.test.ts
Normal file
312
consent-sdk/src/core/ConsentAPI.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
212
consent-sdk/src/core/ConsentAPI.ts
Normal file
212
consent-sdk/src/core/ConsentAPI.ts
Normal 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;
|
||||
605
consent-sdk/src/core/ConsentManager.test.ts
Normal file
605
consent-sdk/src/core/ConsentManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
525
consent-sdk/src/core/ConsentManager.ts
Normal file
525
consent-sdk/src/core/ConsentManager.ts
Normal 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;
|
||||
212
consent-sdk/src/core/ConsentStorage.test.ts
Normal file
212
consent-sdk/src/core/ConsentStorage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
203
consent-sdk/src/core/ConsentStorage.ts
Normal file
203
consent-sdk/src/core/ConsentStorage.ts
Normal 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;
|
||||
305
consent-sdk/src/core/ScriptBlocker.test.ts
Normal file
305
consent-sdk/src/core/ScriptBlocker.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
367
consent-sdk/src/core/ScriptBlocker.ts
Normal file
367
consent-sdk/src/core/ScriptBlocker.ts
Normal 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;
|
||||
7
consent-sdk/src/core/index.ts
Normal file
7
consent-sdk/src/core/index.ts
Normal 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
81
consent-sdk/src/index.ts
Normal 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';
|
||||
182
consent-sdk/src/mobile/README.md
Normal file
182
consent-sdk/src/mobile/README.md
Normal 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)
|
||||
499
consent-sdk/src/mobile/android/ConsentManager.kt
Normal file
499
consent-sdk/src/mobile/android/ConsentManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
658
consent-sdk/src/mobile/flutter/consent_sdk.dart
Normal file
658
consent-sdk/src/mobile/flutter/consent_sdk.dart
Normal 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);
|
||||
}
|
||||
517
consent-sdk/src/mobile/ios/ConsentManager.swift
Normal file
517
consent-sdk/src/mobile/ios/ConsentManager.swift
Normal 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() })
|
||||
}
|
||||
}
|
||||
511
consent-sdk/src/react/index.tsx
Normal file
511
consent-sdk/src/react/index.tsx
Normal 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 };
|
||||
438
consent-sdk/src/types/index.ts
Normal file
438
consent-sdk/src/types/index.ts
Normal 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';
|
||||
177
consent-sdk/src/utils/EventEmitter.test.ts
Normal file
177
consent-sdk/src/utils/EventEmitter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
89
consent-sdk/src/utils/EventEmitter.ts
Normal file
89
consent-sdk/src/utils/EventEmitter.ts
Normal 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;
|
||||
230
consent-sdk/src/utils/fingerprint.test.ts
Normal file
230
consent-sdk/src/utils/fingerprint.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
176
consent-sdk/src/utils/fingerprint.ts
Normal file
176
consent-sdk/src/utils/fingerprint.ts
Normal 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;
|
||||
6
consent-sdk/src/version.ts
Normal file
6
consent-sdk/src/version.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* SDK Version
|
||||
*/
|
||||
export const SDK_VERSION = '1.0.0';
|
||||
|
||||
export default SDK_VERSION;
|
||||
511
consent-sdk/src/vue/index.ts
Normal file
511
consent-sdk/src/vue/index.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user