Compare commits

...

3 Commits

Author SHA1 Message Date
ucef 87c65c6ebd feat: add admin toggle to open/close public pre-registrations with ui messaging
AS Talange CI/CD Pipeline / Build & Run Unit Tests (push) Successful in 2m52s
AS Talange CI/CD Pipeline / Build & Run in Docker Container (push) Successful in 3m18s
2026-06-12 23:37:41 +02:00
ucef bb24363500 fix: resolve HTMX POST CSRF token and unhandled ViewResolver 500 error 2026-06-12 23:37:37 +02:00
ucef 19139124ca fix: use correct dummy secret for local Cloudflare Turnstile verification 2026-06-12 23:37:07 +02:00
11 changed files with 91 additions and 22 deletions
@@ -17,6 +17,9 @@ public class Saison {
@Column(name = "est_active", nullable = false) @Column(name = "est_active", nullable = false)
private Boolean estActive = false; private Boolean estActive = false;
@Column(name = "inscriptions_ouvertes", nullable = false)
private Boolean inscriptionsOuvertes = false;
// Getters and Setters // Getters and Setters
public Long getId() { public Long getId() {
@@ -43,10 +46,20 @@ public class Saison {
this.estActive = estActive; this.estActive = estActive;
} }
public Boolean getInscriptionsOuvertes() {
return inscriptionsOuvertes != null ? inscriptionsOuvertes : false;
}
public void setInscriptionsOuvertes(Boolean inscriptionsOuvertes) {
this.inscriptionsOuvertes = inscriptionsOuvertes;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o)
if (o == null || getClass() != o.getClass()) return false; return true;
if (o == null || getClass() != o.getClass())
return false;
Saison saison = (Saison) o; Saison saison = (Saison) o;
return Objects.equals(id, saison.id); return Objects.equals(id, saison.id);
} }
@@ -15,7 +15,7 @@ import java.util.Map;
@Service @Service
public class CaptchaValidationService { public class CaptchaValidationService {
@Value("${captcha.secret:dummy-secret}") @Value("${captcha.secret:1x0000000000000000000000000000000AA}")
private String captchaSecret; private String captchaSecret;
@Value("${captcha.url:https://challenges.cloudflare.com/turnstile/v0/siteverify}") @Value("${captcha.url:https://challenges.cloudflare.com/turnstile/v0/siteverify}")
@@ -0,0 +1 @@
ALTER TABLE saison ADD COLUMN inscriptions_ouvertes BOOLEAN NOT NULL DEFAULT FALSE;
@@ -28,6 +28,7 @@ public class AdminPreInscriptionController {
} }
@PostMapping("/{id}/valider") @PostMapping("/{id}/valider")
@ResponseBody
public String valider(@PathVariable Long id, HttpServletResponse response) { public String valider(@PathVariable Long id, HttpServletResponse response) {
Adherent adherent = preInscriptionService.validerPreInscription(id, null); Adherent adherent = preInscriptionService.validerPreInscription(id, null);
response.setHeader("HX-Trigger", "refreshBadge"); response.setHeader("HX-Trigger", "refreshBadge");
@@ -35,12 +35,21 @@ public class PublicInscriptionController {
} }
@GetMapping("/demarrer") @GetMapping("/demarrer")
public String showDemarrer() { public String showDemarrer(Model model) {
Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null);
if (activeSaison == null || !activeSaison.getInscriptionsOuvertes()) {
model.addAttribute("inscriptionsFermees", true);
}
return "public/demarrer"; return "public/demarrer";
} }
@PostMapping("/demarrer") @PostMapping("/demarrer")
public String processDemarrer(@RequestParam("cf-turnstile-response") String captchaResponse, RedirectAttributes redirectAttributes) { public String processDemarrer(@RequestParam("cf-turnstile-response") String captchaResponse, RedirectAttributes redirectAttributes) {
Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null);
if (activeSaison == null || !activeSaison.getInscriptionsOuvertes()) {
redirectAttributes.addFlashAttribute("error", "Les inscriptions ne sont pas ouvertes pour le moment, veuillez contacter un responsable du club.");
return "redirect:/inscription-public/demarrer";
}
// En vrai, captchaResponse dépend du fournisseur. Cloudflare Turnstile = cf-turnstile-response // En vrai, captchaResponse dépend du fournisseur. Cloudflare Turnstile = cf-turnstile-response
if (!captchaValidationService.validateCaptcha(captchaResponse)) { if (!captchaValidationService.validateCaptcha(captchaResponse)) {
redirectAttributes.addFlashAttribute("error", "Validation CAPTCHA échouée. Veuillez réessayer."); redirectAttributes.addFlashAttribute("error", "Validation CAPTCHA échouée. Veuillez réessayer.");
@@ -56,7 +65,13 @@ public class PublicInscriptionController {
} }
@GetMapping("/formulaire") @GetMapping("/formulaire")
public String showFormulaire(@RequestParam("token") String tokenUuid, Model model) { public String showFormulaire(@RequestParam("token") String tokenUuid, Model model, RedirectAttributes redirectAttributes) {
Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null);
if (activeSaison == null || !activeSaison.getInscriptionsOuvertes()) {
redirectAttributes.addFlashAttribute("error", "Les inscriptions ne sont pas ouvertes pour le moment, veuillez contacter un responsable du club.");
return "redirect:/inscription-public/demarrer";
}
TokenPreInscription token = tokenRepository.findByValeurUuid(tokenUuid).orElse(null); TokenPreInscription token = tokenRepository.findByValeurUuid(tokenUuid).orElse(null);
if (token == null || !token.isValide()) { if (token == null || !token.isValide()) {
return "public/lien-expire"; return "public/lien-expire";
@@ -77,8 +92,8 @@ public class PublicInscriptionController {
} }
Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null); Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null);
if (activeSaison == null) { if (activeSaison == null || !activeSaison.getInscriptionsOuvertes()) {
redirectAttributes.addFlashAttribute("error", "Les inscriptions sont actuellement fermées."); redirectAttributes.addFlashAttribute("error", "Les inscriptions ne sont pas ouvertes pour le moment, veuillez contacter un responsable du club.");
return "redirect:/inscription-public/demarrer"; return "redirect:/inscription-public/demarrer";
} }
@@ -58,4 +58,13 @@ public class SaisonController {
return "redirect:/saisons"; return "redirect:/saisons";
} }
@PostMapping("/{id}/toggle-inscriptions")
public String toggleInscriptions(@PathVariable Long id) {
Saison saison = saisonRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Saison introuvable : " + id));
saison.setInscriptionsOuvertes(!saison.getInscriptionsOuvertes());
saisonRepository.save(saison);
return "redirect:/saisons";
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -30,12 +30,13 @@
<tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200"> <tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200">
<th class="py-3 px-6 font-medium text-left">Nom de la Saison</th> <th class="py-3 px-6 font-medium text-left">Nom de la Saison</th>
<th class="py-3 px-6 font-medium text-center">Statut</th> <th class="py-3 px-6 font-medium text-center">Statut</th>
<th class="py-3 px-6 font-medium text-center">Inscriptions Publiques</th>
<th class="py-3 px-6 font-medium text-right">Actions</th> <th class="py-3 px-6 font-medium text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 text-sm"> <tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(saisons)}"> <tr th:if="${#lists.isEmpty(saisons)}">
<td colspan="3" class="py-8 text-center text-gray-500">Aucune saison configurée.</td> <td colspan="4" class="py-8 text-center text-gray-500">Aucune saison configurée.</td>
</tr> </tr>
<tr th:each="saison : ${saisons}" class="hover:bg-gray-50 transition-colors"> <tr th:each="saison : ${saisons}" class="hover:bg-gray-50 transition-colors">
<td class="py-4 px-6 font-medium text-gray-900" th:text="${saison.nom}">Saison 2024-2025</td> <td class="py-4 px-6 font-medium text-gray-900" th:text="${saison.nom}">Saison 2024-2025</td>
@@ -47,6 +48,14 @@
Clôturée Clôturée
</span> </span>
</td> </td>
<td class="py-4 px-6 text-center">
<form th:if="${saison.estActive}" th:action="@{/saisons/{id}/toggle-inscriptions(id=${saison.id})}" method="post">
<button type="submit" th:class="${saison.inscriptionsOuvertes ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'} + ' px-3 py-1 rounded-full text-xs font-medium hover:opacity-80 transition-opacity'" title="Cliquez pour ouvrir/fermer">
<span th:text="${saison.inscriptionsOuvertes ? 'Ouvertes' : 'Fermées'}"></span>
</button>
</form>
<span th:unless="${saison.estActive}" class="text-gray-400 text-xs">-</span>
</td>
<td class="py-4 px-6 text-right flex justify-end space-x-3 items-center"> <td class="py-4 px-6 text-right flex justify-end space-x-3 items-center">
<form th:if="${!saison.estActive}" th:action="@{/saisons/{id}/activer(id=${saison.id})}" method="post"> <form th:if="${!saison.estActive}" th:action="@{/saisons/{id}/activer(id=${saison.id})}" method="post">
<button type="submit" class="text-indigo-600 hover:text-indigo-900 font-medium bg-indigo-50 px-3 py-1 rounded-lg">Définir Active</button> <button type="submit" class="text-indigo-600 hover:text-indigo-900 font-medium bg-indigo-50 px-3 py-1 rounded-lg">Définir Active</button>
@@ -7,6 +7,8 @@
<script src="https://unpkg.com/htmx.org@1.9.11"></script> <script src="https://unpkg.com/htmx.org@1.9.11"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> body { font-family: 'Inter', sans-serif; } </style> <style> body { font-family: 'Inter', sans-serif; } </style>
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
</head> </head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden"> <body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar --> <!-- Sidebar -->
@@ -44,12 +46,11 @@
<div class="text-xs text-gray-500" th:text="${pre.email}"></div> <div class="text-xs text-gray-500" th:text="${pre.email}"></div>
</td> </td>
<td class="py-4 px-6 text-right space-x-2"> <td class="py-4 px-6 text-right space-x-2">
<button th:hx-post="@{/admin/pre-inscriptions/{id}/valider(id=${pre.id})}" <button th:attr="hx-post=@{/admin/pre-inscriptions/{id}/valider(id=${pre.id})}"
class="bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 transition-colors"> class="bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 transition-colors">
Créer la fiche Créer la fiche
</button> </button>
<button th:hx-post="@{/admin/pre-inscriptions/{id}/rejeter(id=${pre.id})}" <button th:attr="hx-post=@{/admin/pre-inscriptions/{id}/rejeter(id=${pre.id})}, hx-target=|#pre-${pre.id}|"
th:hx-target="'#pre-' + ${pre.id}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-confirm="Confirmer le rejet du dossier ?" hx-confirm="Confirmer le rejet du dossier ?"
class="bg-red-50 text-red-600 border border-red-200 px-3 py-1.5 rounded hover:bg-red-100 transition-colors"> class="bg-red-50 text-red-600 border border-red-200 px-3 py-1.5 rounded hover:bg-red-100 transition-colors">
@@ -62,5 +63,12 @@
</div> </div>
</div> </div>
</main> </main>
<script>
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers[document.querySelector('meta[name="_csrf_header"]').content] =
document.querySelector('meta[name="_csrf"]').content;
});
</script>
</body> </body>
</html> </html>
@@ -1,30 +1,42 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>AS Talange - Pré-inscription</title> <title>AS Talange - Pré-inscription</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style> body { font-family: 'Inter', sans-serif; } </style> <style>
body {
font-family: 'Inter', sans-serif;
}
</style>
</head> </head>
<body class="bg-gray-50 flex items-center justify-center min-h-screen"> <body class="bg-gray-50 flex items-center justify-center min-h-screen">
<div class="max-w-md w-full bg-white rounded-xl shadow-md p-8 border border-gray-100 text-center"> <div class="max-w-md w-full bg-white rounded-xl shadow-md p-8 border border-gray-100 text-center">
<img src="/images/logo.png" alt="AS Talange" class="h-24 mx-auto mb-6 opacity-80" onerror="this.style.display='none'"> <img src="/images/logo.png" alt="AS Talange" class="h-32 w-auto mx-auto mb-6 object-contain drop-shadow-sm"
onerror="this.style.display='none'">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Inscription au club</h1> <h1 class="text-2xl font-bold text-gray-900 mb-2">Inscription au club</h1>
<p class="text-gray-600 mb-8">Veuillez valider le captcha pour accéder au formulaire sécurisé.</p> <p th:unless="${inscriptionsFermees}" class="text-gray-600 mb-8">Veuillez valider le captcha pour accéder au formulaire sécurisé.</p>
<div th:if="${error}" class="bg-red-50 text-red-600 p-3 rounded-lg mb-6 text-sm" th:text="${error}"></div> <div th:if="${error != null and (inscriptionsFermees == null or !inscriptionsFermees)}" class="bg-red-50 text-red-600 p-3 rounded-lg mb-6 text-sm" th:text="${error}"></div>
<div th:if="${param.error}" class="bg-red-50 text-red-600 p-3 rounded-lg mb-6 text-sm">Les inscriptions sont actuellement fermées.</div>
<form th:action="@{/inscription-public/demarrer}" method="post" class="flex flex-col items-center"> <div th:if="${inscriptionsFermees}" class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 text-sm font-medium">
Les inscriptions ne sont pas ouvertes pour le moment, veuillez contacter un responsable du club.
</div>
<form th:unless="${inscriptionsFermees}" th:action="@{/inscription-public/demarrer}" method="post" class="flex flex-col items-center">
<!-- Remplacer par la vraie clé de site Cloudflare Turnstile --> <!-- Remplacer par la vraie clé de site Cloudflare Turnstile -->
<div class="cf-turnstile mb-6" data-sitekey="1x00000000000000000000AA"></div> <div class="cf-turnstile mb-6" data-sitekey="1x00000000000000000000AA"></div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-lg transition-colors"> <button type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-lg transition-colors">
Accéder au formulaire Accéder au formulaire
</button> </button>
</form> </form>
</div> </div>
</body> </body>
</html> </html>
@@ -9,7 +9,8 @@
</head> </head>
<body class="bg-gray-50 flex items-center justify-center min-h-screen py-12"> <body class="bg-gray-50 flex items-center justify-center min-h-screen py-12">
<div class="max-w-xl w-full bg-white rounded-xl shadow-md p-8 border border-gray-100"> <div class="max-w-xl w-full bg-white rounded-xl shadow-md p-8 border border-gray-100">
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">Formulaire de pré-inscription</h2> <img src="/images/logo.png" alt="AS Talange" class="h-24 w-auto mx-auto mb-4 object-contain drop-shadow-sm" onerror="this.style.display='none'">
<h2 class="text-2xl font-bold text-gray-900 mb-2 text-center">Formulaire de pré-inscription</h2>
<p class="text-sm text-gray-500 mb-8 text-center">Veuillez remplir les informations concernant le futur licencié.</p> <p class="text-sm text-gray-500 mb-8 text-center">Veuillez remplir les informations concernant le futur licencié.</p>
<form th:action="@{/inscription-public/formulaire}" th:object="${preInscription}" method="post" class="space-y-6"> <form th:action="@{/inscription-public/formulaire}" th:object="${preInscription}" method="post" class="space-y-6">