diff --git a/.idea/escapepage.iml b/.idea/escapepage.iml
index c67296b..5dca623 100644
--- a/.idea/escapepage.iml
+++ b/.idea/escapepage.iml
@@ -138,6 +138,7 @@
+
diff --git a/.idea/php.xml b/.idea/php.xml
index 89d06de..26b72af 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -152,6 +152,7 @@
+
diff --git a/composer.json b/composer.json
index 79b894f..62d0f03 100644
--- a/composer.json
+++ b/composer.json
@@ -45,6 +45,7 @@
"symfony/web-link": "7.3.*",
"symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.3.*",
+ "symfonycasts/reset-password-bundle": "^1.24",
"symfonycasts/verify-email-bundle": "^1.18",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
diff --git a/composer.lock b/composer.lock
index 03309f0..05f4ca8 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "663544ea81c4031dcd096a2db68ba5f8",
+ "content-hash": "8e2419832c0841e325a5b748bde61a48",
"packages": [
{
"name": "composer/semver",
@@ -7969,6 +7969,53 @@
],
"time": "2025-12-04T18:07:52+00:00"
},
+ {
+ "name": "symfonycasts/reset-password-bundle",
+ "version": "v1.24.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/SymfonyCasts/reset-password-bundle.git",
+ "reference": "8e5f8f821260ccfe8085563a93b418d3ef9af29f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/8e5f8f821260ccfe8085563a93b418d3ef9af29f",
+ "reference": "8e5f8f821260ccfe8085563a93b418d3ef9af29f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1.10",
+ "symfony/config": "^5.4 | ^6.0 | ^7.0 | ^8.0",
+ "symfony/dependency-injection": "^5.4 | ^6.0 | ^7.0 | ^8.0",
+ "symfony/deprecation-contracts": "^2.2 | ^3.0",
+ "symfony/http-kernel": "^5.4 | ^6.0 | ^7.0 | ^8.0"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^1.0",
+ "doctrine/doctrine-bundle": "^2.8",
+ "doctrine/orm": "^2.13",
+ "symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0 | ^8.0",
+ "symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0 | ^8.0",
+ "symfony/process": "^6.4 | ^7.0 | ^8.0",
+ "symfonycasts/internal-test-helpers": "dev-main"
+ },
+ "type": "symfony-bundle",
+ "autoload": {
+ "psr-4": {
+ "SymfonyCasts\\Bundle\\ResetPassword\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Symfony bundle that adds password reset functionality.",
+ "support": {
+ "issues": "https://github.com/SymfonyCasts/reset-password-bundle/issues",
+ "source": "https://github.com/SymfonyCasts/reset-password-bundle/tree/v1.24.0"
+ },
+ "time": "2025-11-29T13:26:50+00:00"
+ },
{
"name": "symfonycasts/verify-email-bundle",
"version": "v1.18.0",
diff --git a/config/bundles.php b/config/bundles.php
index be3ef62..a28579b 100644
--- a/config/bundles.php
+++ b/config/bundles.php
@@ -16,4 +16,5 @@ return [
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true],
+ SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
];
diff --git a/config/packages/reset_password.yaml b/config/packages/reset_password.yaml
new file mode 100644
index 0000000..e53f102
--- /dev/null
+++ b/config/packages/reset_password.yaml
@@ -0,0 +1,2 @@
+symfonycasts_reset_password:
+ request_password_repository: App\Tech\Repository\ResetPasswordRequestRepository
diff --git a/config/packages/security.yaml b/config/packages/security.yaml
index b1d4cb0..6c029ed 100644
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -7,7 +7,7 @@ security:
app_user_provider:
entity:
class: App\Tech\Entity\User
- property: email
+ property: username
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@@ -20,7 +20,7 @@ security:
login_path: app_login
check_path: app_login
enable_csrf: true
- username_parameter: email
+ username_parameter: username
password_parameter: password
logout:
path: app_logout
diff --git a/migrations/Version20260103214856.php b/migrations/Version20260103214856.php
new file mode 100644
index 0000000..9e6d1bd
--- /dev/null
+++ b/migrations/Version20260103214856.php
@@ -0,0 +1,33 @@
+addSql('ALTER TABLE user ADD username VARCHAR(180) NOT NULL');
+ $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_USERNAME ON user (username)');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('DROP INDEX UNIQ_IDENTIFIER_USERNAME ON `user`');
+ $this->addSql('ALTER TABLE `user` DROP username');
+ }
+}
diff --git a/migrations/Version20260103215543.php b/migrations/Version20260103215543.php
new file mode 100644
index 0000000..acd9f3c
--- /dev/null
+++ b/migrations/Version20260103215543.php
@@ -0,0 +1,33 @@
+addSql('CREATE TABLE reset_password_request (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', expires_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_7CE748AA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395');
+ $this->addSql('DROP TABLE reset_password_request');
+ }
+}
diff --git a/src/Tech/Controller/ResetPasswordController.php b/src/Tech/Controller/ResetPasswordController.php
new file mode 100644
index 0000000..7652561
--- /dev/null
+++ b/src/Tech/Controller/ResetPasswordController.php
@@ -0,0 +1,176 @@
+createForm(ResetPasswordRequestFormType::class);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var string $email */
+ $email = $form->get('email')->getData();
+
+ return $this->processSendingPasswordResetEmail($email, $mailer, $translator
+ );
+ }
+
+ return $this->render('tech/reset_password/request.html.twig', [
+ 'requestForm' => $form,
+ ]);
+ }
+
+ /**
+ * Confirmation page after a user has requested a password reset.
+ */
+ #[Route('/check-email', name: 'app_check_email')]
+ public function checkEmail(): Response
+ {
+ // Generate a fake token if the user does not exist or someone hit this page directly.
+ // This prevents exposing whether or not a user was found with the given email address or not
+ if (null === ($resetToken = $this->getTokenObjectFromSession())) {
+ $resetToken = $this->resetPasswordHelper->generateFakeResetToken();
+ }
+
+ return $this->render('tech/reset_password/check_email.html.twig', [
+ 'resetToken' => $resetToken,
+ ]);
+ }
+
+ /**
+ * Validates and process the reset URL that the user clicked in their email.
+ */
+ #[Route('/reset/{token}', name: 'app_reset_password')]
+ public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, ?string $token = null): Response
+ {
+ if ($token) {
+ // We store the token in session and remove it from the URL, to avoid the URL being
+ // loaded in a browser and potentially leaking the token to 3rd party JavaScript.
+ $this->storeTokenInSession($token);
+
+ return $this->redirectToRoute('app_reset_password');
+ }
+
+ $token = $this->getTokenFromSession();
+
+ if (null === $token) {
+ throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
+ }
+
+ try {
+ /** @var User $user */
+ $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
+ } catch (ResetPasswordExceptionInterface $e) {
+ $this->addFlash('reset_password_error', sprintf(
+ '%s - %s',
+ $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
+ $translator->trans($e->getReason(), [], 'ResetPasswordBundle')
+ ));
+
+ return $this->redirectToRoute('app_forgot_password_request');
+ }
+
+ // The token is valid; allow the user to change their password.
+ $form = $this->createForm(ChangePasswordFormType::class);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ // A password reset token should be used only once, remove it.
+ $this->resetPasswordHelper->removeResetRequest($token);
+
+ /** @var string $plainPassword */
+ $plainPassword = $form->get('plainPassword')->getData();
+
+ // Encode(hash) the plain password, and set it.
+ $user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
+ $this->entityManager->flush();
+
+ // The session is cleaned up after the password has been changed.
+ $this->cleanSessionAfterReset();
+
+ return $this->redirectToRoute('website_home');
+ }
+
+ return $this->render('tech/reset_password/reset.html.twig', [
+ 'resetForm' => $form,
+ ]);
+ }
+
+ private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
+ {
+ $user = $this->entityManager->getRepository(User::class)->findOneBy([
+ 'email' => $emailFormData,
+ ]);
+
+ // Do not reveal whether a user account was found or not.
+ if (!$user) {
+ return $this->redirectToRoute('app_check_email');
+ }
+
+ try {
+ $resetToken = $this->resetPasswordHelper->generateResetToken($user);
+ } catch (ResetPasswordExceptionInterface $e) {
+ // If you want to tell the user why a reset email was not sent, uncomment
+ // the lines below and change the redirect to 'app_forgot_password_request'.
+ // Caution: This may reveal if a user is registered or not.
+ //
+ // $this->addFlash('reset_password_error', sprintf(
+ // '%s - %s',
+ // $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'),
+ // $translator->trans($e->getReason(), [], 'ResetPasswordBundle')
+ // ));
+
+ return $this->redirectToRoute('app_check_email');
+ }
+
+ $email = (new TemplatedEmail())
+ ->from(new Address('noreply@escapepage.com', 'Escapepage'))
+ ->to((string) $user->getEmail())
+ ->subject('Your password reset request')
+ ->htmlTemplate('tech/reset_password/email.html.twig')
+ ->context([
+ 'resetToken' => $resetToken,
+ ])
+ ;
+
+ $mailer->send($email);
+
+ // Store the token object in session for retrieval in check-email route.
+ $this->setTokenObjectInSession($resetToken);
+
+ return $this->redirectToRoute('app_check_email');
+ }
+}
diff --git a/src/Tech/Entity/ResetPasswordRequest.php b/src/Tech/Entity/ResetPasswordRequest.php
new file mode 100644
index 0000000..111ab70
--- /dev/null
+++ b/src/Tech/Entity/ResetPasswordRequest.php
@@ -0,0 +1,40 @@
+user = $user;
+ $this->initialize($expiresAt, $selector, $hashedToken);
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getUser(): User
+ {
+ return $this->user;
+ }
+}
diff --git a/src/Tech/Entity/User.php b/src/Tech/Entity/User.php
index 47150f0..c862ac2 100644
--- a/src/Tech/Entity/User.php
+++ b/src/Tech/Entity/User.php
@@ -10,6 +10,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
+#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
@@ -20,6 +21,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 180)]
private ?string $email = null;
+ #[ORM\Column(length: 180)]
+ private ?string $username = null;
+
/**
* @var list The user roles
*/
@@ -52,6 +56,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
+ public function getUsername(): ?string
+ {
+ return $this->username;
+ }
+
+ public function setUsername(string $username): static
+ {
+ $this->username = $username;
+
+ return $this;
+ }
+
/**
* A visual identifier that represents this user.
*
@@ -59,7 +75,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/
public function getUserIdentifier(): string
{
- return (string) $this->email;
+ return (string) $this->username;
}
/**
diff --git a/src/Tech/Form/ChangePasswordFormType.php b/src/Tech/Form/ChangePasswordFormType.php
new file mode 100644
index 0000000..b2bd7f6
--- /dev/null
+++ b/src/Tech/Form/ChangePasswordFormType.php
@@ -0,0 +1,58 @@
+add('plainPassword', RepeatedType::class, [
+ 'type' => PasswordType::class,
+ 'options' => [
+ 'attr' => [
+ 'autocomplete' => 'new-password',
+ ],
+ ],
+ 'first_options' => [
+ 'constraints' => [
+ new NotBlank([
+ 'message' => 'Please enter a password',
+ ]),
+ new Length([
+ 'min' => 12,
+ 'minMessage' => 'Your password should be at least {{ limit }} characters',
+ // max length allowed by Symfony for security reasons
+ 'max' => 4096,
+ ]),
+ new PasswordStrength(),
+ new NotCompromisedPassword(),
+ ],
+ 'label' => 'New password',
+ ],
+ 'second_options' => [
+ 'label' => 'Repeat Password',
+ ],
+ 'invalid_message' => 'The password fields must match.',
+ // Instead of being set onto the object directly,
+ // this is read and encoded in the controller
+ 'mapped' => false,
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([]);
+ }
+}
diff --git a/src/Tech/Form/RegistrationFormType.php b/src/Tech/Form/RegistrationFormType.php
index 9f77432..1120017 100644
--- a/src/Tech/Form/RegistrationFormType.php
+++ b/src/Tech/Form/RegistrationFormType.php
@@ -5,6 +5,7 @@ namespace App\Tech\Form;
use App\Tech\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -18,6 +19,13 @@ class RegistrationFormType extends AbstractType
{
$builder
->add('email', EmailType::class)
+ ->add('username', TextType::class, [
+ 'constraints' => [
+ new NotBlank([
+ 'message' => 'Please enter a username',
+ ]),
+ ],
+ ])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
diff --git a/src/Tech/Form/ResetPasswordRequestFormType.php b/src/Tech/Form/ResetPasswordRequestFormType.php
new file mode 100644
index 0000000..26f51a4
--- /dev/null
+++ b/src/Tech/Form/ResetPasswordRequestFormType.php
@@ -0,0 +1,31 @@
+add('email', EmailType::class, [
+ 'attr' => ['autocomplete' => 'email'],
+ 'constraints' => [
+ new NotBlank([
+ 'message' => 'Please enter your email',
+ ]),
+ ],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([]);
+ }
+}
diff --git a/src/Tech/Repository/ResetPasswordRequestRepository.php b/src/Tech/Repository/ResetPasswordRequestRepository.php
new file mode 100644
index 0000000..78f14e3
--- /dev/null
+++ b/src/Tech/Repository/ResetPasswordRequestRepository.php
@@ -0,0 +1,32 @@
+
+ */
+class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
+{
+ use ResetPasswordRequestRepositoryTrait;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, ResetPasswordRequest::class);
+ }
+
+ /**
+ * @param User $user
+ */
+ public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface
+ {
+ return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
+ }
+}
diff --git a/symfony.lock b/symfony.lock
index c8acec2..ac5be9a 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -356,6 +356,18 @@
"./webpack.config.js"
]
},
+ "symfonycasts/reset-password-bundle": {
+ "version": "1.24",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "97c1627c0384534997ae1047b93be517ca16de43"
+ },
+ "files": [
+ "./config/packages/reset_password.yaml"
+ ]
+ },
"symfonycasts/verify-email-bundle": {
"version": "v1.18.0"
},
diff --git a/templates/tech/registration/register.html.twig b/templates/tech/registration/register.html.twig
index c0dc234..05752e4 100644
--- a/templates/tech/registration/register.html.twig
+++ b/templates/tech/registration/register.html.twig
@@ -9,6 +9,7 @@
{{ form_start(registrationForm) }}
{{ form_row(registrationForm.email) }}
+ {{ form_row(registrationForm.username) }}
{{ form_row(registrationForm.plainPassword) }}
diff --git a/templates/tech/reset_password/check_email.html.twig b/templates/tech/reset_password/check_email.html.twig
new file mode 100644
index 0000000..786819b
--- /dev/null
+++ b/templates/tech/reset_password/check_email.html.twig
@@ -0,0 +1,11 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Password Reset Email Sent{% endblock %}
+
+{% block body %}
+
+ If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.
+ This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.
+
+
If you don't receive an email please check your spam folder or try again.