Initial commit: breakpilot-compliance - Compliance SDK Platform

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

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

26
consent-sdk/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Build output
dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Test coverage
coverage/
# Temporary files
*.tmp
*.temp

190
consent-sdk/LICENSE Normal file
View File

@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 BreakPilot GmbH
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

243
consent-sdk/README.md Normal file
View File

@@ -0,0 +1,243 @@
# @breakpilot/consent-sdk
DSGVO/TTDSG-konformes Consent Management SDK fuer Web, PWA und Mobile Apps.
## Features
- DSGVO, TTDSG und ePrivacy-Richtlinie konform
- IAB TCF 2.2 Unterstuetzung
- Google Consent Mode v2 Integration
- Plattformuebergreifend (Web, PWA, iOS, Android, Flutter, React Native)
- Typsicher (TypeScript)
- Barrierefreundlich (WCAG 2.1 AA)
- Open Source (Apache 2.0)
## Installation
```bash
npm install @breakpilot/consent-sdk
```
## Quick Start
### Vanilla JavaScript
```javascript
import { ConsentManager } from '@breakpilot/consent-sdk';
const consent = new ConsentManager({
apiEndpoint: 'https://consent.example.com/api/v1',
siteId: 'site_abc123',
language: 'de',
});
await consent.init();
// Consent pruefen
if (consent.hasConsent('analytics')) {
// Analytics laden
}
// Events
consent.on('change', (newConsent) => {
console.log('Consent changed:', newConsent);
});
```
### React
```tsx
import { ConsentProvider, useConsent, ConsentBanner, ConsentGate } from '@breakpilot/consent-sdk/react';
const config = {
apiEndpoint: 'https://consent.example.com/api/v1',
siteId: 'site_abc123',
};
function App() {
return (
<ConsentProvider config={config}>
<ConsentBanner />
<MainContent />
</ConsentProvider>
);
}
function AnalyticsSection() {
return (
<ConsentGate
category="analytics"
placeholder={<p>Analytics erfordert Ihre Zustimmung.</p>}
>
<GoogleAnalytics />
</ConsentGate>
);
}
function SettingsButton() {
const { showSettings } = useConsent();
return <button onClick={showSettings}>Cookie-Einstellungen</button>;
}
```
## Script Blocking
Verwenden Sie `data-consent` um Skripte zu blockieren:
```html
<!-- Externes Script -->
<script
data-consent="analytics"
data-src="https://www.googletagmanager.com/gtag/js?id=GA-XXXXX"
type="text/plain">
</script>
<!-- Inline Script -->
<script data-consent="marketing" type="text/plain">
fbq('init', 'XXXXX');
</script>
<!-- iFrame -->
<iframe
data-consent="social"
data-src="https://www.youtube.com/embed/XXXXX"
title="YouTube Video">
</iframe>
```
## Consent-Kategorien
| Kategorie | Beschreibung | Einwilligung |
|-----------|--------------|--------------|
| `essential` | Technisch notwendig | Nicht erforderlich |
| `functional` | Personalisierung | Erforderlich |
| `analytics` | Nutzungsanalyse | Erforderlich |
| `marketing` | Werbung | Erforderlich |
| `social` | Social Media | Erforderlich |
## Konfiguration
```typescript
const config: ConsentConfig = {
// Pflicht
apiEndpoint: 'https://consent.example.com/api/v1',
siteId: 'site_abc123',
// Sprache
language: 'de',
fallbackLanguage: 'en',
// UI
ui: {
position: 'bottom', // 'bottom' | 'top' | 'center'
layout: 'modal', // 'bar' | 'modal' | 'floating'
theme: 'auto', // 'light' | 'dark' | 'auto'
zIndex: 999999,
},
// Verhalten
consent: {
required: true,
rejectAllVisible: true, // "Alle ablehnen" Button
acceptAllVisible: true, // "Alle akzeptieren" Button
granularControl: true, // Kategorien einzeln waehlbar
rememberDays: 365, // Speicherdauer
recheckAfterDays: 180, // Erneut fragen nach X Tagen
},
// Callbacks
onConsentChange: (consent) => {
console.log('Consent:', consent);
},
// Debug
debug: process.env.NODE_ENV === 'development',
};
```
## API
### ConsentManager
```typescript
// Initialisieren
await consent.init();
// Consent pruefen
consent.hasConsent('analytics'); // boolean
consent.hasVendorConsent('google'); // boolean
// Consent abrufen
consent.getConsent(); // ConsentState | null
// Consent setzen
await consent.setConsent({
essential: true,
analytics: true,
marketing: false,
});
// Aktionen
await consent.acceptAll();
await consent.rejectAll();
await consent.revokeAll();
// Banner
consent.showBanner();
consent.hideBanner();
consent.showSettings();
consent.needsConsent(); // boolean
// Events
consent.on('change', callback);
consent.on('banner_show', callback);
consent.on('banner_hide', callback);
consent.off('change', callback);
// Export (DSGVO Art. 20)
const data = await consent.exportConsent();
```
### React Hooks
```typescript
// Basis-Hook
const {
consent,
isLoading,
isBannerVisible,
needsConsent,
hasConsent,
acceptAll,
rejectAll,
showBanner,
showSettings,
} = useConsent();
// Mit Kategorie
const { allowed } = useConsent('analytics');
// Manager-Zugriff
const manager = useConsentManager();
```
## Rechtliche Compliance
Dieses SDK erfuellt:
- **DSGVO** (EU 2016/679) - Art. 4, 6, 7, 12, 13, 17, 20
- **TTDSG** (Deutschland) - § 25
- **ePrivacy-Richtlinie** (2002/58/EG) - Art. 5
- **Planet49-Urteil** (EuGH C-673/17)
- **BGH Cookie-Einwilligung II** (2023)
- **DSK Orientierungshilfe Telemedien**
## Lizenz
Apache 2.0 - Open Source, kommerziell nutzbar.
```
Copyright 2026 BreakPilot GmbH
Licensed under the Apache License, Version 2.0
```

5403
consent-sdk/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

94
consent-sdk/package.json Normal file
View File

@@ -0,0 +1,94 @@
{
"name": "@breakpilot/consent-sdk",
"version": "1.0.0",
"description": "DSGVO/TTDSG-konformes Consent Management SDK für Web, PWA und Mobile Apps",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./react": {
"import": "./dist/react/index.esm.js",
"require": "./dist/react/index.js",
"types": "./dist/react/index.d.ts"
},
"./vue": {
"import": "./dist/vue/index.esm.js",
"require": "./dist/vue/index.js",
"types": "./dist/vue/index.d.ts"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest",
"test:coverage": "vitest --coverage",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"prepublishOnly": "npm run build"
},
"keywords": [
"consent",
"cookie",
"gdpr",
"dsgvo",
"ttdsg",
"privacy",
"cookie-banner",
"consent-management",
"tcf"
],
"author": "BreakPilot GmbH",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/breakpilot/consent-sdk.git"
},
"homepage": "https://github.com/breakpilot/consent-sdk#readme",
"bugs": {
"url": "https://github.com/breakpilot/consent-sdk/issues"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitest/coverage-v8": "^1.2.1",
"eslint": "^8.56.0",
"jsdom": "^28.0.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^1.2.1",
"vue": "^3.5.27"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"vue": {
"optional": true
}
},
"engines": {
"node": ">=18.0.0"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

137
consent-sdk/test-setup.ts Normal file
View File

@@ -0,0 +1,137 @@
import { vi } from 'vitest';
// Mock localStorage
const localStorageMock = {
store: {} as Record<string, string>,
getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
setItem: vi.fn((key: string, value: string) => {
localStorageMock.store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete localStorageMock.store[key];
}),
clear: vi.fn(() => {
localStorageMock.store = {};
}),
get length() {
return Object.keys(localStorageMock.store).length;
},
key: vi.fn((index: number) => Object.keys(localStorageMock.store)[index] || null),
};
vi.stubGlobal('localStorage', localStorageMock);
// Mock fetch
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({}),
text: () => Promise.resolve(''),
})
)
);
// Mock crypto for fingerprinting
const cryptoMock = {
subtle: {
digest: vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
// Simple mock hash - returns predictable data
const view = new Uint8Array(data);
const hash = new Uint8Array(32);
for (let i = 0; i < hash.length; i++) {
hash[i] = (view[i % view.length] || 0) ^ (i * 7);
}
return hash.buffer;
}),
},
getRandomValues: vi.fn(<T extends ArrayBufferView | null>(arr: T): T => {
if (arr instanceof Uint8Array) {
for (let i = 0; i < arr.length; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
}
return arr;
}),
};
vi.stubGlobal('crypto', cryptoMock);
// Mock document.cookie
let documentCookie = '';
Object.defineProperty(document, 'cookie', {
get: () => documentCookie,
set: (value: string) => {
documentCookie = value;
},
configurable: true,
});
// Mock navigator properties
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,
});
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true,
});
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
Object.defineProperty(navigator, 'doNotTrack', {
value: null,
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 0,
configurable: true,
});
// Mock screen
Object.defineProperty(window, 'screen', {
value: {
width: 1920,
height: 1080,
colorDepth: 24,
pixelDepth: 24,
availWidth: 1920,
availHeight: 1040,
orientation: { type: 'landscape-primary', angle: 0 },
},
configurable: true,
});
// Mock location
Object.defineProperty(window, 'location', {
value: {
protocol: 'https:',
hostname: 'localhost',
port: '3000',
pathname: '/',
href: 'https://localhost:3000/',
},
writable: true,
configurable: true,
});
// Reset mocks before each test
beforeEach(() => {
localStorageMock.store = {};
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
localStorageMock.removeItem.mockClear();
localStorageMock.clear.mockClear();
documentCookie = '';
vi.clearAllMocks();
});
// Export for use in tests
export { localStorageMock };

27
consent-sdk/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -0,0 +1,44 @@
import { defineConfig } from 'tsup';
export default defineConfig([
// Main entry
{
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
sourcemap: true,
clean: true,
outDir: 'dist',
external: ['react', 'react-dom', 'vue'],
},
// React entry
{
entry: ['src/react/index.tsx'],
format: ['cjs', 'esm'],
dts: true,
sourcemap: true,
outDir: 'dist/react',
external: ['react', 'react-dom'],
esbuildOptions(options) {
options.jsx = 'automatic';
},
},
// Vue entry
{
entry: ['src/vue/index.ts'],
format: ['cjs', 'esm'],
dts: true,
sourcemap: true,
outDir: 'dist/vue',
external: ['vue'],
},
// Angular entry
{
entry: ['src/angular/index.ts'],
format: ['cjs', 'esm'],
dts: true,
sourcemap: true,
outDir: 'dist/angular',
external: ['@angular/core', '@angular/common', 'rxjs'],
},
]);

View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./test-setup.ts'],
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.d.ts',
'test-setup.ts',
'vitest.config.ts',
// Framework integrations require separate component testing
'src/react/**',
'src/vue/**',
'src/angular/**',
// Re-export index files
'src/index.ts',
'src/core/index.ts',
],
thresholds: {
statements: 80,
branches: 70,
functions: 80,
lines: 80,
},
},
},
});