merge: integrer feat/conformite-rgpd
This commit is contained in:
commit
8446f879b6
53
apps/backend/package-lock.json
generated
53
apps/backend/package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.2.10",
|
||||
"@nestjs/platform-socket.io": "^10.4.20",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^7.1.16",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^10.0.1",
|
||||
@ -3216,6 +3217,33 @@
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
|
||||
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "3.2.1",
|
||||
"uuid": "11.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule/node_modules/uuid": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
|
||||
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||
@ -4441,6 +4469,12 @@
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
|
||||
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@ -6792,6 +6826,16 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
|
||||
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.4.0",
|
||||
"luxon": "~3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
@ -10843,6 +10887,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
|
||||
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.8",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "nest build && tsc-alias -p tsconfig.build.json",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"knowledge:build": "node scripts/setup/build-knowledge-corpus.js",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
@ -35,6 +36,7 @@
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.2.10",
|
||||
"@nestjs/platform-socket.io": "^10.4.20",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^7.1.16",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^10.0.1",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module';
|
||||
import { McpModule } from './application/mcp/mcp.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
@ -46,6 +47,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
@ -78,6 +80,11 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
||||
SMTP_SECURE: Joi.boolean().default(false),
|
||||
// Stripe Configuration (optional for development)
|
||||
// Purge des donnees arrivees au terme de leur duree de
|
||||
// conservation. Desactivee par defaut : elle supprime
|
||||
// definitivement des lignes, l'activer est une decision
|
||||
// d'exploitation.
|
||||
RETENTION_PURGE_ENABLED: Joi.string().valid('true', 'false').default('false'),
|
||||
OPENAI_API_KEY: Joi.string().allow('').optional(),
|
||||
OPENAI_MODEL: Joi.string().default('gpt-4.1-mini'),
|
||||
OPENAI_EMBEDDING_MODEL: Joi.string().default('text-embedding-3-small'),
|
||||
|
||||
@ -1,169 +1,183 @@
|
||||
/**
|
||||
* GDPR Controller
|
||||
*
|
||||
* Endpoints for GDPR compliance (data export, deletion, consent)
|
||||
* Droits des personnes (RGPD) : accès et portabilité, effacement, consentement.
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
Body,
|
||||
UseGuards,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Res,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
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 } from '../services/gdpr.service';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
import { Roles } from '../decorators/roles.decorator';
|
||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||
import { GDPRService, GDPRDataExport, GDPRErasureReport } from '../services/gdpr.service';
|
||||
import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto';
|
||||
import { DeleteAccountDto } from '../dto/delete-account.dto';
|
||||
import { RetentionService, RetentionReport } from '../services/retention.service';
|
||||
import { RETENTION_RULES } from '@domain/services/data-retention';
|
||||
|
||||
@ApiTags('GDPR')
|
||||
@Controller('gdpr')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@ApiBearerAuth()
|
||||
export class GDPRController {
|
||||
constructor(private readonly gdprService: GDPRService) {}
|
||||
constructor(
|
||||
private readonly gdprService: GDPRService,
|
||||
private readonly retentionService: RetentionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Export user data (GDPR Right to Data Portability)
|
||||
*/
|
||||
/** Export de portabilité au format JSON (art. 20). */
|
||||
@Get('export')
|
||||
@ApiOperation({
|
||||
summary: 'Export all user data',
|
||||
description: 'Export all personal data in JSON format (GDPR Article 20)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Data export successful',
|
||||
})
|
||||
@ApiOperation({ summary: 'Exporter ses données personnelles (JSON)' })
|
||||
@ApiResponse({ status: 200, description: 'Export produit' })
|
||||
async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||
const exportData = await this.gdprService.exportUserData(user.id);
|
||||
const data = await this.gdprService.exportUserData(user.id);
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Set headers for file download
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`
|
||||
);
|
||||
|
||||
res.json(exportData);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="xpeditis-donnees-${day}.json"`);
|
||||
res.json(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export user data as CSV
|
||||
* Même export, en tableur.
|
||||
*
|
||||
* Il ne reprenait que le profil et le consentement cookies, ce qui donnait
|
||||
* deux exports au contenu différent selon le format demandé. Il aplatit
|
||||
* désormais l'export complet.
|
||||
*/
|
||||
@Get('export/csv')
|
||||
@ApiOperation({
|
||||
summary: 'Export user data as CSV',
|
||||
description: 'Export personal data in CSV format for easy viewing',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'CSV export successful',
|
||||
})
|
||||
@ApiOperation({ summary: 'Exporter ses données personnelles (CSV)' })
|
||||
@ApiResponse({ status: 200, description: 'Export produit' })
|
||||
async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||
const exportData = await this.gdprService.exportUserData(user.id);
|
||||
const data = await this.gdprService.exportUserData(user.id);
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Convert to CSV (simplified version)
|
||||
let csv = 'Category,Field,Value\n';
|
||||
|
||||
// User data
|
||||
Object.entries(exportData.userData).forEach(([key, value]) => {
|
||||
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(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`
|
||||
);
|
||||
|
||||
res.send(csv);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="xpeditis-donnees-${day}.csv"`);
|
||||
// BOM : sans lui Excel lit l'UTF-8 comme du latin-1 et casse les accents.
|
||||
res.send('' + toCsv(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user data (GDPR Right to Erasure)
|
||||
* Effacement (art. 17).
|
||||
*
|
||||
* Renvoie le détail de ce qui a été effacé et de ce qui a été anonymisé.
|
||||
* L'endpoint répondait 204 : la personne obtenait une page blanche pour
|
||||
* seule réponse à une demande d'effacement, sans moyen de vérifier ce qui
|
||||
* avait effectivement été traité.
|
||||
*/
|
||||
@Delete('delete-account')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: 'Delete user account and data',
|
||||
description: 'Permanently delete or anonymize user data (GDPR Article 17)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 204,
|
||||
description: 'Account deletion initiated',
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Effacer son compte et ses données' })
|
||||
@ApiResponse({ status: 200, description: 'Effacement appliqué' })
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: { reason?: string; confirmEmail: string }
|
||||
): Promise<void> {
|
||||
// Verify email confirmation (security measure)
|
||||
if (body.confirmEmail !== user.email) {
|
||||
throw new Error('Email confirmation does not match');
|
||||
@Body() body: DeleteAccountDto
|
||||
): Promise<GDPRErasureReport> {
|
||||
// Confirmation par saisie de l'adresse : l'effacement est irréversible.
|
||||
// `new Error` remontait ici en « Internal server error » — une erreur de
|
||||
// saisie affichée comme une panne du service.
|
||||
if (body.confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()) {
|
||||
throw new BadRequestException({
|
||||
code: 'email_mismatch',
|
||||
message: "L'adresse saisie ne correspond pas à celle du compte.",
|
||||
});
|
||||
}
|
||||
|
||||
await this.gdprService.deleteUserData(user.id, body.reason);
|
||||
return this.gdprService.deleteUserData(user.id, body.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record consent
|
||||
* Politique de conservation appliquée (art. 13.2.a).
|
||||
*
|
||||
* L'information sur les durées doit être accessible à la personne, pas
|
||||
* seulement écrite dans une politique de confidentialité : elle est servie
|
||||
* ici depuis la règle réellement appliquée par le code.
|
||||
*/
|
||||
@Get('retention')
|
||||
@ApiOperation({ summary: 'Durées de conservation appliquées' })
|
||||
@ApiResponse({ status: 200, description: 'Politique de conservation' })
|
||||
getRetentionPolicy(): { rules: typeof RETENTION_RULES } {
|
||||
return { rules: RETENTION_RULES };
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal des demandes de droits, pour la console de conformité.
|
||||
*
|
||||
* Réservé aux administrateurs : c'est l'élément qu'on présente à une
|
||||
* autorité de contrôle pour démontrer que les demandes sont traitées
|
||||
* (art. 5.2). Les effacements y figurent sous une adresse anonymisée.
|
||||
*/
|
||||
@Get('admin/requests')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Journal des demandes de droits (administration)' })
|
||||
@ApiResponse({ status: 200, description: 'Demandes récentes' })
|
||||
async listRightsRequests(): Promise<{ requests: Record<string, unknown>[] }> {
|
||||
return { requests: await this.gdprService.listRightsRequests() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que la purge supprimerait, sans rien supprimer.
|
||||
*
|
||||
* Une purge est irréversible : la console la montre avant de l'autoriser.
|
||||
*/
|
||||
@Get('admin/retention/preview')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Aperçu de la purge de conservation (administration)' })
|
||||
@ApiResponse({ status: 200, description: 'Lignes arrivées à échéance' })
|
||||
async previewRetention(): Promise<RetentionReport> {
|
||||
return this.retentionService.preview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche la purge immédiatement.
|
||||
*
|
||||
* Le POST est délibéré : la purge supprime définitivement des lignes, elle
|
||||
* ne peut pas être déclenchée par une simple navigation.
|
||||
*/
|
||||
@Post('admin/retention/purge')
|
||||
@Roles('admin')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Appliquer les durées de conservation (administration)' })
|
||||
@ApiResponse({ status: 200, description: 'Purge appliquée' })
|
||||
async runRetention(): Promise<RetentionReport> {
|
||||
return this.retentionService.purge();
|
||||
}
|
||||
|
||||
/** Recueil du consentement cookies (art. 7). */
|
||||
@Post('consent')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Record user consent',
|
||||
description: 'Record consent for cookies (GDPR Article 7)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent recorded',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
@ApiOperation({ summary: 'Enregistrer ses préférences de cookies' })
|
||||
@ApiResponse({ status: 200, type: ConsentResponseDto })
|
||||
async recordConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: UpdateConsentDto,
|
||||
@Req() req: Request
|
||||
): Promise<ConsentResponseDto> {
|
||||
// Add IP and user agent from request if not provided
|
||||
const consentData: UpdateConsentDto = {
|
||||
return this.gdprService.recordConsent(user.id, {
|
||||
...body,
|
||||
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress,
|
||||
userAgent: body.userAgent || req.headers['user-agent'],
|
||||
};
|
||||
|
||||
return this.gdprService.recordConsent(user.id, consentData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw consent
|
||||
*/
|
||||
/** Retrait du consentement (art. 7.3). */
|
||||
@Post('consent/withdraw')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Withdraw consent',
|
||||
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent withdrawn',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
@ApiOperation({ summary: 'Retirer un consentement' })
|
||||
@ApiResponse({ status: 200, type: ConsentResponseDto })
|
||||
async withdrawConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: WithdrawConsentDto
|
||||
@ -171,20 +185,51 @@ export class GDPRController {
|
||||
return this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consent status
|
||||
*/
|
||||
@Get('consent')
|
||||
@ApiOperation({
|
||||
summary: 'Get current consent status',
|
||||
description: 'Retrieve current consent preferences',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent status retrieved',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
@ApiOperation({ summary: 'Consulter ses préférences de cookies' })
|
||||
@ApiResponse({ status: 200, type: ConsentResponseDto })
|
||||
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
|
||||
return this.gdprService.getConsentStatus(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Échappement CSV : guillemets doublés, valeur toujours encadrée. */
|
||||
const cell = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '""';
|
||||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aplatit l'export en trois colonnes (section, champ, valeur).
|
||||
*
|
||||
* Un CSV par section serait plus lisible mais imposerait une archive ; la
|
||||
* personne qui demande un CSV veut ouvrir un fichier, pas un zip.
|
||||
*/
|
||||
function toCsv(data: GDPRDataExport): string {
|
||||
const lines = ['Section,Champ,Valeur'];
|
||||
|
||||
const flat = (section: string, record: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
lines.push([cell(section), cell(key), cell(value)].join(','));
|
||||
}
|
||||
};
|
||||
|
||||
flat('Compte', data.userData);
|
||||
if (data.organisation) flat('Organisation', data.organisation);
|
||||
if (data.cookieConsent) flat('Consentement cookies', data.cookieConsent);
|
||||
|
||||
const collections: [string, Record<string, unknown>[]][] = [
|
||||
['Réservations', data.bookings],
|
||||
['Notifications', data.notifications],
|
||||
['Conversations assistant', data.assistantConversations],
|
||||
["Clés d'API", data.apiKeys],
|
||||
['Journal d activite', data.activityLog],
|
||||
];
|
||||
|
||||
for (const [section, rows] of collections) {
|
||||
rows.forEach((row, index) => flat(`${section} ${index + 1}`, row));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
27
apps/backend/src/application/dto/delete-account.dto.ts
Normal file
27
apps/backend/src/application/dto/delete-account.dto.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Demande d'effacement (RGPD art. 17).
|
||||
*
|
||||
* Le corps de la requête n'était pas validé : `confirmEmail` arrivait en
|
||||
* `any`, et une valeur absente déclenchait une comparaison sur `undefined`
|
||||
* remontée en erreur 500.
|
||||
*/
|
||||
export class DeleteAccountDto {
|
||||
@ApiProperty({
|
||||
example: 'personne@example.com',
|
||||
description: "Adresse du compte, ressaisie pour confirmer un acte irréversible",
|
||||
})
|
||||
@IsEmail({}, { message: 'Une adresse email valide est requise pour confirmer.' })
|
||||
confirmEmail: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "Je n'utilise plus le service",
|
||||
description: "Motif facultatif. La personne n'a pas à le justifier (art. 17.1).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string;
|
||||
}
|
||||
@ -6,26 +6,26 @@
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { GDPRController } from '../controllers/gdpr.controller';
|
||||
import { GDPRService } from '../services/gdpr.service';
|
||||
import { RetentionService } from '../services/retention.service';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
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: [
|
||||
TypeOrmModule.forFeature([
|
||||
UserOrmEntity,
|
||||
BookingOrmEntity,
|
||||
AuditLogOrmEntity,
|
||||
NotificationOrmEntity,
|
||||
CookieConsentOrmEntity,
|
||||
]),
|
||||
// Les autres tables touchees par l'effacement (csv_bookings, audit_logs,
|
||||
// notifications, trade_conversations, password_reset_tokens) sont lues en
|
||||
// SQL via DataSource : la moitie d'entre elles n'a pas d'entite ORM, et
|
||||
// BookingOrmEntity pointait vers une table `bookings` qui n'existe pas.
|
||||
TypeOrmModule.forFeature([UserOrmEntity, CookieConsentOrmEntity]),
|
||||
// Les demandes de droits sont journalisees : l'article 5.2 impose de
|
||||
// pouvoir demontrer qu'elles ont ete traitees.
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [GDPRController],
|
||||
providers: [GDPRService],
|
||||
exports: [GDPRService],
|
||||
providers: [GDPRService, RetentionService],
|
||||
exports: [GDPRService, RetentionService],
|
||||
})
|
||||
export class GDPRModule {}
|
||||
|
||||
249
apps/backend/src/application/services/gdpr.service.spec.ts
Normal file
249
apps/backend/src/application/services/gdpr.service.spec.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { GDPRService } from './gdpr.service';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
import { AuditService } from './audit.service';
|
||||
import { RETENTION_RULES, ANONYMISED } from '@domain/services/data-retention';
|
||||
import { AuditAction } from '@domain/entities/audit-log.entity';
|
||||
|
||||
/**
|
||||
* Ces tests portent sur une promesse faite à une personne : « vos données sont
|
||||
* effacées ». La version précédente la faisait sans rien effacer. Ils vérifient
|
||||
* donc d'abord ce qui est réellement exécuté en base, table par table.
|
||||
*/
|
||||
|
||||
interface ExecutedQuery {
|
||||
sql: string;
|
||||
parameters: unknown[];
|
||||
}
|
||||
|
||||
const USER_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
const buildUser = (): UserOrmEntity =>
|
||||
({
|
||||
id: USER_ID,
|
||||
organizationId: 'org-1',
|
||||
email: 'jean@example.com',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Durand',
|
||||
phoneNumber: '+33600000000',
|
||||
passwordHash: 'argon2-hash',
|
||||
totpSecret: 'TOTPSECRET',
|
||||
role: 'USER',
|
||||
preferredLanguage: 'fr',
|
||||
isEmailVerified: true,
|
||||
isActive: true,
|
||||
lastLoginAt: new Date('2026-09-01T10:00:00Z'),
|
||||
createdAt: new Date('2026-01-01T10:00:00Z'),
|
||||
updatedAt: new Date('2026-09-01T10:00:00Z'),
|
||||
}) as unknown as UserOrmEntity;
|
||||
|
||||
/** Nombre de lignes renvoyé par le pilote PostgreSQL pour chaque écriture. */
|
||||
const ROWS_TOUCHED = 3;
|
||||
|
||||
function buildService(options: { user?: UserOrmEntity | null } = {}) {
|
||||
const executed: ExecutedQuery[] = [];
|
||||
|
||||
const manager = {
|
||||
// Forme réelle du pilote pour UPDATE et DELETE : [lignes, nombre].
|
||||
query: jest.fn(async (sql: string, parameters: unknown[]) => {
|
||||
executed.push({ sql, parameters });
|
||||
return [[], ROWS_TOUCHED];
|
||||
}),
|
||||
} as unknown as EntityManager;
|
||||
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (callback: (m: EntityManager) => Promise<void>) => callback(manager)),
|
||||
query: jest.fn(async (sql: string, parameters: unknown[]) => {
|
||||
executed.push({ sql, parameters });
|
||||
return [];
|
||||
}),
|
||||
} as unknown as DataSource;
|
||||
|
||||
const user = options.user === undefined ? buildUser() : options.user;
|
||||
|
||||
const userRepository = {
|
||||
findOne: jest.fn(async () => user),
|
||||
} as unknown as Repository<UserOrmEntity>;
|
||||
|
||||
const consentRepository = {
|
||||
findOne: jest.fn(async () => null),
|
||||
create: jest.fn((value: Partial<CookieConsentOrmEntity>) => ({ ...value })),
|
||||
save: jest.fn(async (value: CookieConsentOrmEntity) => value),
|
||||
} as unknown as Repository<CookieConsentOrmEntity>;
|
||||
|
||||
const audit = { log: jest.fn(async () => undefined) } as unknown as AuditService;
|
||||
|
||||
const service = new GDPRService(userRepository, consentRepository, dataSource, audit);
|
||||
|
||||
return { service, executed, manager, dataSource, consentRepository, audit };
|
||||
}
|
||||
|
||||
/** Toutes les instructions écrites contre une table donnée. */
|
||||
const statementsFor = (executed: ExecutedQuery[], table: string, verb: 'DELETE' | 'UPDATE') =>
|
||||
executed.filter(query => query.sql.includes(verb) && query.sql.includes(table));
|
||||
|
||||
describe('GDPRService — effacement (art. 17)', () => {
|
||||
it('applique à chaque table le traitement décrit par la politique de conservation', async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID, 'Fin de collaboration');
|
||||
|
||||
// La politique et le code ne peuvent pas diverger sans faire échouer ce
|
||||
// test : c'est la politique qui pilote l'assertion, pas une liste recopiée.
|
||||
for (const rule of RETENTION_RULES) {
|
||||
if (rule.onErasure === 'delete') {
|
||||
expect(statementsFor(executed, rule.table, 'DELETE').length).toBeGreaterThan(0);
|
||||
}
|
||||
if (rule.onErasure === 'anonymise') {
|
||||
expect(statementsFor(executed, rule.table, 'UPDATE').length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('ne supprime jamais la ligne du compte : les réservations la référencent en cascade', async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID);
|
||||
|
||||
expect(statementsFor(executed, 'FROM users', 'DELETE')).toHaveLength(0);
|
||||
expect(statementsFor(executed, 'csv_bookings', 'DELETE')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("remplace l'identité et rend le compte inutilisable", async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID);
|
||||
|
||||
const [update] = statementsFor(executed, 'UPDATE users', 'UPDATE');
|
||||
expect(update.sql).toContain('is_active = false');
|
||||
expect(update.sql).toContain('totp_secret = NULL');
|
||||
expect(update.parameters[1]).toBe(`${ANONYMISED}+${USER_ID}@invalid.local`);
|
||||
// Mot de passe remplacé par une valeur aléatoire : la colonne est NOT NULL,
|
||||
// et une constante partagée signerait tous les comptes effacés.
|
||||
expect(update.parameters[3]).toMatch(/^erased-/);
|
||||
});
|
||||
|
||||
it('compte les lignes réellement touchées, pas la forme du résultat', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const report = await service.deleteUserData(USER_ID);
|
||||
|
||||
// Le pilote renvoie `[lignes, nombre]` : mesurer la longueur du tableau
|
||||
// renverrait 2 partout, quel que soit le contenu de la base.
|
||||
expect(report.deleted.notifications).toBe(ROWS_TOUCHED);
|
||||
expect(report.anonymised.user).toBe(ROWS_TOUCHED);
|
||||
expect(Object.values(report.deleted)).not.toContain(2);
|
||||
});
|
||||
|
||||
it("journalise l'effacement sans y réinscrire l'identité effacée", async () => {
|
||||
const { service, audit } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID, 'Fin de collaboration');
|
||||
|
||||
const [entry] = (audit.log as jest.Mock).mock.calls[0];
|
||||
expect(entry.action).toBe(AuditAction.GDPR_ERASURE_EXECUTED);
|
||||
// Journaliser avant l'effacement effacerait la trace ; y écrire l'adresse
|
||||
// réelle réintroduirait l'identité qu'on vient de supprimer.
|
||||
expect(entry.userEmail).toBe(`${ANONYMISED}+${USER_ID}@invalid.local`);
|
||||
expect(entry.userEmail).not.toContain('jean@example.com');
|
||||
});
|
||||
|
||||
it('opère dans une transaction', async () => {
|
||||
const { service, dataSource } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("n'écrit rien si le compte n'existe pas", async () => {
|
||||
const { service, executed } = buildService({ user: null });
|
||||
|
||||
await expect(service.deleteUserData(USER_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(executed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GDPRService — portabilité (art. 20)', () => {
|
||||
it("couvre l'ensemble des données rattachées au compte", async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
const data = await service.exportUserData(USER_ID);
|
||||
|
||||
const read = executed.map(query => query.sql).join(' ');
|
||||
for (const table of [
|
||||
'organizations',
|
||||
'csv_bookings',
|
||||
'notifications',
|
||||
'trade_conversations',
|
||||
'api_keys',
|
||||
'audit_logs',
|
||||
]) {
|
||||
expect(read).toContain(table);
|
||||
}
|
||||
expect(data.userId).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it("n'expose aucun secret d'authentification", async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
const data = await service.exportUserData(USER_ID);
|
||||
|
||||
const serialised = JSON.stringify(data);
|
||||
expect(serialised).not.toContain('argon2-hash');
|
||||
expect(serialised).not.toContain('TOTPSECRET');
|
||||
// Le condensat d'une clé d'API reste un secret d'accès.
|
||||
expect(executed.map(query => query.sql).join(' ')).not.toContain('key_hash');
|
||||
});
|
||||
|
||||
it("refuse d'exporter pour un compte inconnu", async () => {
|
||||
const { service } = buildService({ user: null });
|
||||
|
||||
await expect(service.exportUserData(USER_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GDPRService — consentement (art. 7)', () => {
|
||||
it('force les cookies essentiels et horodate le recueil', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const consent = await service.recordConsent(USER_ID, {
|
||||
essential: false,
|
||||
functional: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
});
|
||||
|
||||
expect(consent.essential).toBe(true);
|
||||
expect(consent.functional).toBe(true);
|
||||
expect(consent.consentDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('retire tout ce qui est facultatif quand aucune catégorie n’est précisée', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const consent = await service.withdrawConsent(USER_ID);
|
||||
|
||||
expect(consent).toMatchObject({ functional: false, analytics: false, marketing: false });
|
||||
expect(consent.essential).toBe(true);
|
||||
});
|
||||
|
||||
it('ne retire que la catégorie visée', async () => {
|
||||
const { service, consentRepository } = buildService();
|
||||
(consentRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
});
|
||||
|
||||
const consent = await service.withdrawConsent(USER_ID, 'marketing');
|
||||
|
||||
expect(consent.marketing).toBe(false);
|
||||
expect(consent.analytics).toBe(true);
|
||||
expect(consent.functional).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -1,26 +1,53 @@
|
||||
/**
|
||||
* GDPR Compliance Service
|
||||
* Droits des personnes (RGPD).
|
||||
*
|
||||
* Handles data export, deletion, and consent management
|
||||
* with full database persistence
|
||||
* Portabilité (art. 20), effacement (art. 17), preuve du consentement (art. 7).
|
||||
*
|
||||
* Les requêtes sont écrites en SQL plutôt qu'en repositories : la moitié des
|
||||
* tables concernées (`trade_conversations`, `trade_messages`,
|
||||
* `password_reset_tokens`) n'a pas d'entité ORM, et un effacement doit couvrir
|
||||
* la base réelle, pas la partie qui a été modélisée.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, EntityManager, 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';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity';
|
||||
import { ANONYMISED, anonymisedEmail } from '@domain/services/data-retention';
|
||||
|
||||
export interface GDPRDataExport {
|
||||
exportDate: string;
|
||||
userId: string;
|
||||
userData: any;
|
||||
cookieConsent: any;
|
||||
message: string;
|
||||
userData: Record<string, unknown>;
|
||||
organisation: Record<string, unknown> | null;
|
||||
bookings: Record<string, unknown>[];
|
||||
notifications: Record<string, unknown>[];
|
||||
assistantConversations: Record<string, unknown>[];
|
||||
apiKeys: Record<string, unknown>[];
|
||||
activityLog: Record<string, unknown>[];
|
||||
cookieConsent: Record<string, unknown> | null;
|
||||
notice: string;
|
||||
}
|
||||
|
||||
/** Ce qui a été effacé ou anonymisé, rendu à la personne comme preuve. */
|
||||
export interface GDPRErasureReport {
|
||||
userId: string;
|
||||
erasedAt: string;
|
||||
deleted: Record<string, number>;
|
||||
anonymised: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Borne de l'export : seuls les journaux d'activité peuvent atteindre des
|
||||
* volumes qui transformeraient l'export en vidage de base.
|
||||
*/
|
||||
const MAX_LOG_ROWS = 5000;
|
||||
|
||||
@Injectable()
|
||||
export class GDPRService {
|
||||
private readonly logger = new Logger(GDPRService.name);
|
||||
@ -29,41 +56,119 @@ export class GDPRService {
|
||||
@InjectRepository(UserOrmEntity)
|
||||
private readonly userRepository: Repository<UserOrmEntity>,
|
||||
@InjectRepository(CookieConsentOrmEntity)
|
||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>
|
||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Export all user data (GDPR Article 20 - Right to Data Portability)
|
||||
* Export de portabilité (art. 20).
|
||||
*
|
||||
* L'export précédent ne contenait que le profil et le consentement cookies,
|
||||
* en renvoyant la personne vers « les endpoints respectifs » pour le reste.
|
||||
* Ce n'était pas un export : l'art. 20 porte sur l'ensemble des données
|
||||
* fournies par la personne, pas sur l'échantillon le plus simple à produire.
|
||||
*
|
||||
* Restent volontairement dehors le hachage du mot de passe et le secret TOTP :
|
||||
* ce sont des secrets d'authentification, les livrer affaiblirait le compte
|
||||
* sans rien apporter à la portabilité.
|
||||
*/
|
||||
async exportUserData(userId: string): Promise<GDPRDataExport> {
|
||||
this.logger.log(`Exporting data for user ${userId}`);
|
||||
|
||||
// Fetch user data
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
if (!user) 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 = {
|
||||
const [organisation] = await this.dataSource.query(
|
||||
`SELECT id, name, type, siren, siret, eori, contact_email, contact_phone,
|
||||
address_street, address_city, address_postal_code, address_country
|
||||
FROM organizations WHERE id = $1`,
|
||||
[user.organizationId]
|
||||
);
|
||||
|
||||
const bookings = await this.dataSource.query(
|
||||
`SELECT id, booking_number, carrier_name, origin, destination, volume_cbm, weight_kg,
|
||||
pallet_count, container_type, status, price_eur, price_usd, primary_currency,
|
||||
freight_total, freight_currency, fob_total, fob_currency, commission_amount_eur,
|
||||
transit_days, notes, rejection_reason, requested_at, responded_at, created_at
|
||||
FROM csv_bookings WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const notifications = await this.dataSource.query(
|
||||
`SELECT type, priority, title, message, read, read_at, action_url, created_at
|
||||
FROM notifications WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const assistantConversations = await this.dataSource.query(
|
||||
`SELECT c.id, c.title, c.created_at,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object(
|
||||
'role', m.role, 'content', m.content, 'createdAt', m.created_at)
|
||||
ORDER BY m.created_at)
|
||||
FROM trade_messages m WHERE m.conversation_id = c.id
|
||||
), '[]'::json) AS messages
|
||||
FROM trade_conversations c WHERE c.user_id = $1 ORDER BY c.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Jamais `key_hash` : seule la trace de l'existence de la clé est utile,
|
||||
// et le condensat resterait un secret d'accès.
|
||||
const apiKeys = await this.dataSource.query(
|
||||
`SELECT name, key_prefix, is_active, last_used_at, expires_at, created_at
|
||||
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const activityLog = await this.dataSource.query(
|
||||
`SELECT action, status, resource_type, resource_name, ip_address, timestamp
|
||||
FROM audit_logs WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2`,
|
||||
[userId, MAX_LOG_ROWS]
|
||||
);
|
||||
|
||||
this.logger.log(`GDPR export produced for user ${userId}`);
|
||||
|
||||
// Trace d'accountability (art. 5.2) : pouvoir démontrer que la demande a
|
||||
// été honorée, et quand.
|
||||
await this.audit.log({
|
||||
action: AuditAction.GDPR_DATA_EXPORTED,
|
||||
status: AuditStatus.SUCCESS,
|
||||
userId,
|
||||
userEmail: user.email,
|
||||
organizationId: user.organizationId,
|
||||
resourceType: 'gdpr_request',
|
||||
metadata: {
|
||||
bookings: bookings.length,
|
||||
notifications: notifications.length,
|
||||
assistantConversations: assistantConversations.length,
|
||||
activityLogEntries: activityLog.length,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
exportDate: new Date().toISOString(),
|
||||
userId,
|
||||
userData: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
phoneNumber: user.phoneNumber,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
preferredLanguage: user.preferredLanguage,
|
||||
isEmailVerified: user.isEmailVerified,
|
||||
isActive: user.isActive,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
// Password hash explicitly excluded for security
|
||||
};
|
||||
|
||||
const exportData: GDPRDataExport = {
|
||||
exportDate: new Date().toISOString(),
|
||||
userId,
|
||||
userData: sanitizedUser,
|
||||
},
|
||||
organisation: organisation ?? null,
|
||||
bookings,
|
||||
notifications,
|
||||
assistantConversations,
|
||||
apiKeys,
|
||||
activityLog,
|
||||
cookieConsent: consent
|
||||
? {
|
||||
essential: consent.essential,
|
||||
@ -73,175 +178,205 @@ export class GDPRService {
|
||||
consentDate: consent.consentDate,
|
||||
}
|
||||
: null,
|
||||
message:
|
||||
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||
notice:
|
||||
"Ensemble des données personnelles rattachées à ce compte. Les secrets d'authentification (mot de passe, second facteur, condensats de clés d'API) en sont exclus par sécurité. Le journal d'activité est limité aux " +
|
||||
`${MAX_LOG_ROWS} entrées les plus récentes.`,
|
||||
};
|
||||
|
||||
this.logger.log(`Data export completed for user ${userId}`);
|
||||
|
||||
return exportData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user data (GDPR Article 17 - Right to Erasure)
|
||||
* Note: This is a simplified version. In production, implement full anonymization logic.
|
||||
* Effacement (art. 17).
|
||||
*
|
||||
* L'implémentation précédente supprimait la ligne de consentement cookies,
|
||||
* écrivait « Full implementation pending » dans les logs, et renvoyait un
|
||||
* succès : la personne était informée que ses données étaient effacées alors
|
||||
* que rien ne l'était.
|
||||
*
|
||||
* Deux traitements, décrits dans `domain/services/data-retention.ts` :
|
||||
* ce qui n'existe que pour le confort du service est supprimé ; ce qui répond
|
||||
* à une obligation de conservation (art. 17.3.b) est anonymisé, et sort donc
|
||||
* du champ des données personnelles.
|
||||
*
|
||||
* La ligne `users` est neutralisée plutôt que supprimée : `csv_bookings`,
|
||||
* `licenses` et `api_keys` la référencent en `ON DELETE CASCADE`, un vrai
|
||||
* DELETE emporterait dix ans de pièces comptables avec lui.
|
||||
*
|
||||
* Le tout en transaction : un effacement à moitié appliqué laisserait un
|
||||
* compte ni actif ni effacé, c'est-à-dire un état que rien ne rattrape.
|
||||
*/
|
||||
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
||||
this.logger.warn(
|
||||
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
||||
async deleteUserData(userId: string, reason?: string): Promise<GDPRErasureReport> {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
this.logger.warn(`GDPR erasure starting for user ${userId} — reason: ${reason ?? 'unspecified'}`);
|
||||
|
||||
const deleted: Record<string, number> = {};
|
||||
const anonymised: Record<string, number> = {};
|
||||
const email = user.email;
|
||||
|
||||
await this.dataSource.transaction(async manager => {
|
||||
const rows = (sql: string, parameters: unknown[]) => this.affected(manager, sql, parameters);
|
||||
|
||||
// Supprimé : rien n'impose de le conserver.
|
||||
deleted.notifications = await rows('DELETE FROM notifications WHERE user_id = $1', [userId]);
|
||||
// Les messages suivent par cascade sur `conversation_id`.
|
||||
deleted.assistantConversations = await rows(
|
||||
'DELETE FROM trade_conversations WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
deleted.assistantUsage = await rows('DELETE FROM trade_assistant_usage WHERE user_id = $1', [
|
||||
userId,
|
||||
]);
|
||||
deleted.apiKeys = await rows('DELETE FROM api_keys WHERE user_id = $1', [userId]);
|
||||
deleted.cookieConsent = await rows('DELETE FROM cookie_consents WHERE user_id = $1', [userId]);
|
||||
deleted.passwordResetTokens = await rows(
|
||||
'DELETE FROM password_reset_tokens WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
// Une invitation non consommée porte le nom et l'adresse de la personne
|
||||
// sans qu'aucun compte n'en dépende.
|
||||
deleted.pendingInvitations = await rows(
|
||||
'DELETE FROM invitation_tokens WHERE lower(email) = lower($1) AND is_used = false',
|
||||
[email]
|
||||
);
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
// Anonymisé : conservé, sans rattachement à la personne.
|
||||
// Les notes sont un champ libre : c'est le seul endroit d'une
|
||||
// réservation où une donnée personnelle peut avoir été saisie.
|
||||
anonymised.bookings = await rows('UPDATE csv_bookings SET notes = NULL WHERE user_id = $1', [
|
||||
userId,
|
||||
]);
|
||||
anonymised.auditLogs = await rows(
|
||||
`UPDATE audit_logs SET user_email = $2, ip_address = NULL, user_agent = NULL
|
||||
WHERE user_id = $1`,
|
||||
[userId, anonymisedEmail(userId)]
|
||||
);
|
||||
// Un profil transporteur mêle données d'entreprise (conservées) et
|
||||
// coordonnées d'une personne (effacées).
|
||||
anonymised.carrierProfile = await rows(
|
||||
`UPDATE carrier_profiles SET phone = NULL, notification_email = NULL, is_active = false
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
try {
|
||||
// Delete consent data first (will cascade with user deletion)
|
||||
await this.consentRepository.delete({ userId });
|
||||
// Le compte : identité remplacée, accès rendu impossible.
|
||||
// Le mot de passe reçoit une valeur aléatoire plutôt que NULL — la
|
||||
// colonne est NOT NULL, et une valeur constante partagée par tous les
|
||||
// comptes effacés serait un motif reconnaissable.
|
||||
anonymised.user = await rows(
|
||||
`UPDATE users SET
|
||||
email = $2, first_name = $3, last_name = $3, phone_number = NULL,
|
||||
password_hash = $4, totp_secret = NULL,
|
||||
is_active = false, is_email_verified = false, updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[userId, anonymisedEmail(userId), ANONYMISED, `erased-${uuidv4()}`]
|
||||
);
|
||||
});
|
||||
|
||||
// IMPORTANT: In production, implement full data anonymization
|
||||
// For now, we just mark the account for deletion
|
||||
// Real implementation should:
|
||||
// 1. Anonymize bookings (keep for legal retention)
|
||||
// 2. Delete notifications
|
||||
// 3. Anonymize audit logs
|
||||
// 4. Anonymize user record
|
||||
this.logger.warn(
|
||||
`GDPR erasure completed for user ${userId}: ${JSON.stringify({ deleted, anonymised })}`
|
||||
);
|
||||
|
||||
this.logger.warn(`User ${userId} marked for deletion. Full implementation pending.`);
|
||||
this.logger.log(`Data deletion initiated for user ${userId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Data deletion failed for user ${userId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
// Écrite après la transaction, et avec l'adresse anonymisée : journaliser
|
||||
// avant l'effacement ferait disparaître la trace par l'anonymisation des
|
||||
// journaux, et y inscrire l'adresse réelle réintroduirait l'identité qu'on
|
||||
// vient d'effacer. Ce qu'il faut pouvoir démontrer, c'est que la demande a
|
||||
// été traitée — pas de qui elle émanait.
|
||||
await this.audit.log({
|
||||
action: AuditAction.GDPR_ERASURE_EXECUTED,
|
||||
status: AuditStatus.SUCCESS,
|
||||
userId,
|
||||
userEmail: anonymisedEmail(userId),
|
||||
organizationId: user.organizationId,
|
||||
resourceType: 'gdpr_request',
|
||||
metadata: { reason: reason ?? null, deleted, anonymised },
|
||||
});
|
||||
|
||||
return { userId, erasedAt: new Date().toISOString(), deleted, anonymised };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
||||
* Nombre de lignes réellement touchées.
|
||||
*
|
||||
* Le pilote PostgreSQL de TypeORM renvoie `[lignes, nombre]` pour un UPDATE
|
||||
* ou un DELETE, et la seule liste de lignes pour un SELECT. Compter la
|
||||
* longueur du résultat donnerait donc « 2 » à chaque effacement, quel que
|
||||
* soit le nombre réel — un rapport de conformité faux.
|
||||
*/
|
||||
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');
|
||||
private async affected(
|
||||
manager: EntityManager,
|
||||
sql: string,
|
||||
parameters: unknown[]
|
||||
): Promise<number> {
|
||||
const result = await manager.query(sql, parameters);
|
||||
return Array.isArray(result) && typeof result[1] === 'number' ? result[1] : 0;
|
||||
}
|
||||
|
||||
// Check if consent already exists
|
||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
/**
|
||||
* Journal des demandes de droits, pour la console de conformité.
|
||||
*
|
||||
* Lu en SQL depuis `audit_logs` plutôt que via le dépôt d'audit : le filtre
|
||||
* porte sur un préfixe d'action, que l'interface de dépôt n'expose pas.
|
||||
*/
|
||||
async listRightsRequests(limit = 100): Promise<Record<string, unknown>[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT action, status, user_id, user_email, organization_id, metadata, timestamp
|
||||
FROM audit_logs WHERE action LIKE 'gdpr\\_%' ORDER BY timestamp DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Enregistre le consentement et sa date (art. 7.1 — preuve du consentement).
|
||||
*
|
||||
* Sans entrée d'audit : la ligne de consentement porte déjà l'horodatage,
|
||||
* l'adresse IP et le navigateur, c'est-à-dire exactement la preuve attendue.
|
||||
* Un second enregistrement n'ajouterait rien qu'une écriture par visite.
|
||||
*/
|
||||
async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
|
||||
const existing = await this.consentRepository.findOne({ where: { userId } });
|
||||
const consent = existing ?? this.consentRepository.create({ id: uuidv4(), userId });
|
||||
|
||||
consent.essential = true; // Sans elles le service ne fonctionne pas : pas de choix à recueillir.
|
||||
consent.functional = consentData.functional ?? false;
|
||||
consent.analytics = consentData.analytics ?? false;
|
||||
consent.marketing = consentData.marketing ?? false;
|
||||
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,
|
||||
};
|
||||
return this.toConsentDto(consent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent)
|
||||
* Retrait du consentement (art. 7.3) : aussi simple à retirer qu'à donner.
|
||||
* Sans catégorie précisée, tout ce qui est facultatif est retiré.
|
||||
*/
|
||||
async withdrawConsent(
|
||||
userId: string,
|
||||
consentType: 'functional' | 'analytics' | 'marketing'
|
||||
consentType?: 'functional' | 'analytics' | 'marketing'
|
||||
): Promise<ConsentResponseDto> {
|
||||
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
|
||||
const current = await this.consentRepository.findOne({ where: { 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,
|
||||
const next: UpdateConsentDto = {
|
||||
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,
|
||||
functional: consentType ? consentType !== 'functional' && (current?.functional ?? false) : false,
|
||||
analytics: consentType ? consentType !== 'analytics' && (current?.analytics ?? false) : false,
|
||||
marketing: consentType ? consentType !== 'marketing' && (current?.marketing ?? false) : false,
|
||||
};
|
||||
|
||||
return this.recordConsent(userId, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current consent status
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
// 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 consent ? this.toConsentDto(consent) : null;
|
||||
}
|
||||
|
||||
private toConsentDto(consent: CookieConsentOrmEntity): ConsentResponseDto {
|
||||
return {
|
||||
userId,
|
||||
userId: consent.userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RetentionService } from './retention.service';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditAction } from '@domain/entities/audit-log.entity';
|
||||
import { RETENTION_RULES, purgeableRules } from '@domain/services/data-retention';
|
||||
|
||||
/**
|
||||
* La purge supprime définitivement des lignes. Ces tests portent sur ce qu'elle
|
||||
* touche, et surtout sur ce qu'elle doit épargner.
|
||||
*/
|
||||
|
||||
const ROWS_REMOVED = 4;
|
||||
|
||||
function buildService(enabled = true) {
|
||||
const executed: { sql: string; parameters: unknown[] }[] = [];
|
||||
|
||||
const dataSource = {
|
||||
query: jest.fn(async (sql: string, parameters: unknown[]) => {
|
||||
executed.push({ sql, parameters });
|
||||
return sql.trimStart().startsWith('SELECT')
|
||||
? [{ expired: ROWS_REMOVED, oldest: '2020-01-01T00:00:00.000Z' }]
|
||||
: [[], ROWS_REMOVED];
|
||||
}),
|
||||
} as unknown as DataSource;
|
||||
|
||||
const config = {
|
||||
get: jest.fn(() => (enabled ? 'true' : 'false')),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const audit = { log: jest.fn(async () => undefined) } as unknown as AuditService;
|
||||
|
||||
return { service: new RetentionService(dataSource, config, audit), executed, audit };
|
||||
}
|
||||
|
||||
describe('RetentionService', () => {
|
||||
it("n'applique un délai qu'aux tables qui en ont un", async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
await service.purge();
|
||||
|
||||
const purgeable = purgeableRules().map(rule => rule.table);
|
||||
expect(executed).toHaveLength(purgeable.length);
|
||||
for (const rule of RETENTION_RULES) {
|
||||
const touched = executed.some(query => query.sql.includes(rule.table));
|
||||
// Une durée liée à la vie du compte n'a pas de point de départ en base :
|
||||
// elle est traitée par l'effacement, pas par la purge.
|
||||
expect(touched).toBe(purgeable.includes(rule.table));
|
||||
}
|
||||
});
|
||||
|
||||
it("épargne les traces de traitement des demandes de droits", async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
await service.purge();
|
||||
|
||||
const auditPurge = executed.find(query => query.sql.includes('audit_logs'));
|
||||
// Sans cette clause, la purge des journaux à douze mois effacerait la
|
||||
// preuve, exigée par l'art. 5.2, qu'un effacement a été honoré.
|
||||
expect(auditPurge?.sql).toContain("action NOT LIKE 'gdpr\\_%'");
|
||||
});
|
||||
|
||||
it('journalise ce qui a été supprimé', async () => {
|
||||
const { service, audit } = buildService();
|
||||
|
||||
const report = await service.purge();
|
||||
|
||||
expect(report.lines.every(line => line.expired === ROWS_REMOVED)).toBe(true);
|
||||
const [entry] = (audit.log as jest.Mock).mock.calls[0];
|
||||
expect(entry.action).toBe(AuditAction.GDPR_RETENTION_PURGE);
|
||||
});
|
||||
|
||||
it("ne supprime rien quand la purge automatique n'est pas activée", async () => {
|
||||
const { service, executed } = buildService(false);
|
||||
|
||||
await service.scheduledPurge();
|
||||
|
||||
expect(executed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ne compte pas la forme du résultat à la place des lignes', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const report = await service.purge();
|
||||
|
||||
// Le pilote renvoie `[lignes, nombre]` : mesurer la longueur donnerait 2.
|
||||
expect(report.lines.map(line => line.expired)).not.toContain(2);
|
||||
});
|
||||
|
||||
it('signale ce qui serait supprimé sans rien supprimer', async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
const report = await service.preview();
|
||||
|
||||
expect(executed.every(query => query.sql.trimStart().startsWith('SELECT'))).toBe(true);
|
||||
expect(report.lines.every(line => line.expired === ROWS_REMOVED)).toBe(true);
|
||||
});
|
||||
});
|
||||
157
apps/backend/src/application/services/retention.service.ts
Normal file
157
apps/backend/src/application/services/retention.service.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Application des durées de conservation (RGPD art. 5.1.e).
|
||||
*
|
||||
* La politique de confidentialité annonce que les données sont supprimées au
|
||||
* terme des durées annoncées. Rien ne le faisait : aucune tâche périodique
|
||||
* n'existait dans le projet, et les journaux comme les notifications
|
||||
* s'accumulaient indéfiniment. Annoncer une durée sans l'appliquer revient à
|
||||
* ne pas en avoir.
|
||||
*
|
||||
* La purge est **désactivée par défaut**. Elle supprime définitivement des
|
||||
* lignes : la mettre en route est une décision d'exploitation, pas un effet de
|
||||
* bord d'un déploiement. `preview()` permet de voir exactement ce qu'elle
|
||||
* emporterait avant de l'activer par `RETENTION_PURGE_ENABLED=true`.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity';
|
||||
import { assertSafeIdentifier, purgeableRules } from '@domain/services/data-retention';
|
||||
|
||||
export interface RetentionLine {
|
||||
table: string;
|
||||
months: number;
|
||||
/** Lignes ayant dépassé la durée de conservation. */
|
||||
expired: number;
|
||||
/** Date la plus ancienne encore présente, pour situer l'ampleur. */
|
||||
oldest: string | null;
|
||||
}
|
||||
|
||||
export interface RetentionReport {
|
||||
enabled: boolean;
|
||||
runAt: string;
|
||||
lines: RetentionLine[];
|
||||
}
|
||||
|
||||
/** Compte technique porté au journal : la purge n'émane d'aucune personne. */
|
||||
const SYSTEM_ACTOR = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
@Injectable()
|
||||
export class RetentionService {
|
||||
private readonly logger = new Logger(RetentionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService
|
||||
) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.config.get<string>('RETENTION_PURGE_ENABLED') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que la purge supprimerait, sans rien supprimer.
|
||||
*
|
||||
* C'est la vue que consulte la console de conformité : on voit l'effet avant
|
||||
* de l'autoriser, plutôt que de découvrir après coup ce qui a disparu.
|
||||
*/
|
||||
async preview(): Promise<RetentionReport> {
|
||||
const lines: RetentionLine[] = [];
|
||||
|
||||
for (const rule of purgeableRules()) {
|
||||
const table = assertSafeIdentifier(rule.table);
|
||||
const column = assertSafeIdentifier(rule.timestampColumn);
|
||||
const keep = rule.keepWhere ? ` AND (${rule.keepWhere})` : '';
|
||||
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT count(*)::int AS expired, min(${column}) AS oldest
|
||||
FROM ${table} WHERE ${column} < now() - ($1 || ' months')::interval${keep}`,
|
||||
[rule.months]
|
||||
);
|
||||
|
||||
lines.push({
|
||||
table: rule.table,
|
||||
months: rule.months,
|
||||
expired: row?.expired ?? 0,
|
||||
oldest: row?.oldest ? new Date(row.oldest).toISOString() : null,
|
||||
});
|
||||
}
|
||||
|
||||
return { enabled: this.enabled, runAt: new Date().toISOString(), lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime les lignes dont la durée de conservation est écoulée.
|
||||
*
|
||||
* `trade_messages` n'apparaît pas : les messages suivent la suppression de
|
||||
* leur conversation par cascade.
|
||||
*/
|
||||
async purge(): Promise<RetentionReport> {
|
||||
const lines: RetentionLine[] = [];
|
||||
|
||||
for (const rule of purgeableRules()) {
|
||||
const table = assertSafeIdentifier(rule.table);
|
||||
const column = assertSafeIdentifier(rule.timestampColumn);
|
||||
// `keepWhere` protège notamment les traces de traitement des demandes de
|
||||
// droits : elles vivent dans `audit_logs`, dont le délai est le plus
|
||||
// court. Sans cette exception, la purge effacerait la preuve qu'une
|
||||
// demande d'effacement a été honorée.
|
||||
const keep = rule.keepWhere ? ` AND (${rule.keepWhere})` : '';
|
||||
|
||||
const result = await this.dataSource.query(
|
||||
`DELETE FROM ${table} WHERE ${column} < now() - ($1 || ' months')::interval${keep}`,
|
||||
[rule.months]
|
||||
);
|
||||
// Le pilote renvoie `[lignes, nombre]` pour un DELETE.
|
||||
const removed = Array.isArray(result) && typeof result[1] === 'number' ? result[1] : 0;
|
||||
|
||||
lines.push({ table: rule.table, months: rule.months, expired: removed, oldest: null });
|
||||
}
|
||||
|
||||
const total = lines.reduce((sum, line) => sum + line.expired, 0);
|
||||
this.logger.warn(`Retention purge removed ${total} rows: ${JSON.stringify(lines)}`);
|
||||
|
||||
if (total > 0) {
|
||||
await this.audit.log({
|
||||
action: AuditAction.GDPR_RETENTION_PURGE,
|
||||
status: AuditStatus.SUCCESS,
|
||||
userId: SYSTEM_ACTOR,
|
||||
userEmail: 'system@xpeditis',
|
||||
organizationId: SYSTEM_ACTOR,
|
||||
resourceType: 'retention',
|
||||
metadata: { lines },
|
||||
});
|
||||
}
|
||||
|
||||
return { enabled: this.enabled, runAt: new Date().toISOString(), lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Une fois par nuit, à une heure creuse.
|
||||
*
|
||||
* Quotidien plutôt qu'horaire : une durée exprimée en mois ne gagne rien à
|
||||
* être vérifiée toutes les heures, et une purge est une opération d'écriture
|
||||
* sur des tables volumineuses.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||
async scheduledPurge(): Promise<void> {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug('Retention purge disabled (RETENTION_PURGE_ENABLED)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.purge();
|
||||
} catch (error) {
|
||||
// Une purge qui échoue ne doit pas emporter le processus : elle
|
||||
// repassera demain, et l'erreur doit être visible.
|
||||
this.logger.error(
|
||||
`Retention purge failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,15 @@ export enum AuditAction {
|
||||
// Agent actions — toute capacite invoquee par un agent, via MCP ou via
|
||||
// l'assistant integre. Le nom de la capacite est dans `resourceName`.
|
||||
AGENT_CAPABILITY_INVOKED = 'agent_capability_invoked',
|
||||
|
||||
// Droits des personnes (RGPD). L'article 5.2 impose de pouvoir demontrer
|
||||
// qu'une demande a ete traitee : sans trace, honorer un droit et l'ignorer
|
||||
// se ressemblent. La trace d'un effacement porte l'identifiant technique et
|
||||
// l'adresse anonymisee, jamais l'identite effacee.
|
||||
GDPR_DATA_EXPORTED = 'gdpr_data_exported',
|
||||
GDPR_ERASURE_EXECUTED = 'gdpr_erasure_executed',
|
||||
GDPR_CONSENT_RECORDED = 'gdpr_consent_recorded',
|
||||
GDPR_RETENTION_PURGE = 'gdpr_retention_purge',
|
||||
}
|
||||
|
||||
export enum AuditStatus {
|
||||
|
||||
157
apps/backend/src/domain/services/data-retention.ts
Normal file
157
apps/backend/src/domain/services/data-retention.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Politique de conservation et d'effacement.
|
||||
*
|
||||
* ⚠️ Ce fichier traduit en code des choix **juridiques**, pas techniques. Les
|
||||
* durées ci-dessous doivent être validées par un conseil avant mise en
|
||||
* production : elles sont regroupées ici précisément pour être relues d'un
|
||||
* seul tenant, plutôt que dispersées dans les services.
|
||||
*
|
||||
* L'effacement (RGPD art. 17) ne peut pas être un `DELETE` généralisé : une
|
||||
* partie des données répond à une obligation légale de conservation qui prime
|
||||
* sur la demande d'effacement (art. 17.3.b). D'où deux traitements distincts :
|
||||
*
|
||||
* - **Effacé** : ce qui n'est conservé que pour le service. Disparaît.
|
||||
* - **Anonymisé** : ce qui doit être conservé, mais peut l'être sans rattachement
|
||||
* à une personne. Les pièces comptables gardent leur valeur probante sans
|
||||
* l'identité du demandeur.
|
||||
*
|
||||
* Une donnée anonymisée n'est plus une donnée personnelle : la conserver
|
||||
* ensuite ne relève plus du RGPD. C'est ce qui rend l'arbitrage tenable.
|
||||
*/
|
||||
|
||||
export interface RetentionRule {
|
||||
/** Table concernée. */
|
||||
table: string;
|
||||
/** Ce que devient la donnée à la demande d'effacement. */
|
||||
onErasure: 'delete' | 'anonymise' | 'keep';
|
||||
/**
|
||||
* Durée de conservation en mois, `null` si liée à la vie du compte.
|
||||
*
|
||||
* À ne pas confondre avec `onErasure` : celui-ci décrit la réponse à une
|
||||
* demande de la personne, celle-ci la limite au-delà de laquelle la donnée
|
||||
* n'a plus de raison d'être conservée, même sans demande (art. 5.1.e).
|
||||
*/
|
||||
months: number | null;
|
||||
/**
|
||||
* Colonne horodatée qui fait courir le délai. `null` lorsque la durée est
|
||||
* liée à la vie du compte : il n'y a alors rien à purger dans le temps.
|
||||
*/
|
||||
timestampColumn: string | null;
|
||||
/**
|
||||
* Condition SQL désignant les lignes que la purge ne doit jamais emporter,
|
||||
* même une fois le délai écoulé. `null` quand toute la table suit la règle.
|
||||
*/
|
||||
keepWhere: string | null;
|
||||
/** Pourquoi cette durée — la justification attendue par l'art. 30. */
|
||||
basis: string;
|
||||
}
|
||||
|
||||
export const RETENTION_RULES: readonly RetentionRule[] = [
|
||||
{
|
||||
table: 'users',
|
||||
keepWhere: null,
|
||||
timestampColumn: null,
|
||||
onErasure: 'anonymise',
|
||||
months: null,
|
||||
basis:
|
||||
"Le compte est anonymisé plutôt que supprimé : les réservations y font référence et doivent rester rattachables à une pièce comptable, sans l'identité de la personne.",
|
||||
},
|
||||
{
|
||||
table: 'csv_bookings',
|
||||
keepWhere: null,
|
||||
timestampColumn: 'created_at',
|
||||
onErasure: 'anonymise',
|
||||
months: 120,
|
||||
basis:
|
||||
'Pièce commerciale et comptable. Le code de commerce français impose dix ans de conservation des documents comptables (art. L123-22).',
|
||||
},
|
||||
{
|
||||
table: 'audit_logs',
|
||||
keepWhere: "action NOT LIKE 'gdpr\\_%'",
|
||||
timestampColumn: 'timestamp',
|
||||
onErasure: 'anonymise',
|
||||
months: 12,
|
||||
basis:
|
||||
"Journal de sécurité : nécessaire à la détection d'accès illégitimes (art. 32), et attendu par la CNIL avec une durée de six mois à un an.",
|
||||
},
|
||||
{
|
||||
table: 'notifications',
|
||||
keepWhere: null,
|
||||
timestampColumn: 'created_at',
|
||||
onErasure: 'delete',
|
||||
months: 12,
|
||||
basis: "Confort de service, sans valeur probante : rien ne justifie de les conserver.",
|
||||
},
|
||||
{
|
||||
table: 'trade_conversations',
|
||||
keepWhere: null,
|
||||
timestampColumn: 'updated_at',
|
||||
onErasure: 'delete',
|
||||
months: 12,
|
||||
basis:
|
||||
"Échanges avec l'assistant IA. Conservés pour que la personne retrouve ses conversations, sans obligation légale : ils s'effacent à la demande.",
|
||||
},
|
||||
{
|
||||
table: 'api_keys',
|
||||
keepWhere: null,
|
||||
timestampColumn: null,
|
||||
onErasure: 'delete',
|
||||
months: null,
|
||||
basis: "Moyen d'accès : il disparaît avec le compte.",
|
||||
},
|
||||
{
|
||||
table: 'cookie_consents',
|
||||
keepWhere: null,
|
||||
timestampColumn: 'consent_date',
|
||||
onErasure: 'delete',
|
||||
months: 13,
|
||||
basis:
|
||||
'Preuve du consentement (art. 7.1). La CNIL recommande de conserver cette preuve tant que le consentement est valable, soit treize mois.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Règle dont le délai peut être appliqué dans le temps, sans demande. */
|
||||
export interface PurgeableRule extends RetentionRule {
|
||||
months: number;
|
||||
timestampColumn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Règles que la purge périodique peut appliquer.
|
||||
*
|
||||
* Les tables dont la durée est liée à la vie du compte en sont exclues : leur
|
||||
* point de départ n'est pas une date en base, mais la fermeture du compte, que
|
||||
* l'effacement traite déjà.
|
||||
*/
|
||||
export function purgeableRules(rules: readonly RetentionRule[] = RETENTION_RULES): PurgeableRule[] {
|
||||
return rules.filter(
|
||||
(rule): rule is PurgeableRule => rule.months !== null && rule.timestampColumn !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les noms de table et de colonne sont interpolés dans du SQL — un paramètre
|
||||
* lié ne peut pas porter un identifiant. Ils viennent de constantes, mais le
|
||||
* jour où une règle sera renseignée depuis une configuration, cette barrière
|
||||
* sera déjà là.
|
||||
*/
|
||||
const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
||||
|
||||
export function assertSafeIdentifier(value: string): string {
|
||||
if (!SAFE_IDENTIFIER.test(value)) {
|
||||
throw new Error(`Identifiant SQL refusé par la politique de conservation : ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Valeur substituée aux données identifiantes lors d'une anonymisation. */
|
||||
export const ANONYMISED = 'anonymised';
|
||||
|
||||
/**
|
||||
* Adresse de remplacement d'un compte effacé.
|
||||
*
|
||||
* Unique par compte : la colonne `email` porte une contrainte d'unicité, et
|
||||
* deux effacements successifs échoueraient sur une valeur constante. Elle ne
|
||||
* permet aucun rattachement — l'identifiant technique existait déjà en base.
|
||||
*/
|
||||
export const anonymisedEmail = (userId: string): string => `${ANONYMISED}+${userId}@invalid.local`;
|
||||
309
apps/frontend/app/[locale]/admin/compliance/page.tsx
Normal file
309
apps/frontend/app/[locale]/admin/compliance/page.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { AlertTriangle, CheckCircle2, Eraser, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import {
|
||||
getRetentionPreview,
|
||||
getRetentionRules,
|
||||
getRightsRequests,
|
||||
runRetentionPurge,
|
||||
type RetentionLine,
|
||||
type RetentionRule,
|
||||
type RightsRequest,
|
||||
} from '@/lib/api/compliance';
|
||||
import {
|
||||
CONTROLLER,
|
||||
SUBPROCESSORS,
|
||||
missingControllerFields,
|
||||
} from '@/lib/legal/processing';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Callout } from '@/components/ui/callout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { EmptyState, ErrorState, PageSpinner } from '@/components/ui/states';
|
||||
import { useConfirm } from '@/components/ui/use-confirm';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
/**
|
||||
* Console de conformite.
|
||||
*
|
||||
* Elle repond a trois questions qu'on ne pouvait poser nulle part : que
|
||||
* declare-t-on, que conserve-t-on encore au-dela de la duree annoncee, et
|
||||
* a-t-on trace des demandes de droits. Le premier bloc n'est pas decoratif :
|
||||
* une politique de confidentialite sans identite du responsable est
|
||||
* incomplete au sens de l'article 13, et c'est ici qu'on le voit.
|
||||
*/
|
||||
export default function CompliancePage() {
|
||||
const t = useTranslations('admin.compliance');
|
||||
const tProcessor = useTranslations('marketing.privacy.processors');
|
||||
const tBasis = useTranslations('marketing.privacy.recipientsTable.bases');
|
||||
const locale = useLocale();
|
||||
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const rules = useQuery({ queryKey: ['retention-rules'], queryFn: getRetentionRules });
|
||||
const preview = useQuery({ queryKey: ['retention-preview'], queryFn: getRetentionPreview });
|
||||
const requests = useQuery({ queryKey: ['rights-requests'], queryFn: getRightsRequests });
|
||||
|
||||
const purge = useMutation({
|
||||
mutationFn: runRetentionPurge,
|
||||
onSuccess: report => {
|
||||
const removed = report.lines.reduce((sum, line) => sum + line.expired, 0);
|
||||
toast.success(t('purge.done', { count: removed }));
|
||||
queryClient.invalidateQueries({ queryKey: ['retention-preview'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['rights-requests'] });
|
||||
},
|
||||
onError: () => toast.error(t('purge.failed')),
|
||||
});
|
||||
|
||||
const missing = missingControllerFields();
|
||||
const dateTime = (value: string) =>
|
||||
new Date(value).toLocaleString(dateLocale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
|
||||
const expiredTotal =
|
||||
preview.data?.lines.reduce((sum: number, line: RetentionLine) => sum + line.expired, 0) ?? 0;
|
||||
|
||||
const askPurge = async () => {
|
||||
const ok = await confirm({
|
||||
title: t('purge.confirmTitle'),
|
||||
description: t('purge.confirmBody', { count: expiredTotal }),
|
||||
confirmLabel: t('purge.action'),
|
||||
destructive: true,
|
||||
});
|
||||
if (ok) purge.mutate();
|
||||
};
|
||||
|
||||
if (rules.isPending || preview.isPending) return <PageSpinner />;
|
||||
if (rules.isError || preview.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title={t('errorTitle')}
|
||||
description={t('errorBody')}
|
||||
onRetry={() => {
|
||||
rules.refetch();
|
||||
preview.refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => preview.refetch()}>
|
||||
<RefreshCw />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Mentions obligatoires manquantes ------------------------------- */}
|
||||
{missing.length > 0 ? (
|
||||
<Callout variant="warning" title={t('identity.incompleteTitle')}>
|
||||
<p>{t('identity.incompleteBody')}</p>
|
||||
<ul className="mt-2 list-disc pl-5">
|
||||
{missing.map(field => (
|
||||
<li key={field}>{t(`identity.fields.${field}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-body-xs text-neutral-500">{t('identity.where')}</p>
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout variant="success" title={t('identity.completeTitle')}>
|
||||
{t('identity.completeBody', { name: CONTROLLER.tradingName })}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Conservation --------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between gap-4 pb-3">
|
||||
<div>
|
||||
<CardTitle>{t('retention.title')}</CardTitle>
|
||||
<p className="mt-1 text-body-sm text-neutral-500">{t('retention.subtitle')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={expiredTotal > 0 ? 'default' : 'outline'}
|
||||
disabled={expiredTotal === 0 || purge.isPending}
|
||||
onClick={askPurge}
|
||||
>
|
||||
<Eraser />
|
||||
{t('purge.action')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* La purge automatique supprime definitivement des lignes :
|
||||
son etat doit se lire sans avoir a ouvrir la configuration. */}
|
||||
<Callout
|
||||
variant={preview.data.enabled ? 'info' : 'warning'}
|
||||
className="mb-4"
|
||||
title={
|
||||
preview.data.enabled ? t('retention.autoOn') : t('retention.autoOff')
|
||||
}
|
||||
>
|
||||
{preview.data.enabled ? t('retention.autoOnBody') : t('retention.autoOffBody')}
|
||||
</Callout>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('retention.table')}</TableHead>
|
||||
<TableHead>{t('retention.duration')}</TableHead>
|
||||
<TableHead>{t('retention.onErasure')}</TableHead>
|
||||
<TableHead className="text-right">{t('retention.expired')}</TableHead>
|
||||
<TableHead>{t('retention.basis')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.data.rules.map((rule: RetentionRule) => {
|
||||
const line = preview.data.lines.find(item => item.table === rule.table);
|
||||
return (
|
||||
<TableRow key={rule.table}>
|
||||
<TableCell className="font-mono text-body-xs">{rule.table}</TableCell>
|
||||
<TableCell>
|
||||
{rule.months === null
|
||||
? t('retention.accountLifetime')
|
||||
: t('retention.months', { count: rule.months })}
|
||||
</TableCell>
|
||||
<TableCell>{t(`retention.actions.${rule.onErasure}`)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{line ? (
|
||||
<span
|
||||
className={
|
||||
line.expired > 0 ? 'font-semibold text-amber-600' : 'text-neutral-400'
|
||||
}
|
||||
>
|
||||
{line.expired}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-300">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md text-body-xs text-neutral-500">
|
||||
{rule.basis}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Registre des sous-traitants ------------------------------------ */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('processors.title')}</CardTitle>
|
||||
<p className="mt-1 text-body-sm text-neutral-500">{t('processors.subtitle')}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('processors.name')}</TableHead>
|
||||
<TableHead>{t('processors.role')}</TableHead>
|
||||
<TableHead>{t('processors.location')}</TableHead>
|
||||
<TableHead>{t('processors.basis')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SUBPROCESSORS.map(processor => (
|
||||
<TableRow key={processor.key}>
|
||||
<TableCell className="font-medium text-brand-navy">
|
||||
{processor.name}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm text-body-sm text-neutral-600">
|
||||
{tProcessor(`${processor.key}.role`)}
|
||||
</TableCell>
|
||||
<TableCell className="text-body-sm text-neutral-600">
|
||||
{tProcessor(`${processor.key}.location`)}
|
||||
</TableCell>
|
||||
<TableCell className="text-body-sm text-neutral-600">
|
||||
{tBasis(processor.transferBasis)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Demandes de droits --------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('requests.title')}</CardTitle>
|
||||
<p className="mt-1 text-body-sm text-neutral-500">{t('requests.subtitle')}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{requests.data && requests.data.requests.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('requests.date')}</TableHead>
|
||||
<TableHead>{t('requests.action')}</TableHead>
|
||||
<TableHead>{t('requests.subject')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.data.requests.map((entry: RightsRequest, index: number) => (
|
||||
<TableRow key={`${entry.timestamp}-${index}`}>
|
||||
<TableCell className="whitespace-nowrap text-body-sm">
|
||||
{dateTime(entry.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center gap-1.5 text-body-sm">
|
||||
{entry.action === 'gdpr_erasure_executed' ? (
|
||||
<Eraser className="size-3.5 text-amber-600" aria-hidden="true" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3.5 text-success" aria-hidden="true" />
|
||||
)}
|
||||
{t(`requests.actions.${entry.action}`)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-body-xs text-neutral-500">
|
||||
{entry.user_email}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
icon={ShieldCheck}
|
||||
title={t('requests.emptyTitle')}
|
||||
description={t('requests.emptyBody')}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="flex items-start gap-2 px-1 text-body-xs text-neutral-500">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t('disclaimer')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
import { CONTROLLER } from '@/lib/legal/processing';
|
||||
|
||||
type RightKey = 'access' | 'rectification' | 'erasure' | 'portability';
|
||||
type PrincipleKey = 'minimization' | 'retention' | 'integrity' | 'transparency';
|
||||
@ -43,6 +44,11 @@ const MEASURES: MeasureKey[] = ['technical', 'organizational'];
|
||||
const MEASURE_ITEMS = ['item1', 'item2', 'item3', 'item4', 'item5'] as const;
|
||||
const REGISTER_ITEMS = ['item1', 'item2', 'item3', 'item4', 'item5'] as const;
|
||||
|
||||
// Le lien de contact suit la realite : ecrire a une boite dpo@ qui
|
||||
// n'existe pas laisserait une demande de droits sans destinataire.
|
||||
const hasDpo = Boolean(CONTROLLER.dpo);
|
||||
const contactAddress = CONTROLLER.dpo ?? CONTROLLER.privacyContact;
|
||||
|
||||
export default function CompliancePage() {
|
||||
const t = useTranslations('marketing.compliance');
|
||||
const heroRef = useRef(null);
|
||||
@ -120,10 +126,12 @@ export default function CompliancePage() {
|
||||
<CheckCircle className="w-5 h-5 text-brand-green" />
|
||||
<span className="text-white text-sm">{t('badges.compliant')}</span>
|
||||
</div>
|
||||
{hasDpo && (
|
||||
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
|
||||
<UserCheck className="w-5 h-5 text-brand-green" />
|
||||
<span className="text-white text-sm">{t('badges.dpo')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -197,10 +205,10 @@ export default function CompliancePage() {
|
||||
{t('rightsCta.login')}
|
||||
</Link>
|
||||
<a
|
||||
href="mailto:dpo@xpeditis.com"
|
||||
href={`mailto:${contactAddress}`}
|
||||
className="px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium"
|
||||
>
|
||||
{t('rightsCta.dpo')}
|
||||
{hasDpo ? t('rightsCta.dpo') : t('rightsCta.contact')}
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -279,7 +287,9 @@ export default function CompliancePage() {
|
||||
>
|
||||
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3>
|
||||
<ul className="space-y-4">
|
||||
{MEASURE_ITEMS.map(itemKey => (
|
||||
{MEASURE_ITEMS.filter(
|
||||
itemKey => hasDpo || !(key === 'organizational' && itemKey === 'item1')
|
||||
).map(itemKey => (
|
||||
<li key={itemKey} className="flex items-center space-x-3 text-white/80">
|
||||
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
|
||||
<span>{t(`measures.${key}.${itemKey}` as any)}</span>
|
||||
@ -334,15 +344,19 @@ export default function CompliancePage() {
|
||||
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
>
|
||||
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-4">{t('dpo.title')}</h3>
|
||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">{t('dpo.body')}</p>
|
||||
<h3 className="text-2xl font-bold text-white mb-4">
|
||||
{hasDpo ? t('dpo.title') : t('contact.title')}
|
||||
</h3>
|
||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
|
||||
{hasDpo ? t('dpo.body') : t('contact.body')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<a
|
||||
href="mailto:dpo@xpeditis.com"
|
||||
href={`mailto:${contactAddress}`}
|
||||
className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span>dpo@xpeditis.com</span>
|
||||
<span>{contactAddress}</span>
|
||||
</a>
|
||||
<Link
|
||||
href="/privacy"
|
||||
|
||||
@ -14,63 +14,32 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
import { ESSENTIAL_STORAGE, FUNCTIONAL_STORAGE, type StoredItem } from '@/lib/legal/processing';
|
||||
|
||||
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
|
||||
|
||||
interface CookieRow {
|
||||
name: string;
|
||||
purposeKey: string;
|
||||
durationKey: string;
|
||||
}
|
||||
|
||||
interface CookieTypeConfig {
|
||||
key: CookieTypeKey;
|
||||
icon: LucideIcon;
|
||||
required: boolean;
|
||||
cookies: CookieRow[];
|
||||
items: readonly StoredItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventaire reel de ce que le site depose.
|
||||
*
|
||||
* La version precedente listait Google Analytics (`_ga`, `_gid`, `_gat`),
|
||||
* Facebook Pixel (`_fbp`), LinkedIn (`li_fat_id`) et HubSpot (`hubspotutk`).
|
||||
* Aucun de ces scripts n'est charge par l'application : annoncer des traceurs
|
||||
* qui n'existent pas est aussi inexact que d'en dissimuler, et rendait le
|
||||
* consentement demande sans objet. Les categories analytiques et marketing
|
||||
* restent affichees, vides, pour que leur absence soit une information.
|
||||
*/
|
||||
const COOKIE_TYPES: CookieTypeConfig[] = [
|
||||
{
|
||||
key: 'essential',
|
||||
icon: Shield,
|
||||
required: true,
|
||||
cookies: [
|
||||
{ name: 'session_id', purposeKey: 'session_id', durationKey: 'session' },
|
||||
{ name: 'csrf_token', purposeKey: 'csrf_token', durationKey: 'session' },
|
||||
{ name: 'cookie_consent', purposeKey: 'cookie_consent', durationKey: 'year1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'analytics',
|
||||
icon: BarChart3,
|
||||
required: false,
|
||||
cookies: [
|
||||
{ name: '_ga', purposeKey: '_ga', durationKey: 'years2' },
|
||||
{ name: '_gid', purposeKey: '_gid', durationKey: 'hours24' },
|
||||
{ name: '_gat', purposeKey: '_gat', durationKey: 'minute1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'marketing',
|
||||
icon: Target,
|
||||
required: false,
|
||||
cookies: [
|
||||
{ name: '_fbp', purposeKey: '_fbp', durationKey: 'months3' },
|
||||
{ name: 'li_fat_id', purposeKey: 'li_fat_id', durationKey: 'days30' },
|
||||
{ name: 'hubspotutk', purposeKey: 'hubspotutk', durationKey: 'months13' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'functional',
|
||||
icon: Settings,
|
||||
required: false,
|
||||
cookies: [
|
||||
{ name: 'language', purposeKey: 'language', durationKey: 'year1' },
|
||||
{ name: 'theme', purposeKey: 'theme', durationKey: 'year1' },
|
||||
{ name: 'recent_searches', purposeKey: 'recent_searches', durationKey: 'days30' },
|
||||
],
|
||||
},
|
||||
{ key: 'essential', icon: Shield, required: true, items: ESSENTIAL_STORAGE },
|
||||
{ key: 'functional', icon: Settings, required: false, items: FUNCTIONAL_STORAGE },
|
||||
{ key: 'analytics', icon: BarChart3, required: false, items: [] },
|
||||
{ key: 'marketing', icon: Target, required: false, items: [] },
|
||||
];
|
||||
|
||||
export default function CookiesPage() {
|
||||
@ -230,6 +199,11 @@ export default function CookiesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type.items.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-neutral-500">
|
||||
{t('noneInUse')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@ -237,6 +211,9 @@ export default function CookiesPage() {
|
||||
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
|
||||
{t('tableHeaders.name')}
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
|
||||
{t('tableHeaders.storage')}
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
|
||||
{t('tableHeaders.purpose')}
|
||||
</th>
|
||||
@ -246,22 +223,26 @@ export default function CookiesPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{type.cookies.map(cookie => (
|
||||
<tr key={cookie.name} className="border-b border-border last:border-0">
|
||||
{type.items.map(item => (
|
||||
<tr key={item.name} className="border-b border-border last:border-0">
|
||||
<td className="py-3 px-4 font-mono text-brand-turquoise">
|
||||
{cookie.name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-neutral-600">
|
||||
{t(`purposes.${cookie.purposeKey}` as any)}
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-neutral-500">
|
||||
{t(`durations.${cookie.durationKey}` as any)}
|
||||
{t(`storageKinds.${item.kind}`)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-neutral-600">
|
||||
{t(`storage.${item.name}.purpose`)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-neutral-500">
|
||||
{t(`storage.${item.name}.duration`)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -12,19 +12,51 @@ import {
|
||||
Globe,
|
||||
Mail,
|
||||
Calendar,
|
||||
Building2,
|
||||
Sparkles,
|
||||
Share2,
|
||||
Scale,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
import { CONTROLLER, SUBPROCESSORS, SUPERVISORY_AUTHORITY } from '@/lib/legal/processing';
|
||||
|
||||
const SECTION_KEYS = ['data', 'use', 'protection', 'rights', 'transfers', 'retention'] as const;
|
||||
/**
|
||||
* Politique de confidentialite.
|
||||
*
|
||||
* La version precedente citait le Privacy Shield comme garantie de transfert —
|
||||
* un mecanisme invalide par la CJUE en juillet 2020 — ne nommait aucun
|
||||
* sous-traitant, ne donnait aucune base legale et passait sous silence le
|
||||
* traitement par un modele de langage. Le texte vit dans les messages ; la
|
||||
* liste des destinataires vient de `lib/legal/processing`, la meme source que
|
||||
* la page de conformite de l'administration, pour que les deux ne puissent pas
|
||||
* diverger.
|
||||
*/
|
||||
|
||||
const ICONS: Record<(typeof SECTION_KEYS)[number], LucideIcon> = {
|
||||
const SECTION_KEYS = [
|
||||
'controller',
|
||||
'data',
|
||||
'purposes',
|
||||
'ai',
|
||||
'recipients',
|
||||
'transfers',
|
||||
'retention',
|
||||
'rights',
|
||||
'security',
|
||||
] as const;
|
||||
|
||||
type SectionKey = (typeof SECTION_KEYS)[number];
|
||||
|
||||
const ICONS: Record<SectionKey, LucideIcon> = {
|
||||
controller: Building2,
|
||||
data: Database,
|
||||
use: Eye,
|
||||
protection: Lock,
|
||||
rights: UserCheck,
|
||||
purposes: Eye,
|
||||
ai: Sparkles,
|
||||
recipients: Share2,
|
||||
transfers: Globe,
|
||||
retention: Calendar,
|
||||
rights: UserCheck,
|
||||
security: Lock,
|
||||
};
|
||||
|
||||
function renderInlineBold(content: string) {
|
||||
@ -44,30 +76,29 @@ export default function PrivacyPage() {
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, staggerChildren: 0.1 } },
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
|
||||
};
|
||||
|
||||
// Seuls les champs renseignes sont publies : mieux vaut une mention
|
||||
// manquante, que la console d'administration signale, qu'une mention fausse.
|
||||
const identityRows = [
|
||||
['tradingName', CONTROLLER.tradingName],
|
||||
['legalName', CONTROLLER.legalName],
|
||||
['registrationNumber', CONTROLLER.registrationNumber],
|
||||
['address', CONTROLLER.address],
|
||||
['contact', CONTROLLER.privacyContact],
|
||||
['dpo', CONTROLLER.dpo],
|
||||
].filter(([, value]) => Boolean(value)) as [string, string][];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||
@ -120,9 +151,34 @@ export default function PrivacyPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content Section */}
|
||||
<section ref={contentRef} className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
{/* Identite du responsable : la premiere chose que doit trouver
|
||||
quelqu'un qui veut savoir a qui s'adresser (art. 13.1.a). */}
|
||||
{identityRows.length > 0 && (
|
||||
<motion.dl
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-12 grid gap-x-8 gap-y-4 rounded-2xl border border-border bg-neutral-50 p-8 sm:grid-cols-2"
|
||||
>
|
||||
{identityRows.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt className="text-sm text-neutral-500">{t(`identity.${key}`)}</dt>
|
||||
<dd className="mt-1 font-medium text-brand-navy">
|
||||
{key === 'contact' ? (
|
||||
<a className="hover:text-brand-turquoise" href={`mailto:${value}`}>
|
||||
{value}
|
||||
</a>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</motion.dl>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
@ -141,13 +197,14 @@ export default function PrivacyPage() {
|
||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-2xl font-bold text-brand-navy mb-4">
|
||||
{t(`sections.${key}.title`)}
|
||||
</h2>
|
||||
<div className="text-neutral-600 leading-relaxed whitespace-pre-line prose prose-sm max-w-none">
|
||||
{renderInlineBold(t(`sections.${key}.content`))}
|
||||
</div>
|
||||
{key === 'recipients' && <SubprocessorTable />}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -155,22 +212,46 @@ export default function PrivacyPage() {
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Section */}
|
||||
{/* Droit de reclamation (art. 13.2.d) : absent de la version
|
||||
precedente, alors qu'il doit etre porte a la connaissance. */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="mt-12 flex flex-col gap-4 rounded-2xl border border-border bg-neutral-50 p-8 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-lg font-bold text-brand-navy">
|
||||
<Scale className="h-5 w-5 text-brand-turquoise" />
|
||||
{t('complaint.title')}
|
||||
</h3>
|
||||
<p className="mt-2 text-neutral-600">{t('complaint.body')}</p>
|
||||
</div>
|
||||
<a
|
||||
href={SUPERVISORY_AUTHORITY.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 rounded-lg border border-brand-navy px-5 py-3 text-center font-medium text-brand-navy transition-colors hover:bg-brand-navy hover:text-white"
|
||||
>
|
||||
{t('complaint.cta')}
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
className="mt-8 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
>
|
||||
<Mail className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-4">{t('contact.title')}</h3>
|
||||
<p className="text-white/80 mb-6">{t('contact.body')}</p>
|
||||
<a
|
||||
href="mailto:privacy@xpeditis.com"
|
||||
href={`mailto:${CONTROLLER.privacyContact}`}
|
||||
className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span>privacy@xpeditis.com</span>
|
||||
<span>{CONTROLLER.privacyContact}</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -180,3 +261,55 @@ export default function PrivacyPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registre des sous-traitants.
|
||||
*
|
||||
* Un tableau plutot qu'une liste a puces : la question qu'on se pose devant
|
||||
* cette section est « qui recoit quoi, et ou », et elle se lit en colonnes.
|
||||
*/
|
||||
function SubprocessorTable() {
|
||||
const t = useTranslations('marketing.privacy.recipientsTable');
|
||||
const tProcessor = useTranslations('marketing.privacy.processors');
|
||||
|
||||
return (
|
||||
<div className="mt-6 -mx-2 overflow-x-auto px-2">
|
||||
<table className="w-full min-w-[42rem] border-collapse text-left text-sm">
|
||||
<caption className="sr-only">{t('caption')}</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{(['name', 'role', 'data', 'location', 'basis'] as const).map(column => (
|
||||
<th key={column} scope="col" className="py-3 pr-4 font-semibold text-brand-navy">
|
||||
{t(column)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SUBPROCESSORS.map(processor => (
|
||||
<tr key={processor.key} className="border-b border-border/60 align-top">
|
||||
<th scope="row" className="py-4 pr-4 font-medium text-brand-navy">
|
||||
{processor.name}
|
||||
</th>
|
||||
<td className="py-4 pr-4 text-neutral-600">
|
||||
{tProcessor(`${processor.key}.role`)}
|
||||
{processor.conditional && (
|
||||
<span className="mt-1 block text-xs text-neutral-500">
|
||||
{tProcessor(`${processor.key}.conditional`)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 pr-4 text-neutral-600">{tProcessor(`${processor.key}.data`)}</td>
|
||||
<td className="py-4 pr-4 text-neutral-600">
|
||||
{tProcessor(`${processor.key}.location`)}
|
||||
</td>
|
||||
<td className="py-4 pr-4 text-neutral-600">
|
||||
{t(`bases.${processor.transferBasis}`)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -55,6 +55,76 @@
|
||||
"notAdmin": "Access denied: this account is not an administrator.",
|
||||
"error": "Sign in failed. Please check your credentials.",
|
||||
"backToApp": "Back to site"
|
||||
},
|
||||
"compliance": {
|
||||
"title": "Compliance",
|
||||
"description": "What the product declares, what it keeps, and the record of rights requests.",
|
||||
"refresh": "Refresh",
|
||||
"errorTitle": "Compliance status could not be loaded",
|
||||
"errorBody": "The service did not respond. Try again in a moment.",
|
||||
"disclaimer": "This page describes what the code actually enforces. It does not replace review by legal counsel.",
|
||||
"identity": {
|
||||
"incompleteTitle": "Required details missing",
|
||||
"incompleteBody": "The privacy policy must identify the data controller (Article 13.1.a). These details are not filled in, so they are not published:",
|
||||
"where": "Fill them in at apps/frontend/src/lib/legal/processing.ts, the CONTROLLER constant.",
|
||||
"completeTitle": "Controller identity complete",
|
||||
"completeBody": "{name} is identified on the privacy policy with all required details.",
|
||||
"fields": {
|
||||
"legalName": "Registered company name",
|
||||
"registrationNumber": "Company number",
|
||||
"address": "Registered office address"
|
||||
}
|
||||
},
|
||||
"retention": {
|
||||
"title": "Retention periods",
|
||||
"subtitle": "The rules as the server enforces them, and what has passed its deadline.",
|
||||
"table": "Table",
|
||||
"duration": "Period",
|
||||
"onErasure": "On erasure request",
|
||||
"expired": "Expired",
|
||||
"basis": "Justification",
|
||||
"months": "{count} months",
|
||||
"accountLifetime": "Account lifetime",
|
||||
"actions": {
|
||||
"delete": "Deleted",
|
||||
"anonymise": "Anonymised",
|
||||
"keep": "Kept"
|
||||
},
|
||||
"autoOn": "Automatic purge active",
|
||||
"autoOnBody": "Expired data is deleted every night at 3am.",
|
||||
"autoOff": "Automatic purge disabled",
|
||||
"autoOffBody": "Nothing is deleted automatically, yet the privacy policy states these periods. Set RETENTION_PURGE_ENABLED=true once you have checked the table below."
|
||||
},
|
||||
"purge": {
|
||||
"action": "Purge now",
|
||||
"confirmTitle": "Delete expired data?",
|
||||
"confirmBody": "{count} rows past their retention period will be permanently deleted. This cannot be undone.",
|
||||
"done": "{count} rows deleted",
|
||||
"failed": "The purge could not be applied"
|
||||
},
|
||||
"processors": {
|
||||
"title": "Processor register",
|
||||
"subtitle": "Providers processing data on behalf of Xpeditis (Article 30).",
|
||||
"name": "Provider",
|
||||
"role": "Role",
|
||||
"location": "Processing location",
|
||||
"basis": "Transfer safeguard"
|
||||
},
|
||||
"requests": {
|
||||
"title": "Rights requests",
|
||||
"subtitle": "Record of exports and erasures handled (Article 5.2).",
|
||||
"date": "Date",
|
||||
"action": "Request",
|
||||
"subject": "Data subject",
|
||||
"emptyTitle": "No request recorded",
|
||||
"emptyBody": "Exports and erasures will appear here as soon as someone exercises their rights.",
|
||||
"actions": {
|
||||
"gdpr_data_exported": "Data export",
|
||||
"gdpr_erasure_executed": "Account erasure",
|
||||
"gdpr_consent_recorded": "Consent recorded",
|
||||
"gdpr_retention_purge": "Retention purge"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
@ -3190,7 +3260,8 @@
|
||||
"blogDesc": "Create and manage blog articles",
|
||||
"logs": "System logs",
|
||||
"logsDesc": "View and download application logs",
|
||||
"assistant": "AI assistant"
|
||||
"assistant": "AI assistant",
|
||||
"compliance": "Compliance"
|
||||
}
|
||||
},
|
||||
"csvUpload": {
|
||||
@ -3587,42 +3658,111 @@
|
||||
"footer": "All prices are in euros excl. tax. Yearly billing = 11 months."
|
||||
},
|
||||
"common": {
|
||||
"lastUpdated": "Last updated: January 2025"
|
||||
"lastUpdated": "Last updated: September 2026"
|
||||
},
|
||||
"privacy": {
|
||||
"badge": "Data protection",
|
||||
"title1": "Privacy",
|
||||
"title2": "Policy",
|
||||
"intro": "At Xpeditis, protecting your personal data is a top priority. This policy explains how we collect, use, and protect your information.",
|
||||
"sections": {
|
||||
"data": {
|
||||
"title": "1. Data we collect",
|
||||
"content": "We collect the following data:\n\n• **Identification data**: first name, last name, business email address, phone number\n• **Business data**: company name, role, business registration number\n• **Connection data**: IP address, login records, browsing data\n• **Transaction data**: booking history, quotes, invoices\n• **Communication data**: exchanges with our customer service"
|
||||
"intro": "How Xpeditis collects, uses and protects your data — and what you can require of us. Our use of cookies is detailed in the cookie policy.",
|
||||
"identity": {
|
||||
"title": "Data controller",
|
||||
"tradingName": "Trading name",
|
||||
"legalName": "Registered name",
|
||||
"registrationNumber": "Company number",
|
||||
"address": "Registered office",
|
||||
"contact": "Data protection contact",
|
||||
"dpo": "Data protection officer"
|
||||
},
|
||||
"transfers": {
|
||||
"title": "5. International transfers",
|
||||
"content": "Your data may be transferred to non-EU countries as part of our international maritime freight services. These transfers are governed by:\n\n• Standard contractual clauses approved by the European Commission\n• Appropriate certifications (e.g. Privacy Shield for some providers)\n• Explicit consent for certain specific transfers"
|
||||
},
|
||||
"retention": {
|
||||
"title": "6. Data retention",
|
||||
"content": "We retain your data for the following durations:\n\n• **Account data**: duration of the business relationship + 3 years\n• **Transaction data**: 10 years (accounting obligations)\n• **Connection data**: 1 year\n• **Marketing data**: 3 years after the last contact\n\nAfter these periods, your data is deleted or anonymised."
|
||||
},
|
||||
"rights": {
|
||||
"title": "4. Your rights",
|
||||
"content": "Under the GDPR, you have the following rights:\n\n• **Right of access**: get a copy of your personal data\n• **Right of rectification**: correct your inaccurate data\n• **Right of erasure**: request deletion of your data\n• **Right of portability**: receive your data in a structured format\n• **Right to object**: object to the processing of your data\n• **Right to restriction**: limit the processing of your data\n\nTo exercise these rights, contact us at: privacy@xpeditis.com"
|
||||
},
|
||||
"use": {
|
||||
"title": "2. Use of data",
|
||||
"content": "Your data is used to:\n\n• Provide and improve our maritime freight booking services\n• Manage your account and preferences\n• Process your quote requests and bookings\n• Send you commercial communications (with your consent)\n• Ensure the security of our platform\n• Meet our legal and regulatory obligations"
|
||||
},
|
||||
"protection": {
|
||||
"title": "3. Data protection",
|
||||
"content": "We implement robust security measures:\n\n• SSL/TLS encryption for all communications\n• Encryption of sensitive data at rest (AES-256)\n• Two-factor authentication available\n• Regular security audits\n• Continuous training of our teams\n• Hosting on ISO 27001-certified servers"
|
||||
"recipientsTable": {
|
||||
"caption": "Processors within the meaning of Article 28 GDPR",
|
||||
"name": "Provider",
|
||||
"role": "Role",
|
||||
"data": "Data shared",
|
||||
"location": "Processing location",
|
||||
"basis": "Transfer safeguard",
|
||||
"bases": {
|
||||
"eu": "Processed in the European Union",
|
||||
"adequacy": "Adequacy decision",
|
||||
"scc": "Standard contractual clauses"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"controller": {
|
||||
"title": "1. Who processes your data",
|
||||
"content": "Xpeditis operates a professional LCL ocean freight booking platform at www.xpeditis.com, and acts as the controller for the data described here.\n\nAny question, access request or exercise of your rights: **privacy@xpeditis.com**. We answer within one month."
|
||||
},
|
||||
"data": {
|
||||
"title": "2. Data we process",
|
||||
"content": "• **Identity and contact** : name, business email address, phone number\n• **Company** : registered name, SIREN and SIRET numbers, address, EORI number\n• **Account** : role, language, last sign-in, cookie preferences\n• **Commercial activity** : rate searches, booking requests, transport documents, amounts and commissions\n• **Assistant conversations** : the content of the questions you ask and the answers you receive\n• **Security and technical** : IP address, browser used, sign-in and sensitive-action logs\n\nWe process no special category data within the meaning of Article 9 GDPR: no health data, no opinions, no biometrics. The service is aimed at professionals, and what you provide concerns your business activity."
|
||||
},
|
||||
"purposes": {
|
||||
"title": "3. Why, and on what legal basis",
|
||||
"content": "Each processing activity rests on a specific legal basis (Article 6):\n\n• **Providing the service** — rate search, bookings, documents, invoicing: *performance of the contract*\n• **Managing your account and authentication** — sign-in, two-factor, password reset: *performance of the contract*\n• **Verifying the company at registration** through its SIREN or SIRET: *legitimate interest in contracting only with identified professionals*\n• **Securing the platform** — access logs, intrusion detection, rate limiting: *legitimate interest*\n• **Retaining accounting records**: *legal obligation*\n• **Notifying you about your bookings**: *performance of the contract*\n• **Non-essential cookies and commercial communications**: *your consent*, withdrawable at any time\n\nNo decision producing legal effects concerning you is taken solely by automated means (Article 22). Displayed rates and assistant answers support your decision; the decision itself stays human."
|
||||
},
|
||||
"ai": {
|
||||
"title": "4. Assistant and artificial intelligence",
|
||||
"content": "The assistant built into the platform runs on OpenAI models.\n\n• The content of your messages is sent to OpenAI to produce the answer.\n• These exchanges are **not** used to train models: processing goes through OpenAI's business offering, which excludes that use contractually.\n• The assistant can read your own data — your bookings, your organisation — within the limits of your access rights, and every such lookup is logged.\n• Your conversations are kept so you can find them again, and deleted on request.\n\nAs with any service relying on an external provider, do not enter information you would not want leaving the platform."
|
||||
},
|
||||
"recipients": {
|
||||
"title": "5. Who receives your data",
|
||||
"content": "Your data is never sold, rented or shared for advertising purposes.\n\nIt is accessible to members of your organisation according to their rights, to specifically authorised Xpeditis staff, and to the carrier concerned when you send them a booking request — in which case only what the carrier needs to perform the transport is shared.\n\nIt is also processed, on our behalf and on our instructions, by the following processors:"
|
||||
},
|
||||
"transfers": {
|
||||
"title": "6. Transfers outside the European Union",
|
||||
"content": "Some of the providers above process data in the United States. These transfers are governed by the **standard contractual clauses** adopted by the European Commission on 4 June 2021, supplemented by technical measures: encryption in transit and at rest, and limiting transferred data to what is strictly necessary.\n\nWe rely on no Privacy Shield certification: that mechanism was invalidated by the Court of Justice of the European Union on 16 July 2020 (Schrems II, case C-311/18).\n\nA copy of the applicable safeguards is available on request at privacy@xpeditis.com."
|
||||
},
|
||||
"retention": {
|
||||
"title": "7. Retention periods",
|
||||
"content": "• **Account and profile** : for the duration of the commercial relationship. On closure, the account is anonymised.\n• **Bookings and accounting records** : 10 years, under Article L123-22 of the French Commercial Code\n• **Security logs** : 12 months\n• **Notifications** : 12 months\n• **Assistant conversations** : 12 months\n• **Proof of cookie consent** : 13 months\n\nAt the end of these periods, data is deleted or anonymised. Anonymised data can no longer identify you: it is no longer personal data."
|
||||
},
|
||||
"rights": {
|
||||
"title": "8. Your rights",
|
||||
"content": "• **Access and portability** : download all of your data from your account, as JSON or a spreadsheet, without writing to us\n• **Rectification** : correct your details in your settings, or ask us to do it\n• **Erasure** : deleting your account erases your notifications, conversations, API keys and preferences, and anonymises your account and the logs. Accounting records are kept without your identity, as the law requires.\n• **Objection and restriction** : you may object to processing based on our legitimate interest, or ask for it to be restricted\n• **Withdrawing consent** : at any time, as easily as it was given, without affecting what was done beforehand\n\nWe answer within one month (Article 12.3). If our answer does not satisfy you, you may lodge a complaint with the CNIL."
|
||||
},
|
||||
"security": {
|
||||
"title": "9. Security",
|
||||
"content": "• All communications encrypted with TLS\n• Passwords protected with Argon2: never stored in clear text, never readable by our staff\n• Session tokens set as httpOnly cookies, out of reach of page JavaScript\n• Two-factor authentication available\n• Logging of sensitive actions and administrator access\n• Sign-in attempt limiting\n\nNo measure removes all risk. Should a breach be likely to result in a high risk to your rights, you will be informed (Article 34)."
|
||||
}
|
||||
},
|
||||
"complaint": {
|
||||
"title": "Complaints",
|
||||
"body": "Do you believe your data is not being handled lawfully? You can lodge a complaint with the supervisory authority:",
|
||||
"cta": "Contact the CNIL"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Any questions?",
|
||||
"body": "For any question regarding our privacy policy or your personal data, contact our Data Protection Officer."
|
||||
"title": "Questions?",
|
||||
"body": "For any question about this policy or your personal data, write to us. We answer within one month."
|
||||
},
|
||||
"processors": {
|
||||
"stripe": {
|
||||
"role": "Card payment processing and subscription management",
|
||||
"data": "Billing identity, email address, amounts. Card numbers never pass through Xpeditis.",
|
||||
"location": "Ireland and the United States"
|
||||
},
|
||||
"openai": {
|
||||
"role": "International trade assistant: generating answers and indexing the documentation",
|
||||
"data": "The content of the messages you send to the assistant, and the documentation excerpts used to answer",
|
||||
"location": "United States",
|
||||
"conditional": "Only if you use the assistant."
|
||||
},
|
||||
"sentry": {
|
||||
"role": "Detecting technical errors in the application",
|
||||
"data": "Error message, page concerned, technical account identifier, IP address",
|
||||
"location": "United States"
|
||||
},
|
||||
"pappers": {
|
||||
"role": "Verifying SIREN and SIRET company numbers at registration",
|
||||
"data": "The company identification number you enter",
|
||||
"location": "France",
|
||||
"conditional": "Only when verifying a company."
|
||||
},
|
||||
"oauth": {
|
||||
"role": "Signing in with a Google or Microsoft account",
|
||||
"data": "Email address, first and last name of the account used to sign in",
|
||||
"location": "European Union and United States",
|
||||
"conditional": "Only if you choose that sign-in method."
|
||||
}
|
||||
}
|
||||
},
|
||||
"terms": {
|
||||
@ -3672,7 +3812,7 @@
|
||||
"intro": "Discover how we use cookies to improve your experience on Xpeditis and how you can manage your preferences.",
|
||||
"introBoxTitle": "What is a cookie?",
|
||||
"introBoxBody1": "A cookie is a small text file stored on your device (computer, tablet, smartphone) when you visit a website. Cookies allow the site to remember your actions and preferences over a period of time.",
|
||||
"introBoxBody2": "Cookies do not contain personally identifiable information and cannot access data stored on your device.",
|
||||
"introBoxBody2": "A cookie can be personal data: the session cookies below link you to an account. That is why they are only set to make the service work, or with your agreement.",
|
||||
"typesTitle": "Types of cookies used",
|
||||
"typesSubtitle": "We use different types of cookies on our platform",
|
||||
"required": "Required",
|
||||
@ -3680,7 +3820,8 @@
|
||||
"tableHeaders": {
|
||||
"name": "Name",
|
||||
"purpose": "Purpose",
|
||||
"duration": "Duration"
|
||||
"duration": "Duration",
|
||||
"storage": "Stored as"
|
||||
},
|
||||
"types": {
|
||||
"essential": {
|
||||
@ -3710,29 +3851,32 @@
|
||||
"title": "Questions about cookies?",
|
||||
"body": "Our team is available to answer all your questions regarding the use of cookies on our platform."
|
||||
},
|
||||
"purposes": {
|
||||
"session_id": "Maintains your login session",
|
||||
"csrf_token": "Protects against CSRF attacks",
|
||||
"cookie_consent": "Stores your cookie preferences",
|
||||
"_ga": "Google Analytics — visitor identification",
|
||||
"_gid": "Google Analytics — session identification",
|
||||
"_gat": "Google Analytics — request rate limiting",
|
||||
"_fbp": "Facebook Pixel — conversion tracking",
|
||||
"li_fat_id": "LinkedIn Insight — marketing attribution",
|
||||
"hubspotutk": "HubSpot — visitor identification",
|
||||
"language": "Stores your preferred language",
|
||||
"theme": "Stores the theme (light/dark)",
|
||||
"recent_searches": "History of your recent searches"
|
||||
"storage": {
|
||||
"accessToken": {
|
||||
"purpose": "Keeps you signed in. Out of reach of page JavaScript.",
|
||||
"duration": "15 minutes"
|
||||
},
|
||||
"durations": {
|
||||
"session": "Session",
|
||||
"year1": "1 year",
|
||||
"years2": "2 years",
|
||||
"hours24": "24 hours",
|
||||
"minute1": "1 minute",
|
||||
"months3": "3 months",
|
||||
"days30": "30 days",
|
||||
"months13": "13 months"
|
||||
"refreshToken": {
|
||||
"purpose": "Renews the session without asking for your password again",
|
||||
"duration": "7 days"
|
||||
},
|
||||
"xpeditis_session": {
|
||||
"purpose": "Tells the interface a session exists. Contains no token.",
|
||||
"duration": "15 minutes"
|
||||
},
|
||||
"cookieConsent": {
|
||||
"purpose": "Remembers your choices, so you are not asked again on every visit",
|
||||
"duration": "13 months"
|
||||
},
|
||||
"NEXT_LOCALE": {
|
||||
"purpose": "Remembers the language you chose",
|
||||
"duration": "1 year"
|
||||
}
|
||||
},
|
||||
"noneInUse": "No tracker in this category is in use today. Should that change, this page will be updated and your consent asked for beforehand.",
|
||||
"storageKinds": {
|
||||
"cookie": "Cookie",
|
||||
"localStorage": "Local storage"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
@ -4050,7 +4194,8 @@
|
||||
"rightsCta": {
|
||||
"text": "To exercise your rights, log into your account or contact our DPO.",
|
||||
"login": "Access my account",
|
||||
"dpo": "Contact the DPO"
|
||||
"dpo": "Contact the DPO",
|
||||
"contact": "Contact the data protection team"
|
||||
},
|
||||
"principlesTitle": "Our data protection principles",
|
||||
"principlesSubtitle": "Fundamental principles that guide our data processing",
|
||||
@ -4105,6 +4250,10 @@
|
||||
"title": "Contact our DPO",
|
||||
"body": "Our Data Protection Officer is available for any questions related to the processing of your personal data or the exercise of your rights.",
|
||||
"privacyLink": "Privacy policy"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact us",
|
||||
"body": "For any question about how your personal data is handled, or to exercise your rights, write to us. We answer within one month."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
|
||||
@ -55,6 +55,76 @@
|
||||
"notAdmin": "Accès refusé : ce compte n'est pas administrateur.",
|
||||
"error": "Échec de la connexion. Vérifiez vos identifiants.",
|
||||
"backToApp": "Retour au site"
|
||||
},
|
||||
"compliance": {
|
||||
"title": "Conformité",
|
||||
"description": "Ce que le produit déclare, ce qu'il conserve, et la trace des demandes de droits.",
|
||||
"refresh": "Actualiser",
|
||||
"errorTitle": "Impossible de charger l'état de conformité",
|
||||
"errorBody": "Le service n'a pas répondu. Réessayez dans un instant.",
|
||||
"disclaimer": "Cette page décrit ce que le code applique réellement. Elle ne remplace pas la validation par un conseil juridique.",
|
||||
"identity": {
|
||||
"incompleteTitle": "Mentions obligatoires manquantes",
|
||||
"incompleteBody": "La politique de confidentialité doit identifier le responsable du traitement (article 13.1.a). Ces informations ne sont pas renseignées, et ne sont donc pas publiées :",
|
||||
"where": "À renseigner dans apps/frontend/src/lib/legal/processing.ts, constante CONTROLLER.",
|
||||
"completeTitle": "Identité du responsable complète",
|
||||
"completeBody": "{name} est identifié sur la politique de confidentialité avec l'ensemble des mentions requises.",
|
||||
"fields": {
|
||||
"legalName": "Raison sociale exacte",
|
||||
"registrationNumber": "Numéro SIREN",
|
||||
"address": "Adresse du siège social"
|
||||
}
|
||||
},
|
||||
"retention": {
|
||||
"title": "Durées de conservation",
|
||||
"subtitle": "Les règles telles que le serveur les applique, et ce qui a dépassé son échéance.",
|
||||
"table": "Table",
|
||||
"duration": "Durée",
|
||||
"onErasure": "Sur demande d'effacement",
|
||||
"expired": "Échues",
|
||||
"basis": "Justification",
|
||||
"months": "{count} mois",
|
||||
"accountLifetime": "Vie du compte",
|
||||
"actions": {
|
||||
"delete": "Supprimée",
|
||||
"anonymise": "Anonymisée",
|
||||
"keep": "Conservée"
|
||||
},
|
||||
"autoOn": "Purge automatique active",
|
||||
"autoOnBody": "Les données échues sont supprimées chaque nuit à 3 h.",
|
||||
"autoOff": "Purge automatique désactivée",
|
||||
"autoOffBody": "Rien n'est supprimé automatiquement. La politique de confidentialité annonce pourtant ces durées : activez RETENTION_PURGE_ENABLED=true une fois le tableau ci-dessous vérifié."
|
||||
},
|
||||
"purge": {
|
||||
"action": "Purger maintenant",
|
||||
"confirmTitle": "Supprimer les données échues ?",
|
||||
"confirmBody": "{count} lignes ayant dépassé leur durée de conservation seront définitivement supprimées. Cette action est irréversible.",
|
||||
"done": "{count} lignes supprimées",
|
||||
"failed": "La purge n'a pas pu être appliquée"
|
||||
},
|
||||
"processors": {
|
||||
"title": "Registre des sous-traitants",
|
||||
"subtitle": "Les prestataires qui traitent des données pour le compte d'Xpeditis (article 30).",
|
||||
"name": "Prestataire",
|
||||
"role": "Rôle",
|
||||
"location": "Lieu de traitement",
|
||||
"basis": "Encadrement du transfert"
|
||||
},
|
||||
"requests": {
|
||||
"title": "Demandes de droits",
|
||||
"subtitle": "Trace des exports et des effacements traités (article 5.2).",
|
||||
"date": "Date",
|
||||
"action": "Demande",
|
||||
"subject": "Personne concernée",
|
||||
"emptyTitle": "Aucune demande enregistrée",
|
||||
"emptyBody": "Les exports et effacements apparaîtront ici dès qu'une personne exercera ses droits.",
|
||||
"actions": {
|
||||
"gdpr_data_exported": "Export des données",
|
||||
"gdpr_erasure_executed": "Effacement du compte",
|
||||
"gdpr_consent_recorded": "Consentement enregistré",
|
||||
"gdpr_retention_purge": "Purge de conservation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
@ -3190,7 +3260,8 @@
|
||||
"blogDesc": "Créer et gérer les articles du blog",
|
||||
"logs": "Logs système",
|
||||
"logsDesc": "Visualiser et télécharger les logs applicatifs",
|
||||
"assistant": "Assistant IA"
|
||||
"assistant": "Assistant IA",
|
||||
"compliance": "Conformité"
|
||||
}
|
||||
},
|
||||
"csvUpload": {
|
||||
@ -3587,42 +3658,111 @@
|
||||
"footer": "Tous les prix sont en euros HT. Facturation annuelle = 11 mois."
|
||||
},
|
||||
"common": {
|
||||
"lastUpdated": "Dernière mise à jour : Janvier 2025"
|
||||
"lastUpdated": "Dernière mise à jour : septembre 2026"
|
||||
},
|
||||
"privacy": {
|
||||
"badge": "Protection des données",
|
||||
"title1": "Politique de",
|
||||
"title2": "Confidentialité",
|
||||
"intro": "Chez Xpeditis, la protection de vos données personnelles est une priorité absolue. Cette politique explique comment nous collectons, utilisons et protégeons vos informations.",
|
||||
"intro": "Comment Xpeditis collecte, utilise et protège vos données — et ce que vous pouvez exiger de nous. Nos usages de cookies sont détaillés dans la politique de cookies.",
|
||||
"identity": {
|
||||
"title": "Responsable du traitement",
|
||||
"tradingName": "Nom commercial",
|
||||
"legalName": "Raison sociale",
|
||||
"registrationNumber": "SIREN",
|
||||
"address": "Siège social",
|
||||
"contact": "Contact protection des données",
|
||||
"dpo": "Délégué à la protection des données"
|
||||
},
|
||||
"recipientsTable": {
|
||||
"caption": "Sous-traitants au sens de l'article 28 du RGPD",
|
||||
"name": "Prestataire",
|
||||
"role": "Rôle",
|
||||
"data": "Données transmises",
|
||||
"location": "Lieu de traitement",
|
||||
"basis": "Encadrement du transfert",
|
||||
"bases": {
|
||||
"eu": "Traitement dans l'Union européenne",
|
||||
"adequacy": "Décision d'adéquation",
|
||||
"scc": "Clauses contractuelles types"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"controller": {
|
||||
"title": "1. Qui traite vos données",
|
||||
"content": "Xpeditis exploite une plateforme professionnelle de réservation de fret maritime en groupage, accessible sur www.xpeditis.com, et agit en qualité de responsable du traitement pour les données décrites ici.\n\nToute question, demande d'accès ou exercice de vos droits : **privacy@xpeditis.com**. Nous répondons sous un mois."
|
||||
},
|
||||
"data": {
|
||||
"title": "1. Données collectées",
|
||||
"content": "Nous collectons les données suivantes :\n\n• **Données d'identification** : nom, prénom, adresse email professionnelle, numéro de téléphone\n• **Données professionnelles** : nom de l'entreprise, fonction, numéro SIRET\n• **Données de connexion** : adresse IP, logs de connexion, données de navigation\n• **Données de transaction** : historique des réservations, devis, factures\n• **Données de communication** : échanges avec notre service client"
|
||||
"title": "2. Données que nous traitons",
|
||||
"content": "• **Identité et contact** : nom, prénom, adresse email professionnelle, numéro de téléphone\n• **Entreprise** : raison sociale, SIREN et SIRET, adresse, numéro EORI\n• **Compte** : rôle, langue, date de dernière connexion, préférences de cookies\n• **Activité commerciale** : recherches de tarifs, demandes de réservation, documents de transport, montants et commissions\n• **Échanges avec l'assistant** : le contenu des questions posées et des réponses reçues\n• **Sécurité et technique** : adresse IP, navigateur utilisé, journal des connexions et des actions sensibles\n\nNous ne traitons aucune donnée sensible au sens de l'article 9 du RGPD : ni santé, ni opinions, ni données biométriques. Le service s'adresse à des professionnels, et les informations que vous fournissez concernent votre activité."
|
||||
},
|
||||
"purposes": {
|
||||
"title": "3. Pourquoi, et sur quelle base légale",
|
||||
"content": "Chaque traitement repose sur une base légale précise (article 6) :\n\n• **Fournir le service** — recherche de tarifs, réservations, documents, facturation : *exécution du contrat*\n• **Gérer votre compte et l'authentification** — connexion, double facteur, réinitialisation du mot de passe : *exécution du contrat*\n• **Vérifier l'entreprise à l'inscription** via son SIREN ou SIRET : *intérêt légitime à ne contracter qu'avec des professionnels identifiés*\n• **Sécuriser la plateforme** — journal des accès, détection des tentatives d'intrusion, limitation du débit : *intérêt légitime*\n• **Conserver les pièces comptables** : *obligation légale*\n• **Vous notifier de l'avancement de vos réservations** : *exécution du contrat*\n• **Cookies non essentiels et communications commerciales** : *votre consentement*, retirable à tout moment\n\nAucune décision produisant des effets juridiques à votre égard n'est prise de manière entièrement automatisée (article 22). Les tarifs affichés et les réponses de l'assistant sont des aides à la décision : la validation reste humaine."
|
||||
},
|
||||
"ai": {
|
||||
"title": "4. Assistant et intelligence artificielle",
|
||||
"content": "L'assistant intégré à la plateforme s'appuie sur les modèles d'OpenAI.\n\n• Le contenu de vos messages est transmis à OpenAI pour produire la réponse.\n• Ces échanges ne servent **pas** à entraîner de modèle : le traitement passe par l'offre professionnelle d'OpenAI, qui exclut cet usage par contrat.\n• L'assistant peut consulter vos propres données — vos réservations, votre organisation — dans la limite de vos droits d'accès, et chacune de ces consultations est journalisée.\n• Vos conversations sont conservées pour que vous puissiez les retrouver, et effacées sur simple demande.\n\nComme pour tout service reposant sur un prestataire externe, n'y saisissez pas d'information que vous ne souhaitez pas voir quitter la plateforme."
|
||||
},
|
||||
"recipients": {
|
||||
"title": "5. Destinataires de vos données",
|
||||
"content": "Vos données ne sont ni vendues, ni louées, ni cédées à des fins publicitaires.\n\nY accèdent : les membres de votre organisation selon leurs droits, les équipes d'Xpeditis spécifiquement habilitées, et le transporteur concerné lorsque vous lui adressez une demande de réservation — dans ce cas, seuls les éléments nécessaires à l'exécution du transport lui sont transmis.\n\nElles sont par ailleurs traitées, pour notre compte et sur nos instructions, par les sous-traitants suivants :"
|
||||
},
|
||||
"transfers": {
|
||||
"title": "5. Transferts internationaux",
|
||||
"content": "Vos données peuvent être transférées vers des pays hors UE dans le cadre de nos services de fret maritime international. Ces transferts sont encadrés par :\n\n• Des clauses contractuelles types approuvées par la Commission européenne\n• Des certifications adéquates (ex: Privacy Shield pour certains prestataires)\n• Le consentement explicite pour certains transferts spécifiques"
|
||||
"title": "6. Transferts hors Union européenne",
|
||||
"content": "Certains des prestataires ci-dessus traitent des données aux États-Unis. Ces transferts sont encadrés par les **clauses contractuelles types** adoptées par la Commission européenne le 4 juin 2021, complétées par des mesures techniques : chiffrement en transit et au repos, et limitation des données transmises au strict nécessaire.\n\nNous ne nous prévalons d'aucune certification Privacy Shield : ce mécanisme a été invalidé par la Cour de justice de l'Union européenne le 16 juillet 2020 (arrêt Schrems II, affaire C-311/18).\n\nUne copie des garanties applicables peut vous être communiquée sur demande à privacy@xpeditis.com."
|
||||
},
|
||||
"retention": {
|
||||
"title": "6. Conservation des données",
|
||||
"content": "Nous conservons vos données selon les durées suivantes :\n\n• **Données de compte** : durée de la relation commerciale + 3 ans\n• **Données de transaction** : 10 ans (obligations comptables)\n• **Données de connexion** : 1 an\n• **Données marketing** : 3 ans après le dernier contact\n\nÀ l'expiration de ces délais, vos données sont supprimées ou anonymisées."
|
||||
"title": "7. Durées de conservation",
|
||||
"content": "• **Compte et profil** : durée de la relation commerciale. À sa fermeture, le compte est anonymisé.\n• **Réservations et pièces comptables** : 10 ans, conformément à l'article L123-22 du code de commerce\n• **Journaux de sécurité** : 12 mois\n• **Notifications** : 12 mois\n• **Conversations avec l'assistant** : 12 mois\n• **Preuve du consentement aux cookies** : 13 mois\n\nAu terme de ces durées, les données sont supprimées ou anonymisées. Une donnée anonymisée ne permet plus de vous identifier : elle n'est plus une donnée personnelle."
|
||||
},
|
||||
"rights": {
|
||||
"title": "4. Vos droits",
|
||||
"content": "Conformément au RGPD, vous disposez des droits suivants :\n\n• **Droit d'accès** : obtenir une copie de vos données personnelles\n• **Droit de rectification** : corriger vos données inexactes\n• **Droit à l'effacement** : demander la suppression de vos données\n• **Droit à la portabilité** : recevoir vos données dans un format structuré\n• **Droit d'opposition** : vous opposer au traitement de vos données\n• **Droit de limitation** : limiter le traitement de vos données\n\nPour exercer ces droits, contactez-nous à : privacy@xpeditis.com"
|
||||
"title": "8. Vos droits",
|
||||
"content": "• **Accès et portabilité** : téléchargez l'intégralité de vos données depuis votre espace, en JSON ou en tableur, sans avoir à nous écrire\n• **Rectification** : corrigez vos informations depuis vos paramètres, ou demandez-nous de le faire\n• **Effacement** : la suppression de votre compte efface vos notifications, vos conversations, vos clés d'API et vos préférences, et anonymise votre compte ainsi que les journaux. Les pièces comptables sont conservées sans votre identité, comme la loi l'impose.\n• **Opposition et limitation** : vous pouvez vous opposer à un traitement fondé sur notre intérêt légitime, ou en demander la limitation\n• **Retrait du consentement** : à tout moment, aussi simplement qu'il a été donné, sans effet sur ce qui a été fait auparavant\n\nNous répondons sous un mois (article 12.3). Si notre réponse ne vous satisfait pas, vous pouvez saisir la CNIL."
|
||||
},
|
||||
"use": {
|
||||
"title": "2. Utilisation des données",
|
||||
"content": "Vos données sont utilisées pour :\n\n• Fournir et améliorer nos services de réservation de fret maritime\n• Gérer votre compte et vos préférences\n• Traiter vos demandes de devis et réservations\n• Vous envoyer des communications commerciales (avec votre consentement)\n• Assurer la sécurité de notre plateforme\n• Respecter nos obligations légales et réglementaires"
|
||||
},
|
||||
"protection": {
|
||||
"title": "3. Protection des données",
|
||||
"content": "Nous mettons en œuvre des mesures de sécurité robustes :\n\n• Chiffrement SSL/TLS pour toutes les communications\n• Chiffrement des données sensibles au repos (AES-256)\n• Authentification à deux facteurs disponible\n• Audits de sécurité réguliers\n• Formation continue de nos équipes\n• Hébergement sur des serveurs certifiés ISO 27001"
|
||||
"security": {
|
||||
"title": "9. Sécurité",
|
||||
"content": "• Chiffrement de toutes les communications par TLS\n• Mots de passe protégés par Argon2 : ni stockés en clair, ni lisibles par nos équipes\n• Jetons de session déposés en cookies httpOnly, inaccessibles au JavaScript de la page\n• Double authentification disponible\n• Journalisation des actions sensibles et des accès administrateurs\n• Limitation du nombre de tentatives de connexion\n\nAucune mesure n'élimine tout risque. En cas de violation susceptible d'engendrer un risque élevé pour vos droits, vous en serez informé (article 34)."
|
||||
}
|
||||
},
|
||||
"complaint": {
|
||||
"title": "Réclamation",
|
||||
"body": "Vous estimez que le traitement de vos données ne respecte pas la réglementation ? Vous pouvez introduire une réclamation auprès de l'autorité de contrôle :",
|
||||
"cta": "Saisir la CNIL"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Des questions ?",
|
||||
"body": "Pour toute question concernant notre politique de confidentialité ou vos données personnelles, contactez notre Délégué à la Protection des Données."
|
||||
"body": "Pour toute question sur cette politique ou sur vos données personnelles, écrivez-nous. Nous répondons sous un mois."
|
||||
},
|
||||
"processors": {
|
||||
"stripe": {
|
||||
"role": "Encaissement des paiements par carte et gestion des abonnements",
|
||||
"data": "Identité de facturation, adresse email, montants. Les numéros de carte ne transitent jamais par Xpeditis.",
|
||||
"location": "Irlande et États-Unis"
|
||||
},
|
||||
"openai": {
|
||||
"role": "Assistant commerce international : génération des réponses et indexation de la documentation",
|
||||
"data": "Le contenu des messages envoyés à l'assistant et les extraits de documentation utilisés pour répondre",
|
||||
"location": "États-Unis",
|
||||
"conditional": "Uniquement si vous utilisez l'assistant."
|
||||
},
|
||||
"sentry": {
|
||||
"role": "Détection des erreurs techniques de l'application",
|
||||
"data": "Message d'erreur, page concernée, identifiant technique du compte, adresse IP",
|
||||
"location": "États-Unis"
|
||||
},
|
||||
"pappers": {
|
||||
"role": "Vérification des numéros SIREN et SIRET à l'inscription",
|
||||
"data": "Le numéro d'identification de l'entreprise saisi",
|
||||
"location": "France",
|
||||
"conditional": "Uniquement lors de la vérification d'une entreprise."
|
||||
},
|
||||
"oauth": {
|
||||
"role": "Connexion par compte Google ou Microsoft",
|
||||
"data": "Adresse email, nom et prénom du compte utilisé pour se connecter",
|
||||
"location": "Union européenne et États-Unis",
|
||||
"conditional": "Uniquement si vous choisissez ce mode de connexion."
|
||||
}
|
||||
}
|
||||
},
|
||||
"terms": {
|
||||
@ -3672,7 +3812,7 @@
|
||||
"intro": "Découvrez comment nous utilisons les cookies pour améliorer votre expérience sur Xpeditis et comment vous pouvez gérer vos préférences.",
|
||||
"introBoxTitle": "Qu'est-ce qu'un cookie ?",
|
||||
"introBoxBody1": "Un cookie est un petit fichier texte stocké sur votre appareil (ordinateur, tablette, smartphone) lorsque vous visitez un site web. Les cookies permettent au site de mémoriser vos actions et préférences sur une période donnée.",
|
||||
"introBoxBody2": "Les cookies ne contiennent pas d'informations personnellement identifiables et ne peuvent pas accéder aux données stockées sur votre appareil.",
|
||||
"introBoxBody2": "Un cookie peut constituer une donnée personnelle : les cookies de session ci-dessous permettent de vous rattacher à un compte. C'est pourquoi ils ne sont déposés que pour faire fonctionner le service, ou avec votre accord.",
|
||||
"typesTitle": "Types de cookies utilisés",
|
||||
"typesSubtitle": "Nous utilisons différents types de cookies sur notre plateforme",
|
||||
"required": "Requis",
|
||||
@ -3680,7 +3820,8 @@
|
||||
"tableHeaders": {
|
||||
"name": "Nom",
|
||||
"purpose": "Finalité",
|
||||
"duration": "Durée"
|
||||
"duration": "Durée",
|
||||
"storage": "Emplacement"
|
||||
},
|
||||
"types": {
|
||||
"essential": {
|
||||
@ -3710,29 +3851,32 @@
|
||||
"title": "Des questions sur les cookies ?",
|
||||
"body": "Notre équipe est disponible pour répondre à toutes vos questions concernant l'utilisation des cookies sur notre plateforme."
|
||||
},
|
||||
"purposes": {
|
||||
"session_id": "Maintien de votre session de connexion",
|
||||
"csrf_token": "Protection contre les attaques CSRF",
|
||||
"cookie_consent": "Mémorisation de vos préférences cookies",
|
||||
"_ga": "Google Analytics - Identification des visiteurs",
|
||||
"_gid": "Google Analytics - Identification des sessions",
|
||||
"_gat": "Google Analytics - Limitation du taux de requêtes",
|
||||
"_fbp": "Facebook Pixel - Suivi des conversions",
|
||||
"li_fat_id": "LinkedIn Insight - Attribution marketing",
|
||||
"hubspotutk": "HubSpot - Identification des visiteurs",
|
||||
"language": "Mémorisation de votre langue préférée",
|
||||
"theme": "Mémorisation du thème (clair/sombre)",
|
||||
"recent_searches": "Historique de vos recherches récentes"
|
||||
"storage": {
|
||||
"accessToken": {
|
||||
"purpose": "Maintien de votre session. Inaccessible au JavaScript de la page.",
|
||||
"duration": "15 minutes"
|
||||
},
|
||||
"durations": {
|
||||
"session": "Session",
|
||||
"year1": "1 an",
|
||||
"years2": "2 ans",
|
||||
"hours24": "24 heures",
|
||||
"minute1": "1 minute",
|
||||
"months3": "3 mois",
|
||||
"days30": "30 jours",
|
||||
"months13": "13 mois"
|
||||
"refreshToken": {
|
||||
"purpose": "Renouvellement de la session sans nouvelle saisie du mot de passe",
|
||||
"duration": "7 jours"
|
||||
},
|
||||
"xpeditis_session": {
|
||||
"purpose": "Indique à l'interface qu'une session existe. Ne contient aucun jeton.",
|
||||
"duration": "15 minutes"
|
||||
},
|
||||
"cookieConsent": {
|
||||
"purpose": "Mémorisation de vos choix, pour ne pas vous les redemander à chaque visite",
|
||||
"duration": "13 mois"
|
||||
},
|
||||
"NEXT_LOCALE": {
|
||||
"purpose": "Mémorisation de la langue choisie",
|
||||
"duration": "1 an"
|
||||
}
|
||||
},
|
||||
"noneInUse": "Aucun traceur de cette catégorie n'est utilisé aujourd'hui. Si cela change, cette page sera mise à jour et votre consentement sera demandé au préalable.",
|
||||
"storageKinds": {
|
||||
"cookie": "Cookie",
|
||||
"localStorage": "Stockage local"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
@ -4050,7 +4194,8 @@
|
||||
"rightsCta": {
|
||||
"text": "Pour exercer vos droits, connectez-vous à votre compte ou contactez notre DPO.",
|
||||
"login": "Accéder à mon compte",
|
||||
"dpo": "Contacter le DPO"
|
||||
"dpo": "Contacter le DPO",
|
||||
"contact": "Contacter le service protection des données"
|
||||
},
|
||||
"principlesTitle": "Nos principes de protection des données",
|
||||
"principlesSubtitle": "Des principes fondamentaux qui guident notre traitement des données",
|
||||
@ -4105,6 +4250,10 @@
|
||||
"title": "Contacter notre DPO",
|
||||
"body": "Notre Délégué à la Protection des Données est à votre disposition pour toute question relative au traitement de vos données personnelles ou à l'exercice de vos droits.",
|
||||
"privacyLink": "Politique de confidentialité"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Nous contacter",
|
||||
"body": "Pour toute question sur le traitement de vos données personnelles ou pour exercer vos droits, écrivez-nous. Nous répondons sous un mois."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
|
||||
@ -66,7 +66,7 @@ describe('buildAppNav', () => {
|
||||
});
|
||||
|
||||
describe('buildAdminNav', () => {
|
||||
it('expose les huit destinations d administration', () => {
|
||||
it('expose les neuf destinations d administration', () => {
|
||||
const groups = buildAdminNav({ t, tShell });
|
||||
const keys = groups.flatMap(g => g.items).map(i => i.key);
|
||||
expect(keys).toEqual([
|
||||
@ -77,6 +77,7 @@ describe('buildAdminNav', () => {
|
||||
'csvRates',
|
||||
'blog',
|
||||
'assistant',
|
||||
'compliance',
|
||||
'logs',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
Package,
|
||||
ScrollText,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@ -177,6 +178,12 @@ export function buildAdminNav({ t, tShell }: AdminNavParams): NavGroup[] {
|
||||
label: tShell('groups.system'),
|
||||
items: [
|
||||
{ key: 'assistant', label: t('items.assistant'), href: '/admin/assistant', icon: Bot },
|
||||
{
|
||||
key: 'compliance',
|
||||
label: t('items.compliance'),
|
||||
href: '/admin/compliance',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{ key: 'logs', label: t('items.logs'), href: '/admin/logs', icon: ScrollText },
|
||||
],
|
||||
},
|
||||
|
||||
60
apps/frontend/src/lib/api/compliance.ts
Normal file
60
apps/frontend/src/lib/api/compliance.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Conformité — vue d'administration.
|
||||
*
|
||||
* Les durées de conservation viennent du serveur, pas d'une copie côté client :
|
||||
* ce sont celles que le code applique réellement, et c'est précisément ce
|
||||
* qu'une console de conformité doit montrer.
|
||||
*/
|
||||
|
||||
import { get, post } from './client';
|
||||
|
||||
export interface RetentionRule {
|
||||
table: string;
|
||||
onErasure: 'delete' | 'anonymise' | 'keep';
|
||||
months: number | null;
|
||||
timestampColumn: string | null;
|
||||
keepWhere: string | null;
|
||||
basis: string;
|
||||
}
|
||||
|
||||
export interface RetentionLine {
|
||||
table: string;
|
||||
months: number;
|
||||
/** Lignes ayant dépassé la durée de conservation. */
|
||||
expired: number;
|
||||
oldest: string | null;
|
||||
}
|
||||
|
||||
export interface RetentionReport {
|
||||
/** La purge automatique est-elle activée sur cet environnement ? */
|
||||
enabled: boolean;
|
||||
runAt: string;
|
||||
lines: RetentionLine[];
|
||||
}
|
||||
|
||||
export interface RightsRequest {
|
||||
action: string;
|
||||
status: string;
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
organization_id: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export async function getRetentionRules(): Promise<{ rules: RetentionRule[] }> {
|
||||
return get('/api/v1/gdpr/retention');
|
||||
}
|
||||
|
||||
export async function getRetentionPreview(): Promise<RetentionReport> {
|
||||
return get('/api/v1/gdpr/admin/retention/preview');
|
||||
}
|
||||
|
||||
/** Supprime définitivement les lignes arrivées à échéance. */
|
||||
export async function runRetentionPurge(): Promise<RetentionReport> {
|
||||
return post('/api/v1/gdpr/admin/retention/purge', {});
|
||||
}
|
||||
|
||||
export async function getRightsRequests(): Promise<{ requests: RightsRequest[] }> {
|
||||
return get('/api/v1/gdpr/admin/requests');
|
||||
}
|
||||
113
apps/frontend/src/lib/legal/processing.ts
Normal file
113
apps/frontend/src/lib/legal/processing.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Registre des traitements — source unique.
|
||||
*
|
||||
* ⚠️ Contenu juridique. Ce fichier liste les destinataires des données et ce
|
||||
* que le site dépose sur l'appareil du visiteur. La politique de
|
||||
* confidentialité publique, la politique de cookies et la page de conformité
|
||||
* de l'administration le lisent toutes les trois : un sous-traitant ajouté ici
|
||||
* apparaît partout, et il devient impossible de mettre à jour l'un en oubliant
|
||||
* les autres. Il tient lieu de registre de l'article 30 côté produit.
|
||||
*
|
||||
* Seuls les faits structurels vivent ici — noms, nature, encadrement du
|
||||
* transfert. Les descriptions sont dans les fichiers de messages, sinon la
|
||||
* moitié de la page resterait en français pour un lecteur anglophone.
|
||||
*
|
||||
* Les durées de conservation, elles, vivent côté serveur
|
||||
* (`domain/services/data-retention`) : c'est le serveur qui les applique.
|
||||
*/
|
||||
|
||||
/** Fondement d'un transfert hors Union européenne (chap. V du RGPD). */
|
||||
export type TransferBasis =
|
||||
| 'eu' // Aucune sortie de l'Union.
|
||||
| 'adequacy' // Décision d'adéquation de la Commission.
|
||||
| 'scc'; // Clauses contractuelles types.
|
||||
|
||||
export interface Subprocessor {
|
||||
/** Clé de traduction sous `marketing.privacy.processors`. */
|
||||
key: string;
|
||||
/** Nom commercial, identique dans toutes les langues. */
|
||||
name: string;
|
||||
transferBasis: TransferBasis;
|
||||
/** Vrai si le prestataire n'intervient que sur une fonctionnalité précise. */
|
||||
conditional: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chaque entrée correspond à un service réellement branché, vérifié dans le
|
||||
* code — pas une liste d'intentions.
|
||||
*/
|
||||
export const SUBPROCESSORS: readonly Subprocessor[] = [
|
||||
{ key: 'stripe', name: 'Stripe', transferBasis: 'scc', conditional: false },
|
||||
{ key: 'openai', name: 'OpenAI', transferBasis: 'scc', conditional: true },
|
||||
{ key: 'sentry', name: 'Sentry', transferBasis: 'scc', conditional: false },
|
||||
{ key: 'pappers', name: 'Pappers', transferBasis: 'eu', conditional: true },
|
||||
{ key: 'oauth', name: 'Google, Microsoft', transferBasis: 'scc', conditional: true },
|
||||
];
|
||||
|
||||
export interface StoredItem {
|
||||
/** Nom technique, tel qu'il apparaît dans le navigateur. */
|
||||
name: string;
|
||||
/** Cookie déposé par le serveur, ou valeur écrite par la page. */
|
||||
kind: 'cookie' | 'localStorage';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que le site dépose réellement.
|
||||
*
|
||||
* L'inventaire précédent annonçait Google Analytics (`_ga`, `_gid`, `_gat`),
|
||||
* Facebook Pixel (`_fbp`), LinkedIn (`li_fat_id`) et HubSpot (`hubspotutk`).
|
||||
* Aucun de ces scripts n'est chargé par l'application : décrire des traceurs
|
||||
* qui n'existent pas est aussi inexact que d'en dissimuler, et rendait sans
|
||||
* objet le consentement demandé pour eux.
|
||||
*/
|
||||
export const ESSENTIAL_STORAGE: readonly StoredItem[] = [
|
||||
{ name: 'accessToken', kind: 'cookie' },
|
||||
{ name: 'refreshToken', kind: 'cookie' },
|
||||
{ name: 'xpeditis_session', kind: 'cookie' },
|
||||
{ name: 'cookieConsent', kind: 'localStorage' },
|
||||
];
|
||||
|
||||
export const FUNCTIONAL_STORAGE: readonly StoredItem[] = [{ name: 'NEXT_LOCALE', kind: 'cookie' }];
|
||||
|
||||
/**
|
||||
* Responsable du traitement (art. 13.1.a).
|
||||
*
|
||||
* Les champs à `null` sont obligatoires et manquants. La page publique ne
|
||||
* publie que ce qui est renseigné, et la console d'administration les signale :
|
||||
* mieux vaut une mention absente, visible et corrigeable, qu'une mention
|
||||
* inventée.
|
||||
*/
|
||||
export interface ControllerIdentity {
|
||||
tradingName: string;
|
||||
legalName: string | null;
|
||||
registrationNumber: string | null;
|
||||
address: string | null;
|
||||
privacyContact: string;
|
||||
/** Délégué à la protection des données, si un DPO a été désigné. */
|
||||
dpo: string | null;
|
||||
}
|
||||
|
||||
export const CONTROLLER: ControllerIdentity = {
|
||||
tradingName: 'Xpeditis',
|
||||
legalName: null,
|
||||
registrationNumber: null,
|
||||
address: null,
|
||||
privacyContact: 'privacy@xpeditis.com',
|
||||
dpo: null,
|
||||
};
|
||||
|
||||
/** Autorité de contrôle compétente, à mentionner au titre de l'art. 13.2.d. */
|
||||
export const SUPERVISORY_AUTHORITY = {
|
||||
name: 'Commission nationale de l’informatique et des libertés (CNIL)',
|
||||
url: 'https://www.cnil.fr/fr/plaintes',
|
||||
} as const;
|
||||
|
||||
/** Mentions obligatoires encore absentes de l'identité du responsable. */
|
||||
export function missingControllerFields(controller: ControllerIdentity = CONTROLLER): string[] {
|
||||
const required: [keyof ControllerIdentity, string][] = [
|
||||
['legalName', 'legalName'],
|
||||
['registrationNumber', 'registrationNumber'],
|
||||
['address', 'address'],
|
||||
];
|
||||
return required.filter(([field]) => !controller[field]).map(([, key]) => key);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user