From 85363e98bd0c9f431e42be2c5144095e3edff60e Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Mon, 27 Apr 2026 23:25:42 -0400 Subject: [PATCH] feat(story-1.3): user registration with auto-login and inline validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RegistrationFormType: email + plainPassword, NotBlank/Email/Length(min=8) constraints - SecurityController: register action hashes password, persists user, auto-logs in via Security::login() - User entity: UniqueEntity constraint — "An account with this email already exists" - Register Twig template: inline errors per field (role=alert), blur-validation JS (client fires on blur not keystroke; server-error flag prevents blur clobbering server messages) - csrf.yaml: switched from stateless UX-dependent tokens to standard session CSRF (stateless token IDs require Stimulus JS to inject the real value — we removed Stimulus) Verified: happy path → 302 + auto-login; duplicate email → 422 + inline error; short password → 422 + inline error Co-Authored-By: Claude Sonnet 4.6 --- config/packages/csrf.yaml | 10 +- src/Controller/SecurityController.php | 40 ++++++- src/Entity/User.php | 2 + src/Form/RegistrationFormType.php | 46 ++++++++ templates/security/register.html.twig | 164 +++++++++++++++++++++++++- 5 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 src/Form/RegistrationFormType.php diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml index 40d4040..da56142 100644 --- a/config/packages/csrf.yaml +++ b/config/packages/csrf.yaml @@ -1,11 +1,3 @@ -# Enable stateless CSRF protection for forms and logins/logouts framework: form: - csrf_protection: - token_id: submit - - csrf_protection: - stateless_token_ids: - - submit - - authenticate - - logout + csrf_protection: true diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index c5da2b2..7378bfe 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -4,8 +4,14 @@ declare(strict_types=1); namespace App\Controller; +use App\Entity\User; +use App\Form\RegistrationFormType; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; @@ -31,9 +37,35 @@ class SecurityController extends AbstractController } #[Route('/register', name: 'app_register', methods: ['GET', 'POST'])] - public function register(): Response - { - // Implemented in Story 1.3 - return $this->render('security/register.html.twig'); + public function register( + Request $request, + UserPasswordHasherInterface $hasher, + EntityManagerInterface $em, + Security $security, + ): Response { + if ($this->getUser()) { + return $this->redirectToRoute('spa'); + } + + $user = new User(); + $form = $this->createForm(RegistrationFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var string $plainPassword */ + $plainPassword = $form->get('plainPassword')->getData(); + $user->setPassword($hasher->hashPassword($user, $plainPassword)); + + $em->persist($user); + $em->flush(); + + $response = $security->login($user, 'form_login', 'main'); + + return $response ?? $this->redirectToRoute('spa'); + } + + return $this->render('security/register.html.twig', [ + 'form' => $form, + ]); } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 4793b9c..cef6e04 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -6,11 +6,13 @@ namespace App\Entity; use App\Repository\UserRepository; use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] +#[UniqueEntity(fields: ['email'], message: 'An account with this email already exists')] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php new file mode 100644 index 0000000..b5d6772 --- /dev/null +++ b/src/Form/RegistrationFormType.php @@ -0,0 +1,46 @@ +add('email', EmailType::class, [ + 'label' => 'Email address', + 'constraints' => [ + new NotBlank(message: 'Please enter your email address'), + new Email(message: 'Please enter a valid email address'), + ], + ]) + ->add('plainPassword', PasswordType::class, [ + 'label' => 'Password', + 'mapped' => false, + 'constraints' => [ + new NotBlank(message: 'Please enter a password'), + new Length( + min: 8, + minMessage: 'Your password must be at least {{ limit }} characters', + ), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => User::class]); + } +} diff --git a/templates/security/register.html.twig b/templates/security/register.html.twig index 1ddb382..0cdce69 100644 --- a/templates/security/register.html.twig +++ b/templates/security/register.html.twig @@ -4,8 +4,170 @@ Create account — pictureFrame + -

Registration — Story 1.3

+
+

Create account

+ + {{ form_start(form, {attr: {novalidate: 'novalidate', id: 'reg-form'}}) }} + +
+ {{ form_label(form.email) }} + {{ form_widget(form.email, {attr: { + id: 'reg-email', + autocomplete: 'email', + 'aria-describedby': 'reg-email-error', + 'aria-invalid': form.email.vars.errors|length > 0 ? 'true' : 'false' + }}) }} + +
+ +
+ {{ form_label(form.plainPassword) }} + {{ form_widget(form.plainPassword, {attr: { + id: 'reg-password', + autocomplete: 'new-password', + 'aria-describedby': 'reg-password-error', + 'aria-invalid': form.plainPassword.vars.errors|length > 0 ? 'true' : 'false' + }}) }} + +
+ + + + {{ form_end(form) }} + + +
+ +