From 3e654af8a3b8bea67a1117b4a4a745b8db86e20b Mon Sep 17 00:00:00 2001 From: David Date: Tue, 27 Jan 2026 19:57:15 +0100 Subject: [PATCH] fix --- .../controllers/gdpr.controller.ts | 50 +- .../src/application/dto/consent.dto.ts | 139 +++++ .../src/application/gdpr/gdpr.module.ts | 2 + .../src/application/services/gdpr.service.ts | 179 +++++-- .../entities/cookie-consent.orm-entity.ts | 58 ++ .../1738100000000-CreateCookieConsent.ts | 62 +++ .../frontend/src/components/CookieConsent.tsx | 498 +++++++++--------- apps/frontend/src/components/providers.tsx | 9 +- apps/frontend/src/lib/api/gdpr.ts | 146 +++-- apps/frontend/src/lib/api/index.ts | 7 +- .../src/lib/context/cookie-context.tsx | 228 ++++++++ 11 files changed, 1026 insertions(+), 352 deletions(-) create mode 100644 apps/backend/src/application/dto/consent.dto.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts create mode 100644 apps/frontend/src/lib/context/cookie-context.tsx diff --git a/apps/backend/src/application/controllers/gdpr.controller.ts b/apps/backend/src/application/controllers/gdpr.controller.ts index b21f2fc..1c77436 100644 --- a/apps/backend/src/application/controllers/gdpr.controller.ts +++ b/apps/backend/src/application/controllers/gdpr.controller.ts @@ -14,13 +14,20 @@ import { HttpCode, HttpStatus, Res, + Req, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; -import { Response } from 'express'; +import { Response, Request } from 'express'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser } from '../decorators/current-user.decorator'; import { UserPayload } from '../decorators/current-user.decorator'; -import { GDPRService, ConsentData } from '../services/gdpr.service'; +import { GDPRService } from '../services/gdpr.service'; +import { + UpdateConsentDto, + ConsentResponseDto, + WithdrawConsentDto, + ConsentSuccessDto, +} from '../dto/consent.dto'; @ApiTags('GDPR') @Controller('gdpr') @@ -77,6 +84,13 @@ export class GDPRController { csv += `User Data,${key},"${value}"\n`; }); + // Cookie consent data + if (exportData.cookieConsent) { + Object.entries(exportData.cookieConsent).forEach(([key, value]) => { + csv += `Cookie Consent,${key},"${value}"\n`; + }); + } + // Set headers res.setHeader('Content-Type', 'text/csv'); res.setHeader( @@ -119,22 +133,26 @@ export class GDPRController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Record user consent', - description: 'Record consent for marketing, analytics, etc. (GDPR Article 7)', + description: 'Record consent for cookies (GDPR Article 7)', }) @ApiResponse({ status: 200, description: 'Consent recorded', + type: ConsentResponseDto, }) async recordConsent( @CurrentUser() user: UserPayload, - @Body() body: Omit - ): Promise<{ success: boolean }> { - await this.gdprService.recordConsent({ + @Body() body: UpdateConsentDto, + @Req() req: Request + ): Promise { + // Add IP and user agent from request if not provided + const consentData: UpdateConsentDto = { ...body, - userId: user.id, - }); + ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress, + userAgent: body.userAgent || req.headers['user-agent'], + }; - return { success: true }; + return this.gdprService.recordConsent(user.id, consentData); } /** @@ -144,19 +162,18 @@ export class GDPRController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Withdraw consent', - description: 'Withdraw consent for marketing or analytics (GDPR Article 7.3)', + description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)', }) @ApiResponse({ status: 200, description: 'Consent withdrawn', + type: ConsentResponseDto, }) async withdrawConsent( @CurrentUser() user: UserPayload, - @Body() body: { consentType: 'marketing' | 'analytics' } - ): Promise<{ success: boolean }> { - await this.gdprService.withdrawConsent(user.id, body.consentType); - - return { success: true }; + @Body() body: WithdrawConsentDto + ): Promise { + return this.gdprService.withdrawConsent(user.id, body.consentType); } /** @@ -170,8 +187,9 @@ export class GDPRController { @ApiResponse({ status: 200, description: 'Consent status retrieved', + type: ConsentResponseDto, }) - async getConsentStatus(@CurrentUser() user: UserPayload): Promise { + async getConsentStatus(@CurrentUser() user: UserPayload): Promise { return this.gdprService.getConsentStatus(user.id); } } diff --git a/apps/backend/src/application/dto/consent.dto.ts b/apps/backend/src/application/dto/consent.dto.ts new file mode 100644 index 0000000..fa3b883 --- /dev/null +++ b/apps/backend/src/application/dto/consent.dto.ts @@ -0,0 +1,139 @@ +/** + * Cookie Consent DTOs + * GDPR compliant consent management + */ + +import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Request DTO for recording/updating cookie consent + */ +export class UpdateConsentDto { + @ApiProperty({ + example: true, + description: 'Essential cookies consent (always true, required for functionality)', + default: true, + }) + @IsBoolean() + essential: boolean; + + @ApiProperty({ + example: false, + description: 'Functional cookies consent (preferences, language, etc.)', + default: false, + }) + @IsBoolean() + functional: boolean; + + @ApiProperty({ + example: false, + description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)', + default: false, + }) + @IsBoolean() + analytics: boolean; + + @ApiProperty({ + example: false, + description: 'Marketing cookies consent (ads, tracking, remarketing)', + default: false, + }) + @IsBoolean() + marketing: boolean; + + @ApiPropertyOptional({ + example: '192.168.1.1', + description: 'IP address at time of consent (for GDPR audit trail)', + }) + @IsOptional() + @IsString() + ipAddress?: string; + + @ApiPropertyOptional({ + example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + description: 'User agent at time of consent', + }) + @IsOptional() + @IsString() + userAgent?: string; +} + +/** + * Response DTO for consent status + */ +export class ConsentResponseDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'User ID', + }) + userId: string; + + @ApiProperty({ + example: true, + description: 'Essential cookies consent (always true)', + }) + essential: boolean; + + @ApiProperty({ + example: false, + description: 'Functional cookies consent', + }) + functional: boolean; + + @ApiProperty({ + example: false, + description: 'Analytics cookies consent', + }) + analytics: boolean; + + @ApiProperty({ + example: false, + description: 'Marketing cookies consent', + }) + marketing: boolean; + + @ApiProperty({ + example: '2025-01-27T10:30:00.000Z', + description: 'Date when consent was recorded', + }) + consentDate: Date; + + @ApiProperty({ + example: '2025-01-27T10:30:00.000Z', + description: 'Last update timestamp', + }) + updatedAt: Date; +} + +/** + * Request DTO for withdrawing specific consent + */ +export class WithdrawConsentDto { + @ApiProperty({ + example: 'marketing', + description: 'Type of consent to withdraw', + enum: ['functional', 'analytics', 'marketing'], + }) + @IsEnum(['functional', 'analytics', 'marketing'], { + message: 'Consent type must be functional, analytics, or marketing', + }) + consentType: 'functional' | 'analytics' | 'marketing'; +} + +/** + * Success response DTO + */ +export class ConsentSuccessDto { + @ApiProperty({ + example: true, + description: 'Operation success status', + }) + success: boolean; + + @ApiProperty({ + example: 'Consent preferences saved successfully', + description: 'Response message', + }) + message: string; +} diff --git a/apps/backend/src/application/gdpr/gdpr.module.ts b/apps/backend/src/application/gdpr/gdpr.module.ts index a9355a4..6869942 100644 --- a/apps/backend/src/application/gdpr/gdpr.module.ts +++ b/apps/backend/src/application/gdpr/gdpr.module.ts @@ -12,6 +12,7 @@ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity'; import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity'; +import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/ BookingOrmEntity, AuditLogOrmEntity, NotificationOrmEntity, + CookieConsentOrmEntity, ]), ], controllers: [GDPRController], diff --git a/apps/backend/src/application/services/gdpr.service.ts b/apps/backend/src/application/services/gdpr.service.ts index 3f637e8..b2d8541 100644 --- a/apps/backend/src/application/services/gdpr.service.ts +++ b/apps/backend/src/application/services/gdpr.service.ts @@ -1,37 +1,35 @@ /** - * GDPR Compliance Service - Simplified Version + * GDPR Compliance Service * * Handles data export, deletion, and consent management + * with full database persistence */ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; +import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto'; export interface GDPRDataExport { exportDate: string; userId: string; userData: any; + cookieConsent: any; message: string; } -export interface ConsentData { - userId: string; - marketing: boolean; - analytics: boolean; - functional: boolean; - consentDate: Date; - ipAddress?: string; -} - @Injectable() export class GDPRService { private readonly logger = new Logger(GDPRService.name); constructor( @InjectRepository(UserOrmEntity) - private readonly userRepository: Repository + private readonly userRepository: Repository, + @InjectRepository(CookieConsentOrmEntity) + private readonly consentRepository: Repository ) {} /** @@ -46,6 +44,9 @@ export class GDPRService { throw new NotFoundException('User not found'); } + // Fetch consent data + const consent = await this.consentRepository.findOne({ where: { userId } }); + // Sanitize user data (remove password hash) const sanitizedUser = { id: user.id, @@ -63,6 +64,15 @@ export class GDPRService { exportDate: new Date().toISOString(), userId, userData: sanitizedUser, + cookieConsent: consent + ? { + essential: consent.essential, + functional: consent.functional, + analytics: consent.analytics, + marketing: consent.marketing, + consentDate: consent.consentDate, + } + : null, message: 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.', }; @@ -88,6 +98,9 @@ export class GDPRService { } try { + // Delete consent data first (will cascade with user deletion) + await this.consentRepository.delete({ userId }); + // IMPORTANT: In production, implement full data anonymization // For now, we just mark the account for deletion // Real implementation should: @@ -105,55 +118,139 @@ export class GDPRService { } /** - * Record consent (GDPR Article 7 - Conditions for consent) + * Record or update consent (GDPR Article 7 - Conditions for consent) */ - async recordConsent(consentData: ConsentData): Promise { - this.logger.log(`Recording consent for user ${consentData.userId}`); - - const user = await this.userRepository.findOne({ - where: { id: consentData.userId }, - }); - - if (!user) { - throw new NotFoundException('User not found'); - } - - // In production, store in separate consent table - // For now, just log the consent - this.logger.log( - `Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}` - ); - } - - /** - * Withdraw consent (GDPR Article 7.3 - Withdrawal of consent) - */ - async withdrawConsent(userId: string, consentType: 'marketing' | 'analytics'): Promise { - this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`); + async recordConsent( + userId: string, + consentData: UpdateConsentDto + ): Promise { + this.logger.log(`Recording consent for user ${userId}`); + // Verify user exists const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException('User not found'); } + // Check if consent already exists + let consent = await this.consentRepository.findOne({ where: { userId } }); + + if (consent) { + // Update existing consent + consent.essential = true; // Always true + consent.functional = consentData.functional; + consent.analytics = consentData.analytics; + consent.marketing = consentData.marketing; + consent.ipAddress = consentData.ipAddress || consent.ipAddress; + consent.userAgent = consentData.userAgent || consent.userAgent; + consent.consentDate = new Date(); + + await this.consentRepository.save(consent); + this.logger.log(`Consent updated for user ${userId}`); + } else { + // Create new consent record + consent = this.consentRepository.create({ + id: uuidv4(), + userId, + essential: true, // Always true + functional: consentData.functional, + analytics: consentData.analytics, + marketing: consentData.marketing, + ipAddress: consentData.ipAddress, + userAgent: consentData.userAgent, + consentDate: new Date(), + }); + + await this.consentRepository.save(consent); + this.logger.log(`New consent created for user ${userId}`); + } + + return { + userId, + essential: consent.essential, + functional: consent.functional, + analytics: consent.analytics, + marketing: consent.marketing, + consentDate: consent.consentDate, + updatedAt: consent.updatedAt, + }; + } + + /** + * Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent) + */ + async withdrawConsent( + userId: string, + consentType: 'functional' | 'analytics' | 'marketing' + ): Promise { + this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`); + + // Verify user exists + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + // Find consent record + let consent = await this.consentRepository.findOne({ where: { userId } }); + + if (!consent) { + // Create default consent with withdrawn type + consent = this.consentRepository.create({ + id: uuidv4(), + userId, + essential: true, + functional: consentType === 'functional' ? false : false, + analytics: consentType === 'analytics' ? false : false, + marketing: consentType === 'marketing' ? false : false, + consentDate: new Date(), + }); + } else { + // Update specific consent type + consent[consentType] = false; + consent.consentDate = new Date(); + } + + await this.consentRepository.save(consent); this.logger.log(`${consentType} consent withdrawn for user ${userId}`); + + return { + userId, + essential: consent.essential, + functional: consent.functional, + analytics: consent.analytics, + marketing: consent.marketing, + consentDate: consent.consentDate, + updatedAt: consent.updatedAt, + }; } /** * Get current consent status */ - async getConsentStatus(userId: string): Promise { + async getConsentStatus(userId: string): Promise { + // Verify user exists const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException('User not found'); } - // Default consent status + // Find consent record + const consent = await this.consentRepository.findOne({ where: { userId } }); + + if (!consent) { + // No consent recorded yet - return null to indicate user should provide consent + return null; + } + return { - marketing: false, - analytics: false, - functional: true, - message: 'Consent management fully implemented in production version', + userId, + essential: consent.essential, + functional: consent.functional, + analytics: consent.analytics, + marketing: consent.marketing, + consentDate: consent.consentDate, + updatedAt: consent.updatedAt, }; } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts new file mode 100644 index 0000000..2f8188c --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts @@ -0,0 +1,58 @@ +/** + * Cookie Consent ORM Entity (Infrastructure Layer) + * + * TypeORM entity for cookie consent persistence + */ + +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { UserOrmEntity } from './user.orm-entity'; + +@Entity('cookie_consents') +@Index('idx_cookie_consents_user', ['userId']) +export class CookieConsentOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid', unique: true }) + userId: string; + + @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserOrmEntity; + + @Column({ type: 'boolean', default: true }) + essential: boolean; + + @Column({ type: 'boolean', default: false }) + functional: boolean; + + @Column({ type: 'boolean', default: false }) + analytics: boolean; + + @Column({ type: 'boolean', default: false }) + marketing: boolean; + + @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) + ipAddress: string | null; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string | null; + + @Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' }) + consentDate: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts new file mode 100644 index 0000000..561df7c --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts @@ -0,0 +1,62 @@ +/** + * Migration: Create Cookie Consents Table + * GDPR compliant cookie preference storage + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCookieConsent1738100000000 implements MigrationInterface { + name = 'CreateCookieConsent1738100000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Create cookie_consents table + await queryRunner.query(` + CREATE TABLE "cookie_consents" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "user_id" UUID NOT NULL, + "essential" BOOLEAN NOT NULL DEFAULT TRUE, + "functional" BOOLEAN NOT NULL DEFAULT FALSE, + "analytics" BOOLEAN NOT NULL DEFAULT FALSE, + "marketing" BOOLEAN NOT NULL DEFAULT FALSE, + "ip_address" VARCHAR(45) NULL, + "user_agent" TEXT NULL, + "consent_date" TIMESTAMP NOT NULL DEFAULT NOW(), + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"), + CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"), + CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id") + REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + // Create index for fast user lookups + await queryRunner.query(` + CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "cookie_consents"`); + } +} diff --git a/apps/frontend/src/components/CookieConsent.tsx b/apps/frontend/src/components/CookieConsent.tsx index 06aab0b..0050b1d 100644 --- a/apps/frontend/src/components/CookieConsent.tsx +++ b/apps/frontend/src/components/CookieConsent.tsx @@ -1,265 +1,279 @@ /** * Cookie Consent Banner - * GDPR Compliant + * GDPR Compliant - French version */ -import React, { useState, useEffect } from 'react'; -import Link from 'next/link'; +'use client'; -interface CookiePreferences { - essential: boolean; // Always true (required for functionality) - functional: boolean; - analytics: boolean; - marketing: boolean; -} +import React, { useState } from 'react'; +import Link from 'next/link'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Cookie, X, Settings, Check, Shield } from 'lucide-react'; +import { useCookieConsent } from '@/lib/context/cookie-context'; +import type { CookiePreferences } from '@/lib/api/gdpr'; export default function CookieConsent() { - const [showBanner, setShowBanner] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const [preferences, setPreferences] = useState({ - essential: true, - functional: true, - analytics: false, - marketing: false, - }); + const { + preferences, + showBanner, + showSettings, + isLoading, + setShowBanner, + setShowSettings, + acceptAll, + acceptEssentialOnly, + savePreferences, + openPreferences, + } = useCookieConsent(); - useEffect(() => { - // Check if user has already made a choice - const consent = localStorage.getItem('cookieConsent'); - if (!consent) { - setShowBanner(true); - } else { - const savedPreferences = JSON.parse(consent); - setPreferences(savedPreferences); - } - }, []); + const [localPrefs, setLocalPrefs] = useState(preferences); - const acceptAll = () => { - const allAccepted: CookiePreferences = { - essential: true, - functional: true, - analytics: true, - marketing: true, - }; - savePreferences(allAccepted); + // Sync local prefs when context changes + React.useEffect(() => { + setLocalPrefs(preferences); + }, [preferences]); + + const handleSaveCustom = async () => { + await savePreferences(localPrefs); }; - const acceptEssentialOnly = () => { - const essentialOnly: CookiePreferences = { - essential: true, - functional: false, - analytics: false, - marketing: false, - }; - savePreferences(essentialOnly); - }; - - const saveCustomPreferences = () => { - savePreferences(preferences); - }; - - const savePreferences = (prefs: CookiePreferences) => { - localStorage.setItem('cookieConsent', JSON.stringify(prefs)); - localStorage.setItem('cookieConsentDate', new Date().toISOString()); - setShowBanner(false); - setShowSettings(false); - - // Apply preferences - applyPreferences(prefs); - }; - - const applyPreferences = (prefs: CookiePreferences) => { - // Enable/disable analytics tracking - if (prefs.analytics) { - // Enable Google Analytics, Sentry, etc. - if (typeof window !== 'undefined' && (window as any).gtag) { - (window as any).gtag('consent', 'update', { - analytics_storage: 'granted', - }); - } - } else { - if (typeof window !== 'undefined' && (window as any).gtag) { - (window as any).gtag('consent', 'update', { - analytics_storage: 'denied', - }); - } - } - - // Enable/disable marketing tracking - if (prefs.marketing) { - if (typeof window !== 'undefined' && (window as any).gtag) { - (window as any).gtag('consent', 'update', { - ad_storage: 'granted', - }); - } - } else { - if (typeof window !== 'undefined' && (window as any).gtag) { - (window as any).gtag('consent', 'update', { - ad_storage: 'denied', - }); - } - } - }; - - if (!showBanner) { + // Don't render anything while loading + if (isLoading) { return null; } return ( <> + {/* Floating Cookie Button (shown when banner is closed) */} + + {!showBanner && ( + + + + )} + + {/* Cookie Banner */} -
-
- {!showSettings ? ( - // Simple banner -
-
-

🍪 We use cookies

-

- We use cookies to improve your experience, analyze site traffic, and personalize - content. By clicking "Accept All", you consent to our use of cookies.{' '} - - Learn more - -

-
- -
- - - -
-
- ) : ( - // Detailed settings -
-
-

Cookie Preferences

- -
- -
- {/* Essential Cookies */} -
-
-
-

Essential Cookies

- - Always Active - + + {showBanner && ( + +
+ + {!showSettings ? ( + // Simple banner + +
+
+ +

+ Nous utilisons des cookies +

+
+

+ Nous utilisons des cookies pour améliorer votre expérience, analyser le + trafic du site et personnaliser le contenu. En cliquant sur « Tout + accepter », vous consentez à notre utilisation des cookies.{' '} + + En savoir plus + +

-

- Required for the website to function. Cannot be disabled. + +

+ + + +
+
+ ) : ( + // Detailed settings + +
+
+ +

+ Préférences de cookies +

+
+ +
+ +
+ {/* Essential Cookies */} +
+
+
+

+ Cookies essentiels +

+ + Toujours actif + +
+

+ Nécessaires au fonctionnement du site. Ne peuvent pas être désactivés. +

+
+
+ +
+
+ + {/* Functional Cookies */} +
+
+

+ Cookies fonctionnels +

+

+ Permettent de mémoriser vos préférences et paramètres (langue, région). +

+
+
+ + setLocalPrefs({ ...localPrefs, functional: e.target.checked }) + } + className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer" + /> +
+
+ + {/* Analytics Cookies */} +
+
+

+ Cookies analytiques +

+

+ Nous aident Ă  comprendre comment les visiteurs interagissent avec notre + site (Google Analytics, Sentry). +

+
+
+ + setLocalPrefs({ ...localPrefs, analytics: e.target.checked }) + } + className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer" + /> +
+
+ + {/* Marketing Cookies */} +
+
+

+ Cookies marketing +

+

+ Utilisés pour afficher des publicités personnalisées et mesurer + l'efficacité des campagnes. +

+
+
+ + setLocalPrefs({ ...localPrefs, marketing: e.target.checked }) + } + className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer" + /> +
+
+
+ +
+ + +
+ +

+ Vous pouvez modifier vos préférences à tout moment dans les paramètres de + votre compte ou en cliquant sur l'icône cookie en bas à gauche.{' '} + + Politique de cookies +

-
- -
- - {/* Functional Cookies */} -
-
-

Functional Cookies

-

- Remember your preferences and settings (e.g., language, region). -

-
- setPreferences({ ...preferences, functional: e.target.checked })} - className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" - /> -
- - {/* Analytics Cookies */} -
-
-

Analytics Cookies

-

- Help us understand how visitors interact with our website (Google Analytics, - Sentry). -

-
- setPreferences({ ...preferences, analytics: e.target.checked })} - className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" - /> -
- - {/* Marketing Cookies */} -
-
-

Marketing Cookies

-

- Used to deliver personalized ads and measure campaign effectiveness. -

-
- setPreferences({ ...preferences, marketing: e.target.checked })} - className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" - /> -
-
- -
- - -
- -

- You can change your preferences at any time in your account settings or by clicking - the cookie icon in the footer. -

+ + )} +
- )} -
-
+ + )} + ); } diff --git a/apps/frontend/src/components/providers.tsx b/apps/frontend/src/components/providers.tsx index 95d2978..d0237df 100644 --- a/apps/frontend/src/components/providers.tsx +++ b/apps/frontend/src/components/providers.tsx @@ -9,6 +9,8 @@ import { useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from '@/lib/context/auth-context'; +import { CookieProvider } from '@/lib/context/cookie-context'; +import CookieConsent from '@/components/CookieConsent'; export function Providers({ children }: { children: React.ReactNode }) { // Create a client instance per component instance @@ -27,7 +29,12 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + + {children} + + + ); } diff --git a/apps/frontend/src/lib/api/gdpr.ts b/apps/frontend/src/lib/api/gdpr.ts index a5153eb..ae67a4c 100644 --- a/apps/frontend/src/lib/api/gdpr.ts +++ b/apps/frontend/src/lib/api/gdpr.ts @@ -5,11 +5,38 @@ */ import { get, post, patch } from './client'; -import type { - SuccessResponse, -} from '@/types/api'; +import type { SuccessResponse } from '@/types/api'; -// TODO: These types should be moved to @/types/api.ts +/** + * Cookie consent preferences + */ +export interface CookiePreferences { + essential: boolean; + functional: boolean; + analytics: boolean; + marketing: boolean; +} + +/** + * Response from consent API + */ +export interface ConsentResponse extends CookiePreferences { + userId: string; + consentDate: string; + updatedAt: string; +} + +/** + * Request to update consent + */ +export interface UpdateConsentRequest extends CookiePreferences { + ipAddress?: string; + userAgent?: string; +} + +/** + * Data export response + */ export interface GdprDataExportResponse { exportId: string; status: 'PENDING' | 'COMPLETED' | 'FAILED'; @@ -18,49 +45,50 @@ export interface GdprDataExportResponse { downloadUrl?: string; } -export interface GdprConsentResponse { - userId: string; - marketingEmails: boolean; - dataProcessing: boolean; - thirdPartySharing: boolean; - updatedAt: string; -} - -export interface UpdateGdprConsentRequest { - marketingEmails?: boolean; - dataProcessing?: boolean; - thirdPartySharing?: boolean; -} - /** * Request data export (GDPR right to data portability) - * POST /api/v1/gdpr/export - * Generates export job and sends download link via email + * GET /api/v1/gdpr/export + * Triggers download of JSON file */ -export async function requestDataExport(): Promise { - return post('/api/v1/gdpr/export'); -} - -/** - * Download exported data - * GET /api/v1/gdpr/export/:exportId/download - * Returns blob (JSON file) - */ -export async function downloadDataExport(exportId: string): Promise { +export async function requestDataExport(): Promise { const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/${exportId}/download`, + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`, { method: 'GET', headers: { Authorization: `Bearer ${ - typeof window !== 'undefined' ? localStorage.getItem('access_token') : '' + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : '' }`, }, } ); if (!response.ok) { - throw new Error(`Download failed: ${response.statusText}`); + throw new Error(`Export failed: ${response.statusText}`); + } + + return response.blob(); +} + +/** + * Request data export as CSV + * GET /api/v1/gdpr/export/csv + */ +export async function requestDataExportCSV(): Promise { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${ + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : '' + }`, + }, + } + ); + + if (!response.ok) { + throw new Error(`Export failed: ${response.statusText}`); } return response.blob(); @@ -68,35 +96,53 @@ export async function downloadDataExport(exportId: string): Promise { /** * Request account deletion (GDPR right to be forgotten) - * POST /api/v1/gdpr/delete-account - * Initiates 30-day account deletion process + * DELETE /api/v1/gdpr/delete-account + * Initiates account deletion process */ -export async function requestAccountDeletion(): Promise { - return post('/api/v1/gdpr/delete-account'); -} +export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : '' + }`, + }, + body: JSON.stringify({ confirmEmail, reason }), + } + ); -/** - * Cancel pending account deletion - * POST /api/v1/gdpr/cancel-deletion - */ -export async function cancelAccountDeletion(): Promise { - return post('/api/v1/gdpr/cancel-deletion'); + if (!response.ok) { + throw new Error(`Deletion failed: ${response.statusText}`); + } } /** * Get user consent preferences * GET /api/v1/gdpr/consent */ -export async function getConsentPreferences(): Promise { - return get('/api/v1/gdpr/consent'); +export async function getConsentPreferences(): Promise { + return get('/api/v1/gdpr/consent'); } /** * Update consent preferences - * PATCH /api/v1/gdpr/consent + * POST /api/v1/gdpr/consent */ export async function updateConsentPreferences( - data: UpdateGdprConsentRequest -): Promise { - return patch('/api/v1/gdpr/consent', data); + data: UpdateConsentRequest +): Promise { + return post('/api/v1/gdpr/consent', data); +} + +/** + * Withdraw specific consent + * POST /api/v1/gdpr/consent/withdraw + */ +export async function withdrawConsent( + consentType: 'functional' | 'analytics' | 'marketing' +): Promise { + return post('/api/v1/gdpr/consent/withdraw', { consentType }); } diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 7a850c8..f3d6e82 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -107,11 +107,14 @@ export { // GDPR (6 endpoints) export { requestDataExport, - downloadDataExport, + requestDataExportCSV, requestAccountDeletion, - cancelAccountDeletion, getConsentPreferences, updateConsentPreferences, + withdrawConsent, + type CookiePreferences, + type ConsentResponse, + type UpdateConsentRequest, } from './gdpr'; // Admin CSV Rates (5 endpoints) - already exists diff --git a/apps/frontend/src/lib/context/cookie-context.tsx b/apps/frontend/src/lib/context/cookie-context.tsx new file mode 100644 index 0000000..c1e88a1 --- /dev/null +++ b/apps/frontend/src/lib/context/cookie-context.tsx @@ -0,0 +1,228 @@ +/** + * Cookie Consent Context + * + * Provides cookie consent state and methods to the application + * Syncs with backend for authenticated users + */ + +'use client'; + +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { + getConsentPreferences, + updateConsentPreferences, + type CookiePreferences, +} from '../api/gdpr'; +import { getAuthToken } from '../api/client'; + +const STORAGE_KEY = 'cookieConsent'; +const STORAGE_DATE_KEY = 'cookieConsentDate'; + +interface CookieContextType { + preferences: CookiePreferences; + showBanner: boolean; + showSettings: boolean; + isLoading: boolean; + setShowBanner: (show: boolean) => void; + setShowSettings: (show: boolean) => void; + acceptAll: () => Promise; + acceptEssentialOnly: () => Promise; + savePreferences: (prefs: CookiePreferences) => Promise; + openPreferences: () => void; +} + +const defaultPreferences: CookiePreferences = { + essential: true, + functional: false, + analytics: false, + marketing: false, +}; + +const CookieContext = createContext(undefined); + +export function CookieProvider({ children }: { children: React.ReactNode }) { + const [preferences, setPreferences] = useState(defaultPreferences); + const [showBanner, setShowBanner] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [hasInitialized, setHasInitialized] = useState(false); + + // Check if user is authenticated + const isAuthenticated = useCallback(() => { + return !!getAuthToken(); + }, []); + + // Load preferences from localStorage + const loadFromLocalStorage = useCallback((): CookiePreferences | null => { + if (typeof window === 'undefined') return null; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + return JSON.parse(stored); + } catch { + return null; + } + } + return null; + }, []); + + // Save preferences to localStorage + const saveToLocalStorage = useCallback((prefs: CookiePreferences) => { + if (typeof window === 'undefined') return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + localStorage.setItem(STORAGE_DATE_KEY, new Date().toISOString()); + }, []); + + // Apply preferences (gtag, etc.) + const applyPreferences = useCallback((prefs: CookiePreferences) => { + if (typeof window === 'undefined') return; + + // Google Analytics consent + const gtag = (window as any).gtag; + if (gtag) { + gtag('consent', 'update', { + analytics_storage: prefs.analytics ? 'granted' : 'denied', + ad_storage: prefs.marketing ? 'granted' : 'denied', + functionality_storage: prefs.functional ? 'granted' : 'denied', + }); + } + + // Sentry (if analytics enabled) + const Sentry = (window as any).Sentry; + if (Sentry) { + if (prefs.analytics) { + Sentry.init && Sentry.getCurrentHub && Sentry.getCurrentHub().getClient()?.getOptions(); + } + } + }, []); + + // Initialize preferences + useEffect(() => { + const initializePreferences = async () => { + setIsLoading(true); + + // First, check localStorage + const localPrefs = loadFromLocalStorage(); + + if (localPrefs) { + setPreferences(localPrefs); + applyPreferences(localPrefs); + setShowBanner(false); + } else { + // No local consent - show banner + setShowBanner(true); + } + + // If authenticated, try to sync with backend + if (isAuthenticated() && localPrefs) { + try { + const backendPrefs = await getConsentPreferences(); + if (backendPrefs) { + // Backend has preferences - use them + const prefs: CookiePreferences = { + essential: backendPrefs.essential, + functional: backendPrefs.functional, + analytics: backendPrefs.analytics, + marketing: backendPrefs.marketing, + }; + setPreferences(prefs); + saveToLocalStorage(prefs); + applyPreferences(prefs); + } else if (localPrefs) { + // No backend preferences but we have local - sync to backend + try { + await updateConsentPreferences({ + ...localPrefs, + userAgent: navigator.userAgent, + }); + } catch (syncError) { + console.warn('Failed to sync consent to backend:', syncError); + } + } + } catch (error) { + console.warn('Failed to fetch consent from backend:', error); + // Continue with local preferences + } + } + + setIsLoading(false); + setHasInitialized(true); + }; + + initializePreferences(); + }, [isAuthenticated, loadFromLocalStorage, saveToLocalStorage, applyPreferences]); + + // Save preferences (to localStorage and optionally backend) + const savePreferences = useCallback( + async (prefs: CookiePreferences) => { + // Ensure essential is always true + const safePrefs = { ...prefs, essential: true }; + + setPreferences(safePrefs); + saveToLocalStorage(safePrefs); + applyPreferences(safePrefs); + setShowBanner(false); + setShowSettings(false); + + // Sync to backend if authenticated + if (isAuthenticated()) { + try { + await updateConsentPreferences({ + ...safePrefs, + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + }); + } catch (error) { + console.warn('Failed to sync consent to backend:', error); + // Local save succeeded, backend sync failed - that's okay + } + } + }, + [isAuthenticated, saveToLocalStorage, applyPreferences] + ); + + const acceptAll = useCallback(async () => { + await savePreferences({ + essential: true, + functional: true, + analytics: true, + marketing: true, + }); + }, [savePreferences]); + + const acceptEssentialOnly = useCallback(async () => { + await savePreferences({ + essential: true, + functional: false, + analytics: false, + marketing: false, + }); + }, [savePreferences]); + + const openPreferences = useCallback(() => { + setShowBanner(true); + setShowSettings(true); + }, []); + + const value: CookieContextType = { + preferences, + showBanner, + showSettings, + isLoading, + setShowBanner, + setShowSettings, + acceptAll, + acceptEssentialOnly, + savePreferences, + openPreferences, + }; + + return {children}; +} + +export function useCookieConsent() { + const context = useContext(CookieContext); + if (context === undefined) { + throw new Error('useCookieConsent must be used within a CookieProvider'); + } + return context; +}