fix
This commit is contained in:
parent
4c7b07a911
commit
3e654af8a3
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
139
apps/backend/src/application/dto/consent.dto.ts
Normal file
139
apps/backend/src/application/dto/consent.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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],
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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, ré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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
228
apps/frontend/src/lib/context/cookie-context.tsx
Normal file
228
apps/frontend/src/lib/context/cookie-context.tsx
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user