From 3093a44cc7ef6931804d49897ee977d1d7e075a6 Mon Sep 17 00:00:00 2001
From: Kevin Bond
Date: Mon, 28 Mar 2022 15:11:14 -0400
Subject: [PATCH 01/21] wip add scaffolding system
---
src/Maker/MakeScaffold.php | 183 +++++++++
src/Resources/config/makers.xml | 5 +
src/Resources/scaffolds/6.0/auth.json | 10 +
.../6.0/auth/config/packages/security.yaml | 58 +++
.../scaffolds/6.0/auth/config/services.yaml | 28 ++
.../src/Controller/SecurityController.php | 35 ++
.../6.0/auth/src/Security/LoginUser.php | 22 +
.../6.0/auth/templates/login.html.twig | 31 ++
.../6.0/auth/tests/Browser/Authentication.php | 61 +++
.../tests/Functional/AuthenticationTest.php | 260 ++++++++++++
.../scaffolds/6.0/change-password.json | 10 +
.../User/ChangePasswordController.php | 55 +++
.../src/Form/User/ChangePasswordFormType.php | 57 +++
.../templates/user/change_password.html.twig | 15 +
.../Functional/User/ChangePasswordTest.php | 178 ++++++++
src/Resources/scaffolds/6.0/homepage.json | 8 +
.../src/Controller/HomepageController.php | 16 +
.../6.0/homepage/templates/index.html.twig | 15 +
.../tests/Functional/HomepageTest.php | 22 +
src/Resources/scaffolds/6.0/profile.json | 10 +
.../src/Controller/User/ProfileController.php | 41 ++
.../profile/src/Form/User/ProfileFormType.php | 30 ++
.../profile/templates/user/profile.html.twig | 13 +
.../tests/Functional/User/ProfileTest.php | 73 ++++
src/Resources/scaffolds/6.0/register.json | 10 +
.../Controller/User/RegisterController.php | 52 +++
.../6.0/register/src/Entity/User.php | 113 +++++
.../src/Form/User/RegistrationFormType.php | 56 +++
.../templates/user/register.html.twig | 17 +
.../tests/Functional/User/RegisterTest.php | 159 +++++++
.../scaffolds/6.0/reset-password.json | 13 +
.../config/packages/reset_password.yaml | 4 +
.../Controller/ResetPasswordController.php | 188 +++++++++
.../src/Entity/ResetPasswordRequest.php | 39 ++
.../Factory/ResetPasswordRequestFactory.php | 40 ++
.../ResetPassword/RequestResetFormType.php | 31 ++
.../ResetPassword/ResetPasswordFormType.php | 51 +++
.../ResetPasswordRequestRepository.php | 31 ++
.../reset_password/check_email.html.twig | 11 +
.../templates/reset_password/email.html.twig | 9 +
.../reset_password/request.html.twig | 22 +
.../templates/reset_password/reset.html.twig | 12 +
.../tests/Functional/ResetPasswordTest.php | 387 ++++++++++++++++++
src/Resources/scaffolds/6.0/user.json | 9 +
.../scaffolds/6.0/user/src/Entity/User.php | 111 +++++
.../6.0/user/src/Factory/UserFactory.php | 61 +++
.../user/src/Repository/UserRepository.php | 43 ++
.../6.0/user/tests/Unit/Entity/UserTest.php | 22 +
src/Test/MakerTestEnvironment.php | 2 +-
tests/Maker/MakeScaffoldTest.php | 65 +++
50 files changed, 2793 insertions(+), 1 deletion(-)
create mode 100644 src/Maker/MakeScaffold.php
create mode 100644 src/Resources/scaffolds/6.0/auth.json
create mode 100644 src/Resources/scaffolds/6.0/auth/config/packages/security.yaml
create mode 100644 src/Resources/scaffolds/6.0/auth/config/services.yaml
create mode 100644 src/Resources/scaffolds/6.0/auth/src/Controller/SecurityController.php
create mode 100644 src/Resources/scaffolds/6.0/auth/src/Security/LoginUser.php
create mode 100644 src/Resources/scaffolds/6.0/auth/templates/login.html.twig
create mode 100644 src/Resources/scaffolds/6.0/auth/tests/Browser/Authentication.php
create mode 100644 src/Resources/scaffolds/6.0/auth/tests/Functional/AuthenticationTest.php
create mode 100644 src/Resources/scaffolds/6.0/change-password.json
create mode 100644 src/Resources/scaffolds/6.0/change-password/src/Controller/User/ChangePasswordController.php
create mode 100644 src/Resources/scaffolds/6.0/change-password/src/Form/User/ChangePasswordFormType.php
create mode 100644 src/Resources/scaffolds/6.0/change-password/templates/user/change_password.html.twig
create mode 100644 src/Resources/scaffolds/6.0/change-password/tests/Functional/User/ChangePasswordTest.php
create mode 100644 src/Resources/scaffolds/6.0/homepage.json
create mode 100644 src/Resources/scaffolds/6.0/homepage/src/Controller/HomepageController.php
create mode 100644 src/Resources/scaffolds/6.0/homepage/templates/index.html.twig
create mode 100644 src/Resources/scaffolds/6.0/homepage/tests/Functional/HomepageTest.php
create mode 100644 src/Resources/scaffolds/6.0/profile.json
create mode 100644 src/Resources/scaffolds/6.0/profile/src/Controller/User/ProfileController.php
create mode 100644 src/Resources/scaffolds/6.0/profile/src/Form/User/ProfileFormType.php
create mode 100644 src/Resources/scaffolds/6.0/profile/templates/user/profile.html.twig
create mode 100644 src/Resources/scaffolds/6.0/profile/tests/Functional/User/ProfileTest.php
create mode 100644 src/Resources/scaffolds/6.0/register.json
create mode 100644 src/Resources/scaffolds/6.0/register/src/Controller/User/RegisterController.php
create mode 100644 src/Resources/scaffolds/6.0/register/src/Entity/User.php
create mode 100644 src/Resources/scaffolds/6.0/register/src/Form/User/RegistrationFormType.php
create mode 100644 src/Resources/scaffolds/6.0/register/templates/user/register.html.twig
create mode 100644 src/Resources/scaffolds/6.0/register/tests/Functional/User/RegisterTest.php
create mode 100644 src/Resources/scaffolds/6.0/reset-password.json
create mode 100644 src/Resources/scaffolds/6.0/reset-password/config/packages/reset_password.yaml
create mode 100644 src/Resources/scaffolds/6.0/reset-password/src/Controller/ResetPasswordController.php
create mode 100644 src/Resources/scaffolds/6.0/reset-password/src/Entity/ResetPasswordRequest.php
create mode 100644 src/Resources/scaffolds/6.0/reset-password/src/Factory/ResetPasswordRequestFactory.php
create mode 100644 src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPassword/RequestResetFormType.php
create mode 100644 src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPassword/ResetPasswordFormType.php
create mode 100644 src/Resources/scaffolds/6.0/reset-password/src/Repository/ResetPasswordRequestRepository.php
create mode 100644 src/Resources/scaffolds/6.0/reset-password/templates/reset_password/check_email.html.twig
create mode 100644 src/Resources/scaffolds/6.0/reset-password/templates/reset_password/email.html.twig
create mode 100644 src/Resources/scaffolds/6.0/reset-password/templates/reset_password/request.html.twig
create mode 100644 src/Resources/scaffolds/6.0/reset-password/templates/reset_password/reset.html.twig
create mode 100644 src/Resources/scaffolds/6.0/reset-password/tests/Functional/ResetPasswordTest.php
create mode 100644 src/Resources/scaffolds/6.0/user.json
create mode 100644 src/Resources/scaffolds/6.0/user/src/Entity/User.php
create mode 100644 src/Resources/scaffolds/6.0/user/src/Factory/UserFactory.php
create mode 100644 src/Resources/scaffolds/6.0/user/src/Repository/UserRepository.php
create mode 100644 src/Resources/scaffolds/6.0/user/tests/Unit/Entity/UserTest.php
create mode 100644 tests/Maker/MakeScaffoldTest.php
diff --git a/src/Maker/MakeScaffold.php b/src/Maker/MakeScaffold.php
new file mode 100644
index 000000000..6c9c31dfe
--- /dev/null
+++ b/src/Maker/MakeScaffold.php
@@ -0,0 +1,183 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\MakerBundle\Maker;
+
+use Composer\InstalledVersions;
+use Symfony\Bundle\MakerBundle\ConsoleStyle;
+use Symfony\Bundle\MakerBundle\DependencyBuilder;
+use Symfony\Bundle\MakerBundle\Generator;
+use Symfony\Bundle\MakerBundle\InputConfiguration;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\HttpKernel\Kernel;
+use Symfony\Component\Process\Process;
+
+/**
+ * @author Kevin Bond
+ */
+final class MakeScaffold extends AbstractMaker
+{
+ private $projectDir;
+ private $availableScaffolds;
+ private $installedScaffolds = [];
+ private $installedPackages = [];
+
+ public function __construct($projectDir)
+ {
+ $this->projectDir = $projectDir;
+ }
+
+ public static function getCommandName(): string
+ {
+ return 'make:scaffold';
+ }
+
+ public static function getCommandDescription(): string
+ {
+ return 'Create scaffold'; // todo
+ }
+
+ public function configureCommand(Command $command, InputConfiguration $inputConfig): void
+ {
+ $command
+ ->addArgument('name', InputArgument::OPTIONAL|InputArgument::IS_ARRAY, 'Scaffold name(s) to create')
+ ;
+
+ $inputConfig->setArgumentAsNonInteractive('name');
+ }
+
+ public function configureDependencies(DependencyBuilder $dependencies): void
+ {
+ $dependencies->addClassDependency(Process::class, 'process');
+ }
+
+ public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
+ {
+ $names = $input->getArgument('name');
+
+ if (!$names) {
+ throw new \InvalidArgumentException('You must select at least one scaffold.');
+ }
+
+ foreach ($names as $name) {
+ $this->generateScaffold($name, $io);
+ }
+ }
+
+ public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
+ {
+ if ($input->getArgument('name')) {
+ return;
+ }
+
+ $availableScaffolds = array_combine(
+ array_keys($this->availableScaffolds()),
+ array_map(fn(array $scaffold) => $scaffold['description'], $this->availableScaffolds())
+ );
+
+ $input->setArgument('name', [$io->choice('Available scaffolds', $availableScaffolds)]);
+ }
+
+ private function generateScaffold(string $name, ConsoleStyle $io): void
+ {
+ if ($this->isScaffoldInstalled($name)) {
+ return;
+ }
+
+ if (!isset($this->availableScaffolds()[$name])) {
+ throw new \InvalidArgumentException("Scaffold \"{$name}\" does not exist for your version of Symfony.");
+ }
+
+ $scaffold = $this->availableScaffolds()[$name];
+
+ // install dependent scaffolds
+ foreach ($scaffold['dependents'] ?? [] as $dependent) {
+ $this->generateScaffold($dependent, $io);
+ }
+
+ $io->text("Generating {$name} Scaffold...");
+
+ // install required packages
+ foreach ($scaffold['packages'] ?? [] as $package => $env) {
+ if (!$this->isPackageInstalled($package)) {
+ $io->text("Installing {$package}...");
+
+ // todo composer bin detection
+ $command = ['composer', 'require', '--no-scripts', 'dev' === $env ? '--dev' : null, $package];
+ $process = new Process(array_filter($command), $this->projectDir);
+
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw new \RuntimeException("Error installing \"{$package}\".");
+ }
+
+ $this->installedPackages[] = $package;
+ }
+ }
+
+ $io->text('Copying scaffold files...');
+
+ (new Filesystem())->mirror($scaffold['dir'], $this->projectDir, null, ['override' => true]);
+
+ $io->text("Successfully installed scaffold {$name}.");
+ $io->newLine();
+
+ $this->installedScaffolds[] = $name;
+ }
+
+ private function availableScaffolds(): array
+ {
+ if (is_array($this->availableScaffolds)) {
+ return $this->availableScaffolds;
+ }
+
+ $this->availableScaffolds = [];
+ $finder = Finder::create()
+ // todo, improve versioning system
+ ->in(\sprintf('%s/../Resources/scaffolds/%s.0', __DIR__, Kernel::MAJOR_VERSION))
+ ->name('*.json')
+ ;
+
+ foreach ($finder as $file) {
+ $name = $file->getFilenameWithoutExtension();
+
+ $this->availableScaffolds[$name] = array_merge(
+ json_decode(file_get_contents($file), true),
+ ['dir' => dirname($file->getRealPath()).'/'.$name]
+ );
+ }
+
+ return $this->availableScaffolds;
+ }
+
+ /**
+ * Detect if package already installed or installed in this process
+ * (when installing multiple scaffolds at once).
+ */
+ private function isPackageInstalled(string $package): bool
+ {
+ return InstalledVersions::isInstalled($package) || \in_array($package, $this->installedPackages, true);
+ }
+
+ /**
+ * Detect if package is installed in the same process (when installing
+ * multiple scaffolds at once).
+ */
+ private function isScaffoldInstalled(string $name): bool
+ {
+ return \in_array($name, $this->installedScaffolds, true);
+ }
+}
diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml
index 624a230e3..80b9f092a 100644
--- a/src/Resources/config/makers.xml
+++ b/src/Resources/config/makers.xml
@@ -25,6 +25,11 @@
+
+ %kernel.project_dir%
+
+
+
diff --git a/src/Resources/scaffolds/6.0/auth.json b/src/Resources/scaffolds/6.0/auth.json
new file mode 100644
index 000000000..810935c8c
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth.json
@@ -0,0 +1,10 @@
+{
+ "description": "Create login form and tests.",
+ "dependents": [
+ "homepage",
+ "user"
+ ],
+ "packages": {
+ "profiler": "dev"
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/auth/config/packages/security.yaml b/src/Resources/scaffolds/6.0/auth/config/packages/security.yaml
new file mode 100644
index 000000000..f2a480054
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth/config/packages/security.yaml
@@ -0,0 +1,58 @@
+security:
+ enable_authenticator_manager: true
+ # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
+ password_hashers:
+ Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
+ # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
+ providers:
+ # used to reload user from session & other features (e.g. switch_user)
+ app_user_provider:
+ entity:
+ class: App\Entity\User
+ property: email
+ firewalls:
+ dev:
+ pattern: ^/(_(profiler|wdt)|css|images|js)/
+ security: false
+ main:
+ lazy: true
+ provider: app_user_provider
+ form_login:
+ login_path: login
+ check_path: login
+ username_parameter: email
+ password_parameter: password
+ enable_csrf: true
+ logout:
+ path: logout
+ # where to redirect after logout
+ target: homepage
+ remember_me:
+ secret: '%kernel.secret%'
+ secure: auto
+ samesite: lax
+
+ # activate different ways to authenticate
+ # https://symfony.com/doc/current/security.html#the-firewall
+
+ # https://symfony.com/doc/current/security/impersonating_user.html
+ # switch_user: true
+
+ # Easy way to control access for large sections of your site
+ # Note: Only the *first* access control that matches will be used
+ access_control:
+ # - { path: ^/admin, roles: ROLE_ADMIN }
+ # - { path: ^/profile, roles: ROLE_USER }
+
+when@test:
+ security:
+ password_hashers:
+ # By default, password hashers are resource intensive and take time. This is
+ # important to generate secure password hashes. In tests however, secure hashes
+ # are not important, waste resources and increase test times. The following
+ # reduces the work factor to the lowest possible values.
+ Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
+ algorithm: auto
+ cost: 4 # Lowest possible value for bcrypt
+ time_cost: 3 # Lowest possible value for argon
+ memory_cost: 10 # Lowest possible value for argon
diff --git a/src/Resources/scaffolds/6.0/auth/config/services.yaml b/src/Resources/scaffolds/6.0/auth/config/services.yaml
new file mode 100644
index 000000000..198b98572
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth/config/services.yaml
@@ -0,0 +1,28 @@
+# This file is the entry point to configure your own services.
+# Files in the packages/ subdirectory configure your dependencies.
+
+# Put parameters here that don't need to change on each machine where the app is deployed
+# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
+parameters:
+
+services:
+ # default configuration for services in *this* file
+ _defaults:
+ autowire: true # Automatically injects dependencies in your services.
+ autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
+
+ # makes classes in src/ available to be used as services
+ # this creates a service per class whose id is the fully-qualified class name
+ App\:
+ resource: '../src/'
+ exclude:
+ - '../src/DependencyInjection/'
+ - '../src/Entity/'
+ - '../src/Kernel.php'
+
+ # add more service definitions when explicit configuration is needed
+ # please note that last definitions always *replace* previous ones
+
+ # programmatic user login service
+ App\Security\LoginUser:
+ $authenticator: '@security.authenticator.form_login.main'
diff --git a/src/Resources/scaffolds/6.0/auth/src/Controller/SecurityController.php b/src/Resources/scaffolds/6.0/auth/src/Controller/SecurityController.php
new file mode 100644
index 000000000..2e0b2e385
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth/src/Controller/SecurityController.php
@@ -0,0 +1,35 @@
+isGranted('IS_AUTHENTICATED_FULLY')) {
+ return new RedirectResponse($request->query->get('target', $this->generateUrl('homepage')));
+ }
+
+ // get the login error if there is one
+ $error = $authenticationUtils->getLastAuthenticationError();
+
+ // current user's username (if already logged in) or last username entered by the user
+ $lastUsername = $this->getUser()?->getUserIdentifier() ?? $authenticationUtils->getLastUsername();
+
+ return $this->render('login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
+ }
+
+ #[Route(path: '/logout', name: 'logout')]
+ public function logout(): void
+ {
+ throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/auth/src/Security/LoginUser.php b/src/Resources/scaffolds/6.0/auth/src/Security/LoginUser.php
new file mode 100644
index 000000000..7eca75d72
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth/src/Security/LoginUser.php
@@ -0,0 +1,22 @@
+userAuthenticator->authenticateUser($user, $this->authenticator, $request);
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/auth/templates/login.html.twig b/src/Resources/scaffolds/6.0/auth/templates/login.html.twig
new file mode 100644
index 000000000..c0cb0c3b6
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth/templates/login.html.twig
@@ -0,0 +1,31 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Log in!{% endblock %}
+
+{% block body %}
+
+{% endblock %}
diff --git a/src/Resources/scaffolds/6.0/auth/tests/Browser/Authentication.php b/src/Resources/scaffolds/6.0/auth/tests/Browser/Authentication.php
new file mode 100644
index 000000000..576c31b81
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth/tests/Browser/Authentication.php
@@ -0,0 +1,61 @@
+collector()->isAuthenticated());
+ };
+ }
+
+ public static function assertAuthenticatedAs(string $email): \Closure
+ {
+ return static function(self $auth) use ($email) {
+ $collector = $auth->collector();
+
+ Assert::assertTrue($collector->isAuthenticated());
+ Assert::assertSame($email, $collector->getUser());
+ };
+ }
+
+ public static function assertNotAuthenticated(): \Closure
+ {
+ return static function(self $auth) {
+ Assert::assertFalse($auth->collector()->isAuthenticated());
+ };
+ }
+
+ public static function expireSession(): \Closure
+ {
+ return static function(CookieJar $cookies) {
+ $cookies->expire('MOCKSESSID');
+ };
+ }
+
+ private function collector(): SecurityDataCollector
+ {
+ $browser = $this->browser();
+
+ assert($browser instanceof KernelBrowser);
+
+ $collector = $browser
+ ->withProfiling()
+ ->visit('/')
+ ->profile()
+ ->getCollector('security')
+ ;
+
+ assert($collector instanceof SecurityDataCollector);
+
+ return $collector;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/auth/tests/Functional/AuthenticationTest.php b/src/Resources/scaffolds/6.0/auth/tests/Functional/AuthenticationTest.php
new file mode 100644
index 000000000..2cd26ad0f
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth/tests/Functional/AuthenticationTest.php
@@ -0,0 +1,260 @@
+ 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->use(Authentication::assertNotAuthenticated())
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->assertSuccessful()
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ->visit('/logout')
+ ->assertOn('/')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function login_with_target(): void
+ {
+ UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->use(Authentication::assertNotAuthenticated())
+ ->visit('/login?target=/some/page')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/some/page')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function login_with_invalid_password(): void
+ {
+ UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', 'invalid')
+ ->click('Sign in')
+ ->assertOn('/login')
+ ->assertSuccessful()
+ ->assertFieldEquals('Email', 'mary@example.com')
+ ->assertSee('Invalid credentials.')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function login_with_invalid_email(): void
+ {
+ $this->browser()
+ ->visit('/login')
+ ->fillField('Email', 'invalid@example.com')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/login')
+ ->assertSuccessful()
+ ->assertFieldEquals('Email', 'invalid@example.com')
+ ->assertSee('Invalid credentials.')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function login_with_invalid_csrf(): void
+ {
+ UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->use(Authentication::assertNotAuthenticated())
+ ->post('/login', ['body' => ['email' => 'mary@example.com', 'password' => '1234']])
+ ->assertOn('/login')
+ ->assertSuccessful()
+ ->assertSee('Invalid CSRF token.')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function remember_me_enabled_by_default(): void
+ {
+ UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->assertSuccessful()
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ->use(Authentication::expireSession())
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function can_disable_remember_me(): void
+ {
+ UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', '1234')
+ ->uncheckField('Remember me')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->assertSuccessful()
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ->use(Authentication::expireSession())
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function fully_authenticated_login_redirect(): void
+ {
+ UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->use(Authentication::assertAuthenticated())
+ ->visit('/login')
+ ->assertOn('/')
+ ->use(Authentication::assertAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function fully_authenticated_login_target(): void
+ {
+ UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->use(Authentication::assertAuthenticated())
+ ->visit('/login?target=/some/page')
+ ->assertOn('/some/page')
+ ->use(Authentication::assertAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function can_fully_authenticate_if_only_remembered(): void
+ {
+ UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ $this->browser()
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ->use(Authentication::expireSession())
+ ->visit('/login')
+ ->assertOn('/login')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function legacy_password_hash_is_automatically_migrated_on_login(): void
+ {
+ $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+
+ // set the password to a legacy hash (argon2id, 1234)
+ $user->setPassword('$argon2id$v=19$m=10,t=3,p=1$K9AFR15goJiUD6AdpK0a6Q$RsP6y+FRnYUBovBmhVZO7wN6Caj2eI8dMTnm3+5aTxk');
+ $user->save();
+
+ $this->assertSame(\PASSWORD_ARGON2ID, \password_get_info($user->getPassword())['algo']);
+
+ $this->browser()
+ ->use(Authentication::assertNotAuthenticated())
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', '1234')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->assertSuccessful()
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+
+ $this->assertSame(\PASSWORD_DEFAULT, \password_get_info($user->getPassword())['algo']);
+ }
+
+ /**
+ * @test
+ */
+ public function auto_redirected_to_authenticated_resource_after_login(): void
+ {
+ // complete this test when you have a page that requires authentication
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * @test
+ */
+ public function auto_redirected_to_fully_authenticated_resource_after_fully_authenticated(): void
+ {
+ // complete this test when/if you have a page that requires the user be "fully authenticated"
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/change-password.json b/src/Resources/scaffolds/6.0/change-password.json
new file mode 100644
index 000000000..89c310704
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/change-password.json
@@ -0,0 +1,10 @@
+{
+ "description": "Create change password form and tests.",
+ "dependents": [
+ "auth"
+ ],
+ "packages": {
+ "form": "all",
+ "validator": "all"
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/change-password/src/Controller/User/ChangePasswordController.php b/src/Resources/scaffolds/6.0/change-password/src/Controller/User/ChangePasswordController.php
new file mode 100644
index 000000000..2d8084427
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/change-password/src/Controller/User/ChangePasswordController.php
@@ -0,0 +1,55 @@
+createAccessDeniedException();
+ }
+
+ if (!$user instanceof User) {
+ throw new \LogicException('Invalid user type.');
+ }
+
+ $form = $this->createForm(ChangePasswordFormType::class, $user);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ // encode the plain password
+ $user->setPassword(
+ $userPasswordHasher->hashPassword(
+ $user,
+ $form->get('plainPassword')->getData()
+ )
+ );
+
+ $userRepository->save($user);
+ $this->addFlash('success', 'You\'ve successfully changed your password.');
+
+ return $this->redirectToRoute('homepage');
+ }
+
+ return $this->render('user/change_password.html.twig', [
+ 'changePasswordForm' => $form->createView()
+ ]);
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/change-password/src/Form/User/ChangePasswordFormType.php b/src/Resources/scaffolds/6.0/change-password/src/Form/User/ChangePasswordFormType.php
new file mode 100644
index 000000000..fa227d2ca
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/change-password/src/Form/User/ChangePasswordFormType.php
@@ -0,0 +1,57 @@
+add('currentPassword', PasswordType::class, [
+ 'constraints' => [
+ new UserPassword(['message' => 'This is not your current password.']),
+ ],
+ 'mapped' => false,
+ ])
+ ->add('plainPassword', RepeatedType::class, [
+ 'type' => PasswordType::class,
+ 'first_options' => [
+ 'attr' => ['autocomplete' => 'new-password'],
+ 'constraints' => [
+ new NotBlank([
+ 'message' => 'Please enter a password.',
+ ]),
+ new Length([
+ 'min' => 6,
+ 'minMessage' => 'Your password should be at least {{ limit }} characters',
+ // max length allowed by Symfony for security reasons
+ 'max' => 4096,
+ ]),
+ ],
+ 'label' => 'New password',
+ ],
+ 'second_options' => [
+ 'attr' => ['autocomplete' => 'new-password'],
+ '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
+ {
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/change-password/templates/user/change_password.html.twig b/src/Resources/scaffolds/6.0/change-password/templates/user/change_password.html.twig
new file mode 100644
index 000000000..99da0c874
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/change-password/templates/user/change_password.html.twig
@@ -0,0 +1,15 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Change Password{% endblock %}
+
+{% block body %}
+ Change Password
+
+ {{ form_start(changePasswordForm) }}
+ {{ form_row(changePasswordForm.currentPassword, { label: 'Current Password' }) }}
+ {{ form_row(changePasswordForm.plainPassword.first, { label: 'New Password' }) }}
+ {{ form_row(changePasswordForm.plainPassword.second, { label: 'Repeat New Password' }) }}
+
+
+ {{ form_end(changePasswordForm) }}
+{% endblock %}
diff --git a/src/Resources/scaffolds/6.0/change-password/tests/Functional/User/ChangePasswordTest.php b/src/Resources/scaffolds/6.0/change-password/tests/Functional/User/ChangePasswordTest.php
new file mode 100644
index 000000000..a96ced7f1
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/change-password/tests/Functional/User/ChangePasswordTest.php
@@ -0,0 +1,178 @@
+
+ */
+final class ChangePasswordTest extends KernelTestCase
+{
+ use HasBrowser, Factories, ResetDatabase;
+
+ /**
+ * @test
+ */
+ public function can_change_password(): void
+ {
+ $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->actingAs($user->object())
+ ->visit('/user/change-password')
+ ->fillField('Current Password', '1234')
+ ->fillField('New Password', 'new-password')
+ ->fillField('Repeat New Password', 'new-password')
+ ->click('Change Password')
+ ->assertSuccessful()
+ ->assertOn('/')
+ ->assertSeeIn('.flash', 'You\'ve successfully changed your password.')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ->visit('/logout')
+ ->visit('/login')
+ ->fillField('Email', 'mary@example.com')
+ ->fillField('Password', 'new-password')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->assertSuccessful()
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+
+ $this->assertNotSame($currentPassword, $user->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function current_password_must_be_correct(): void
+ {
+ $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->actingAs($user->object())
+ ->visit('/user/change-password')
+ ->fillField('Current Password', 'invalid')
+ ->fillField('New Password', 'new-password')
+ ->fillField('Repeat New Password', 'new-password')
+ ->click('Change Password')
+ ->assertSuccessful()
+ ->assertOn('/user/change-password')
+ ->assertSee('This is not your current password.')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+
+ $this->assertSame($currentPassword, $user->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function current_password_is_required(): void
+ {
+ $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->actingAs($user->object())
+ ->visit('/user/change-password')
+ ->fillField('New Password', 'new-password')
+ ->fillField('Repeat New Password', 'new-password')
+ ->click('Change Password')
+ ->assertSuccessful()
+ ->assertOn('/user/change-password')
+ ->assertSee('This is not your current password.')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+
+ $this->assertSame($currentPassword, $user->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function new_password_is_required(): void
+ {
+ $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->actingAs($user->object())
+ ->visit('/user/change-password')
+ ->fillField('Current Password', '1234')
+ ->click('Change Password')
+ ->assertSuccessful()
+ ->assertOn('/user/change-password')
+ ->assertSee('Please enter a password.')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+
+ $this->assertSame($currentPassword, $user->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function new_passwords_must_match(): void
+ {
+ $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->actingAs($user->object())
+ ->visit('/user/change-password')
+ ->fillField('Current Password', '1234')
+ ->fillField('New Password', 'new-password')
+ ->fillField('Repeat New Password', 'different-new-password')
+ ->click('Change Password')
+ ->assertSuccessful()
+ ->assertOn('/user/change-password')
+ ->assertSee('The password fields must match.')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+
+ $this->assertSame($currentPassword, $user->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function new_password_must_be_min_length(): void
+ {
+ $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->actingAs($user->object())
+ ->visit('/user/change-password')
+ ->fillField('Current Password', '1234')
+ ->fillField('New Password', '4321')
+ ->fillField('Repeat New Password', '4321')
+ ->click('Change Password')
+ ->assertSuccessful()
+ ->assertOn('/user/change-password')
+ ->assertSee('Your password should be at least 6 characters')
+ ->use(Authentication::assertAuthenticatedAs('mary@example.com'))
+ ;
+
+ $this->assertSame($currentPassword, $user->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function cannot_access_change_password_page_if_not_logged_in(): void
+ {
+ $this->browser()
+ ->visit('/user/change-password')
+ ->assertOn('/login')
+ ;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/homepage.json b/src/Resources/scaffolds/6.0/homepage.json
new file mode 100644
index 000000000..8eb83473f
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/homepage.json
@@ -0,0 +1,8 @@
+{
+ "description": "Create a basic homepage controller/template/test.",
+ "packages": {
+ "twig": "all",
+ "phpunit": "dev",
+ "zenstruck/browser": "dev"
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/homepage/src/Controller/HomepageController.php b/src/Resources/scaffolds/6.0/homepage/src/Controller/HomepageController.php
new file mode 100644
index 000000000..fd9ace081
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/homepage/src/Controller/HomepageController.php
@@ -0,0 +1,16 @@
+render('index.html.twig');
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/homepage/templates/index.html.twig b/src/Resources/scaffolds/6.0/homepage/templates/index.html.twig
new file mode 100644
index 000000000..c11b35c7b
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/homepage/templates/index.html.twig
@@ -0,0 +1,15 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Home{% endblock %}
+
+{% block body %}
+ Home
+
+ {% for type, messages in app.flashes %}
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+ {% endfor %}
+{% endblock %}
diff --git a/src/Resources/scaffolds/6.0/homepage/tests/Functional/HomepageTest.php b/src/Resources/scaffolds/6.0/homepage/tests/Functional/HomepageTest.php
new file mode 100644
index 000000000..8faac80e3
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/homepage/tests/Functional/HomepageTest.php
@@ -0,0 +1,22 @@
+browser()
+ ->visit('/')
+ ->assertSuccessful()
+ ;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/profile.json b/src/Resources/scaffolds/6.0/profile.json
new file mode 100644
index 000000000..7246dedc7
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/profile.json
@@ -0,0 +1,10 @@
+{
+ "description": "Create user profile form and tests.",
+ "dependents": [
+ "auth"
+ ],
+ "packages": {
+ "form": "all",
+ "validator": "all"
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/profile/src/Controller/User/ProfileController.php b/src/Resources/scaffolds/6.0/profile/src/Controller/User/ProfileController.php
new file mode 100644
index 000000000..1e943bed8
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/profile/src/Controller/User/ProfileController.php
@@ -0,0 +1,41 @@
+createAccessDeniedException();
+ }
+
+ if (!$user instanceof User) {
+ throw new \LogicException('Invalid user type.');
+ }
+
+ $form = $this->createForm(ProfileFormType::class, $user);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $userRepository->save($user);
+ $this->addFlash('success', 'You\'ve successfully updated your profile.');
+
+ return $this->redirectToRoute('homepage');
+ }
+
+ return $this->render('user/profile.html.twig', [
+ 'profileForm' => $form->createView(),
+ ]);
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/profile/src/Form/User/ProfileFormType.php b/src/Resources/scaffolds/6.0/profile/src/Form/User/ProfileFormType.php
new file mode 100644
index 000000000..2a88fe4f6
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/profile/src/Form/User/ProfileFormType.php
@@ -0,0 +1,30 @@
+add('name', null, [
+ 'constraints' => [
+ new NotBlank(['message' => 'Name is required']),
+ ],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => User::class,
+ ]);
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/profile/templates/user/profile.html.twig b/src/Resources/scaffolds/6.0/profile/templates/user/profile.html.twig
new file mode 100644
index 000000000..a2bae421f
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/profile/templates/user/profile.html.twig
@@ -0,0 +1,13 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Manage Profile{% endblock %}
+
+{% block body %}
+ Manage Profile
+
+ {{ form_start(profileForm) }}
+ {{ form_row(profileForm.name) }}
+
+
+ {{ form_end(profileForm) }}
+{% endblock %}
diff --git a/src/Resources/scaffolds/6.0/profile/tests/Functional/User/ProfileTest.php b/src/Resources/scaffolds/6.0/profile/tests/Functional/User/ProfileTest.php
new file mode 100644
index 000000000..26afef8c3
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/profile/tests/Functional/User/ProfileTest.php
@@ -0,0 +1,73 @@
+
+ */
+final class ProfileTest extends KernelTestCase
+{
+ use HasBrowser, Factories, ResetDatabase;
+
+ /**
+ * @test
+ */
+ public function can_update_profile(): void
+ {
+ $user = UserFactory::createOne(['name' => 'Mary Edwards']);
+
+ $this->assertSame('Mary Edwards', $user->getName());
+
+ $this->browser()
+ ->actingAs($user->object())
+ ->visit('/user')
+ ->assertFieldEquals('Name', 'Mary Edwards')
+ ->fillField('Name', 'John Smith')
+ ->click('Save')
+ ->assertOn('/')
+ ->assertSuccessful()
+ ->assertSeeIn('.flash', 'You\'ve successfully updated your profile.')
+ ;
+
+ $this->assertSame('John Smith', $user->getName());
+ }
+
+ /**
+ * @test
+ */
+ public function name_is_required(): void
+ {
+ $user = UserFactory::createOne(['name' => 'Mary Edwards']);
+
+ UserFactory::assert()->exists(['name' => 'Mary Edwards']);
+
+ $this->browser()
+ ->actingAs($user->object())
+ ->visit('/user')
+ ->fillField('Name', '')
+ ->click('Save')
+ ->assertOn('/user')
+ ->assertSuccessful()
+ ->assertSee('Name is required')
+ ;
+
+ UserFactory::assert()->exists(['name' => 'Mary Edwards']);
+ }
+
+ /**
+ * @test
+ */
+ public function cannot_access_profile_page_if_not_logged_in(): void
+ {
+ $this->browser()
+ ->visit('/user')
+ ->assertOn('/login')
+ ;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/register.json b/src/Resources/scaffolds/6.0/register.json
new file mode 100644
index 000000000..015a2f497
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/register.json
@@ -0,0 +1,10 @@
+{
+ "description": "Create registration form and tests.",
+ "dependents": [
+ "auth"
+ ],
+ "packages": {
+ "form": "all",
+ "validator": "all"
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/register/src/Controller/User/RegisterController.php b/src/Resources/scaffolds/6.0/register/src/Controller/User/RegisterController.php
new file mode 100644
index 000000000..b1b317011
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/register/src/Controller/User/RegisterController.php
@@ -0,0 +1,52 @@
+createForm(RegistrationFormType::class, $user);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ // encode the plain password
+ $user->setPassword(
+ $userPasswordHasher->hashPassword(
+ $user,
+ $form->get('plainPassword')->getData()
+ )
+ );
+
+ $userRepository->save($user);
+ // do anything else you need here, like send an email
+
+ // authenticate the user
+ $login($user, $request);
+ $this->addFlash('success', 'You\'ve successfully registered and are now logged in.');
+
+ return $this->redirectToRoute('homepage');
+ }
+
+ return $this->render('user/register.html.twig', [
+ 'registrationForm' => $form->createView(),
+ ]);
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/register/src/Entity/User.php b/src/Resources/scaffolds/6.0/register/src/Entity/User.php
new file mode 100644
index 000000000..5579a6115
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/register/src/Entity/User.php
@@ -0,0 +1,113 @@
+id;
+ }
+
+ public function getEmail(): ?string
+ {
+ return $this->email;
+ }
+
+ public function setEmail(string $email): self
+ {
+ $this->email = $email;
+
+ return $this;
+ }
+
+ /**
+ * A visual identifier that represents this user.
+ *
+ * @see UserInterface
+ */
+ public function getUserIdentifier(): string
+ {
+ return (string) $this->email;
+ }
+
+ /**
+ * @see UserInterface
+ */
+ public function getRoles(): array
+ {
+ $roles = $this->roles;
+ // guarantee every user at least has ROLE_USER
+ $roles[] = 'ROLE_USER';
+
+ return array_unique($roles);
+ }
+
+ public function setRoles(array $roles): self
+ {
+ $this->roles = $roles;
+
+ return $this;
+ }
+
+ /**
+ * @see PasswordAuthenticatedUserInterface
+ */
+ public function getPassword(): string
+ {
+ return $this->password;
+ }
+
+ public function setPassword(string $password): self
+ {
+ $this->password = $password;
+
+ return $this;
+ }
+
+ /**
+ * @see UserInterface
+ */
+ public function eraseCredentials()
+ {
+ // If you store any temporary, sensitive data on the user, clear it here
+ // $this->plainPassword = null;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/register/src/Form/User/RegistrationFormType.php b/src/Resources/scaffolds/6.0/register/src/Form/User/RegistrationFormType.php
new file mode 100644
index 000000000..a3a6f7e14
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/register/src/Form/User/RegistrationFormType.php
@@ -0,0 +1,56 @@
+add('name', null, [
+ 'constraints' => [
+ new NotBlank(['message' => 'Name is required']),
+ ],
+ ])
+ ->add('email', null, [
+ 'constraints' => [
+ new NotBlank(['message' => 'Email is required']),
+ new Email(['message' => 'This is not a valid email address']),
+ ],
+ ])
+ ->add('plainPassword', PasswordType::class, [
+ // instead of being set onto the object directly,
+ // this is read and encoded in the controller
+ 'mapped' => false,
+ 'attr' => ['autocomplete' => 'new-password'],
+ 'constraints' => [
+ new NotBlank([
+ 'message' => 'Please enter a password',
+ ]),
+ new Length([
+ 'min' => 6,
+ 'minMessage' => 'Your password should be at least {{ limit }} characters',
+ // max length allowed by Symfony for security reasons
+ 'max' => 4096,
+ ]),
+ ],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => User::class,
+ ]);
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/register/templates/user/register.html.twig b/src/Resources/scaffolds/6.0/register/templates/user/register.html.twig
new file mode 100644
index 000000000..35b032945
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/register/templates/user/register.html.twig
@@ -0,0 +1,17 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Register{% endblock %}
+
+{% block body %}
+ Register
+
+ {{ form_start(registrationForm) }}
+ {{ form_row(registrationForm.name) }}
+ {{ form_row(registrationForm.email) }}
+ {{ form_row(registrationForm.plainPassword, {
+ label: 'Password'
+ }) }}
+
+
+ {{ form_end(registrationForm) }}
+{% endblock %}
diff --git a/src/Resources/scaffolds/6.0/register/tests/Functional/User/RegisterTest.php b/src/Resources/scaffolds/6.0/register/tests/Functional/User/RegisterTest.php
new file mode 100644
index 000000000..c213f1fa0
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/register/tests/Functional/User/RegisterTest.php
@@ -0,0 +1,159 @@
+empty();
+
+ $this->browser()
+ ->visit('/register')
+ ->assertSuccessful()
+ ->fillField('Name', 'Madison')
+ ->fillField('Email', 'madison@example.com')
+ ->fillField('Password', 'password')
+ ->click('Register')
+ ->assertOn('/')
+ ->assertSeeIn('.flash', 'You\'ve successfully registered and are now logged in.')
+ ->use(Authentication::assertAuthenticatedAs('madison@example.com'))
+ ->visit('/logout')
+ ->use(Authentication::assertNotAuthenticated())
+ ->visit('/login')
+ ->fillField('Email', 'madison@example.com')
+ ->fillField('Password', 'password')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->use(Authentication::assertAuthenticatedAs('madison@example.com'))
+ ;
+
+ UserFactory::assert()->count(1);
+ UserFactory::assert()->exists(['name' => 'Madison', 'email' => 'madison@example.com']);
+ }
+
+ /**
+ * @test
+ */
+ public function name_is_required(): void
+ {
+ $this->browser()
+ ->throwExceptions()
+ ->visit('/register')
+ ->assertSuccessful()
+ ->fillField('Email', 'madison@example.com')
+ ->fillField('Password', 'password')
+ ->click('Register')
+ ->assertOn('/register')
+ ->assertSee('Name is required')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function email_is_required(): void
+ {
+ $this->browser()
+ ->throwExceptions()
+ ->visit('/register')
+ ->assertSuccessful()
+ ->fillField('Name', 'Madison')
+ ->fillField('Password', 'password')
+ ->click('Register')
+ ->assertOn('/register')
+ ->assertSee('Email is required')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function email_must_be_email_address(): void
+ {
+ $this->browser()
+ ->throwExceptions()
+ ->visit('/register')
+ ->assertSuccessful()
+ ->fillField('Name', 'Madison')
+ ->fillField('Email', 'invalid-email')
+ ->fillField('Password', 'password')
+ ->click('Register')
+ ->assertOn('/register')
+ ->assertSee('This is not a valid email address')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function email_must_be_unique(): void
+ {
+ UserFactory::createOne(['email' => 'madison@example.com']);
+
+ $this->browser()
+ ->throwExceptions()
+ ->visit('/register')
+ ->assertSuccessful()
+ ->fillField('Name', 'Madison')
+ ->fillField('Email', 'madison@example.com')
+ ->fillField('Password', 'password')
+ ->click('Register')
+ ->assertOn('/register')
+ ->assertSee('There is already an account with this email')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function password_is_required(): void
+ {
+ $this->browser()
+ ->throwExceptions()
+ ->visit('/register')
+ ->assertSuccessful()
+ ->fillField('Name', 'Madison')
+ ->fillField('Email', 'madison@example.com')
+ ->click('Register')
+ ->assertOn('/register')
+ ->assertSee('Please enter a password')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function password_must_be_min_length(): void
+ {
+ $this->browser()
+ ->throwExceptions()
+ ->visit('/register')
+ ->assertSuccessful()
+ ->fillField('Name', 'Madison')
+ ->fillField('Email', 'madison@example.com')
+ ->fillField('Password', '1234')
+ ->click('Register')
+ ->assertOn('/register')
+ ->assertSee('Your password should be at least 6 characters')
+ ->use(Authentication::assertNotAuthenticated())
+ ;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/reset-password.json b/src/Resources/scaffolds/6.0/reset-password.json
new file mode 100644
index 000000000..0626bd699
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password.json
@@ -0,0 +1,13 @@
+{
+ "description": "Reset password system using symfonycasts/reset-password-bundle with tests.",
+ "dependents": [
+ "auth"
+ ],
+ "packages": {
+ "form": "all",
+ "validator": "all",
+ "mailer": "all",
+ "symfonycasts/reset-password-bundle": "all",
+ "zenstruck/mailer-test": "dev"
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/reset-password/config/packages/reset_password.yaml b/src/Resources/scaffolds/6.0/reset-password/config/packages/reset_password.yaml
new file mode 100644
index 000000000..fdc6a4bc4
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/config/packages/reset_password.yaml
@@ -0,0 +1,4 @@
+symfonycasts_reset_password:
+ request_password_repository: App\Repository\ResetPasswordRequestRepository
+ throttle_limit: 900 # 15 minutes
+ lifetime: 3600 # 1 hour
diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Controller/ResetPasswordController.php b/src/Resources/scaffolds/6.0/reset-password/src/Controller/ResetPasswordController.php
new file mode 100644
index 000000000..834c8c2f6
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/src/Controller/ResetPasswordController.php
@@ -0,0 +1,188 @@
+createForm(RequestResetFormType::class);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ return $this->processSendingPasswordResetEmail(
+ $form->get('email')->getData(),
+ $mailer
+ );
+ }
+
+ return $this->render('reset_password/request.html.twig', [
+ 'requestForm' => $form->createView(),
+ ]);
+ }
+
+ /**
+ * Confirmation page after a user has requested a password reset.
+ */
+ #[Route('/check-email', name: 'reset_password_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('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: 'reset_password')]
+ public function reset(Request $request, LoginUser $login, UserPasswordHasherInterface $userPasswordHasher, 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('reset_password');
+ }
+
+ if (null === $token = $this->getTokenFromSession()) {
+ 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('error', $e->getReason());
+
+ return $this->redirectToRoute('homepage');
+ }
+
+ // The token is valid; allow the user to change their password.
+ $form = $this->createForm(ResetPasswordFormType::class);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ // A password reset token should be used only once, remove it.
+ $this->resetPasswordHelper->removeResetRequest($token);
+
+ // Encode(hash) the plain password, and set it.
+ $user->setPassword($userPasswordHasher->hashPassword($user, $form->get('plainPassword')->getData()));
+
+ $this->userRepository->save($user);
+
+ // The session is cleaned up after the password has been changed.
+ $this->cleanSessionAfterReset();
+
+ // programmatic user login
+ $login($user, $request);
+
+ $this->addFlash('success', 'Your password was successfully reset, you are now logged in.');
+
+ return $this->redirectToRoute('homepage');
+ }
+
+ return $this->render('reset_password/reset.html.twig', [
+ 'resetForm' => $form->createView(),
+ ]);
+ }
+
+ private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer): RedirectResponse
+ {
+ $user = $this->userRepository->findOneBy([
+ 'email' => $emailFormData,
+ ]);
+
+ // Do not reveal whether a user account was found or not.
+ if (!$user) {
+ return $this->redirectToRoute('reset_password_check_email');
+ }
+
+ try {
+ $resetToken = $this->resetPasswordHelper->generateResetToken($user);
+ } catch (TooManyPasswordRequestsException $e) {
+ $this->addFlash('error', $e->getReason());
+
+ return $this->redirectToRoute('homepage');
+ } 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 'reset_password_request'.
+ // Caution: This may reveal if a user is registered or not.
+ //
+ // $this->addFlash('reset_password_error', sprintf(
+ // '%s - %s',
+ // ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE,
+ // $e->getReason()
+ // ));
+
+ return $this->redirectToRoute('reset_password_check_email');
+ }
+
+ $email = (new TemplatedEmail())
+ ->from(new Address('webmaster@example.com', 'Webmaster')) // todo change email/name
+ ->to(new Address($user->getEmail(), $user->getName()))
+ ->subject('Your password reset request')
+ ->htmlTemplate('reset_password/email.html.twig')
+ ->context([
+ 'resetToken' => $resetToken,
+ ])
+ ;
+
+ $email->getHeaders()
+ ->add(new TagHeader('reset-password')) // https://symfony.com/doc/current/mailer.html#adding-tags-and-metadata-to-emails
+ ->addTextHeader('X-CTA', $this->generateUrl( // helps with testing
+ 'reset_password',
+ ['token' => $resetToken->getToken()],
+ UrlGeneratorInterface::ABSOLUTE_URL
+ ))
+ ;
+
+ $mailer->send($email);
+
+ // Store the token object in session for retrieval in check-email route.
+ $this->setTokenObjectInSession($resetToken);
+
+ return $this->redirectToRoute('reset_password_check_email');
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Entity/ResetPasswordRequest.php b/src/Resources/scaffolds/6.0/reset-password/src/Entity/ResetPasswordRequest.php
new file mode 100644
index 000000000..2676ef571
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/src/Entity/ResetPasswordRequest.php
@@ -0,0 +1,39 @@
+user = $user;
+ $this->initialize($expiresAt, $selector, $hashedToken);
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getUser(): object
+ {
+ return $this->user;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Factory/ResetPasswordRequestFactory.php b/src/Resources/scaffolds/6.0/reset-password/src/Factory/ResetPasswordRequestFactory.php
new file mode 100644
index 000000000..5696a7499
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/src/Factory/ResetPasswordRequestFactory.php
@@ -0,0 +1,40 @@
+
+ *
+ * @method static ResetPasswordRequest|Proxy createOne(array $attributes = [])
+ * @method static ResetPasswordRequest[]|Proxy[] createMany(int $number, array|callable $attributes = [])
+ * @method static ResetPasswordRequest|Proxy find(object|array|mixed $criteria)
+ * @method static ResetPasswordRequest|Proxy findOrCreate(array $attributes)
+ * @method static ResetPasswordRequest|Proxy first(string $sortedField = 'id')
+ * @method static ResetPasswordRequest|Proxy last(string $sortedField = 'id')
+ * @method static ResetPasswordRequest|Proxy random(array $attributes = [])
+ * @method static ResetPasswordRequest|Proxy randomOrCreate(array $attributes = [])
+ * @method static ResetPasswordRequest[]|Proxy[] all()
+ * @method static ResetPasswordRequest[]|Proxy[] findBy(array $attributes)
+ * @method static ResetPasswordRequest[]|Proxy[] randomSet(int $number, array $attributes = [])
+ * @method static ResetPasswordRequest[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
+ * @method static ResetPasswordRequestRepository|RepositoryProxy repository()
+ * @method ResetPasswordRequest|Proxy create(array|callable $attributes = [])
+ */
+final class ResetPasswordRequestFactory extends ModelFactory
+{
+ protected function getDefaults(): array
+ {
+ return [];
+ }
+
+ protected static function getClass(): string
+ {
+ return ResetPasswordRequest::class;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPassword/RequestResetFormType.php b/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPassword/RequestResetFormType.php
new file mode 100644
index 000000000..2a3c838e9
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPassword/RequestResetFormType.php
@@ -0,0 +1,31 @@
+add('email', EmailType::class, [
+ 'attr' => ['autocomplete' => 'email'],
+ 'constraints' => [
+ new NotBlank(['message' => 'Please enter your email']),
+ new Email(['message' => 'This is not a valid email address']),
+ ],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([]);
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPassword/ResetPasswordFormType.php b/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPassword/ResetPasswordFormType.php
new file mode 100644
index 000000000..0a794e90e
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPassword/ResetPasswordFormType.php
@@ -0,0 +1,51 @@
+add('plainPassword', RepeatedType::class, [
+ 'type' => PasswordType::class,
+ 'first_options' => [
+ 'attr' => ['autocomplete' => 'new-password'],
+ 'constraints' => [
+ new NotBlank([
+ 'message' => 'Please enter a password',
+ ]),
+ new Length([
+ 'min' => 6,
+ 'minMessage' => 'Your password should be at least {{ limit }} characters',
+ // max length allowed by Symfony for security reasons
+ 'max' => 4096,
+ ]),
+ ],
+ 'label' => 'New password',
+ ],
+ 'second_options' => [
+ 'attr' => ['autocomplete' => 'new-password'],
+ '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/Resources/scaffolds/6.0/reset-password/src/Repository/ResetPasswordRequestRepository.php b/src/Resources/scaffolds/6.0/reset-password/src/Repository/ResetPasswordRequestRepository.php
new file mode 100644
index 000000000..5a428b78d
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/src/Repository/ResetPasswordRequestRepository.php
@@ -0,0 +1,31 @@
+
+ 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.
+{% endblock %}
diff --git a/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/email.html.twig b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/email.html.twig
new file mode 100644
index 000000000..7acbbdca6
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/email.html.twig
@@ -0,0 +1,9 @@
+Hi!
+
+To reset your password, please visit the following link
+
+{{ url('reset_password', {token: resetToken.token}) }}
+
+This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.
+
+Cheers!
diff --git a/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/request.html.twig b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/request.html.twig
new file mode 100644
index 000000000..095903052
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/request.html.twig
@@ -0,0 +1,22 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Reset your password{% endblock %}
+
+{% block body %}
+ {% for flash_error in app.flashes('reset_password_error') %}
+ {{ flash_error }}
+ {% endfor %}
+ Reset your password
+
+ {{ form_start(requestForm) }}
+ {{ form_row(requestForm.email) }}
+
+
+ Enter your email address and we will send you a
+ link to reset your password.
+
+
+
+
+ {{ form_end(requestForm) }}
+{% endblock %}
diff --git a/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/reset.html.twig b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/reset.html.twig
new file mode 100644
index 000000000..799aa10f5
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/reset.html.twig
@@ -0,0 +1,12 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Reset your password{% endblock %}
+
+{% block body %}
+ Reset your password
+
+ {{ form_start(resetForm) }}
+ {{ form_row(resetForm.plainPassword) }}
+
+ {{ form_end(resetForm) }}
+{% endblock %}
diff --git a/src/Resources/scaffolds/6.0/reset-password/tests/Functional/ResetPasswordTest.php b/src/Resources/scaffolds/6.0/reset-password/tests/Functional/ResetPasswordTest.php
new file mode 100644
index 000000000..dc5b09d12
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password/tests/Functional/ResetPasswordTest.php
@@ -0,0 +1,387 @@
+
+ */
+final class ResetPasswordTest extends KernelTestCase
+{
+ use HasBrowser, Factories, ResetDatabase, InteractsWithMailer;
+
+ /**
+ * @test
+ */
+ public function can_reset_password(): void
+ {
+ UserFactory::createOne(['email' => 'john@example.com', 'name' => 'John', 'password' => '1234']);
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->assertSuccessful()
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->assertSuccessful()
+ ->assertSee('Password Reset Email Sent')
+ ;
+
+ $email = $this->mailer()
+ ->sentEmails()
+ ->assertCount(1)
+ ->first()
+ ;
+ $resetUrl = $email->getHeaders()->get('X-CTA')?->getBody();
+
+ $this->assertNotNull($resetUrl, 'The reset url header was not set.');
+
+ $email
+ ->assertTo('john@example.com', 'John')
+ ->assertContains('To reset your password, please visit the following link')
+ ->assertContains($resetUrl)
+ ->assertHasTag('reset-password')
+ ;
+
+ $this->browser()
+ ->visit($resetUrl)
+ ->fillField('New password', 'new-password')
+ ->fillField('Repeat Password', 'new-password')
+ ->click('Reset password')
+ ->assertOn('/')
+ ->assertSeeIn('.flash', 'Your password was successfully reset, you are now logged in.')
+ ->use(Authentication::assertAuthenticatedAs('john@example.com'))
+ ->visit('/logout')
+ ->visit('/login')
+ ->fillField('Email', 'john@example.com')
+ ->fillField('Password', 'new-password')
+ ->click('Sign in')
+ ->assertOn('/')
+ ->use(Authentication::assertAuthenticatedAs('john@example.com'))
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function request_email_is_required(): void
+ {
+ $this->browser()
+ ->visit('/reset-password')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password')
+ ->assertSee('Please enter your email')
+ ;
+
+ $this->mailer()->assertNoEmailSent();
+ }
+
+ /**
+ * @test
+ */
+ public function request_email_must_be_an_email(): void
+ {
+ $this->browser()
+ ->visit('/reset-password')
+ ->fillField('Email', 'invalid')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password')
+ ->assertSee('This is not a valid email address')
+ ;
+
+ $this->mailer()->assertNoEmailSent();
+ }
+
+ /**
+ * @test
+ */
+ public function requests_are_throttled(): void
+ {
+ UserFactory::createOne(['email' => 'john@example.com']);
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->assertSuccessful()
+ ->assertSee('Password Reset Email Sent')
+ ->visit('/reset-password')
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/')
+ ->assertSeeIn('.flash', 'You have already requested a reset password email. Please check your email or try again soon.')
+ ;
+
+ $this->mailer()->assertSentEmailCount(1);
+
+ ResetPasswordRequestFactory::assert()->count(1);
+ }
+
+ /**
+ * @test
+ */
+ public function can_request_again_after_throttle_expires(): void
+ {
+ UserFactory::createOne(['email' => 'john@example.com']);
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->assertSuccessful()
+ ->assertSee('Password Reset Email Sent')
+ ->use(function() {
+ ResetPasswordRequestFactory::first()
+ ->forceSet('requestedAt', new \DateTimeImmutable('-16 minutes'))
+ ->save()
+ ;
+ })
+ ->visit('/reset-password')
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->assertSuccessful()
+ ->assertSee('Password Reset Email Sent')
+ ;
+
+ $this->mailer()->assertSentEmailCount(2);
+
+ ResetPasswordRequestFactory::assert()->count(2);
+ }
+
+ /**
+ * @test
+ */
+ public function request_does_not_expose_if_user_was_not_found(): void
+ {
+ $this->browser()
+ ->visit('/reset-password')
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->assertSuccessful()
+ ->assertSee('Password Reset Email Sent')
+ ;
+
+ $this->mailer()->assertNoEmailSent();
+ }
+
+ /**
+ * @test
+ */
+ public function reset_password_is_required(): void
+ {
+ $user = UserFactory::createOne(['email' => 'john@example.com']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->assertSuccessful()
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->visit($this->mailer()->sentEmails()->first()->getHeaders()->get('X-CTA')->getBody())
+ ->click('Reset password')
+ ->assertOn('/reset-password/reset')
+ ->assertSee('Please enter a password')
+ ;
+
+ $this->assertSame($currentPassword, UserFactory::find(['email' => 'john@example.com'])->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function reset_passwords_must_match(): void
+ {
+ $user = UserFactory::createOne(['email' => 'john@example.com']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->assertSuccessful()
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->visit($this->mailer()->sentEmails()->first()->getHeaders()->get('X-CTA')->getBody())
+ ->fillField('New password', 'new-password')
+ ->fillField('Repeat Password', 'mismatch-password')
+ ->click('Reset password')
+ ->assertOn('/reset-password/reset')
+ ->assertSee('The password fields must match.')
+ ;
+
+ $this->assertSame($currentPassword, UserFactory::find(['email' => 'john@example.com'])->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function reset_password_must_be_min_length(): void
+ {
+ $user = UserFactory::createOne(['email' => 'john@example.com']);
+ $currentPassword = $user->getPassword();
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->assertSuccessful()
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->visit($this->mailer()->sentEmails()->first()->getHeaders()->get('X-CTA')->getBody())
+ ->fillField('New password', '1234')
+ ->fillField('Repeat Password', '1234')
+ ->click('Reset password')
+ ->assertOn('/reset-password/reset')
+ ->assertSee('Your password should be at least 6 characters')
+ ;
+
+ $this->assertSame($currentPassword, UserFactory::find(['email' => 'john@example.com'])->getPassword());
+ }
+
+ /**
+ * @test
+ */
+ public function cannot_reset_with_invalid_token(): void
+ {
+ $this->browser()
+ ->visit('/reset-password/reset/invalid-token')
+ ->assertOn('/')
+ ->assertSeeIn('.flash', 'The reset password link is invalid. Please try to reset your password again.')
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function can_use_old_token_even_after_requesting_another(): void
+ {
+ UserFactory::createOne(['email' => 'john@example.com']);
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->assertSuccessful()
+ ->assertSee('Password Reset Email Sent')
+ ->use(function() {
+ ResetPasswordRequestFactory::first()
+ ->forceSet('requestedAt', new \DateTimeImmutable('-16 minutes'))
+ ->save()
+ ;
+ })
+ ->visit('/reset-password')
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->assertSuccessful()
+ ->assertSee('Password Reset Email Sent')
+ ->use(function() {
+ ResetPasswordRequestFactory::assert()->count(2);
+ })
+ ->visit($this->mailer()->sentEmails()->first()->getHeaders()->get('X-CTA')->getBody())
+ ->assertOn('/reset-password/reset')
+ ->fillField('New password', 'new-password')
+ ->fillField('Repeat Password', 'new-password')
+ ->click('Reset password')
+ ->assertOn('/')
+ ;
+
+ ResetPasswordRequestFactory::assert()->empty();
+ }
+
+ /**
+ * @test
+ */
+ public function reset_tokens_expire(): void
+ {
+ UserFactory::createOne(['email' => 'john@example.com']);
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->assertSuccessful()
+ ->assertSee('Password Reset Email Sent')
+ ->use(function() {
+ ResetPasswordRequestFactory::first()
+ ->forceSet('expiresAt', new \DateTimeImmutable('-10 minutes'))
+ ->save()
+ ;
+ })
+ ->visit($this->mailer()->sentEmails()->first()->getHeaders()->get('X-CTA')->getBody())
+ ->assertOn('/')
+ ->assertSuccessful()
+ ->assertSeeIn('.flash', 'The link in your email is expired. Please try to reset your password again.')
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function cannot_use_token_after_password_change(): void
+ {
+ UserFactory::createOne(['email' => 'john@example.com']);
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->assertSuccessful()
+ ->fillField('Email', 'john@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ->visit($resetUrl = $this->mailer()->sentEmails()->first()->getHeaders()->get('X-CTA')->getBody())
+ ->fillField('New password', 'new-password')
+ ->fillField('Repeat Password', 'new-password')
+ ->click('Reset password')
+ ->assertOn('/')
+ ->assertSeeIn('.flash', 'Your password was successfully reset, you are now logged in.')
+ ->visit('/logout')
+ ->visit($resetUrl)
+ ->assertOn('/')
+ ->assertSuccessful()
+ ->assertSeeIn('.flash', 'The reset password link is invalid. Please try to reset your password again.')
+ ;
+ }
+
+ /**
+ * @test
+ */
+ public function old_tokens_are_garbage_collected(): void
+ {
+ $user = UserFactory::createOne(['email' => 'jane@example.com']);
+
+ ResetPasswordRequestFactory::createOne([
+ 'user' => $user,
+ 'selector' => 'selector',
+ 'hashedToken' => 'hash',
+ 'expiresAt' => new \DateTimeImmutable('-1 month'),
+ ])
+ ->forceSet('requestedAt', new \DateTimeImmutable('-1 month'))
+ ->save()
+ ;
+
+ ResetPasswordRequestFactory::assert()->exists(['selector' => 'selector']);
+
+ $this->browser()
+ ->visit('/reset-password')
+ ->assertSuccessful()
+ ->fillField('Email', 'jane@example.com')
+ ->click('Send password reset email')
+ ->assertOn('/reset-password/check-email')
+ ;
+
+ ResetPasswordRequestFactory::assert()
+ ->count(1)
+ ->notExists(['selector' => 'selector'])
+ ;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/user.json b/src/Resources/scaffolds/6.0/user.json
new file mode 100644
index 000000000..8e5a009b7
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/user.json
@@ -0,0 +1,9 @@
+{
+ "description": "Create a basic user and unit test.",
+ "packages": {
+ "orm": "all",
+ "security": "all",
+ "phpunit": "dev",
+ "zenstruck/foundry": "dev"
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/user/src/Entity/User.php b/src/Resources/scaffolds/6.0/user/src/Entity/User.php
new file mode 100644
index 000000000..cc61e8592
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/user/src/Entity/User.php
@@ -0,0 +1,111 @@
+id;
+ }
+
+ public function getEmail(): ?string
+ {
+ return $this->email;
+ }
+
+ public function setEmail(string $email): self
+ {
+ $this->email = $email;
+
+ return $this;
+ }
+
+ /**
+ * A visual identifier that represents this user.
+ *
+ * @see UserInterface
+ */
+ public function getUserIdentifier(): string
+ {
+ return (string) $this->email;
+ }
+
+ /**
+ * @see UserInterface
+ */
+ public function getRoles(): array
+ {
+ $roles = $this->roles;
+ // guarantee every user at least has ROLE_USER
+ $roles[] = 'ROLE_USER';
+
+ return array_unique($roles);
+ }
+
+ public function setRoles(array $roles): self
+ {
+ $this->roles = $roles;
+
+ return $this;
+ }
+
+ /**
+ * @see PasswordAuthenticatedUserInterface
+ */
+ public function getPassword(): string
+ {
+ return $this->password;
+ }
+
+ public function setPassword(string $password): self
+ {
+ $this->password = $password;
+
+ return $this;
+ }
+
+ /**
+ * @see UserInterface
+ */
+ public function eraseCredentials()
+ {
+ // If you store any temporary, sensitive data on the user, clear it here
+ // $this->plainPassword = null;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/user/src/Factory/UserFactory.php b/src/Resources/scaffolds/6.0/user/src/Factory/UserFactory.php
new file mode 100644
index 000000000..8166c7203
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/user/src/Factory/UserFactory.php
@@ -0,0 +1,61 @@
+
+ *
+ * @method static User|Proxy createOne(array $attributes = [])
+ * @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = [])
+ * @method static User|Proxy find(object|array|mixed $criteria)
+ * @method static User|Proxy findOrCreate(array $attributes)
+ * @method static User|Proxy first(string $sortedField = 'id')
+ * @method static User|Proxy last(string $sortedField = 'id')
+ * @method static User|Proxy random(array $attributes = [])
+ * @method static User|Proxy randomOrCreate(array $attributes = [])
+ * @method static User[]|Proxy[] all()
+ * @method static User[]|Proxy[] findBy(array $attributes)
+ * @method static User[]|Proxy[] randomSet(int $number, array $attributes = [])
+ * @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
+ * @method static UserRepository|RepositoryProxy repository()
+ * @method User|Proxy create(array|callable $attributes = [])
+ */
+final class UserFactory extends ModelFactory
+{
+ public const DEFAULT_PASSWORD = '1234';
+
+ public function __construct(private UserPasswordHasherInterface $passwordHasher)
+ {
+ parent::__construct();
+ }
+
+ protected function getDefaults(): array
+ {
+ return [
+ 'email' => self::faker()->unique()->safeEmail(),
+ 'name' => self::faker()->name(),
+ 'password' => self::DEFAULT_PASSWORD,
+ ];
+ }
+
+ protected function initialize(): self
+ {
+ return $this
+ ->afterInstantiate(function(User $user) {
+ $user->setPassword($this->passwordHasher->hashPassword($user, $user->getPassword()));
+ })
+ ;
+ }
+
+ protected static function getClass(): string
+ {
+ return User::class;
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/user/src/Repository/UserRepository.php b/src/Resources/scaffolds/6.0/user/src/Repository/UserRepository.php
new file mode 100644
index 000000000..0846acd19
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/user/src/Repository/UserRepository.php
@@ -0,0 +1,43 @@
+setPassword($newHashedPassword);
+ $this->save($user);
+ }
+
+ public function save(User $user): void
+ {
+ $this->_em->persist($user);
+ $this->_em->flush();
+ }
+}
diff --git a/src/Resources/scaffolds/6.0/user/tests/Unit/Entity/UserTest.php b/src/Resources/scaffolds/6.0/user/tests/Unit/Entity/UserTest.php
new file mode 100644
index 000000000..91212ffab
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/user/tests/Unit/Entity/UserTest.php
@@ -0,0 +1,22 @@
+assertSame(['ROLE_USER'], (new User())->getRoles());
+ $this->assertSame(['ROLE_ADMIN', 'ROLE_USER'], (new User())->setRoles(['ROLE_ADMIN'])->getRoles());
+ $this->assertSame(
+ ['ROLE_ADMIN', 'ROLE_USER'],
+ (new User())->setRoles(['ROLE_ADMIN', 'ROLE_USER'])->getRoles()
+ );
+ }
+}
diff --git a/src/Test/MakerTestEnvironment.php b/src/Test/MakerTestEnvironment.php
index 1cd63aa26..d9d34f577 100644
--- a/src/Test/MakerTestEnvironment.php
+++ b/src/Test/MakerTestEnvironment.php
@@ -345,7 +345,7 @@ public function createInteractiveCommandProcess(string $commandName, array $user
[
'SHELL_INTERACTIVE' => '1',
],
- 10
+ 20
);
if ($userInputs) {
diff --git a/tests/Maker/MakeScaffoldTest.php b/tests/Maker/MakeScaffoldTest.php
new file mode 100644
index 000000000..f7e29c666
--- /dev/null
+++ b/tests/Maker/MakeScaffoldTest.php
@@ -0,0 +1,65 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\MakerBundle\Tests\Maker;
+
+use Symfony\Bundle\MakerBundle\Maker\MakeScaffold;
+use Symfony\Bundle\MakerBundle\Test\MakerTestCase;
+use Symfony\Bundle\MakerBundle\Test\MakerTestRunner;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\HttpKernel\Kernel;
+
+class MakeScaffoldTest extends MakerTestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ if (Kernel::MAJOR_VERSION < 6) {
+ $this->markTestSkipped('Only available on Symfony 6+.');
+ }
+ }
+
+ public function getTestDetails(): iterable
+ {
+ foreach (self::scaffoldProvider() as $name) {
+ yield $name => [$this->createMakerTest()
+ ->preRun(function (MakerTestRunner $runner) {
+ $runner->writeFile('.env.test.local', implode("\n", [
+ 'DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"',
+ 'MAILER_DSN=null://null',
+ ]));
+ })
+ ->addExtraDependencies(
+ 'process'
+ )
+ ->run(function (MakerTestRunner $runner) use ($name) {
+ $runner->runMaker([$name]);
+ $runner->runTests();
+
+ $this->assertTrue(true); // successfully ran tests
+ }),
+ ];
+ }
+ }
+
+ protected function getMakerClass(): string
+ {
+ return MakeScaffold::class;
+ }
+
+ private static function scaffoldProvider(): iterable
+ {
+ foreach (Finder::create()->in(__DIR__.'/../../src/Resources/scaffolds/6.0')->name('*.json') as $file) {
+ yield $file->getFilenameWithoutExtension();
+ }
+ }
+}
From 010a2fad90a328510a347a34ca0a2f88eaa49018 Mon Sep 17 00:00:00 2001
From: Kevin Bond
Date: Wed, 30 Mar 2022 15:08:35 -0400
Subject: [PATCH 02/21] use php files for scaffold manifests
---
src/Maker/MakeScaffold.php | 5 +++--
src/Resources/scaffolds/6.0/auth.json | 10 ----------
src/Resources/scaffolds/6.0/auth.php | 12 ++++++++++++
src/Resources/scaffolds/6.0/change-password.json | 10 ----------
src/Resources/scaffolds/6.0/change-password.php | 12 ++++++++++++
src/Resources/scaffolds/6.0/homepage.json | 8 --------
src/Resources/scaffolds/6.0/homepage.php | 10 ++++++++++
src/Resources/scaffolds/6.0/profile.json | 10 ----------
src/Resources/scaffolds/6.0/profile.php | 12 ++++++++++++
src/Resources/scaffolds/6.0/register.json | 10 ----------
src/Resources/scaffolds/6.0/register.php | 12 ++++++++++++
src/Resources/scaffolds/6.0/reset-password.json | 13 -------------
src/Resources/scaffolds/6.0/reset-password.php | 15 +++++++++++++++
src/Resources/scaffolds/6.0/user.json | 9 ---------
src/Resources/scaffolds/6.0/user.php | 11 +++++++++++
src/Test/MakerTestEnvironment.php | 2 +-
tests/Maker/MakeScaffoldTest.php | 2 +-
17 files changed, 89 insertions(+), 74 deletions(-)
delete mode 100644 src/Resources/scaffolds/6.0/auth.json
create mode 100644 src/Resources/scaffolds/6.0/auth.php
delete mode 100644 src/Resources/scaffolds/6.0/change-password.json
create mode 100644 src/Resources/scaffolds/6.0/change-password.php
delete mode 100644 src/Resources/scaffolds/6.0/homepage.json
create mode 100644 src/Resources/scaffolds/6.0/homepage.php
delete mode 100644 src/Resources/scaffolds/6.0/profile.json
create mode 100644 src/Resources/scaffolds/6.0/profile.php
delete mode 100644 src/Resources/scaffolds/6.0/register.json
create mode 100644 src/Resources/scaffolds/6.0/register.php
delete mode 100644 src/Resources/scaffolds/6.0/reset-password.json
create mode 100644 src/Resources/scaffolds/6.0/reset-password.php
delete mode 100644 src/Resources/scaffolds/6.0/user.json
create mode 100644 src/Resources/scaffolds/6.0/user.php
diff --git a/src/Maker/MakeScaffold.php b/src/Maker/MakeScaffold.php
index 6c9c31dfe..47e464e84 100644
--- a/src/Maker/MakeScaffold.php
+++ b/src/Maker/MakeScaffold.php
@@ -148,14 +148,15 @@ private function availableScaffolds(): array
$finder = Finder::create()
// todo, improve versioning system
->in(\sprintf('%s/../Resources/scaffolds/%s.0', __DIR__, Kernel::MAJOR_VERSION))
- ->name('*.json')
+ ->name('*.php')
+ ->depth(0)
;
foreach ($finder as $file) {
$name = $file->getFilenameWithoutExtension();
$this->availableScaffolds[$name] = array_merge(
- json_decode(file_get_contents($file), true),
+ require $file,
['dir' => dirname($file->getRealPath()).'/'.$name]
);
}
diff --git a/src/Resources/scaffolds/6.0/auth.json b/src/Resources/scaffolds/6.0/auth.json
deleted file mode 100644
index 810935c8c..000000000
--- a/src/Resources/scaffolds/6.0/auth.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "description": "Create login form and tests.",
- "dependents": [
- "homepage",
- "user"
- ],
- "packages": {
- "profiler": "dev"
- }
-}
diff --git a/src/Resources/scaffolds/6.0/auth.php b/src/Resources/scaffolds/6.0/auth.php
new file mode 100644
index 000000000..5711fa641
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/auth.php
@@ -0,0 +1,12 @@
+ 'Create login form and tests.',
+ 'dependents' => [
+ 'user',
+ 'homepage',
+ ],
+ 'packages' => [
+ 'profiler' => 'dev',
+ ]
+];
diff --git a/src/Resources/scaffolds/6.0/change-password.json b/src/Resources/scaffolds/6.0/change-password.json
deleted file mode 100644
index 89c310704..000000000
--- a/src/Resources/scaffolds/6.0/change-password.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "description": "Create change password form and tests.",
- "dependents": [
- "auth"
- ],
- "packages": {
- "form": "all",
- "validator": "all"
- }
-}
diff --git a/src/Resources/scaffolds/6.0/change-password.php b/src/Resources/scaffolds/6.0/change-password.php
new file mode 100644
index 000000000..8368c9620
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/change-password.php
@@ -0,0 +1,12 @@
+ 'Create change password form and tests.',
+ 'dependents' => [
+ 'auth',
+ ],
+ 'packages' => [
+ 'form' => 'all',
+ 'validator' => 'all',
+ ]
+];
diff --git a/src/Resources/scaffolds/6.0/homepage.json b/src/Resources/scaffolds/6.0/homepage.json
deleted file mode 100644
index 8eb83473f..000000000
--- a/src/Resources/scaffolds/6.0/homepage.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "description": "Create a basic homepage controller/template/test.",
- "packages": {
- "twig": "all",
- "phpunit": "dev",
- "zenstruck/browser": "dev"
- }
-}
diff --git a/src/Resources/scaffolds/6.0/homepage.php b/src/Resources/scaffolds/6.0/homepage.php
new file mode 100644
index 000000000..c82f9c120
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/homepage.php
@@ -0,0 +1,10 @@
+ 'Create a basic homepage controller/template/test.',
+ 'packages' => [
+ 'twig' => 'all',
+ 'phpunit' => 'dev',
+ 'zenstruck/browser' => 'dev',
+ ]
+];
diff --git a/src/Resources/scaffolds/6.0/profile.json b/src/Resources/scaffolds/6.0/profile.json
deleted file mode 100644
index 7246dedc7..000000000
--- a/src/Resources/scaffolds/6.0/profile.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "description": "Create user profile form and tests.",
- "dependents": [
- "auth"
- ],
- "packages": {
- "form": "all",
- "validator": "all"
- }
-}
diff --git a/src/Resources/scaffolds/6.0/profile.php b/src/Resources/scaffolds/6.0/profile.php
new file mode 100644
index 000000000..11b592b85
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/profile.php
@@ -0,0 +1,12 @@
+ 'Create user profile form and tests.',
+ 'dependents' => [
+ 'auth',
+ ],
+ 'packages' => [
+ 'form' => 'all',
+ 'validator' => 'all',
+ ]
+];
diff --git a/src/Resources/scaffolds/6.0/register.json b/src/Resources/scaffolds/6.0/register.json
deleted file mode 100644
index 015a2f497..000000000
--- a/src/Resources/scaffolds/6.0/register.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "description": "Create registration form and tests.",
- "dependents": [
- "auth"
- ],
- "packages": {
- "form": "all",
- "validator": "all"
- }
-}
diff --git a/src/Resources/scaffolds/6.0/register.php b/src/Resources/scaffolds/6.0/register.php
new file mode 100644
index 000000000..9bb2e9953
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/register.php
@@ -0,0 +1,12 @@
+ 'Create registration form and tests.',
+ 'dependents' => [
+ 'auth',
+ ],
+ 'packages' => [
+ 'form' => 'all',
+ 'validator' => 'all',
+ ]
+];
diff --git a/src/Resources/scaffolds/6.0/reset-password.json b/src/Resources/scaffolds/6.0/reset-password.json
deleted file mode 100644
index 0626bd699..000000000
--- a/src/Resources/scaffolds/6.0/reset-password.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "description": "Reset password system using symfonycasts/reset-password-bundle with tests.",
- "dependents": [
- "auth"
- ],
- "packages": {
- "form": "all",
- "validator": "all",
- "mailer": "all",
- "symfonycasts/reset-password-bundle": "all",
- "zenstruck/mailer-test": "dev"
- }
-}
diff --git a/src/Resources/scaffolds/6.0/reset-password.php b/src/Resources/scaffolds/6.0/reset-password.php
new file mode 100644
index 000000000..36287e3ed
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/reset-password.php
@@ -0,0 +1,15 @@
+ 'Reset password system using symfonycasts/reset-password-bundle with tests.',
+ 'dependents' => [
+ 'auth',
+ ],
+ 'packages' => [
+ 'form' => 'all',
+ 'validator' => 'all',
+ 'mailer' => 'all',
+ 'symfonycasts/reset-password-bundle' => 'all',
+ 'zenstruck/mailer-test' => 'dev',
+ ]
+];
diff --git a/src/Resources/scaffolds/6.0/user.json b/src/Resources/scaffolds/6.0/user.json
deleted file mode 100644
index 8e5a009b7..000000000
--- a/src/Resources/scaffolds/6.0/user.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "description": "Create a basic user and unit test.",
- "packages": {
- "orm": "all",
- "security": "all",
- "phpunit": "dev",
- "zenstruck/foundry": "dev"
- }
-}
diff --git a/src/Resources/scaffolds/6.0/user.php b/src/Resources/scaffolds/6.0/user.php
new file mode 100644
index 000000000..dcd93d2ab
--- /dev/null
+++ b/src/Resources/scaffolds/6.0/user.php
@@ -0,0 +1,11 @@
+ 'Create a basic user and unit test.',
+ 'packages' => [
+ 'orm' => 'all',
+ 'security' => 'all',
+ 'phpunit' => 'dev',
+ 'zenstruck/foundry' => 'dev',
+ ]
+];
diff --git a/src/Test/MakerTestEnvironment.php b/src/Test/MakerTestEnvironment.php
index d9d34f577..ee38b4d60 100644
--- a/src/Test/MakerTestEnvironment.php
+++ b/src/Test/MakerTestEnvironment.php
@@ -345,7 +345,7 @@ public function createInteractiveCommandProcess(string $commandName, array $user
[
'SHELL_INTERACTIVE' => '1',
],
- 20
+ 30
);
if ($userInputs) {
diff --git a/tests/Maker/MakeScaffoldTest.php b/tests/Maker/MakeScaffoldTest.php
index f7e29c666..18daee398 100644
--- a/tests/Maker/MakeScaffoldTest.php
+++ b/tests/Maker/MakeScaffoldTest.php
@@ -58,7 +58,7 @@ protected function getMakerClass(): string
private static function scaffoldProvider(): iterable
{
- foreach (Finder::create()->in(__DIR__.'/../../src/Resources/scaffolds/6.0')->name('*.json') as $file) {
+ foreach (Finder::create()->in(__DIR__.'/../../src/Resources/scaffolds/6.0')->name('*.php')->depth(0) as $file) {
yield $file->getFilenameWithoutExtension();
}
}
From 2e4b5c77fed8902f72890f31ea2d53166ba8f08a Mon Sep 17 00:00:00 2001
From: Kevin Bond
Date: Wed, 30 Mar 2022 16:21:12 -0400
Subject: [PATCH 03/21] add scaffold configuration system to manipulate files
---
src/Maker/MakeScaffold.php | 17 ++-
src/Resources/config/makers.xml | 2 +-
src/Resources/scaffolds/6.0/auth.php | 45 ++++++-
.../6.0/auth/config/packages/security.yaml | 58 ---------
.../scaffolds/6.0/auth/config/services.yaml | 28 -----
src/Resources/scaffolds/6.0/register.php | 25 +++-
.../6.0/register/src/Entity/User.php | 113 ------------------
.../scaffolds/6.0/reset-password.php | 10 +-
8 files changed, 90 insertions(+), 208 deletions(-)
delete mode 100644 src/Resources/scaffolds/6.0/auth/config/packages/security.yaml
delete mode 100644 src/Resources/scaffolds/6.0/auth/config/services.yaml
delete mode 100644 src/Resources/scaffolds/6.0/register/src/Entity/User.php
diff --git a/src/Maker/MakeScaffold.php b/src/Maker/MakeScaffold.php
index 47e464e84..fa6444de2 100644
--- a/src/Maker/MakeScaffold.php
+++ b/src/Maker/MakeScaffold.php
@@ -14,6 +14,7 @@
use Composer\InstalledVersions;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
+use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Component\Console\Command\Command;
@@ -29,14 +30,14 @@
*/
final class MakeScaffold extends AbstractMaker
{
- private $projectDir;
+ private $files;
private $availableScaffolds;
private $installedScaffolds = [];
private $installedPackages = [];
- public function __construct($projectDir)
+ public function __construct(FileManager $files)
{
- $this->projectDir = $projectDir;
+ $this->files = $files;
}
public static function getCommandName(): string
@@ -116,7 +117,7 @@ private function generateScaffold(string $name, ConsoleStyle $io): void
// todo composer bin detection
$command = ['composer', 'require', '--no-scripts', 'dev' === $env ? '--dev' : null, $package];
- $process = new Process(array_filter($command), $this->projectDir);
+ $process = new Process(array_filter($command), $this->files->getRootDirectory());
$process->run();
@@ -130,7 +131,13 @@ private function generateScaffold(string $name, ConsoleStyle $io): void
$io->text('Copying scaffold files...');
- (new Filesystem())->mirror($scaffold['dir'], $this->projectDir, null, ['override' => true]);
+ (new Filesystem())->mirror($scaffold['dir'], $this->files->getRootDirectory());
+
+ if (isset($scaffold['configure'])) {
+ $io->text('Executing configuration...');
+
+ $scaffold['configure']($this->files);
+ }
$io->text("Successfully installed scaffold {$name}.");
$io->newLine();
diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml
index 80b9f092a..f31124892 100644
--- a/src/Resources/config/makers.xml
+++ b/src/Resources/config/makers.xml
@@ -26,7 +26,7 @@
- %kernel.project_dir%
+
diff --git a/src/Resources/scaffolds/6.0/auth.php b/src/Resources/scaffolds/6.0/auth.php
index 5711fa641..764141b38 100644
--- a/src/Resources/scaffolds/6.0/auth.php
+++ b/src/Resources/scaffolds/6.0/auth.php
@@ -1,5 +1,8 @@
'Create login form and tests.',
'dependents' => [
@@ -8,5 +11,45 @@
],
'packages' => [
'profiler' => 'dev',
- ]
+ ],
+ 'configure' => function(FileManager $files) {
+ $services = new YamlSourceManipulator($files->getFileContents('config/services.yaml'));
+ $data = $services->getData();
+ $data['services']['App\Security\LoginUser']['$authenticator'] ='@security.authenticator.form_login.main';
+ $services->setData($data);
+ $files->dumpFile('config/services.yaml', $services->getContents());
+
+ $security = new YamlSourceManipulator($files->getFileContents('config/packages/security.yaml'));
+ $data = $security->getData();
+ $data['security']['providers'] = [
+ 'app_user_provider' => [
+ 'entity' => [
+ 'class' => 'App\Entity\User',
+ 'property' => 'email',
+ ],
+ ],
+ ];
+ $data['security']['firewalls']['main'] = [
+ 'lazy' => true,
+ 'provider' => 'app_user_provider',
+ 'form_login' => [
+ 'login_path' => 'login',
+ 'check_path' => 'login',
+ 'username_parameter' => 'email',
+ 'password_parameter' => 'password',
+ 'enable_csrf' => true,
+ ],
+ 'logout' => [
+ 'path' => 'logout',
+ 'target' => 'homepage',
+ ],
+ 'remember_me' => [
+ 'secret' => '%kernel.secret%',
+ 'secure' => 'auto',
+ 'samesite' => 'lax',
+ ],
+ ];
+ $security->setData($data);
+ $files->dumpFile('config/packages/security.yaml', $security->getContents());
+ },
];
diff --git a/src/Resources/scaffolds/6.0/auth/config/packages/security.yaml b/src/Resources/scaffolds/6.0/auth/config/packages/security.yaml
deleted file mode 100644
index f2a480054..000000000
--- a/src/Resources/scaffolds/6.0/auth/config/packages/security.yaml
+++ /dev/null
@@ -1,58 +0,0 @@
-security:
- enable_authenticator_manager: true
- # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
- password_hashers:
- Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
- # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
- providers:
- # used to reload user from session & other features (e.g. switch_user)
- app_user_provider:
- entity:
- class: App\Entity\User
- property: email
- firewalls:
- dev:
- pattern: ^/(_(profiler|wdt)|css|images|js)/
- security: false
- main:
- lazy: true
- provider: app_user_provider
- form_login:
- login_path: login
- check_path: login
- username_parameter: email
- password_parameter: password
- enable_csrf: true
- logout:
- path: logout
- # where to redirect after logout
- target: homepage
- remember_me:
- secret: '%kernel.secret%'
- secure: auto
- samesite: lax
-
- # activate different ways to authenticate
- # https://symfony.com/doc/current/security.html#the-firewall
-
- # https://symfony.com/doc/current/security/impersonating_user.html
- # switch_user: true
-
- # Easy way to control access for large sections of your site
- # Note: Only the *first* access control that matches will be used
- access_control:
- # - { path: ^/admin, roles: ROLE_ADMIN }
- # - { path: ^/profile, roles: ROLE_USER }
-
-when@test:
- security:
- password_hashers:
- # By default, password hashers are resource intensive and take time. This is
- # important to generate secure password hashes. In tests however, secure hashes
- # are not important, waste resources and increase test times. The following
- # reduces the work factor to the lowest possible values.
- Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
- algorithm: auto
- cost: 4 # Lowest possible value for bcrypt
- time_cost: 3 # Lowest possible value for argon
- memory_cost: 10 # Lowest possible value for argon
diff --git a/src/Resources/scaffolds/6.0/auth/config/services.yaml b/src/Resources/scaffolds/6.0/auth/config/services.yaml
deleted file mode 100644
index 198b98572..000000000
--- a/src/Resources/scaffolds/6.0/auth/config/services.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-# This file is the entry point to configure your own services.
-# Files in the packages/ subdirectory configure your dependencies.
-
-# Put parameters here that don't need to change on each machine where the app is deployed
-# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
-parameters:
-
-services:
- # default configuration for services in *this* file
- _defaults:
- autowire: true # Automatically injects dependencies in your services.
- autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
-
- # makes classes in src/ available to be used as services
- # this creates a service per class whose id is the fully-qualified class name
- App\:
- resource: '../src/'
- exclude:
- - '../src/DependencyInjection/'
- - '../src/Entity/'
- - '../src/Kernel.php'
-
- # add more service definitions when explicit configuration is needed
- # please note that last definitions always *replace* previous ones
-
- # programmatic user login service
- App\Security\LoginUser:
- $authenticator: '@security.authenticator.form_login.main'
diff --git a/src/Resources/scaffolds/6.0/register.php b/src/Resources/scaffolds/6.0/register.php
index 9bb2e9953..c1cef1e22 100644
--- a/src/Resources/scaffolds/6.0/register.php
+++ b/src/Resources/scaffolds/6.0/register.php
@@ -1,5 +1,7 @@
'Create registration form and tests.',
'dependents' => [
@@ -8,5 +10,26 @@
'packages' => [
'form' => 'all',
'validator' => 'all',
- ]
+ ],
+ 'configure' => function(FileManager $files) {
+ $userEntity = $files->getFileContents('src/Entity/User.php');
+
+ if (str_contains($userEntity, $attribute = "#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]")) {
+ // unique constraint already there
+ return;
+ }
+
+ $userEntity = str_replace(
+ [
+ '#[ORM\Entity(repositoryClass: UserRepository::class)]',
+ 'use Doctrine\ORM\Mapping as ORM;'
+ ],
+ [
+ "#[ORM\Entity(repositoryClass: UserRepository::class)]\n{$attribute}",
+ "use Doctrine\ORM\Mapping as ORM;\nuse Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;",
+ ],
+ $userEntity
+ );
+ $files->dumpFile('src/Entity/User.php', $userEntity);
+ },
];
diff --git a/src/Resources/scaffolds/6.0/register/src/Entity/User.php b/src/Resources/scaffolds/6.0/register/src/Entity/User.php
deleted file mode 100644
index 5579a6115..000000000
--- a/src/Resources/scaffolds/6.0/register/src/Entity/User.php
+++ /dev/null
@@ -1,113 +0,0 @@
-id;
- }
-
- public function getEmail(): ?string
- {
- return $this->email;
- }
-
- public function setEmail(string $email): self
- {
- $this->email = $email;
-
- return $this;
- }
-
- /**
- * A visual identifier that represents this user.
- *
- * @see UserInterface
- */
- public function getUserIdentifier(): string
- {
- return (string) $this->email;
- }
-
- /**
- * @see UserInterface
- */
- public function getRoles(): array
- {
- $roles = $this->roles;
- // guarantee every user at least has ROLE_USER
- $roles[] = 'ROLE_USER';
-
- return array_unique($roles);
- }
-
- public function setRoles(array $roles): self
- {
- $this->roles = $roles;
-
- return $this;
- }
-
- /**
- * @see PasswordAuthenticatedUserInterface
- */
- public function getPassword(): string
- {
- return $this->password;
- }
-
- public function setPassword(string $password): self
- {
- $this->password = $password;
-
- return $this;
- }
-
- /**
- * @see UserInterface
- */
- public function eraseCredentials()
- {
- // If you store any temporary, sensitive data on the user, clear it here
- // $this->plainPassword = null;
- }
-
- public function getName(): ?string
- {
- return $this->name;
- }
-
- public function setName(?string $name): self
- {
- $this->name = $name;
-
- return $this;
- }
-}
diff --git a/src/Resources/scaffolds/6.0/reset-password.php b/src/Resources/scaffolds/6.0/reset-password.php
index 36287e3ed..02c52e343 100644
--- a/src/Resources/scaffolds/6.0/reset-password.php
+++ b/src/Resources/scaffolds/6.0/reset-password.php
@@ -1,5 +1,7 @@
'Reset password system using symfonycasts/reset-password-bundle with tests.',
'dependents' => [
@@ -11,5 +13,11 @@
'mailer' => 'all',
'symfonycasts/reset-password-bundle' => 'all',
'zenstruck/mailer-test' => 'dev',
- ]
+ ],
+ 'configure' => function(FileManager $files) {
+ $files->dumpFile(
+ 'config/packages/reset_password.yaml',
+ file_get_contents(__DIR__.'/reset-password/config/packages/reset_password.yaml')
+ );
+ },
];
From 3fcdd30bbc5ee64455aef560f385b23390d50234 Mon Sep 17 00:00:00 2001
From: Kevin Bond
Date: Wed, 30 Mar 2022 18:53:36 -0400
Subject: [PATCH 04/21] add bootstrapcss scaffold
---
src/Maker/MakeScaffold.php | 6 ++-
.../6.0/auth/templates/login.html.twig | 51 +++++++++++--------
src/Resources/scaffolds/6.0/bootstrapcss.php | 34 +++++++++++++
.../templates/user/change_password.html.twig | 18 ++++---
.../Functional/User/ChangePasswordTest.php | 2 +-
.../6.0/homepage/templates/index.html.twig | 2 +-
.../profile/templates/user/profile.html.twig | 14 +++--
.../tests/Functional/User/ProfileTest.php | 2 +-
.../src/Form/User/RegistrationFormType.php | 3 +-
.../templates/user/register.html.twig | 22 ++++----
.../tests/Functional/User/RegisterTest.php | 2 +-
.../reset_password/check_email.html.twig | 15 ++++--
.../reset_password/request.html.twig | 23 ++++-----
.../templates/reset_password/reset.html.twig | 14 +++--
.../tests/Functional/ResetPasswordTest.php | 12 ++---
tests/Maker/MakeScaffoldTest.php | 8 ++-
16 files changed, 147 insertions(+), 81 deletions(-)
create mode 100644 src/Resources/scaffolds/6.0/bootstrapcss.php
diff --git a/src/Maker/MakeScaffold.php b/src/Maker/MakeScaffold.php
index fa6444de2..043166489 100644
--- a/src/Maker/MakeScaffold.php
+++ b/src/Maker/MakeScaffold.php
@@ -129,9 +129,11 @@ private function generateScaffold(string $name, ConsoleStyle $io): void
}
}
- $io->text('Copying scaffold files...');
+ if (is_dir($scaffold['dir'])) {
+ $io->text('Copying scaffold files...');
- (new Filesystem())->mirror($scaffold['dir'], $this->files->getRootDirectory());
+ (new Filesystem())->mirror($scaffold['dir'], $this->files->getRootDirectory());
+ }
if (isset($scaffold['configure'])) {
$io->text('Executing configuration...');
diff --git a/src/Resources/scaffolds/6.0/auth/templates/login.html.twig b/src/Resources/scaffolds/6.0/auth/templates/login.html.twig
index c0cb0c3b6..a37381471 100644
--- a/src/Resources/scaffolds/6.0/auth/templates/login.html.twig
+++ b/src/Resources/scaffolds/6.0/auth/templates/login.html.twig
@@ -3,29 +3,36 @@
{% block title %}Log in!{% endblock %}
{% block body %}
-