This commit is contained in:
David 2026-01-27 19:57:15 +01:00
parent 4c7b07a911
commit 3e654af8a3
11 changed files with 1026 additions and 352 deletions

View File

@ -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<ConsentData, 'userId'>
): Promise<{ success: boolean }> {
await this.gdprService.recordConsent({
@Body() body: UpdateConsentDto,
@Req() req: Request
): Promise<ConsentResponseDto> {
// 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<ConsentResponseDto> {
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<any> {
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
return this.gdprService.getConsentStatus(user.id);
}
}

View File

@ -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;
}

View File

@ -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],

View File

@ -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<UserOrmEntity>
private readonly userRepository: Repository<UserOrmEntity>,
@InjectRepository(CookieConsentOrmEntity)
private readonly consentRepository: Repository<CookieConsentOrmEntity>
) {}
/**
@ -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<void> {
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<void> {
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
async recordConsent(
userId: string,
consentData: UpdateConsentDto
): Promise<ConsentResponseDto> {
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<ConsentResponseDto> {
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<any> {
async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> {
// 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,
};
}
}

View File

@ -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;
}

View File

@ -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<void> {
// 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<void> {
await queryRunner.query(`DROP TABLE "cookie_consents"`);
}
}

View File

@ -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<CookiePreferences>({
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<CookiePreferences>(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) */}
<AnimatePresence>
{!showBanner && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
onClick={openPreferences}
className="fixed bottom-4 left-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors"
aria-label="Ouvrir les paramètres de cookies"
>
<Cookie className="w-5 h-5" />
</motion.button>
)}
</AnimatePresence>
{/* Cookie Banner */}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t-2 border-gray-200 shadow-2xl">
<AnimatePresence>
{showBanner && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-2xl"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<AnimatePresence mode="wait">
{!showSettings ? (
// Simple banner
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<motion.div
key="simple"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4"
>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">🍪 We use cookies</h3>
<p className="text-sm text-gray-600">
We use cookies to improve your experience, analyze site traffic, and personalize
content. By clicking "Accept All", you consent to our use of cookies.{' '}
<Link href="/privacy" className="text-blue-600 hover:text-blue-800 underline">
Learn more
<div className="flex items-center gap-2 mb-2">
<Cookie className="w-5 h-5 text-brand-navy" />
<h3 className="text-lg font-semibold text-brand-navy">
Nous utilisons des cookies
</h3>
</div>
<p className="text-sm text-gray-600 max-w-2xl">
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.{' '}
<Link
href="/cookies"
className="text-brand-turquoise hover:text-brand-turquoise/80 underline"
>
En savoir plus
</Link>
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
<button
onClick={() => setShowSettings(true)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
>
Customize
<Settings className="w-4 h-4" />
Personnaliser
</button>
<button
onClick={acceptEssentialOnly}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
>
Essential Only
Essentiel uniquement
</button>
<button
onClick={acceptAll}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
>
Accept All
<Check className="w-4 h-4" />
Tout accepter
</button>
</div>
</div>
</motion.div>
) : (
// Detailed settings
<div>
<motion.div
key="settings"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Cookie Preferences</h3>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-brand-navy" />
<h3 className="text-lg font-semibold text-brand-navy">
Préférences de cookies
</h3>
</div>
<button
onClick={() => setShowSettings(false)}
className="text-gray-400 hover:text-gray-600"
className="p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Fermer les paramètres"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4 mb-6">
<div className="grid gap-3 mb-6 max-h-[40vh] overflow-y-auto pr-2">
{/* Essential Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex-1">
<div className="flex items-center">
<h4 className="text-sm font-semibold text-gray-900">Essential Cookies</h4>
<span className="ml-2 px-2 py-1 text-xs font-medium text-gray-600 bg-gray-200 rounded">
Always Active
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-900">
Cookies essentiels
</h4>
<span className="px-2 py-0.5 text-xs font-medium text-brand-navy bg-brand-navy/10 rounded-full">
Toujours actif
</span>
</div>
<p className="mt-1 text-sm text-gray-600">
Required for the website to function. Cannot be disabled.
Nécessaires au fonctionnement du site. Ne peuvent pas être désactivés.
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={true}
disabled
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded"
className="h-5 w-5 text-brand-navy border-gray-300 rounded cursor-not-allowed opacity-60"
/>
</div>
</div>
{/* Functional Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">Functional Cookies</h4>
<h4 className="text-sm font-semibold text-gray-900">
Cookies fonctionnels
</h4>
<p className="mt-1 text-sm text-gray-600">
Remember your preferences and settings (e.g., language, region).
Permettent de mémoriser vos préférences et paramètres (langue, gion).
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={preferences.functional}
onChange={e => setPreferences({ ...preferences, functional: e.target.checked })}
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={localPrefs.functional}
onChange={e =>
setLocalPrefs({ ...localPrefs, functional: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
/>
</div>
</div>
{/* Analytics Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">Analytics Cookies</h4>
<h4 className="text-sm font-semibold text-gray-900">
Cookies analytiques
</h4>
<p className="mt-1 text-sm text-gray-600">
Help us understand how visitors interact with our website (Google Analytics,
Sentry).
Nous aident à comprendre comment les visiteurs interagissent avec notre
site (Google Analytics, Sentry).
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={preferences.analytics}
onChange={e => setPreferences({ ...preferences, analytics: e.target.checked })}
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={localPrefs.analytics}
onChange={e =>
setLocalPrefs({ ...localPrefs, analytics: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
/>
</div>
</div>
{/* Marketing Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">Marketing Cookies</h4>
<h4 className="text-sm font-semibold text-gray-900">
Cookies marketing
</h4>
<p className="mt-1 text-sm text-gray-600">
Used to deliver personalized ads and measure campaign effectiveness.
Utilisés pour afficher des publicités personnalisées et mesurer
l'efficacité des campagnes.
</p>
</div>
<div className="ml-4 flex items-center">
<input
type="checkbox"
checked={preferences.marketing}
onChange={e => setPreferences({ ...preferences, marketing: e.target.checked })}
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={localPrefs.marketing}
onChange={e =>
setLocalPrefs({ ...localPrefs, marketing: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
/>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={saveCustomPreferences}
className="flex-1 px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={handleSaveCustom}
className="flex-1 flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
>
Save Preferences
<Check className="w-4 h-4" />
Enregistrer mes préférences
</button>
<button
onClick={acceptAll}
className="flex-1 px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="flex-1 px-6 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
>
Accept All
Tout accepter
</button>
</div>
<p className="mt-4 text-xs text-gray-500 text-center">
You can change your preferences at any time in your account settings or by clicking
the cookie icon in the footer.
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.{' '}
<Link href="/cookies" className="text-brand-turquoise hover:underline">
Politique de cookies
</Link>
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
<CookieProvider>
{children}
<CookieConsent />
</CookieProvider>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@ -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<GdprDataExportResponse> {
return post<GdprDataExportResponse>('/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<Blob> {
export async function requestDataExport(): Promise<Blob> {
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<Blob> {
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<Blob> {
/**
* 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<SuccessResponse> {
return post<SuccessResponse>('/api/v1/gdpr/delete-account');
}
export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
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<SuccessResponse> {
return post<SuccessResponse>('/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<GdprConsentResponse> {
return get<GdprConsentResponse>('/api/v1/gdpr/consent');
export async function getConsentPreferences(): Promise<ConsentResponse | null> {
return get<ConsentResponse | null>('/api/v1/gdpr/consent');
}
/**
* Update consent preferences
* PATCH /api/v1/gdpr/consent
* POST /api/v1/gdpr/consent
*/
export async function updateConsentPreferences(
data: UpdateGdprConsentRequest
): Promise<GdprConsentResponse> {
return patch<GdprConsentResponse>('/api/v1/gdpr/consent', data);
data: UpdateConsentRequest
): Promise<ConsentResponse> {
return post<ConsentResponse>('/api/v1/gdpr/consent', data);
}
/**
* Withdraw specific consent
* POST /api/v1/gdpr/consent/withdraw
*/
export async function withdrawConsent(
consentType: 'functional' | 'analytics' | 'marketing'
): Promise<ConsentResponse> {
return post<ConsentResponse>('/api/v1/gdpr/consent/withdraw', { consentType });
}

View File

@ -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

View File

@ -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<void>;
acceptEssentialOnly: () => Promise<void>;
savePreferences: (prefs: CookiePreferences) => Promise<void>;
openPreferences: () => void;
}
const defaultPreferences: CookiePreferences = {
essential: true,
functional: false,
analytics: false,
marketing: false,
};
const CookieContext = createContext<CookieContextType | undefined>(undefined);
export function CookieProvider({ children }: { children: React.ReactNode }) {
const [preferences, setPreferences] = useState<CookiePreferences>(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 <CookieContext.Provider value={value}>{children}</CookieContext.Provider>;
}
export function useCookieConsent() {
const context = useContext(CookieContext);
if (context === undefined) {
throw new Error('useCookieConsent must be used within a CookieProvider');
}
return context;
}