From b915d87d4a39c5542c1d63e99513cc2facdfffd8 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Tue, 7 Jul 2026 23:43:24 +0200 Subject: [PATCH] feat: password reset (#179) (#186) * feat: password reset via symfonycasts/reset-password-bundle (#179) Implements the full password reset flow using the SymfonyCasts reset-password-bundle. * ci: share base layer cache between dev and prod builds * fix: use CSS form selector in tests instead of translated button text * fix: translate missing validator string for reset password email field * fix: translate reset password form labels via TranslatorInterface * ci: override MAILER_DSN to null for PHPUnit so mailer host is not required --- .env.test | 2 + .github/workflows/ci.yml | 4 +- .idea/TijdVoorDeTest.iml | 1 + .idea/php.xml | 1 + composer.json | 1 + composer.lock | 50 +++++- config/bundles.php | 2 + config/packages/reset_password.yaml | 2 + config/reference.php | 10 ++ migrations/Version20260707205155.php | 34 ++++ src/Controller/ResetPasswordController.php | 149 ++++++++++++++++++ src/Entity/ResetPasswordRequest.php | 36 +++++ src/Entity/User.php | 4 + src/Form/ChangePasswordFormType.php | 54 +++++++ src/Form/RegistrationFormType.php | 2 +- src/Form/ResetPasswordRequestFormType.php | 36 +++++ .../ResetPasswordRequestRepository.php | 31 ++++ symfony.lock | 12 ++ templates/backoffice/login/login.html.twig | 4 +- .../registration/register.html.twig | 2 +- .../reset_password/check_email.html.twig | 16 ++ templates/reset_password/email.html.twig | 9 ++ templates/reset_password/request.html.twig | 18 +++ templates/reset_password/reset.html.twig | 12 ++ .../ResetPasswordControllerTest.php | 104 ++++++++++++ translations/ResetPasswordBundle.nl.xliff | 54 +++++++ translations/messages+intl-icu.nl.xliff | 52 ++++++ translations/validators.nl.xliff | 4 + 28 files changed, 701 insertions(+), 5 deletions(-) create mode 100644 config/packages/reset_password.yaml create mode 100644 migrations/Version20260707205155.php create mode 100644 src/Controller/ResetPasswordController.php create mode 100644 src/Entity/ResetPasswordRequest.php create mode 100644 src/Form/ChangePasswordFormType.php create mode 100644 src/Form/ResetPasswordRequestFormType.php create mode 100644 src/Repository/ResetPasswordRequestRepository.php create mode 100644 templates/reset_password/check_email.html.twig create mode 100644 templates/reset_password/email.html.twig create mode 100644 templates/reset_password/request.html.twig create mode 100644 templates/reset_password/reset.html.twig create mode 100644 tests/Controller/ResetPasswordControllerTest.php create mode 100644 translations/ResetPasswordBundle.nl.xliff diff --git a/.env.test b/.env.test index e2b1fb1..7d11cc8 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,5 @@ # define your env variables for the test env here KERNEL_CLASS='Tvdt\Kernel' APP_SECRET='$ecretf0rt3st' +MAILER_DSN=null://null +MAILER_SENDER=test@example.org diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d960e1d..38870df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: - name: Load fixtures run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test - name: Run PHPUnit - run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml + run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml - name: Publish PHPUnit test results if: always() uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6 @@ -260,6 +260,8 @@ jobs: compose.yaml compose.build.yaml set: | + *.cache-from=type=gha,scope=${{github.ref}}-devbuild + *.cache-from=type=gha,scope=refs/heads/main-devbuild *.cache-from=type=gha,scope=${{github.ref}} *.cache-from=type=gha,scope=refs/heads/main *.cache-to=type=gha,scope=${{github.ref}},mode=max diff --git a/.idea/TijdVoorDeTest.iml b/.idea/TijdVoorDeTest.iml index bcec616..01637f5 100644 --- a/.idea/TijdVoorDeTest.iml +++ b/.idea/TijdVoorDeTest.iml @@ -171,6 +171,7 @@ + diff --git a/.idea/php.xml b/.idea/php.xml index 9c78f81..1019d6e 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -205,6 +205,7 @@ + diff --git a/composer.json b/composer.json index 2c7e4cd..c634daf 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ "symfony/ux-turbo": "^3.1", "symfony/validator": "8.1.*", "symfony/yaml": "8.1.*", + "symfonycasts/reset-password-bundle": "^1.25", "symfonycasts/sass-bundle": "^0.10", "symfonycasts/verify-email-bundle": "^1.18.0", "thecodingmachine/safe": "^3.4.0", diff --git a/composer.lock b/composer.lock index 014b707..395c844 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": "7171824ca13f4df0801dfa5d7f58d6a0", + "content-hash": "010a4456ebc1a8ebaf73c6db051d3d09", "packages": [ { "name": "composer/pcre", @@ -8281,6 +8281,54 @@ ], "time": "2026-06-09T11:06:24+00:00" }, + { + "name": "symfonycasts/reset-password-bundle", + "version": "v1.25.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/reset-password-bundle.git", + "reference": "084aac1cc40ef75b26134c7967d1e423eeff72f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/084aac1cc40ef75b26134c7967d1e423eeff72f4", + "reference": "084aac1cc40ef75b26134c7967d1e423eeff72f4", + "shasum": "" + }, + "require": { + "php": ">=8.1.10", + "symfony/clock": "^6.3 | ^7.0 | ^8.0", + "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 | ^2.0", + "doctrine/doctrine-bundle": "^2.13 | ^3.0", + "doctrine/orm": "^2.20 | ^3.0", + "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.25.0" + }, + "time": "2026-03-26T10:16:40+00:00" + }, { "name": "symfonycasts/sass-bundle", "version": "v0.10.0", diff --git a/config/bundles.php b/config/bundles.php index bf0ee87..717fcf1 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -15,6 +15,7 @@ use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; use Symfony\UX\StimulusBundle\StimulusBundle; use Symfony\UX\Turbo\TurboBundle; +use SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle; use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle; use Symfonycasts\SassBundle\SymfonycastsSassBundle; use Twig\Extra\TwigExtraBundle\TwigExtraBundle; @@ -36,4 +37,5 @@ return [ TurboBundle::class => ['all' => true], DAMADoctrineTestBundle::class => ['test' => true], StofDoctrineExtensionsBundle::class => ['all' => true], + SymfonyCastsResetPasswordBundle::class => ['all' => true], ]; diff --git a/config/packages/reset_password.yaml b/config/packages/reset_password.yaml new file mode 100644 index 0000000..7950305 --- /dev/null +++ b/config/packages/reset_password.yaml @@ -0,0 +1,2 @@ +symfonycasts_reset_password: + request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository diff --git a/config/reference.php b/config/reference.php index dad68a9..2847703 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1490,6 +1490,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * skip_translation_on_load?: bool|Param, // Default: false * metadata_cache_pool?: scalar|Param|null, // Default: null * } + * @psalm-type SymfonycastsResetPasswordConfig = array{ + * request_password_repository?: scalar|Param|null, // A class that implements ResetPasswordRequestRepositoryInterface - usually your ResetPasswordRequestRepository. + * lifetime?: int|Param, // The length of time in seconds that a password reset request is valid for after it is created. // Default: 3600 + * throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600 + * enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1505,6 +1511,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * stimulus?: StimulusConfig, * turbo?: TurboConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig, + * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1523,6 +1530,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * stimulus?: StimulusConfig, * turbo?: TurboConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig, + * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1540,6 +1548,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * stimulus?: StimulusConfig, * turbo?: TurboConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig, + * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1558,6 +1567,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * turbo?: TurboConfig, * dama_doctrine_test?: DamaDoctrineTestConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig, + * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * }, * ...addSql('CREATE TABLE reset_password_request (id UUID NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_7CE748AA76ED395 ON reset_password_request (user_id)'); + $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE'); + } + + #[\Override] + 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 CONSTRAINT FK_7CE748AA76ED395'); + $this->addSql('DROP TABLE reset_password_request'); + } +} diff --git a/src/Controller/ResetPasswordController.php b/src/Controller/ResetPasswordController.php new file mode 100644 index 0000000..e484d20 --- /dev/null +++ b/src/Controller/ResetPasswordController.php @@ -0,0 +1,149 @@ +createForm(ResetPasswordRequestFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var string $email */ + $email = $form->get('email')->getData(); + + return $this->processSendingPasswordResetEmail($email, $this->mailer, $this->translator); + } + + return $this->render('reset_password/request.html.twig', [ + 'requestForm' => $form, + ]); + } + + #[Route('/reset-password/check-email', name: 'tvdt_check_email')] + public function checkEmail(): Response + { + if (!($resetToken = $this->getTokenObjectFromSession()) instanceof ResetPasswordToken) { + $resetToken = $this->resetPasswordHelper->generateFakeResetToken(); + } + + return $this->render('reset_password/check_email.html.twig', [ + 'resetToken' => $resetToken, + ]); + } + + #[Route('/reset-password/reset/{token}', name: 'tvdt_reset_password')] + public function reset(Request $request, ?string $token = null): Response + { + if ($token) { + $this->storeTokenInSession($token); + + return $this->redirectToRoute('tvdt_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 $resetPasswordException) { + $this->addFlash(FlashType::Danger->value, \sprintf( + '%s - %s', + $this->translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'), + $this->translator->trans($resetPasswordException->getReason(), [], 'ResetPasswordBundle'), + )); + + return $this->redirectToRoute('tvdt_forgot_password_request'); + } + + $form = $this->createForm(ChangePasswordFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->resetPasswordHelper->removeResetRequest($token); + + /** @var string $plainPassword */ + $plainPassword = $form->get('plainPassword')->getData(); + + $user->password = $this->passwordHasher->hashPassword($user, $plainPassword); + $this->entityManager->flush(); + + $this->cleanSessionAfterReset(); + + return $this->redirectToRoute('tvdt_backoffice_index'); + } + + return $this->render('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, + ]); + + if (!$user instanceof User) { + return $this->redirectToRoute('tvdt_check_email'); + } + + try { + $resetToken = $this->resetPasswordHelper->generateResetToken($user); + } catch (ResetPasswordExceptionInterface) { + return $this->redirectToRoute('tvdt_check_email'); + } + + $email = new TemplatedEmail() + ->from(new Address('info@tijdvoordetest.nl', 'Tijd voor de Test')) + ->to($user->getUserIdentifier()) + ->subject($translator->trans('Your password reset request')) + ->htmlTemplate('reset_password/email.html.twig') + ->context([ + 'resetToken' => $resetToken, + ]); + + $mailer->send($email); + + $this->setTokenObjectInSession($resetToken); + + return $this->redirectToRoute('tvdt_check_email'); + } +} diff --git a/src/Entity/ResetPasswordRequest.php b/src/Entity/ResetPasswordRequest.php new file mode 100644 index 0000000..047cf14 --- /dev/null +++ b/src/Entity/ResetPasswordRequest.php @@ -0,0 +1,36 @@ +initialize($expiresAt, $selector, $hashedToken); + } + + public function getUser(): User + { + return $this->user; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 660ad03..a2cd4f6 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -21,6 +21,10 @@ use Tvdt\Repository\UserRepository; #[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')] class User implements UserInterface, PasswordAuthenticatedUserInterface { + public const int PASSWORD_MIN_LENGTH = 8; + + public const int PASSWORD_MAX_LENGTH = 4096; + #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] #[ORM\GeneratedValue(strategy: 'CUSTOM')] diff --git a/src/Form/ChangePasswordFormType.php b/src/Form/ChangePasswordFormType.php new file mode 100644 index 0000000..28c53f3 --- /dev/null +++ b/src/Form/ChangePasswordFormType.php @@ -0,0 +1,54 @@ + */ +final class ChangePasswordFormType extends AbstractType +{ + public function __construct(private readonly TranslatorInterface $translator) {} + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'options' => [ + 'attr' => ['autocomplete' => 'new-password'], + ], + 'first_options' => [ + 'label' => $this->translator->trans('New password'), + 'constraints' => [ + new NotBlank(message: 'Please enter a password'), + new Length( + min: User::PASSWORD_MIN_LENGTH, + max: User::PASSWORD_MAX_LENGTH, + minMessage: 'Your password should be at least {{ limit }} characters', + ), + ], + ], + 'second_options' => [ + 'label' => $this->translator->trans('Repeat Password'), + ], + 'invalid_message' => $this->translator->trans('The password fields must match.'), + 'mapped' => false, + 'translation_domain' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php index acfb946..a0618a8 100644 --- a/src/Form/RegistrationFormType.php +++ b/src/Form/RegistrationFormType.php @@ -38,7 +38,7 @@ class RegistrationFormType extends AbstractType 'mapped' => false, 'constraints' => [ new NotBlank(message: 'Please enter a password'), - new Length(min: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'), + new Length(min: User::PASSWORD_MIN_LENGTH, max: User::PASSWORD_MAX_LENGTH, minMessage: 'Your password should be at least {{ limit }} characters'), ], 'translation_domain' => false, ]) diff --git a/src/Form/ResetPasswordRequestFormType.php b/src/Form/ResetPasswordRequestFormType.php new file mode 100644 index 0000000..cdaaf7e --- /dev/null +++ b/src/Form/ResetPasswordRequestFormType.php @@ -0,0 +1,36 @@ + */ +final class ResetPasswordRequestFormType extends AbstractType +{ + public function __construct(private readonly TranslatorInterface $translator) {} + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('email', EmailType::class, [ + 'label' => $this->translator->trans('Email'), + 'attr' => ['autocomplete' => 'email'], + 'translation_domain' => false, + 'constraints' => [ + new NotBlank(message: 'Please enter your email'), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Repository/ResetPasswordRequestRepository.php b/src/Repository/ResetPasswordRequestRepository.php new file mode 100644 index 0000000..a7eb4f2 --- /dev/null +++ b/src/Repository/ResetPasswordRequestRepository.php @@ -0,0 +1,31 @@ + */ +final class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface +{ + use ResetPasswordRequestRepositoryTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResetPasswordRequest::class); + } + + public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface + { + \assert($user instanceof User); + + return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken); + } +} diff --git a/symfony.lock b/symfony.lock index 9d1f209..99aafa4 100644 --- a/symfony.lock +++ b/symfony.lock @@ -356,6 +356,18 @@ "config/routes/web_profiler.yaml" ] }, + "symfonycasts/reset-password-bundle": { + "version": "1.25", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "97c1627c0384534997ae1047b93be517ca16de43" + }, + "files": [ + "config/packages/reset_password.yaml" + ] + }, "symfonycasts/sass-bundle": { "version": "v0.8.2" }, diff --git a/templates/backoffice/login/login.html.twig b/templates/backoffice/login/login.html.twig index a306a63..28d9f7a 100644 --- a/templates/backoffice/login/login.html.twig +++ b/templates/backoffice/login/login.html.twig @@ -4,7 +4,7 @@ {% block body %}
-

{{ 'Please sign in'|trans }}

+

{{ 'Please sign in'|trans }}

{{ 'Create an account'|trans }} + {{ 'Forgot your password?'|trans }} {% endblock %} diff --git a/templates/backoffice/registration/register.html.twig b/templates/backoffice/registration/register.html.twig index d795784..3331c58 100644 --- a/templates/backoffice/registration/register.html.twig +++ b/templates/backoffice/registration/register.html.twig @@ -2,7 +2,7 @@ {% block title %}{{ 'Register'|trans }}{% endblock %} {% block body %} -

{{ 'Register'|trans }}

+

{{ 'Register'|trans }}

{{ form_errors(registrationForm) }} diff --git a/templates/reset_password/check_email.html.twig b/templates/reset_password/check_email.html.twig new file mode 100644 index 0000000..de0f3ca --- /dev/null +++ b/templates/reset_password/check_email.html.twig @@ -0,0 +1,16 @@ +{% extends 'backoffice/base.html.twig' %} + +{% block title %}E-mail verstuurd{% endblock %} + +{% block body %} +

{{ 'Check your email'|trans }}

+ +

+ {{ '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.'|trans }} + {{ 'This link will expire in %count%.'|trans({'%count%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle')}) }} +

+

+ {{ 'If you don\'t receive an email please check your spam folder or'|trans }} + {{ 'try again'|trans }}. +

+{% endblock %} diff --git a/templates/reset_password/email.html.twig b/templates/reset_password/email.html.twig new file mode 100644 index 0000000..c714d22 --- /dev/null +++ b/templates/reset_password/email.html.twig @@ -0,0 +1,9 @@ +

Hi!

+ +

To reset your password, please visit the following link

+ +{{ url('tvdt_reset_password', {token: resetToken.token}) }} + +

This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.

+ +

Cheers!

diff --git a/templates/reset_password/request.html.twig b/templates/reset_password/request.html.twig new file mode 100644 index 0000000..3b539bc --- /dev/null +++ b/templates/reset_password/request.html.twig @@ -0,0 +1,18 @@ +{% extends 'backoffice/base.html.twig' %} + +{% block title %}Wachtwoord vergeten{% endblock %} + +{% block body %} +

{{ 'Reset your password'|trans }}

+ + {{ form_start(requestForm) }} + {{ form_row(requestForm.email) }} + + + {{ 'Enter your email address, and we will send you a link to reset your password.'|trans }} + + + + {{ 'Back to login'|trans }} + {{ form_end(requestForm) }} +{% endblock %} diff --git a/templates/reset_password/reset.html.twig b/templates/reset_password/reset.html.twig new file mode 100644 index 0000000..cfe4873 --- /dev/null +++ b/templates/reset_password/reset.html.twig @@ -0,0 +1,12 @@ +{% extends 'backoffice/base.html.twig' %} + +{% block title %}Nieuw wachtwoord instellen{% endblock %} + +{% block body %} +

{{ 'Reset your password'|trans }}

+ + {{ form_start(resetForm) }} + {{ form_row(resetForm.plainPassword) }} + + {{ form_end(resetForm) }} +{% endblock %} diff --git a/tests/Controller/ResetPasswordControllerTest.php b/tests/Controller/ResetPasswordControllerTest.php new file mode 100644 index 0000000..dd49031 --- /dev/null +++ b/tests/Controller/ResetPasswordControllerTest.php @@ -0,0 +1,104 @@ +client = self::createClient(); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + public function testRequestPageLoads(): void + { + $this->client->request(Request::METHOD_GET, '/reset-password'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists('form'); + } + + public function testRequestWithUnknownEmailRedirectsToCheckEmail(): void + { + $this->client->request(Request::METHOD_GET, '/reset-password'); + $form = $this->client->getCrawler()->filter('form')->form([ + 'reset_password_request_form[email]' => 'unknown@example.org', + ]); + $this->client->submit($form); + + $this->assertResponseRedirects('/reset-password/check-email'); + } + + public function testRequestWithKnownEmailRedirectsToCheckEmail(): void + { + $this->client->request(Request::METHOD_GET, '/reset-password'); + $form = $this->client->getCrawler()->filter('form')->form([ + 'reset_password_request_form[email]' => 'test@example.org', + ]); + $this->client->submit($form); + + $this->assertResponseRedirects('/reset-password/check-email'); + } + + public function testCheckEmailPageLoads(): void + { + $this->client->request(Request::METHOD_GET, '/reset-password/check-email'); + + $this->assertResponseIsSuccessful(); + } + + public function testResetWithInvalidTokenRedirectsToRequest(): void + { + $this->client->request(Request::METHOD_GET, '/reset-password/reset/invalidtoken'); + $this->client->followRedirect(); + + $this->assertResponseRedirects('/reset-password'); + } + + public function testFullResetFlow(): void + { + $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); + $this->assertInstanceOf(User::class, $user); + + /** @var ResetPasswordHelperInterface $helper */ + $helper = self::getContainer()->get(ResetPasswordHelperInterface::class); + $resetToken = $helper->generateResetToken($user); + + $this->client->request(Request::METHOD_GET, '/reset-password/reset/'.$resetToken->getToken()); + $this->assertResponseRedirects('/reset-password/reset'); + + $this->client->followRedirect(); + $this->assertResponseIsSuccessful(); + + $form = $this->client->getCrawler()->filter('form')->form([ + 'change_password_form[plainPassword][first]' => 'NewPass123!', + 'change_password_form[plainPassword][second]' => 'NewPass123!', + ]); + $this->client->submit($form); + $this->assertResponseRedirects('/backoffice/'); + + $this->entityManager->clear(); + $updatedUser = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); + $this->assertInstanceOf(User::class, $updatedUser); + + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + $this->assertTrue($hasher->isPasswordValid($updatedUser, 'NewPass123!')); + } +} diff --git a/translations/ResetPasswordBundle.nl.xliff b/translations/ResetPasswordBundle.nl.xliff new file mode 100644 index 0000000..ee3c386 --- /dev/null +++ b/translations/ResetPasswordBundle.nl.xliff @@ -0,0 +1,54 @@ + + + +
+ +
+ + + %count% day|%count% days + %count% dag|%count% dagen + + + %count% hour|%count% hours + %count% uur|%count% uren + + + %count% minute|%count% minutes + %count% minuut|%count% minuten + + + %count% month|%count% months + %count% maand|%count% maanden + + + %count% year|%count% years + %count% jaar|%count% jaar + + + Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service. + Update de request_password_repository waarde in config/packages/reset_password.yaml met een verwijzing naar je "request password repository" service. + + + The link in your email is expired. Please try to reset your password again. + De link in je e-mail is verlopen. Probeer opnieuw je wachtwoord te herstellen. + + + The reset password link is invalid. Please try to reset your password again. + De link om je wachtwoord te herstellen is niet geldig. Probeer opnieuw je wachtwoord te herstellen. + + + There was a problem handling your password reset request + Er is een probleem opgetreden bij het afhandelen van het verzoek om je wachtwoord te herstellen + + + There was a problem validating your password reset request + Er is een probleem opgetreden bij het valideren van het verzoek om je wachtwoord te herstellen + + + You have already requested a reset password email. Please check your email or try again soon. + Je hebt al een verzoek ingediend voor een e-mail om je wachtwoord te herstellen. Controleer of je een e-mail hebt ontvangen of probeer het later nog eens. + + +
+
diff --git a/translations/messages+intl-icu.nl.xliff b/translations/messages+intl-icu.nl.xliff index e786c53..068365a 100644 --- a/translations/messages+intl-icu.nl.xliff +++ b/translations/messages+intl-icu.nl.xliff @@ -109,6 +109,10 @@ Back Terug + + Back to login + Terug naar inloggen + Backoffice Backoffice @@ -145,6 +149,10 @@ Candidates Kandidaten + + Check your email + Controleer je e-mail + Clear Quiz... Test leegmaken... @@ -249,6 +257,10 @@ Enter name Voer een naam in + + Enter your email address, and we will send you a link to reset your password. + Voer je e-mailadres in en we sturen je een link om je wachtwoord te herstellen. + Enter your name Voer je naam in @@ -273,6 +285,10 @@ Finalized Afgerond + + Forgot your password? + Wachtwoord vergeten? + Gray Grijs @@ -301,6 +317,14 @@ Home Home + + 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. + Als er een account met dit e-mailadres bestaat, is er zojuist een e-mail verstuurd met een link om je wachtwoord te herstellen. + + + If you don't receive an email please check your spam folder or + Als je geen e-mail ontvangt, controleer dan je spammap of + Import Importeren @@ -373,6 +397,10 @@ New label Nieuw label + + New password + Nieuw wachtwoord + Next Volgende @@ -593,6 +621,14 @@ Repeat Password Herhaal wachtwoord + + Reset password + Wachtwoord herstellen + + + Reset your password + Wachtwoord herstellen + Results & Elimination @@ -629,6 +665,10 @@ Seasons Seizoenen + + Send password reset email + Stuur herstel-e-mail + Settings Instellingen @@ -689,6 +729,10 @@ There is no active quiz Er is geen test actief + + This link will expire in %count%. + Deze link verloopt over %count%. + This question cannot be deleted because it is used in a locked or active quiz Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test @@ -773,6 +817,14 @@ Your email address has been verified. Je e-mailadres is geverifieerd. + + Your password reset request + Verzoek tot wachtwoordherstel + + + try again + probeer opnieuw + diff --git a/translations/validators.nl.xliff b/translations/validators.nl.xliff index 567871e..8087587 100644 --- a/translations/validators.nl.xliff +++ b/translations/validators.nl.xliff @@ -113,6 +113,10 @@ Please enter an integer. Vul een geldig getal in. + + Please enter your email + Voer je e-mailadres in + Please provide a valid phone number. Vul een geldig telefoonnummer in.