fix
This commit is contained in:
parent
4c7b07a911
commit
3e654af8a3
@ -14,13 +14,20 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Res,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { Response, Request } from 'express';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
import { UserPayload } from '../decorators/current-user.decorator';
|
||||
import { GDPRService, ConsentData } from '../services/gdpr.service';
|
||||
import { GDPRService } from '../services/gdpr.service';
|
||||
import {
|
||||
UpdateConsentDto,
|
||||
ConsentResponseDto,
|
||||
WithdrawConsentDto,
|
||||
ConsentSuccessDto,
|
||||
} from '../dto/consent.dto';
|
||||
|
||||
@ApiTags('GDPR')
|
||||
@Controller('gdpr')
|
||||
@ -77,6 +84,13 @@ export class GDPRController {
|
||||
csv += `User Data,${key},"${value}"\n`;
|
||||
});
|
||||
|
||||
// Cookie consent data
|
||||
if (exportData.cookieConsent) {
|
||||
Object.entries(exportData.cookieConsent).forEach(([key, value]) => {
|
||||
csv += `Cookie Consent,${key},"${value}"\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader(
|
||||
@ -119,22 +133,26 @@ export class GDPRController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Record user consent',
|
||||
description: 'Record consent for marketing, analytics, etc. (GDPR Article 7)',
|
||||
description: 'Record consent for cookies (GDPR Article 7)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent recorded',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
async recordConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: Omit<ConsentData, 'userId'>
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.gdprService.recordConsent({
|
||||
@Body() body: UpdateConsentDto,
|
||||
@Req() req: Request
|
||||
): Promise<ConsentResponseDto> {
|
||||
// Add IP and user agent from request if not provided
|
||||
const consentData: UpdateConsentDto = {
|
||||
...body,
|
||||
userId: user.id,
|
||||
});
|
||||
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress,
|
||||
userAgent: body.userAgent || req.headers['user-agent'],
|
||||
};
|
||||
|
||||
return { success: true };
|
||||
return this.gdprService.recordConsent(user.id, consentData);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -144,19 +162,18 @@ export class GDPRController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Withdraw consent',
|
||||
description: 'Withdraw consent for marketing or analytics (GDPR Article 7.3)',
|
||||
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent withdrawn',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
async withdrawConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: { consentType: 'marketing' | 'analytics' }
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||
|
||||
return { success: true };
|
||||
@Body() body: WithdrawConsentDto
|
||||
): Promise<ConsentResponseDto> {
|
||||
return this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -170,8 +187,9 @@ export class GDPRController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent status retrieved',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
|
||||
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
|
||||
return this.gdprService.getConsentStatus(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
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 { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
||||
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -20,6 +21,7 @@ import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/
|
||||
BookingOrmEntity,
|
||||
AuditLogOrmEntity,
|
||||
NotificationOrmEntity,
|
||||
CookieConsentOrmEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [GDPRController],
|
||||
|
||||
@ -1,37 +1,35 @@
|
||||
/**
|
||||
* GDPR Compliance Service - Simplified Version
|
||||
* GDPR Compliance Service
|
||||
*
|
||||
* Handles data export, deletion, and consent management
|
||||
* with full database persistence
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto';
|
||||
|
||||
export interface GDPRDataExport {
|
||||
exportDate: string;
|
||||
userId: string;
|
||||
userData: any;
|
||||
cookieConsent: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ConsentData {
|
||||
userId: string;
|
||||
marketing: boolean;
|
||||
analytics: boolean;
|
||||
functional: boolean;
|
||||
consentDate: Date;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GDPRService {
|
||||
private readonly logger = new Logger(GDPRService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserOrmEntity)
|
||||
private readonly userRepository: Repository<UserOrmEntity>
|
||||
private readonly userRepository: Repository<UserOrmEntity>,
|
||||
@InjectRepository(CookieConsentOrmEntity)
|
||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -46,6 +44,9 @@ export class GDPRService {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Fetch consent data
|
||||
const consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
// Sanitize user data (remove password hash)
|
||||
const sanitizedUser = {
|
||||
id: user.id,
|
||||
@ -63,6 +64,15 @@ export class GDPRService {
|
||||
exportDate: new Date().toISOString(),
|
||||
userId,
|
||||
userData: sanitizedUser,
|
||||
cookieConsent: consent
|
||||
? {
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
}
|
||||
: null,
|
||||
message:
|
||||
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||
};
|
||||
@ -88,6 +98,9 @@ export class GDPRService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete consent data first (will cascade with user deletion)
|
||||
await this.consentRepository.delete({ userId });
|
||||
|
||||
// IMPORTANT: In production, implement full data anonymization
|
||||
// For now, we just mark the account for deletion
|
||||
// Real implementation should:
|
||||
@ -105,55 +118,139 @@ export class GDPRService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record consent (GDPR Article 7 - Conditions for consent)
|
||||
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
||||
*/
|
||||
async recordConsent(consentData: ConsentData): Promise<void> {
|
||||
this.logger.log(`Recording consent for user ${consentData.userId}`);
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: consentData.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// In production, store in separate consent table
|
||||
// For now, just log the consent
|
||||
this.logger.log(
|
||||
`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw consent (GDPR Article 7.3 - Withdrawal of consent)
|
||||
*/
|
||||
async withdrawConsent(userId: string, consentType: 'marketing' | 'analytics'): Promise<void> {
|
||||
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
|
||||
async recordConsent(
|
||||
userId: string,
|
||||
consentData: UpdateConsentDto
|
||||
): Promise<ConsentResponseDto> {
|
||||
this.logger.log(`Recording consent for user ${userId}`);
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Check if consent already exists
|
||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
if (consent) {
|
||||
// Update existing consent
|
||||
consent.essential = true; // Always true
|
||||
consent.functional = consentData.functional;
|
||||
consent.analytics = consentData.analytics;
|
||||
consent.marketing = consentData.marketing;
|
||||
consent.ipAddress = consentData.ipAddress || consent.ipAddress;
|
||||
consent.userAgent = consentData.userAgent || consent.userAgent;
|
||||
consent.consentDate = new Date();
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`Consent updated for user ${userId}`);
|
||||
} else {
|
||||
// Create new consent record
|
||||
consent = this.consentRepository.create({
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
essential: true, // Always true
|
||||
functional: consentData.functional,
|
||||
analytics: consentData.analytics,
|
||||
marketing: consentData.marketing,
|
||||
ipAddress: consentData.ipAddress,
|
||||
userAgent: consentData.userAgent,
|
||||
consentDate: new Date(),
|
||||
});
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`New consent created for user ${userId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
updatedAt: consent.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent)
|
||||
*/
|
||||
async withdrawConsent(
|
||||
userId: string,
|
||||
consentType: 'functional' | 'analytics' | 'marketing'
|
||||
): Promise<ConsentResponseDto> {
|
||||
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Find consent record
|
||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
if (!consent) {
|
||||
// Create default consent with withdrawn type
|
||||
consent = this.consentRepository.create({
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
essential: true,
|
||||
functional: consentType === 'functional' ? false : false,
|
||||
analytics: consentType === 'analytics' ? false : false,
|
||||
marketing: consentType === 'marketing' ? false : false,
|
||||
consentDate: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Update specific consent type
|
||||
consent[consentType] = false;
|
||||
consent.consentDate = new Date();
|
||||
}
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`${consentType} consent withdrawn for user ${userId}`);
|
||||
|
||||
return {
|
||||
userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
updatedAt: consent.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current consent status
|
||||
*/
|
||||
async getConsentStatus(userId: string): Promise<any> {
|
||||
async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> {
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Default consent status
|
||||
// Find consent record
|
||||
const consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
if (!consent) {
|
||||
// No consent recorded yet - return null to indicate user should provide consent
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
marketing: false,
|
||||
analytics: false,
|
||||
functional: true,
|
||||
message: 'Consent management fully implemented in production version',
|
||||
userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
updatedAt: consent.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
* GDPR Compliant
|
||||
* GDPR Compliant - French version
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
'use client';
|
||||
|
||||
interface CookiePreferences {
|
||||
essential: boolean; // Always true (required for functionality)
|
||||
functional: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
}
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Cookie, X, Settings, Check, Shield } from 'lucide-react';
|
||||
import { useCookieConsent } from '@/lib/context/cookie-context';
|
||||
import type { CookiePreferences } from '@/lib/api/gdpr';
|
||||
|
||||
export default function CookieConsent() {
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [preferences, setPreferences] = useState<CookiePreferences>({
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
});
|
||||
const {
|
||||
preferences,
|
||||
showBanner,
|
||||
showSettings,
|
||||
isLoading,
|
||||
setShowBanner,
|
||||
setShowSettings,
|
||||
acceptAll,
|
||||
acceptEssentialOnly,
|
||||
savePreferences,
|
||||
openPreferences,
|
||||
} = useCookieConsent();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already made a choice
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
setShowBanner(true);
|
||||
} else {
|
||||
const savedPreferences = JSON.parse(consent);
|
||||
setPreferences(savedPreferences);
|
||||
}
|
||||
}, []);
|
||||
const [localPrefs, setLocalPrefs] = useState<CookiePreferences>(preferences);
|
||||
|
||||
const acceptAll = () => {
|
||||
const allAccepted: CookiePreferences = {
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
};
|
||||
savePreferences(allAccepted);
|
||||
// Sync local prefs when context changes
|
||||
React.useEffect(() => {
|
||||
setLocalPrefs(preferences);
|
||||
}, [preferences]);
|
||||
|
||||
const handleSaveCustom = async () => {
|
||||
await savePreferences(localPrefs);
|
||||
};
|
||||
|
||||
const acceptEssentialOnly = () => {
|
||||
const essentialOnly: CookiePreferences = {
|
||||
essential: true,
|
||||
functional: false,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
};
|
||||
savePreferences(essentialOnly);
|
||||
};
|
||||
|
||||
const saveCustomPreferences = () => {
|
||||
savePreferences(preferences);
|
||||
};
|
||||
|
||||
const savePreferences = (prefs: CookiePreferences) => {
|
||||
localStorage.setItem('cookieConsent', JSON.stringify(prefs));
|
||||
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||
setShowBanner(false);
|
||||
setShowSettings(false);
|
||||
|
||||
// Apply preferences
|
||||
applyPreferences(prefs);
|
||||
};
|
||||
|
||||
const applyPreferences = (prefs: CookiePreferences) => {
|
||||
// Enable/disable analytics tracking
|
||||
if (prefs.analytics) {
|
||||
// Enable Google Analytics, Sentry, etc.
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('consent', 'update', {
|
||||
analytics_storage: 'granted',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('consent', 'update', {
|
||||
analytics_storage: 'denied',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enable/disable marketing tracking
|
||||
if (prefs.marketing) {
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('consent', 'update', {
|
||||
ad_storage: 'granted',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('consent', 'update', {
|
||||
ad_storage: 'denied',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!showBanner) {
|
||||
// Don't render anything while loading
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Cookie Button (shown when banner is closed) */}
|
||||
<AnimatePresence>
|
||||
{!showBanner && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
||||
onClick={openPreferences}
|
||||
className="fixed bottom-4 left-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors"
|
||||
aria-label="Ouvrir les paramètres de cookies"
|
||||
>
|
||||
<Cookie className="w-5 h-5" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Cookie Banner */}
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t-2 border-gray-200 shadow-2xl">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{!showSettings ? (
|
||||
// Simple banner
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">🍪 We use cookies</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
We use cookies to improve your experience, analyze site traffic, and personalize
|
||||
content. By clicking "Accept All", you consent to our use of cookies.{' '}
|
||||
<Link href="/privacy" className="text-blue-600 hover:text-blue-800 underline">
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptEssentialOnly}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Essential Only
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Detailed settings
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Cookie Preferences</h3>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Essential Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<h4 className="text-sm font-semibold text-gray-900">Essential Cookies</h4>
|
||||
<span className="ml-2 px-2 py-1 text-xs font-medium text-gray-600 bg-gray-200 rounded">
|
||||
Always Active
|
||||
</span>
|
||||
<AnimatePresence>
|
||||
{showBanner && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-2xl"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{!showSettings ? (
|
||||
// Simple banner
|
||||
<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 items-center gap-2 mb-2">
|
||||
<Cookie className="w-5 h-5 text-brand-navy" />
|
||||
<h3 className="text-lg font-semibold text-brand-navy">
|
||||
Nous utilisons des cookies
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 max-w-2xl">
|
||||
Nous utilisons des cookies pour améliorer votre expérience, analyser le
|
||||
trafic du site et personnaliser le contenu. En cliquant sur « Tout
|
||||
accepter », vous consentez à notre utilisation des cookies.{' '}
|
||||
<Link
|
||||
href="/cookies"
|
||||
className="text-brand-turquoise hover:text-brand-turquoise/80 underline"
|
||||
>
|
||||
En savoir plus
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Required for the website to function. Cannot be disabled.
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
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"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Personnaliser
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptEssentialOnly}
|
||||
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"
|
||||
>
|
||||
Essentiel uniquement
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
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"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Tout accepter
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
// Detailed settings
|
||||
<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 gap-2">
|
||||
<Shield className="w-5 h-5 text-brand-navy" />
|
||||
<h3 className="text-lg font-semibold text-brand-navy">
|
||||
Préférences de cookies
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100 transition-colors"
|
||||
aria-label="Fermer les paramètres"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 mb-6 max-h-[40vh] overflow-y-auto pr-2">
|
||||
{/* Essential Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
Cookies essentiels
|
||||
</h4>
|
||||
<span className="px-2 py-0.5 text-xs font-medium text-brand-navy bg-brand-navy/10 rounded-full">
|
||||
Toujours actif
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Nécessaires au fonctionnement du site. Ne peuvent pas être désactivés.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
disabled
|
||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded cursor-not-allowed opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Functional Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
Cookies fonctionnels
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Permettent de mémoriser vos préférences et paramètres (langue, région).
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localPrefs.functional}
|
||||
onChange={e =>
|
||||
setLocalPrefs({ ...localPrefs, functional: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
Cookies analytiques
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Nous aident à comprendre comment les visiteurs interagissent avec notre
|
||||
site (Google Analytics, Sentry).
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localPrefs.analytics}
|
||||
onChange={e =>
|
||||
setLocalPrefs({ ...localPrefs, analytics: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
Cookies marketing
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Utilisés pour afficher des publicités personnalisées et mesurer
|
||||
l'efficacité des campagnes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localPrefs.marketing}
|
||||
onChange={e =>
|
||||
setLocalPrefs({ ...localPrefs, marketing: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={handleSaveCustom}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Enregistrer mes préférences
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
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"
|
||||
>
|
||||
Tout accepter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-gray-500 text-center">
|
||||
Vous pouvez modifier vos préférences à tout moment dans les paramètres de
|
||||
votre compte ou en cliquant sur l'icône cookie en bas à gauche.{' '}
|
||||
<Link href="/cookies" className="text-brand-turquoise hover:underline">
|
||||
Politique de cookies
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
disabled
|
||||
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Functional Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">Functional Cookies</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Remember your preferences and settings (e.g., language, region).
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.functional}
|
||||
onChange={e => setPreferences({ ...preferences, functional: e.target.checked })}
|
||||
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Analytics Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">Analytics Cookies</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Help us understand how visitors interact with our website (Google Analytics,
|
||||
Sentry).
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.analytics}
|
||||
onChange={e => setPreferences({ ...preferences, analytics: e.target.checked })}
|
||||
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Marketing Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">Marketing Cookies</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Used to deliver personalized ads and measure campaign effectiveness.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.marketing}
|
||||
onChange={e => setPreferences({ ...preferences, marketing: e.target.checked })}
|
||||
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={saveCustomPreferences}
|
||||
className="flex-1 px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Save Preferences
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="flex-1 px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-gray-500 text-center">
|
||||
You can change your preferences at any time in your account settings or by clicking
|
||||
the cookie icon in the footer.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
import { useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '@/lib/context/auth-context';
|
||||
import { CookieProvider } from '@/lib/context/cookie-context';
|
||||
import CookieConsent from '@/components/CookieConsent';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
// Create a client instance per component instance
|
||||
@ -27,7 +29,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<AuthProvider>
|
||||
<CookieProvider>
|
||||
{children}
|
||||
<CookieConsent />
|
||||
</CookieProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,11 +5,38 @@
|
||||
*/
|
||||
|
||||
import { get, post, patch } from './client';
|
||||
import type {
|
||||
SuccessResponse,
|
||||
} from '@/types/api';
|
||||
import type { SuccessResponse } from '@/types/api';
|
||||
|
||||
// TODO: These types should be moved to @/types/api.ts
|
||||
/**
|
||||
* Cookie consent preferences
|
||||
*/
|
||||
export interface CookiePreferences {
|
||||
essential: boolean;
|
||||
functional: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from consent API
|
||||
*/
|
||||
export interface ConsentResponse extends CookiePreferences {
|
||||
userId: string;
|
||||
consentDate: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to update consent
|
||||
*/
|
||||
export interface UpdateConsentRequest extends CookiePreferences {
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data export response
|
||||
*/
|
||||
export interface GdprDataExportResponse {
|
||||
exportId: string;
|
||||
status: 'PENDING' | 'COMPLETED' | 'FAILED';
|
||||
@ -18,49 +45,50 @@ export interface GdprDataExportResponse {
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export interface GdprConsentResponse {
|
||||
userId: string;
|
||||
marketingEmails: boolean;
|
||||
dataProcessing: boolean;
|
||||
thirdPartySharing: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateGdprConsentRequest {
|
||||
marketingEmails?: boolean;
|
||||
dataProcessing?: boolean;
|
||||
thirdPartySharing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request data export (GDPR right to data portability)
|
||||
* POST /api/v1/gdpr/export
|
||||
* Generates export job and sends download link via email
|
||||
* GET /api/v1/gdpr/export
|
||||
* Triggers download of JSON file
|
||||
*/
|
||||
export async function requestDataExport(): Promise<GdprDataExportResponse> {
|
||||
return post<GdprDataExportResponse>('/api/v1/gdpr/export');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download exported data
|
||||
* GET /api/v1/gdpr/export/:exportId/download
|
||||
* Returns blob (JSON file)
|
||||
*/
|
||||
export async function downloadDataExport(exportId: string): Promise<Blob> {
|
||||
export async function requestDataExport(): Promise<Blob> {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/${exportId}/download`,
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.statusText}`);
|
||||
throw new Error(`Export failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request data export as CSV
|
||||
* GET /api/v1/gdpr/export/csv
|
||||
*/
|
||||
export async function requestDataExportCSV(): Promise<Blob> {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Export failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@ -68,35 +96,53 @@ export async function downloadDataExport(exportId: string): Promise<Blob> {
|
||||
|
||||
/**
|
||||
* Request account deletion (GDPR right to be forgotten)
|
||||
* POST /api/v1/gdpr/delete-account
|
||||
* Initiates 30-day account deletion process
|
||||
* DELETE /api/v1/gdpr/delete-account
|
||||
* Initiates account deletion process
|
||||
*/
|
||||
export async function requestAccountDeletion(): Promise<SuccessResponse> {
|
||||
return post<SuccessResponse>('/api/v1/gdpr/delete-account');
|
||||
}
|
||||
export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
}`,
|
||||
},
|
||||
body: JSON.stringify({ confirmEmail, reason }),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel pending account deletion
|
||||
* POST /api/v1/gdpr/cancel-deletion
|
||||
*/
|
||||
export async function cancelAccountDeletion(): Promise<SuccessResponse> {
|
||||
return post<SuccessResponse>('/api/v1/gdpr/cancel-deletion');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Deletion failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user consent preferences
|
||||
* GET /api/v1/gdpr/consent
|
||||
*/
|
||||
export async function getConsentPreferences(): Promise<GdprConsentResponse> {
|
||||
return get<GdprConsentResponse>('/api/v1/gdpr/consent');
|
||||
export async function getConsentPreferences(): Promise<ConsentResponse | null> {
|
||||
return get<ConsentResponse | null>('/api/v1/gdpr/consent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update consent preferences
|
||||
* PATCH /api/v1/gdpr/consent
|
||||
* POST /api/v1/gdpr/consent
|
||||
*/
|
||||
export async function updateConsentPreferences(
|
||||
data: UpdateGdprConsentRequest
|
||||
): Promise<GdprConsentResponse> {
|
||||
return patch<GdprConsentResponse>('/api/v1/gdpr/consent', data);
|
||||
data: UpdateConsentRequest
|
||||
): Promise<ConsentResponse> {
|
||||
return post<ConsentResponse>('/api/v1/gdpr/consent', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw specific consent
|
||||
* POST /api/v1/gdpr/consent/withdraw
|
||||
*/
|
||||
export async function withdrawConsent(
|
||||
consentType: 'functional' | 'analytics' | 'marketing'
|
||||
): Promise<ConsentResponse> {
|
||||
return post<ConsentResponse>('/api/v1/gdpr/consent/withdraw', { consentType });
|
||||
}
|
||||
|
||||
@ -107,11 +107,14 @@ export {
|
||||
// GDPR (6 endpoints)
|
||||
export {
|
||||
requestDataExport,
|
||||
downloadDataExport,
|
||||
requestDataExportCSV,
|
||||
requestAccountDeletion,
|
||||
cancelAccountDeletion,
|
||||
getConsentPreferences,
|
||||
updateConsentPreferences,
|
||||
withdrawConsent,
|
||||
type CookiePreferences,
|
||||
type ConsentResponse,
|
||||
type UpdateConsentRequest,
|
||||
} from './gdpr';
|
||||
|
||||
// Admin CSV Rates (5 endpoints) - already exists
|
||||
|
||||
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