Add RGPD data access/deletion forms and admin Analytics dashboard
RGPD (/rgpd): - Access form: search by IP, generate PDF with all visitor data, email it - Deletion form: delete all visitor data by IP, generate attestation PDF - Both forms pre-fill client IP, require email for response - PDF templates with E-Cosplay branding, RGPD article references Admin Analytics (/admin/analytics): - KPIs: unique visitors, pageviews, pages/visitor - Top pages and referrers tables - Device type, browser, OS breakdowns - Period filter: today, 7d, 30d, all Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\AnalyticsEvent;
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\BilletBuyer;
|
||||
use App\Entity\Event;
|
||||
@@ -666,6 +668,101 @@ class AdminController extends AbstractController
|
||||
return $this->render('admin/infra.html.twig', $data);
|
||||
}
|
||||
|
||||
#[Route('/analytics', name: 'app_admin_analytics', methods: ['GET'])]
|
||||
public function analytics(Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$period = $request->query->getString('period', '7d');
|
||||
$since = match ($period) {
|
||||
'today' => new \DateTimeImmutable('today'),
|
||||
'30d' => new \DateTimeImmutable('-30 days'),
|
||||
'all' => new \DateTimeImmutable('2020-01-01'),
|
||||
default => new \DateTimeImmutable('-7 days'),
|
||||
};
|
||||
|
||||
$visitors = (int) $em->createQueryBuilder()
|
||||
->select('COUNT(v.id)')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$pageviews = (int) $em->createQueryBuilder()
|
||||
->select('COUNT(e.id)')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$topPages = $em->createQueryBuilder()
|
||||
->select('e.url, COUNT(e.id) AS hits')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('e.url')
|
||||
->orderBy('hits', 'DESC')
|
||||
->setMaxResults(20)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$topReferrers = $em->createQueryBuilder()
|
||||
->select('e.referrer, COUNT(e.id) AS hits')
|
||||
->from(AnalyticsEvent::class, 'e')
|
||||
->where('e.createdAt >= :since')
|
||||
->andWhere('e.referrer IS NOT NULL')
|
||||
->andWhere("e.referrer != ''")
|
||||
->setParameter('since', $since)
|
||||
->groupBy('e.referrer')
|
||||
->orderBy('hits', 'DESC')
|
||||
->setMaxResults(10)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$devices = $em->createQueryBuilder()
|
||||
->select('v.deviceType, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.deviceType')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$browsers = $em->createQueryBuilder()
|
||||
->select('v.browser, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->andWhere('v.browser IS NOT NULL')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.browser')
|
||||
->orderBy('cnt', 'DESC')
|
||||
->setMaxResults(10)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$osList = $em->createQueryBuilder()
|
||||
->select('v.os, COUNT(v.id) AS cnt')
|
||||
->from(AnalyticsUniqId::class, 'v')
|
||||
->where('v.createdAt >= :since')
|
||||
->andWhere('v.os IS NOT NULL')
|
||||
->setParameter('since', $since)
|
||||
->groupBy('v.os')
|
||||
->orderBy('cnt', 'DESC')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
return $this->render('admin/analytics.html.twig', [
|
||||
'period' => $period,
|
||||
'visitors' => $visitors,
|
||||
'pageviews' => $pageviews,
|
||||
'top_pages' => $topPages,
|
||||
'top_referrers' => $topReferrers,
|
||||
'devices' => $devices,
|
||||
'browsers' => $browsers,
|
||||
'os_list' => $osList,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]
|
||||
public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response
|
||||
{
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\RgpdService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -63,10 +65,11 @@ class LegalController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/rgpd', name: 'app_rgpd')]
|
||||
public function rgpd(): Response
|
||||
#[Route('/rgpd', name: 'app_rgpd', methods: ['GET'])]
|
||||
public function rgpd(Request $request): Response
|
||||
{
|
||||
return $this->render('legal/rgpd.html.twig', [
|
||||
'client_ip' => $request->getClientIp(),
|
||||
'breadcrumbs' => [
|
||||
['name' => 'Accueil', 'url' => '/'],
|
||||
['name' => 'Politique RGPD', 'url' => '/rgpd'],
|
||||
@@ -74,6 +77,52 @@ class LegalController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/rgpd/acces', name: 'app_rgpd_access', methods: ['POST'])]
|
||||
public function rgpdAccess(Request $request, RgpdService $rgpd): Response
|
||||
{
|
||||
$ip = trim($request->request->getString('ip'));
|
||||
$email = trim($request->request->getString('email'));
|
||||
|
||||
if ('' === $ip || '' === $email) {
|
||||
$this->addFlash('error', 'Veuillez remplir tous les champs.');
|
||||
|
||||
return $this->redirectToRoute('app_rgpd');
|
||||
}
|
||||
|
||||
$result = $rgpd->handleAccessRequest($ip, $email);
|
||||
|
||||
if ($result['found']) {
|
||||
$this->addFlash('success', 'Vos donnees ont ete envoyees par email.');
|
||||
} else {
|
||||
$this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP. Un email de confirmation a ete envoye.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_rgpd');
|
||||
}
|
||||
|
||||
#[Route('/rgpd/suppression', name: 'app_rgpd_deletion', methods: ['POST'])]
|
||||
public function rgpdDeletion(Request $request, RgpdService $rgpd): Response
|
||||
{
|
||||
$ip = trim($request->request->getString('ip'));
|
||||
$email = trim($request->request->getString('email'));
|
||||
|
||||
if ('' === $ip || '' === $email) {
|
||||
$this->addFlash('error', 'Veuillez remplir tous les champs.');
|
||||
|
||||
return $this->redirectToRoute('app_rgpd');
|
||||
}
|
||||
|
||||
$result = $rgpd->handleDeletionRequest($ip, $email);
|
||||
|
||||
if ($result['found']) {
|
||||
$this->addFlash('success', 'Vos donnees ont ete supprimees. Une attestation a ete envoyee par email.');
|
||||
} else {
|
||||
$this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_rgpd');
|
||||
}
|
||||
|
||||
#[Route('/conformite', name: 'app_conformite')]
|
||||
public function conformite(): Response
|
||||
{
|
||||
|
||||
147
src/Service/RgpdService.php
Normal file
147
src/Service/RgpdService.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\AnalyticsEvent;
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Dompdf\Dompdf;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore RGPD data access/deletion with PDF generation
|
||||
*/
|
||||
class RgpdService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private Environment $twig,
|
||||
private MailerService $mailer,
|
||||
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{found: bool, count: int}
|
||||
*/
|
||||
public function handleAccessRequest(string $ip, string $email): array
|
||||
{
|
||||
$ipHash = hash('sha256', $ip);
|
||||
$visitors = $this->em->getRepository(AnalyticsUniqId::class)->findBy(['ipHash' => $ipHash]);
|
||||
|
||||
if (0 === \count($visitors)) {
|
||||
return ['found' => false, 'count' => 0];
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($visitors as $visitor) {
|
||||
$events = $this->em->getRepository(AnalyticsEvent::class)->findBy(
|
||||
['visitor' => $visitor],
|
||||
['createdAt' => 'DESC'],
|
||||
);
|
||||
|
||||
$data[] = [
|
||||
'visitor' => $visitor,
|
||||
'events' => $events,
|
||||
];
|
||||
}
|
||||
|
||||
$pdfPath = $this->generateAccessPdf($data, $ip);
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$email,
|
||||
'E-Ticket - Vos donnees personnelles (RGPD)',
|
||||
$this->twig->render('emails/rgpd_access.html.twig'),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
[['path' => $pdfPath, 'name' => 'donnees-personnelles.pdf']],
|
||||
);
|
||||
|
||||
@unlink($pdfPath);
|
||||
|
||||
return ['found' => true, 'count' => \count($visitors)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{found: bool, deleted: int}
|
||||
*/
|
||||
public function handleDeletionRequest(string $ip, string $email): array
|
||||
{
|
||||
$ipHash = hash('sha256', $ip);
|
||||
$visitors = $this->em->getRepository(AnalyticsUniqId::class)->findBy(['ipHash' => $ipHash]);
|
||||
|
||||
if (0 === \count($visitors)) {
|
||||
return ['found' => false, 'deleted' => 0];
|
||||
}
|
||||
|
||||
$count = \count($visitors);
|
||||
|
||||
foreach ($visitors as $visitor) {
|
||||
$this->em->remove($visitor);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
$pdfPath = $this->generateDeletionPdf($ip);
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$email,
|
||||
'E-Ticket - Attestation de suppression (RGPD)',
|
||||
$this->twig->render('emails/rgpd_deletion.html.twig'),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
[['path' => $pdfPath, 'name' => 'attestation-suppression.pdf']],
|
||||
);
|
||||
|
||||
@unlink($pdfPath);
|
||||
|
||||
return ['found' => true, 'deleted' => $count];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{visitor: AnalyticsUniqId, events: list<AnalyticsEvent>}> $data
|
||||
*/
|
||||
private function generateAccessPdf(array $data, string $ip): string
|
||||
{
|
||||
$logoPath = $this->projectDir.'/public/logo.png';
|
||||
$logoBase64 = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : '';
|
||||
|
||||
$html = $this->twig->render('pdf/rgpd_access.html.twig', [
|
||||
'data' => $data,
|
||||
'ip' => $ip,
|
||||
'logo' => $logoBase64,
|
||||
'date' => new \DateTimeImmutable(),
|
||||
]);
|
||||
|
||||
return $this->renderPdf($html);
|
||||
}
|
||||
|
||||
private function generateDeletionPdf(string $ip): string
|
||||
{
|
||||
$logoPath = $this->projectDir.'/public/logo.png';
|
||||
$logoBase64 = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : '';
|
||||
|
||||
$html = $this->twig->render('pdf/rgpd_deletion.html.twig', [
|
||||
'ip' => $ip,
|
||||
'logo' => $logoBase64,
|
||||
'date' => new \DateTimeImmutable(),
|
||||
]);
|
||||
|
||||
return $this->renderPdf($html);
|
||||
}
|
||||
|
||||
private function renderPdf(string $html): string
|
||||
{
|
||||
$dompdf = new Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'rgpd_').'.pdf';
|
||||
file_put_contents($path, $dompdf->output());
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
124
templates/admin/analytics.html.twig
Normal file
124
templates/admin/analytics.html.twig
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends 'admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Analytics{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Analytics</h1>
|
||||
<div class="flex gap-2">
|
||||
{% for key, label in {today: "Aujourd'hui", '7d': '7 jours', '30d': '30 jours', all: 'Tout'} %}
|
||||
<a href="{{ path('app_admin_analytics', {period: key}) }}"
|
||||
class="px-3 py-1.5 text-xs font-black uppercase tracking-widest border-2 transition-all {{ period == key ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-300 bg-white hover:bg-gray-100' }}">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# KPIs #}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="admin-card text-center">
|
||||
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Visiteurs uniques</p>
|
||||
<p class="text-3xl font-black">{{ visitors|number_format(0, '.', ' ') }}</p>
|
||||
</div>
|
||||
<div class="admin-card text-center">
|
||||
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Pages vues</p>
|
||||
<p class="text-3xl font-black">{{ pageviews|number_format(0, '.', ' ') }}</p>
|
||||
</div>
|
||||
<div class="admin-card text-center">
|
||||
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Pages / visiteur</p>
|
||||
<p class="text-3xl font-black">{{ visitors > 0 ? (pageviews / visitors)|number_format(1) : '0' }}</p>
|
||||
</div>
|
||||
<div class="admin-card text-center">
|
||||
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Periode</p>
|
||||
<p class="text-xl font-black">{{ {today: "Aujourd'hui", '7d': '7 derniers jours', '30d': '30 derniers jours', all: 'Depuis le debut'}[period] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{# Top pages #}
|
||||
<div class="admin-card">
|
||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Pages les plus visitees</h2>
|
||||
{% if top_pages|length > 0 %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="admin-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">URL</th>
|
||||
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Vues</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for page in top_pages %}
|
||||
<tr>
|
||||
<td class="text-sm font-bold truncate max-w-[300px]">{{ page.url }}</td>
|
||||
<td class="text-right font-black text-sm">{{ page.hits }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400">Aucune donnee.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Top referrers #}
|
||||
<div class="admin-card">
|
||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Referrers</h2>
|
||||
{% if top_referrers|length > 0 %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="admin-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">Source</th>
|
||||
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Vues</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ref in top_referrers %}
|
||||
<tr>
|
||||
<td class="text-sm font-bold truncate max-w-[300px]">{{ ref.referrer }}</td>
|
||||
<td class="text-right font-black text-sm">{{ ref.hits }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400">Aucun referrer.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Devices, Browsers, OS #}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div class="admin-card">
|
||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Appareils</h2>
|
||||
{% for d in devices %}
|
||||
<div class="flex justify-between py-1 text-sm">
|
||||
<span class="font-bold text-gray-500">{{ d.deviceType|capitalize }}</span>
|
||||
<span class="font-black">{{ d.cnt }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Navigateurs</h2>
|
||||
{% for b in browsers %}
|
||||
<div class="flex justify-between py-1 text-sm">
|
||||
<span class="font-bold text-gray-500">{{ b.browser }}</span>
|
||||
<span class="font-black">{{ b.cnt }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Systemes</h2>
|
||||
{% for o in os_list %}
|
||||
<div class="flex justify-between py-1 text-sm">
|
||||
<span class="font-bold text-gray-500">{{ o.os }}</span>
|
||||
<span class="font-black">{{ o.cnt }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -24,6 +24,7 @@
|
||||
<a href="{{ path('app_admin_events') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_event' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Evenements</a>
|
||||
<a href="{{ path('app_admin_orders') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_orders' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Commandes</a>
|
||||
<a href="{{ path('app_admin_logs') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_logs' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Logs</a>
|
||||
<a href="{{ path('app_admin_analytics') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_analytics' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Analytics</a>
|
||||
<a href="{{ path('app_admin_infra') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_infra' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Infra</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
7
templates/emails/rgpd_access.html.twig
Normal file
7
templates/emails/rgpd_access.html.twig
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends 'emails/base.html.twig' %}
|
||||
{% block content %}
|
||||
<p>Bonjour,</p>
|
||||
<p>Suite a votre demande d'acces a vos donnees personnelles (article 15 du RGPD), vous trouverez en piece jointe un document PDF contenant l'ensemble des donnees que nous detenons vous concernant.</p>
|
||||
<p>Si vous souhaitez exercer votre droit a l'effacement, vous pouvez le faire depuis notre page RGPD.</p>
|
||||
<p>Cordialement,<br>L'equipe E-Ticket</p>
|
||||
{% endblock %}
|
||||
7
templates/emails/rgpd_deletion.html.twig
Normal file
7
templates/emails/rgpd_deletion.html.twig
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends 'emails/base.html.twig' %}
|
||||
{% block content %}
|
||||
<p>Bonjour,</p>
|
||||
<p>Suite a votre demande de suppression de vos donnees personnelles (article 17 du RGPD), nous vous confirmons que vos donnees ont ete supprimees de nos systemes.</p>
|
||||
<p>Vous trouverez en piece jointe une attestation de suppression.</p>
|
||||
<p>Cordialement,<br>L'equipe E-Ticket</p>
|
||||
{% endblock %}
|
||||
@@ -151,7 +151,56 @@
|
||||
<p>Tout litige en relation avec le traitement des donnees personnelles est soumis au droit francais. Il est fait attribution exclusive de juridiction aux tribunaux competents de Laon.</p>
|
||||
</section>
|
||||
|
||||
<p class="text-sm opacity-70 italic">Derniere mise a jour : {{ "now"|date("d/m/Y") }}</p>
|
||||
<p class="text-sm opacity-70 italic mb-8">Derniere mise a jour : {{ "now"|date("d/m/Y") }}</p>
|
||||
|
||||
{% for message in app.flashes('success') %}
|
||||
<div class="flash-success mb-4"><p class="font-black text-sm">{{ message }}</p></div>
|
||||
{% endfor %}
|
||||
{% for message in app.flashes('error') %}
|
||||
<div class="flash-error mb-4"><p class="font-black text-sm">{{ message }}</p></div>
|
||||
{% endfor %}
|
||||
|
||||
<section id="exercer-droits">
|
||||
<h2 class="text-xl font-black uppercase mb-4">Exercer vos droits</h2>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<div class="flex-1 border-2 border-gray-900 p-6">
|
||||
<h3 class="text-lg font-black uppercase mb-2">Droit d'acces</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Recevez par email un PDF contenant toutes les donnees de navigation que nous detenons vous concernant (article 15 du RGPD).</p>
|
||||
<form method="post" action="{{ path('app_rgpd_access') }}" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label for="access_email" class="text-xs font-black uppercase block mb-1">Email</label>
|
||||
<input type="email" id="access_email" name="email" required placeholder="votre@email.fr" class="w-full px-3 py-2 border-2 border-gray-900 font-bold text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="access_ip" class="text-xs font-black uppercase block mb-1">Adresse IP</label>
|
||||
<input type="text" id="access_ip" name="ip" required value="{{ client_ip }}" class="w-full px-3 py-2 border-2 border-gray-900 font-bold text-sm">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 border-2 border-gray-900 bg-[#fabf04] font-black uppercase text-xs tracking-widest cursor-pointer hover:bg-yellow-500 transition-all">
|
||||
Demander mes donnees
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 border-2 border-red-600 p-6">
|
||||
<h3 class="text-lg font-black uppercase mb-2">Droit a l'effacement</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Supprimez toutes vos donnees de navigation de nos systemes et recevez une attestation par email (article 17 du RGPD).</p>
|
||||
<form method="post" action="{{ path('app_rgpd_deletion') }}" data-confirm="Cette action est irreversible. Toutes vos donnees de navigation seront definitivement supprimees. Continuer ?" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label for="delete_email" class="text-xs font-black uppercase block mb-1">Email</label>
|
||||
<input type="email" id="delete_email" name="email" required placeholder="votre@email.fr" class="w-full px-3 py-2 border-2 border-gray-900 font-bold text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="delete_ip" class="text-xs font-black uppercase block mb-1">Adresse IP</label>
|
||||
<input type="text" id="delete_ip" name="ip" required value="{{ client_ip }}" class="w-full px-3 py-2 border-2 border-gray-900 font-bold text-sm">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 border-2 border-red-600 bg-red-600 text-white font-black uppercase text-xs tracking-widest cursor-pointer hover:bg-red-800 transition-all">
|
||||
Supprimer mes donnees
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
52
templates/pdf/rgpd_access.html.twig
Normal file
52
templates/pdf/rgpd_access.html.twig
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="UTF-8"><style>
|
||||
body { font-family: DejaVu Sans, sans-serif; font-size: 11px; color: #111; }
|
||||
h1 { font-size: 18px; margin-bottom: 5px; }
|
||||
h2 { font-size: 14px; margin-top: 20px; border-bottom: 1px solid #ccc; padding-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 10px; }
|
||||
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
|
||||
th { background: #f5f5f5; font-weight: bold; }
|
||||
.header { display: flex; align-items: center; margin-bottom: 15px; }
|
||||
.logo { width: 60px; margin-right: 15px; }
|
||||
.meta { color: #666; font-size: 10px; margin-bottom: 15px; }
|
||||
</style></head>
|
||||
<body>
|
||||
{% if logo %}<img src="{{ logo }}" class="logo" alt="Logo">{% endif %}
|
||||
<h1>Droit d'acces aux donnees personnelles</h1>
|
||||
<p class="meta">Article 15 du RGPD — Genere le {{ date|date('d/m/Y a H:i') }}</p>
|
||||
<p>Adresse IP concernee : <strong>{{ ip }}</strong></p>
|
||||
|
||||
{% for entry in data %}
|
||||
<h2>Session #{{ loop.index }} — {{ entry.visitor.createdAt|date('d/m/Y H:i') }}</h2>
|
||||
<table>
|
||||
<tr><th>Appareil</th><td>{{ entry.visitor.deviceType }}</td><th>OS</th><td>{{ entry.visitor.os ?? 'Inconnu' }}</td></tr>
|
||||
<tr><th>Navigateur</th><td>{{ entry.visitor.browser ?? 'Inconnu' }}</td><th>Langue</th><td>{{ entry.visitor.language ?? 'Inconnu' }}</td></tr>
|
||||
<tr><th>Ecran</th><td>{{ entry.visitor.screenWidth ?? '?' }}x{{ entry.visitor.screenHeight ?? '?' }}</td><th>Compte lie</th><td>{{ entry.visitor.user ? entry.visitor.user.email : 'Non' }}</td></tr>
|
||||
</table>
|
||||
|
||||
{% if entry.events|length > 0 %}
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th>Page</th><th>Titre</th><th>Referrer</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in entry.events %}
|
||||
<tr>
|
||||
<td>{{ event.createdAt|date('d/m/Y H:i') }}</td>
|
||||
<td>{{ event.url }}</td>
|
||||
<td>{{ event.title ?? '-' }}</td>
|
||||
<td>{{ event.referrer ?? '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p><em>Aucune page visitee enregistree.</em></p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<p style="margin-top: 20px; font-size: 10px; color: #666;">
|
||||
Document genere automatiquement par E-Ticket (Association E-Cosplay) conformement au Reglement General sur la Protection des Donnees (RGPD).
|
||||
Pour toute question : contact@e-cosplay.fr
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
40
templates/pdf/rgpd_deletion.html.twig
Normal file
40
templates/pdf/rgpd_deletion.html.twig
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="UTF-8"><style>
|
||||
body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #111; }
|
||||
h1 { font-size: 20px; text-align: center; margin-bottom: 30px; }
|
||||
.content { max-width: 500px; margin: 0 auto; }
|
||||
.logo { width: 80px; display: block; margin: 0 auto 20px; }
|
||||
.box { border: 2px solid #111; padding: 20px; margin: 20px 0; }
|
||||
.signature { margin-top: 40px; font-size: 11px; color: #666; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="content">
|
||||
{% if logo %}<img src="{{ logo }}" class="logo" alt="Logo">{% endif %}
|
||||
|
||||
<h1>Attestation de suppression de donnees</h1>
|
||||
|
||||
<div class="box">
|
||||
<p>Conformement a votre demande et en application de l'<strong>article 17 du Reglement General sur la Protection des Donnees (RGPD)</strong>, nous attestons que :</p>
|
||||
|
||||
<p style="margin: 15px 0;">Les donnees personnelles associees a l'adresse IP <strong>{{ ip }}</strong> ont ete <strong>supprimees</strong> de nos systemes le <strong>{{ date|date('d/m/Y a H:i') }}</strong>.</p>
|
||||
|
||||
<p>Cette suppression concerne l'ensemble des donnees de navigation collectees par notre systeme d'analyse d'audience, incluant :</p>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>Identifiants de session</li>
|
||||
<li>Pages visitees</li>
|
||||
<li>Donnees techniques (appareil, navigateur, systeme d'exploitation)</li>
|
||||
<li>Liens eventuels avec un compte utilisateur</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="signature">
|
||||
<p><strong>Association E-Cosplay</strong></p>
|
||||
<p>SIREN : 943121517 / RNA : W022006988</p>
|
||||
<p>42 rue de Saint-Quentin, 02800 Beautor, France</p>
|
||||
<p>contact@e-cosplay.fr</p>
|
||||
<p style="margin-top: 10px;">Document genere automatiquement — Reference : RGPD-DEL-{{ date|date('YmdHis') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user