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, HttpCode,
HttpStatus, HttpStatus,
Res, Res,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; 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 { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator'; import { CurrentUser } from '../decorators/current-user.decorator';
import { UserPayload } 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') @ApiTags('GDPR')
@Controller('gdpr') @Controller('gdpr')
@ -77,6 +84,13 @@ export class GDPRController {
csv += `User Data,${key},"${value}"\n`; 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 // Set headers
res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Type', 'text/csv');
res.setHeader( res.setHeader(
@ -119,22 +133,26 @@ export class GDPRController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Record user consent', summary: 'Record user consent',
description: 'Record consent for marketing, analytics, etc. (GDPR Article 7)', description: 'Record consent for cookies (GDPR Article 7)',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Consent recorded', description: 'Consent recorded',
type: ConsentResponseDto,
}) })
async recordConsent( async recordConsent(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: Omit<ConsentData, 'userId'> @Body() body: UpdateConsentDto,
): Promise<{ success: boolean }> { @Req() req: Request
await this.gdprService.recordConsent({ ): Promise<ConsentResponseDto> {
// Add IP and user agent from request if not provided
const consentData: UpdateConsentDto = {
...body, ...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) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Withdraw consent', 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({ @ApiResponse({
status: 200, status: 200,
description: 'Consent withdrawn', description: 'Consent withdrawn',
type: ConsentResponseDto,
}) })
async withdrawConsent( async withdrawConsent(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: { consentType: 'marketing' | 'analytics' } @Body() body: WithdrawConsentDto
): Promise<{ success: boolean }> { ): Promise<ConsentResponseDto> {
await this.gdprService.withdrawConsent(user.id, body.consentType); return this.gdprService.withdrawConsent(user.id, body.consentType);
return { success: true };
} }
/** /**
@ -170,8 +187,9 @@ export class GDPRController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Consent status retrieved', 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); 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 { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity'; import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity'; import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
@Module({ @Module({
imports: [ imports: [
@ -20,6 +21,7 @@ import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/
BookingOrmEntity, BookingOrmEntity,
AuditLogOrmEntity, AuditLogOrmEntity,
NotificationOrmEntity, NotificationOrmEntity,
CookieConsentOrmEntity,
]), ]),
], ],
controllers: [GDPRController], controllers: [GDPRController],

View File

@ -1,37 +1,35 @@
/** /**
* GDPR Compliance Service - Simplified Version * GDPR Compliance Service
* *
* Handles data export, deletion, and consent management * Handles data export, deletion, and consent management
* with full database persistence
*/ */
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; 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 { export interface GDPRDataExport {
exportDate: string; exportDate: string;
userId: string; userId: string;
userData: any; userData: any;
cookieConsent: any;
message: string; message: string;
} }
export interface ConsentData {
userId: string;
marketing: boolean;
analytics: boolean;
functional: boolean;
consentDate: Date;
ipAddress?: string;
}
@Injectable() @Injectable()
export class GDPRService { export class GDPRService {
private readonly logger = new Logger(GDPRService.name); private readonly logger = new Logger(GDPRService.name);
constructor( constructor(
@InjectRepository(UserOrmEntity) @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'); throw new NotFoundException('User not found');
} }
// Fetch consent data
const consent = await this.consentRepository.findOne({ where: { userId } });
// Sanitize user data (remove password hash) // Sanitize user data (remove password hash)
const sanitizedUser = { const sanitizedUser = {
id: user.id, id: user.id,
@ -63,6 +64,15 @@ export class GDPRService {
exportDate: new Date().toISOString(), exportDate: new Date().toISOString(),
userId, userId,
userData: sanitizedUser, userData: sanitizedUser,
cookieConsent: consent
? {
essential: consent.essential,
functional: consent.functional,
analytics: consent.analytics,
marketing: consent.marketing,
consentDate: consent.consentDate,
}
: null,
message: message:
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.', 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
}; };
@ -88,6 +98,9 @@ export class GDPRService {
} }
try { try {
// Delete consent data first (will cascade with user deletion)
await this.consentRepository.delete({ userId });
// IMPORTANT: In production, implement full data anonymization // IMPORTANT: In production, implement full data anonymization
// For now, we just mark the account for deletion // For now, we just mark the account for deletion
// Real implementation should: // 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> { async recordConsent(
this.logger.log(`Recording consent for user ${consentData.userId}`); userId: string,
consentData: UpdateConsentDto
const user = await this.userRepository.findOne({ ): Promise<ConsentResponseDto> {
where: { id: consentData.userId }, this.logger.log(`Recording consent for user ${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}`);
// Verify user exists
const user = await this.userRepository.findOne({ where: { id: userId } }); const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) { if (!user) {
throw new NotFoundException('User not found'); 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}`); 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 * 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 } }); const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) { if (!user) {
throw new NotFoundException('User not found'); 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 { return {
marketing: false, userId,
analytics: false, essential: consent.essential,
functional: true, functional: consent.functional,
message: 'Consent management fully implemented in production version', 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 * Cookie Consent Banner
* GDPR Compliant * GDPR Compliant - French version
*/ */
import React, { useState, useEffect } from 'react'; 'use client';
import Link from 'next/link';
interface CookiePreferences { import React, { useState } from 'react';
essential: boolean; // Always true (required for functionality) import Link from 'next/link';
functional: boolean; import { motion, AnimatePresence } from 'framer-motion';
analytics: boolean; import { Cookie, X, Settings, Check, Shield } from 'lucide-react';
marketing: boolean; import { useCookieConsent } from '@/lib/context/cookie-context';
} import type { CookiePreferences } from '@/lib/api/gdpr';
export default function CookieConsent() { export default function CookieConsent() {
const [showBanner, setShowBanner] = useState(false); const {
const [showSettings, setShowSettings] = useState(false); preferences,
const [preferences, setPreferences] = useState<CookiePreferences>({ showBanner,
essential: true, showSettings,
functional: true, isLoading,
analytics: false, setShowBanner,
marketing: false, setShowSettings,
}); acceptAll,
acceptEssentialOnly,
savePreferences,
openPreferences,
} = useCookieConsent();
useEffect(() => { const [localPrefs, setLocalPrefs] = useState<CookiePreferences>(preferences);
// 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 acceptAll = () => { // Sync local prefs when context changes
const allAccepted: CookiePreferences = { React.useEffect(() => {
essential: true, setLocalPrefs(preferences);
functional: true, }, [preferences]);
analytics: true,
marketing: true, const handleSaveCustom = async () => {
}; await savePreferences(localPrefs);
savePreferences(allAccepted);
}; };
const acceptEssentialOnly = () => { // Don't render anything while loading
const essentialOnly: CookiePreferences = { if (isLoading) {
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) {
return null; return null;
} }
return ( 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 */} {/* 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"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<AnimatePresence mode="wait">
{!showSettings ? ( {!showSettings ? (
// Simple banner // 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"> <div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">🍪 We use cookies</h3> <div className="flex items-center gap-2 mb-2">
<p className="text-sm text-gray-600"> <Cookie className="w-5 h-5 text-brand-navy" />
We use cookies to improve your experience, analyze site traffic, and personalize <h3 className="text-lg font-semibold text-brand-navy">
content. By clicking "Accept All", you consent to our use of cookies.{' '} Nous utilisons des cookies
<Link href="/privacy" className="text-blue-600 hover:text-blue-800 underline"> </h3>
Learn more </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> </Link>
</p> </p>
</div> </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 <button
onClick={() => setShowSettings(true)} 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>
<button <button
onClick={acceptEssentialOnly} 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>
<button <button
onClick={acceptAll} 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> </button>
</div> </div>
</div> </motion.div>
) : ( ) : (
// Detailed settings // 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"> <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 <button
onClick={() => setShowSettings(false)} 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"> <X className="w-5 h-5" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button> </button>
</div> </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 */} {/* 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-1">
<div className="flex items-center"> <div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-900">Essential Cookies</h4> <h4 className="text-sm font-semibold text-gray-900">
<span className="ml-2 px-2 py-1 text-xs font-medium text-gray-600 bg-gray-200 rounded"> Cookies essentiels
Always Active </h4>
<span className="px-2 py-0.5 text-xs font-medium text-brand-navy bg-brand-navy/10 rounded-full">
Toujours actif
</span> </span>
</div> </div>
<p className="mt-1 text-sm text-gray-600"> <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> </p>
</div> </div>
<div className="ml-4 flex items-center">
<input <input
type="checkbox" type="checkbox"
checked={true} checked={true}
disabled 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>
</div>
{/* Functional Cookies */} {/* 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"> <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"> <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> </p>
</div> </div>
<div className="ml-4 flex items-center">
<input <input
type="checkbox" type="checkbox"
checked={preferences.functional} checked={localPrefs.functional}
onChange={e => setPreferences({ ...preferences, functional: e.target.checked })} onChange={e =>
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" 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>
</div>
{/* Analytics Cookies */} {/* 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"> <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"> <p className="mt-1 text-sm text-gray-600">
Help us understand how visitors interact with our website (Google Analytics, Nous aident à comprendre comment les visiteurs interagissent avec notre
Sentry). site (Google Analytics, Sentry).
</p> </p>
</div> </div>
<div className="ml-4 flex items-center">
<input <input
type="checkbox" type="checkbox"
checked={preferences.analytics} checked={localPrefs.analytics}
onChange={e => setPreferences({ ...preferences, analytics: e.target.checked })} onChange={e =>
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" 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>
</div>
{/* Marketing Cookies */} {/* 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"> <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"> <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> </p>
</div> </div>
<div className="ml-4 flex items-center">
<input <input
type="checkbox" type="checkbox"
checked={preferences.marketing} checked={localPrefs.marketing}
onChange={e => setPreferences({ ...preferences, marketing: e.target.checked })} onChange={e =>
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" 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>
</div>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<button <button
onClick={saveCustomPreferences} onClick={handleSaveCustom}
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" 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>
<button <button
onClick={acceptAll} 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> </button>
</div> </div>
<p className="mt-4 text-xs text-gray-500 text-center"> <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 Vous pouvez modifier vos préférences à tout moment dans les paramètres de
the cookie icon in the footer. 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> </p>
</div> </motion.div>
)} )}
</AnimatePresence>
</div> </div>
</div> </motion.div>
)}
</AnimatePresence>
</> </>
); );
} }

View File

@ -9,6 +9,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/lib/context/auth-context'; 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 }) { export function Providers({ children }: { children: React.ReactNode }) {
// Create a client instance per component instance // Create a client instance per component instance
@ -27,7 +29,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider> <AuthProvider>
<CookieProvider>
{children}
<CookieConsent />
</CookieProvider>
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@ -5,11 +5,38 @@
*/ */
import { get, post, patch } from './client'; import { get, post, patch } from './client';
import type { import type { SuccessResponse } from '@/types/api';
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 { export interface GdprDataExportResponse {
exportId: string; exportId: string;
status: 'PENDING' | 'COMPLETED' | 'FAILED'; status: 'PENDING' | 'COMPLETED' | 'FAILED';
@ -18,49 +45,50 @@ export interface GdprDataExportResponse {
downloadUrl?: string; 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) * Request data export (GDPR right to data portability)
* POST /api/v1/gdpr/export * GET /api/v1/gdpr/export
* Generates export job and sends download link via email * Triggers download of JSON file
*/ */
export async function requestDataExport(): Promise<GdprDataExportResponse> { export async function requestDataExport(): Promise<Blob> {
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> {
const response = await fetch( 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', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${ Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('access_token') : '' typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
}`, }`,
}, },
} }
); );
if (!response.ok) { 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(); return response.blob();
@ -68,35 +96,53 @@ export async function downloadDataExport(exportId: string): Promise<Blob> {
/** /**
* Request account deletion (GDPR right to be forgotten) * Request account deletion (GDPR right to be forgotten)
* POST /api/v1/gdpr/delete-account * DELETE /api/v1/gdpr/delete-account
* Initiates 30-day account deletion process * Initiates account deletion process
*/ */
export async function requestAccountDeletion(): Promise<SuccessResponse> { export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
return post<SuccessResponse>('/api/v1/gdpr/delete-account'); 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 }),
} }
);
/** if (!response.ok) {
* Cancel pending account deletion throw new Error(`Deletion failed: ${response.statusText}`);
* POST /api/v1/gdpr/cancel-deletion }
*/
export async function cancelAccountDeletion(): Promise<SuccessResponse> {
return post<SuccessResponse>('/api/v1/gdpr/cancel-deletion');
} }
/** /**
* Get user consent preferences * Get user consent preferences
* GET /api/v1/gdpr/consent * GET /api/v1/gdpr/consent
*/ */
export async function getConsentPreferences(): Promise<GdprConsentResponse> { export async function getConsentPreferences(): Promise<ConsentResponse | null> {
return get<GdprConsentResponse>('/api/v1/gdpr/consent'); return get<ConsentResponse | null>('/api/v1/gdpr/consent');
} }
/** /**
* Update consent preferences * Update consent preferences
* PATCH /api/v1/gdpr/consent * POST /api/v1/gdpr/consent
*/ */
export async function updateConsentPreferences( export async function updateConsentPreferences(
data: UpdateGdprConsentRequest data: UpdateConsentRequest
): Promise<GdprConsentResponse> { ): Promise<ConsentResponse> {
return patch<GdprConsentResponse>('/api/v1/gdpr/consent', data); 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) // GDPR (6 endpoints)
export { export {
requestDataExport, requestDataExport,
downloadDataExport, requestDataExportCSV,
requestAccountDeletion, requestAccountDeletion,
cancelAccountDeletion,
getConsentPreferences, getConsentPreferences,
updateConsentPreferences, updateConsentPreferences,
withdrawConsent,
type CookiePreferences,
type ConsentResponse,
type UpdateConsentRequest,
} from './gdpr'; } from './gdpr';
// Admin CSV Rates (5 endpoints) - already exists // 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;
}