mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-08 00:20:15 +02:00
* 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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -171,6 +171,7 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
Generated
+1
@@ -205,6 +205,7 @@
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpInterpreters">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+49
-1
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
symfonycasts_reset_password:
|
||||
request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository
|
||||
Generated
+10
@@ -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,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/** Auto-generated Migration: Please modify to your needs! */
|
||||
final class Version20260707205155 extends AbstractMigration
|
||||
{
|
||||
#[\Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Controller;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordToken;
|
||||
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Form\ChangePasswordFormType;
|
||||
use Tvdt\Form\ResetPasswordRequestFormType;
|
||||
|
||||
final class ResetPasswordController extends AbstractController
|
||||
{
|
||||
use ResetPasswordControllerTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResetPasswordHelperInterface $resetPasswordHelper,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MailerInterface $mailer,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
#[Route('/reset-password', name: 'tvdt_forgot_password_request')]
|
||||
public function request(Request $request): Response
|
||||
{
|
||||
$form = $this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
|
||||
use Tvdt\Repository\ResetPasswordRequestRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
|
||||
class ResetPasswordRequest implements ResetPasswordRequestInterface
|
||||
{
|
||||
use ResetPasswordRequestTrait;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
public function __construct(#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne]
|
||||
private User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
|
||||
{
|
||||
$this->initialize($expiresAt, $selector, $hashedToken);
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
@@ -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')]
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/** @extends AbstractType<array{plainPassword: string}> */
|
||||
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([]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/** @extends AbstractType<array{email: string}> */
|
||||
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([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
|
||||
use Tvdt\Entity\ResetPasswordRequest;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/** @extends ServiceEntityRepository<ResetPasswordRequest> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block body %}
|
||||
<form method="post">
|
||||
<h1 class="py-2 h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1>
|
||||
<h3 class="mb-3">{{ 'Please sign in'|trans }}</h3>
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{ 'Email'|trans }}</label>
|
||||
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control"
|
||||
@@ -31,5 +31,7 @@
|
||||
</button>
|
||||
<a href="{{ path('tvdt_register') }}"
|
||||
class="btn btn-link">{{ 'Create an account'|trans }}</a>
|
||||
<a href="{{ path('tvdt_forgot_password_request') }}"
|
||||
class="btn btn-link">{{ 'Forgot your password?'|trans }}</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}{{ 'Register'|trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h3>{{ 'Register'|trans }}</h3>
|
||||
<h3 class="mb-3">{{ 'Register'|trans }}</h3>
|
||||
|
||||
{{ form_errors(registrationForm) }}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
|
||||
{% block title %}E-mail verstuurd{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h3 class="mb-3">{{ 'Check your email'|trans }}</h3>
|
||||
|
||||
<p>
|
||||
{{ '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')}) }}
|
||||
</p>
|
||||
<p>
|
||||
{{ 'If you don\'t receive an email please check your spam folder or'|trans }}
|
||||
<a href="{{ path('tvdt_forgot_password_request') }}">{{ 'try again'|trans }}</a>.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
<h1>Hi!</h1>
|
||||
|
||||
<p>To reset your password, please visit the following link</p>
|
||||
|
||||
<a href="{{ url('tvdt_reset_password', {token: resetToken.token}) }}">{{ url('tvdt_reset_password', {token: resetToken.token}) }}</a>
|
||||
|
||||
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p>
|
||||
|
||||
<p>Cheers!</p>
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
|
||||
{% block title %}Wachtwoord vergeten{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
|
||||
|
||||
{{ form_start(requestForm) }}
|
||||
{{ form_row(requestForm.email) }}
|
||||
|
||||
<small class="d-block mb-3">
|
||||
{{ 'Enter your email address, and we will send you a link to reset your password.'|trans }}
|
||||
</small>
|
||||
|
||||
<button class="btn btn-primary" type="submit">{{ 'Send password reset email'|trans }}</button>
|
||||
<a href="{{ path('tvdt_login_login') }}" class="btn btn-link">{{ 'Back to login'|trans }}</a>
|
||||
{{ form_end(requestForm) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'backoffice/base.html.twig' %}
|
||||
|
||||
{% block title %}Nieuw wachtwoord instellen{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
|
||||
|
||||
{{ form_start(resetForm) }}
|
||||
{{ form_row(resetForm.plainPassword) }}
|
||||
<button class="btn btn-primary" type="submit">{{ 'Reset password'|trans }}</button>
|
||||
{{ form_end(resetForm) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||
use Tvdt\Controller\ResetPasswordController;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(ResetPasswordController::class)]
|
||||
final class ResetPasswordControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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!'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="nl" target-language="nl" datatype="plaintext" original="file.ext">
|
||||
<header>
|
||||
<tool tool-id="symfony" tool-name="Symfony"/>
|
||||
</header>
|
||||
<body>
|
||||
<trans-unit id="E69bPZ6" resname="%count% day|%count% days">
|
||||
<source>%count% day|%count% days</source>
|
||||
<target>%count% dag|%count% dagen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="H3jnVKg" resname="%count% hour|%count% hours">
|
||||
<source>%count% hour|%count% hours</source>
|
||||
<target>%count% uur|%count% uren</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="30tji6_" resname="%count% minute|%count% minutes">
|
||||
<source>%count% minute|%count% minutes</source>
|
||||
<target>%count% minuut|%count% minuten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="NCe6niW" resname="%count% month|%count% months">
|
||||
<source>%count% month|%count% months</source>
|
||||
<target>%count% maand|%count% maanden</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="U_NoHhq" resname="%count% year|%count% years">
|
||||
<source>%count% year|%count% years</source>
|
||||
<target>%count% jaar|%count% jaar</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="NUQGnNx" resname="Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service.">
|
||||
<source>Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service.</source>
|
||||
<target>Update de request_password_repository waarde in config/packages/reset_password.yaml met een verwijzing naar je "request password repository" service.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="XyawDv9" resname="The link in your email is expired. Please try to reset your password again.">
|
||||
<source>The link in your email is expired. Please try to reset your password again.</source>
|
||||
<target>De link in je e-mail is verlopen. Probeer opnieuw je wachtwoord te herstellen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="RPp.BKQ" resname="The reset password link is invalid. Please try to reset your password again.">
|
||||
<source>The reset password link is invalid. Please try to reset your password again.</source>
|
||||
<target>De link om je wachtwoord te herstellen is niet geldig. Probeer opnieuw je wachtwoord te herstellen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id=".BeFwl." resname="There was a problem handling your password reset request">
|
||||
<source>There was a problem handling your password reset request</source>
|
||||
<target>Er is een probleem opgetreden bij het afhandelen van het verzoek om je wachtwoord te herstellen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="ZeI3pSX" resname="There was a problem validating your password reset request">
|
||||
<source>There was a problem validating your password reset request</source>
|
||||
<target>Er is een probleem opgetreden bij het valideren van het verzoek om je wachtwoord te herstellen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="IZsa0iw" resname="You have already requested a reset password email. Please check your email or try again soon.">
|
||||
<source>You have already requested a reset password email. Please check your email or try again soon.</source>
|
||||
<target>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.</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
@@ -109,6 +109,10 @@
|
||||
<source>Back</source>
|
||||
<target>Terug</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="mR7n673" resname="Back to login">
|
||||
<source>Back to login</source>
|
||||
<target>Terug naar inloggen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="JUdglpF" resname="Backoffice">
|
||||
<source>Backoffice</source>
|
||||
<target>Backoffice</target>
|
||||
@@ -145,6 +149,10 @@
|
||||
<source>Candidates</source>
|
||||
<target>Kandidaten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="o6WwCao" resname="Check your email">
|
||||
<source>Check your email</source>
|
||||
<target>Controleer je e-mail</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="J1c2y63" resname="Clear Quiz...">
|
||||
<source>Clear Quiz...</source>
|
||||
<target>Test leegmaken...</target>
|
||||
@@ -249,6 +257,10 @@
|
||||
<source>Enter name</source>
|
||||
<target>Voer een naam in</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="ifotYH6" resname="Enter your email address, and we will send you a link to reset your password.">
|
||||
<source>Enter your email address, and we will send you a link to reset your password.</source>
|
||||
<target>Voer je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="RnI7jJT" resname="Enter your name">
|
||||
<source>Enter your name</source>
|
||||
<target>Voer je naam in</target>
|
||||
@@ -273,6 +285,10 @@
|
||||
<source>Finalized</source>
|
||||
<target>Afgerond</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Fztxgjr" resname="Forgot your password?">
|
||||
<source>Forgot your password?</source>
|
||||
<target>Wachtwoord vergeten?</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="MebBrmp" resname="Gray">
|
||||
<source>Gray</source>
|
||||
<target>Grijs</target>
|
||||
@@ -301,6 +317,14 @@
|
||||
<source>Home</source>
|
||||
<target>Home</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="NJn0f4_" resname="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.">
|
||||
<source>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.</source>
|
||||
<target>Als er een account met dit e-mailadres bestaat, is er zojuist een e-mail verstuurd met een link om je wachtwoord te herstellen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="bDPZm0a" resname="If you don't receive an email please check your spam folder or">
|
||||
<source>If you don't receive an email please check your spam folder or</source>
|
||||
<target>Als je geen e-mail ontvangt, controleer dan je spammap of</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="oPzjCWg" resname="Import">
|
||||
<source>Import</source>
|
||||
<target>Importeren</target>
|
||||
@@ -373,6 +397,10 @@
|
||||
<source>New label</source>
|
||||
<target>Nieuw label</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Zm.jZiK" resname="New password">
|
||||
<source>New password</source>
|
||||
<target>Nieuw wachtwoord</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="gefhnBC" resname="Next">
|
||||
<source>Next</source>
|
||||
<target>Volgende</target>
|
||||
@@ -593,6 +621,14 @@
|
||||
<source>Repeat Password</source>
|
||||
<target>Herhaal wachtwoord</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="9JCvQkt" resname="Reset password">
|
||||
<source>Reset password</source>
|
||||
<target>Wachtwoord herstellen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="eyayNGN" resname="Reset your password">
|
||||
<source>Reset your password</source>
|
||||
<target>Wachtwoord herstellen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="7UvBPrb" resname="Results & Elimination">
|
||||
<source>Results & Elimination</source>
|
||||
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
||||
@@ -629,6 +665,10 @@
|
||||
<source>Seasons</source>
|
||||
<target>Seizoenen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="5XiCCZa" resname="Send password reset email">
|
||||
<source>Send password reset email</source>
|
||||
<target>Stuur herstel-e-mail</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="VXFwlwn" resname="Settings">
|
||||
<source>Settings</source>
|
||||
<target>Instellingen</target>
|
||||
@@ -689,6 +729,10 @@
|
||||
<source>There is no active quiz</source>
|
||||
<target>Er is geen test actief</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="YueaA2f" resname="This link will expire in %count%.">
|
||||
<source>This link will expire in %count%.</source>
|
||||
<target>Deze link verloopt over %count%.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="o.FTilS" resname="This question cannot be deleted because it is used in a locked or active quiz">
|
||||
<source>This question cannot be deleted because it is used in a locked or active quiz</source>
|
||||
<target>Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test</target>
|
||||
@@ -773,6 +817,14 @@
|
||||
<source>Your email address has been verified.</source>
|
||||
<target>Je e-mailadres is geverifieerd.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="YDIkAA1" resname="Your password reset request">
|
||||
<source>Your password reset request</source>
|
||||
<target>Verzoek tot wachtwoordherstel</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zqHSO6v" resname="try again">
|
||||
<source>try again</source>
|
||||
<target>probeer opnieuw</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
<source>Please enter an integer.</source>
|
||||
<target>Vul een geldig getal in.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="7VWHcE0" resname="Please enter your email">
|
||||
<source>Please enter your email</source>
|
||||
<target>Voer je e-mailadres in</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="9RtYCIm" resname="Please provide a valid phone number.">
|
||||
<source>Please provide a valid phone number.</source>
|
||||
<target>Vul een geldig telefoonnummer in.</target>
|
||||
|
||||
Reference in New Issue
Block a user