feat: protection code email + boutons signer/refuser + page refus
Protection :
- /echeancier/verify/{id} : verification par code email 6 chiffres
(meme pattern que OrderPayment verify, 15 min expiry)
- process redirige vers verify si non authentifie
Pages client :
- Boutons "Signer l'echeancier" (vert) et "Refuser" (rouge) dans process
- /echeancier/sign/{id} : redirige vers DocuSeal
- /echeancier/refuse/{id} : passe en cancelled, affiche page refused
- echeancier/verify.html.twig : saisie code
- echeancier/refused.html.twig : confirmation refus
Email :
- Lien pointe vers /echeancier/verify/{id} (protege)
- Texte Stripe ajoute dans page process
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -296,7 +296,7 @@ class EcheancierController extends AbstractController
|
||||
$slug = $docuSeal->getSubmitterSlug($submitterId);
|
||||
$signUrl = null !== $slug ? rtrim($docuSealUrl, '/').'/s/'.$slug : null;
|
||||
|
||||
$processUrl = $urlGenerator->generate('app_echeancier_process', [
|
||||
$processUrl = $urlGenerator->generate('app_echeancier_verify', [
|
||||
'id' => $echeancier->getId(),
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
|
||||
@@ -3,29 +3,118 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Echeancier;
|
||||
use App\Service\DocuSealService;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Twig\Environment;
|
||||
|
||||
class EcheancierProcessController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private MailerService $mailer,
|
||||
private Environment $twig,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Page de detail echeancier avant signature (publique).
|
||||
* Verification par code email avant d'acceder a l'echeancier.
|
||||
*/
|
||||
#[Route('/echeancier/process/{id}', name: 'app_echeancier_process', requirements: ['id' => '\d+'])]
|
||||
public function process(int $id): Response
|
||||
#[Route('/echeancier/verify/{id}', name: 'app_echeancier_verify', requirements: ['id' => '\d+'])]
|
||||
public function verify(int $id, Request $request): Response
|
||||
{
|
||||
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
|
||||
if (null === $echeancier) {
|
||||
throw $this->createNotFoundException('Echeancier introuvable.');
|
||||
}
|
||||
|
||||
$customer = $echeancier->getCustomer();
|
||||
$session = $request->getSession();
|
||||
$sessionKey = 'echeancier_verified_'.$echeancier->getId();
|
||||
|
||||
if ($session->get($sessionKey, false)) {
|
||||
return $this->redirectToRoute('app_echeancier_process', ['id' => $id]);
|
||||
}
|
||||
|
||||
if (null === $customer->getEmail()) {
|
||||
$session->set($sessionKey, true);
|
||||
|
||||
return $this->redirectToRoute('app_echeancier_process', ['id' => $id]);
|
||||
}
|
||||
|
||||
$codeKey = 'echeancier_code_'.$id;
|
||||
$codeExpiresKey = 'echeancier_code_expires_'.$id;
|
||||
$error = null;
|
||||
|
||||
if ('POST' === $request->getMethod()) {
|
||||
$code = trim($request->request->getString('code'));
|
||||
$storedCode = $session->get($codeKey);
|
||||
$expiresAt = $session->get($codeExpiresKey, 0);
|
||||
|
||||
if (time() > $expiresAt) {
|
||||
$error = 'Code expire. Cliquez sur "Renvoyer" pour en recevoir un nouveau.';
|
||||
$session->remove($codeKey);
|
||||
$session->remove($codeExpiresKey);
|
||||
} elseif ($code === $storedCode) {
|
||||
$session->set($sessionKey, true);
|
||||
$session->remove($codeKey);
|
||||
$session->remove($codeExpiresKey);
|
||||
|
||||
return $this->redirectToRoute('app_echeancier_process', ['id' => $id]);
|
||||
} else {
|
||||
$error = 'Code incorrect. Veuillez reessayer.';
|
||||
}
|
||||
}
|
||||
|
||||
// Envoyer le code si pas encore envoye ou expire
|
||||
if (null === $session->get($codeKey) || time() > $session->get($codeExpiresKey, 0)) {
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
|
||||
$session->set($codeKey, $code);
|
||||
$session->set($codeExpiresKey, time() + 900);
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Code de verification - Echeancier',
|
||||
$this->twig->render('emails/order_verify_code.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => null,
|
||||
'code' => $code,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->render('echeancier/verify.html.twig', [
|
||||
'echeancier' => $echeancier,
|
||||
'customer' => $customer,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page de detail echeancier avant signature (protegee par code).
|
||||
*/
|
||||
#[Route('/echeancier/process/{id}', name: 'app_echeancier_process', requirements: ['id' => '\d+'])]
|
||||
public function process(int $id, Request $request): Response
|
||||
{
|
||||
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
|
||||
if (null === $echeancier) {
|
||||
throw $this->createNotFoundException('Echeancier introuvable.');
|
||||
}
|
||||
|
||||
// Verifier si authentifie
|
||||
$session = $request->getSession();
|
||||
if (!$session->get('echeancier_verified_'.$echeancier->getId(), false)) {
|
||||
return $this->redirectToRoute('app_echeancier_verify', ['id' => $id]);
|
||||
}
|
||||
|
||||
if (\in_array($echeancier->getState(), [Echeancier::STATE_SIGNED, Echeancier::STATE_ACTIVE, Echeancier::STATE_COMPLETED], true)) {
|
||||
return $this->render('echeancier/signed.html.twig', [
|
||||
'echeancier' => $echeancier,
|
||||
@@ -33,12 +122,66 @@ class EcheancierProcessController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
if (Echeancier::STATE_CANCELLED === $echeancier->getState()) {
|
||||
return $this->render('echeancier/refused.html.twig', [
|
||||
'echeancier' => $echeancier,
|
||||
'customer' => $echeancier->getCustomer(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->render('echeancier/process.html.twig', [
|
||||
'echeancier' => $echeancier,
|
||||
'customer' => $echeancier->getCustomer(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirige vers DocuSeal pour signer.
|
||||
*/
|
||||
#[Route('/echeancier/sign/{id}', name: 'app_echeancier_sign', requirements: ['id' => '\d+'])]
|
||||
public function sign(
|
||||
int $id,
|
||||
DocuSealService $docuSeal,
|
||||
#[Autowire(env: 'DOCUSEAL_URL')] string $docuSealUrl = '',
|
||||
): Response {
|
||||
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
|
||||
if (null === $echeancier) {
|
||||
throw $this->createNotFoundException('Echeancier introuvable.');
|
||||
}
|
||||
|
||||
$submitterId = (int) ($echeancier->getSubmissionId() ?? '0');
|
||||
if ($submitterId <= 0) {
|
||||
throw $this->createNotFoundException('Echeancier non envoye pour signature.');
|
||||
}
|
||||
|
||||
$slug = $docuSeal->getSubmitterSlug($submitterId);
|
||||
if (null === $slug) {
|
||||
throw $this->createNotFoundException('Lien de signature introuvable.');
|
||||
}
|
||||
|
||||
return $this->redirect(rtrim($docuSealUrl, '/').'/s/'.$slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse l'echeancier.
|
||||
*/
|
||||
#[Route('/echeancier/refuse/{id}', name: 'app_echeancier_refuse', requirements: ['id' => '\d+'])]
|
||||
public function refuse(int $id): Response
|
||||
{
|
||||
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
|
||||
if (null === $echeancier) {
|
||||
throw $this->createNotFoundException('Echeancier introuvable.');
|
||||
}
|
||||
|
||||
$echeancier->setState(Echeancier::STATE_CANCELLED);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->render('echeancier/refused.html.twig', [
|
||||
'echeancier' => $echeancier,
|
||||
'customer' => $echeancier->getCustomer(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback DocuSeal apres signature du client.
|
||||
*/
|
||||
|
||||
@@ -83,6 +83,25 @@
|
||||
Une fois le contrat signe, un email vous sera envoye pour effectuer la configuration des prelevements automatiques.
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-gray-500 mb-6">
|
||||
Les prelevements seront effectues via <strong>Stripe</strong>, plateforme de paiement securisee. Vous recevrez un email de confirmation a chaque echeance.
|
||||
</p>
|
||||
|
||||
{# Boutons signer / refuser #}
|
||||
{% if echeancier.submissionId %}
|
||||
<div class="flex justify-center gap-4 mb-6">
|
||||
<a href="{{ path('app_echeancier_sign', {id: echeancier.id}) }}"
|
||||
class="px-6 py-3 bg-green-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-green-700 transition-all">
|
||||
Signer l'echeancier
|
||||
</a>
|
||||
<a href="{{ path('app_echeancier_refuse', {id: echeancier.id}) }}"
|
||||
class="px-6 py-3 bg-red-500/20 text-red-700 font-bold uppercase text-xs tracking-wider hover:bg-red-500 hover:text-white transition-all"
|
||||
onclick="return confirm('Etes-vous sur de vouloir refuser cet echeancier ?')">
|
||||
Refuser
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-center text-xs text-gray-400 mt-6">
|
||||
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
|
||||
</p>
|
||||
|
||||
25
templates/echeancier/refused.html.twig
Normal file
25
templates/echeancier/refused.html.twig
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Echeancier refuse - Association E-Cosplay{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
|
||||
<div class="glass-heavy w-full max-w-lg overflow-hidden">
|
||||
<div class="glass-dark text-white px-8 py-6 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-3 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h1 class="text-xl font-bold uppercase tracking-widest">Echeancier refuse</h1>
|
||||
</div>
|
||||
<div class="p-8 text-center">
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
L'echeancier de paiement a ete refuse.
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Si vous souhaitez discuter d'autres modalites de paiement, contactez-nous a
|
||||
<a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
34
templates/echeancier/verify.html.twig
Normal file
34
templates/echeancier/verify.html.twig
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Verification - Echeancier - Association E-Cosplay{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
|
||||
<div class="glass-heavy w-full max-w-md overflow-hidden">
|
||||
<div class="glass-dark text-white px-8 py-6 text-center">
|
||||
<h1 class="text-lg font-bold uppercase tracking-widest">Verification</h1>
|
||||
<p class="text-xs text-white/60 mt-1">Un code a ete envoye a {{ customer.email }}</p>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
{% if error %}
|
||||
<div class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Saisissez le code de verification a 6 chiffres recu par email pour acceder a votre echeancier.
|
||||
</p>
|
||||
|
||||
<form method="post" action="{{ path('app_echeancier_verify', {id: echeancier.id}) }}">
|
||||
<div class="mb-4">
|
||||
<label for="code" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Code de verification</label>
|
||||
<input type="text" id="code" name="code" maxlength="6" pattern="[0-9]{6}" required autofocus
|
||||
class="input-glass w-full px-4 py-3 text-center text-2xl font-bold tracking-[0.5em]" placeholder="000000">
|
||||
</div>
|
||||
<button type="submit" class="w-full btn-gold px-4 py-3 font-bold uppercase text-xs tracking-wider text-gray-900">Verifier</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-xs text-gray-400 mt-4">Le code expire dans 15 minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user