Feature: Implement secure public pre-registration flow with CAPTCHA and HTMX dashboard
- Created V16 Flyway migration for pre_inscription and token_pre_inscription tables. - Added PreInscription and TokenPreInscription entities with respective repositories. - Updated SecurityConfig to allow public access to /inscription-public/**. - Implemented CaptchaValidationService to interact with Cloudflare Turnstile API. - Created PublicInscriptionController for managing the QR code entry, CAPTCHA validation, ephemeral tokens, and public form submission. - Added public Thymeleaf views (demarrer, formulaire, succes, lien-expire) with TailwindCSS. - Developed AdminPreInscriptionController and PreInscriptionService to handle back-office validation and rejection workflows. - Built the admin pre-inscriptions dashboard list view. - Added a dynamic, polling notification badge in the sidebar using HTMX, auto-updating on validation/rejection events.
This commit is contained in:
@@ -25,7 +25,7 @@ public class SecurityConfig {
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
|
||||
.requestMatchers("/css/**", "/js/**", "/images/**", "/inscription-public/**").permitAll()
|
||||
.requestMatchers("/change-password").authenticated()
|
||||
.requestMatchers("/utilisateurs/**", "/saisons/**", "/categories/**", "/equipes/**", "/modespaiement/**", "/equipements/**").hasRole("ADMIN")
|
||||
.requestMatchers("/paiements/**", "/paiement/**").hasAnyRole("ADMIN", "TRESORERIE")
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.astalange.web.controller;
|
||||
|
||||
import com.astalange.core.entity.Adherent;
|
||||
import com.astalange.core.repository.PreInscriptionRepository;
|
||||
import com.astalange.core.service.PreInscriptionService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/pre-inscriptions")
|
||||
public class AdminPreInscriptionController {
|
||||
|
||||
private final PreInscriptionRepository preInscriptionRepository;
|
||||
private final PreInscriptionService preInscriptionService;
|
||||
|
||||
public AdminPreInscriptionController(PreInscriptionRepository preInscriptionRepository,
|
||||
PreInscriptionService preInscriptionService) {
|
||||
this.preInscriptionRepository = preInscriptionRepository;
|
||||
this.preInscriptionService = preInscriptionService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String listPreInscriptions(Model model) {
|
||||
model.addAttribute("preInscriptions", preInscriptionRepository.findByStatutTraiteFalseOrderByDateCreationDesc());
|
||||
return "preinscriptions/list";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/valider")
|
||||
public String valider(@PathVariable Long id, HttpServletResponse response) {
|
||||
Adherent adherent = preInscriptionService.validerPreInscription(id, null);
|
||||
response.setHeader("HX-Trigger", "refreshBadge");
|
||||
// En HTMX, on renvoie une ligne vide pour la faire disparaître, ou on redirige via HX-Redirect
|
||||
// Mais comme on veut aller sur la page de l'adhérent pour finaliser la licence :
|
||||
response.setHeader("HX-Redirect", "/adherents/" + adherent.getId() + "/edit");
|
||||
return "";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/rejeter")
|
||||
@ResponseBody
|
||||
public String rejeter(@PathVariable Long id, HttpServletResponse response) {
|
||||
preInscriptionService.rejeterPreInscription(id);
|
||||
response.setHeader("HX-Trigger", "refreshBadge");
|
||||
return ""; // HTMX target la ligne avec outerHTML -> supprime la ligne
|
||||
}
|
||||
|
||||
@GetMapping("/count")
|
||||
@ResponseBody
|
||||
public String countBadge() {
|
||||
long count = preInscriptionRepository.countByStatutTraiteFalse();
|
||||
if (count == 0) return "";
|
||||
return "<span class=\"bg-red-500 text-white rounded-full px-2 py-0.5 text-xs font-bold ml-2\">" + count + "</span>";
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.astalange.web.controller;
|
||||
|
||||
import com.astalange.core.entity.PreInscription;
|
||||
import com.astalange.core.entity.TokenPreInscription;
|
||||
import com.astalange.core.entity.Saison;
|
||||
import com.astalange.core.repository.PreInscriptionRepository;
|
||||
import com.astalange.core.repository.TokenPreInscriptionRepository;
|
||||
import com.astalange.core.repository.SaisonRepository;
|
||||
import com.astalange.core.service.CaptchaValidationService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/inscription-public")
|
||||
public class PublicInscriptionController {
|
||||
|
||||
private final CaptchaValidationService captchaValidationService;
|
||||
private final TokenPreInscriptionRepository tokenRepository;
|
||||
private final PreInscriptionRepository preInscriptionRepository;
|
||||
private final SaisonRepository saisonRepository;
|
||||
|
||||
public PublicInscriptionController(CaptchaValidationService captchaValidationService,
|
||||
TokenPreInscriptionRepository tokenRepository,
|
||||
PreInscriptionRepository preInscriptionRepository,
|
||||
SaisonRepository saisonRepository) {
|
||||
this.captchaValidationService = captchaValidationService;
|
||||
this.tokenRepository = tokenRepository;
|
||||
this.preInscriptionRepository = preInscriptionRepository;
|
||||
this.saisonRepository = saisonRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/demarrer")
|
||||
public String showDemarrer() {
|
||||
return "public/demarrer";
|
||||
}
|
||||
|
||||
@PostMapping("/demarrer")
|
||||
public String processDemarrer(@RequestParam("cf-turnstile-response") String captchaResponse, RedirectAttributes redirectAttributes) {
|
||||
// En vrai, captchaResponse dépend du fournisseur. Cloudflare Turnstile = cf-turnstile-response
|
||||
if (!captchaValidationService.validateCaptcha(captchaResponse)) {
|
||||
redirectAttributes.addFlashAttribute("error", "Validation CAPTCHA échouée. Veuillez réessayer.");
|
||||
return "redirect:/inscription-public/demarrer";
|
||||
}
|
||||
|
||||
TokenPreInscription token = new TokenPreInscription();
|
||||
token.setValeurUuid(UUID.randomUUID().toString());
|
||||
token.setDateExpiration(LocalDateTime.now().plusMinutes(10));
|
||||
tokenRepository.save(token);
|
||||
|
||||
return "redirect:/inscription-public/formulaire?token=" + token.getValeurUuid();
|
||||
}
|
||||
|
||||
@GetMapping("/formulaire")
|
||||
public String showFormulaire(@RequestParam("token") String tokenUuid, Model model) {
|
||||
TokenPreInscription token = tokenRepository.findByValeurUuid(tokenUuid).orElse(null);
|
||||
if (token == null || !token.isValide()) {
|
||||
return "public/lien-expire";
|
||||
}
|
||||
|
||||
model.addAttribute("preInscription", new PreInscription());
|
||||
model.addAttribute("tokenUuid", tokenUuid);
|
||||
return "public/formulaire";
|
||||
}
|
||||
|
||||
@PostMapping("/formulaire")
|
||||
public String processFormulaire(@RequestParam("token") String tokenUuid,
|
||||
@ModelAttribute("preInscription") PreInscription preInscription,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
TokenPreInscription token = tokenRepository.findByValeurUuid(tokenUuid).orElse(null);
|
||||
if (token == null || !token.isValide()) {
|
||||
return "public/lien-expire";
|
||||
}
|
||||
|
||||
Saison activeSaison = saisonRepository.findByEstActiveTrue().orElse(null);
|
||||
if (activeSaison == null) {
|
||||
redirectAttributes.addFlashAttribute("error", "Les inscriptions sont actuellement fermées.");
|
||||
return "redirect:/inscription-public/demarrer";
|
||||
}
|
||||
|
||||
// Sauvegarde de la pré-inscription
|
||||
preInscription.setSaison(activeSaison);
|
||||
preInscription.setStatutTraite(false);
|
||||
preInscriptionRepository.save(preInscription);
|
||||
|
||||
// Invalidation du token
|
||||
token.setEstUtilise(true);
|
||||
tokenRepository.save(token);
|
||||
|
||||
return "redirect:/inscription-public/succes";
|
||||
}
|
||||
|
||||
@GetMapping("/succes")
|
||||
public String showSucces() {
|
||||
return "public/succes";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user