EasyCorp / EasyAdminBundle

EasyAdmin is a fast, beautiful and modern admin generator for Symfony applications.
MIT License
4.04k stars 1.02k forks source link

Doc: Add password integration example #3349

Closed rogergerecke closed 1 year ago

rogergerecke commented 4 years ago

Short description of what this feature will allow to do: Add to the doc a example for password genaration

Example of how to use this feature UserCrudController.php

  /**
     * @var UserPasswordEncoderInterface
     */
    private $passwordEncoder;
   /**
     * @var Security
     */
    private $security;

     /**
     * UserCrudController constructor.
     * @param UserPasswordEncoderInterface $passwordEncoder
     * @param Security $security
     */
    public function __construct(
        UserPasswordEncoderInterface $passwordEncoder,
        Security $security
    ) {
        $this->passwordEncoder = $passwordEncoder;
        $this->security = $security;

        // get the user id from the logged in user
        if (null !== $this->security->getUser()) {
            $this->password = $this->security->getUser()->getPassword();
        }
    }
 /**
     * @param string $pageName
     * @return iterable
     */
    public function configureFields(string $pageName): iterable
    {
        $password = TextField::new('password')
            ->setFormType(PasswordType::class)
            ->setFormTypeOption('empty_data', '')
            ->setRequired(false)
            ->setHelp('If the right is not given, leave the field blank.');

        switch ($pageName) {
            case Crud::PAGE_INDEX:
               return [
                    $password,
                ];
                break;
            case Crud::PAGE_DETAIL:
                return [
                    $password,
                ];
                break;
            case Crud::PAGE_NEW:
               return [
                    $password,
                ];
                break;
            case Crud::PAGE_EDIT:
                return [
                    $password,
                ];
                break;
        }

    }

 /**
     *
     * @param EntityManagerInterface $entityManager
     * @param $entityInstance
     */
    public function updateEntity(EntityManagerInterface $entityManager, $entityInstance): void
    {

        // set new password with encoder interface
        if (method_exists($entityInstance, 'setPassword')) {
            $clearPassword = trim($this->get('request_stack')->getCurrentRequest()->request->all('User')['password']);

            // if user password not change save the old one
            if (isset($clearPassword) === true && $clearPassword === '') {
                $entityInstance->setPassword($this->password);
            } else {
                $encodedPassword = $this->passwordEncoder->encodePassword($this->getUser(), $clearPassword);
                $entityInstance->setPassword($encodedPassword);
            }
        }

        parent::updateEntity($entityManager, $entityInstance);
    }
morgan-blondellet commented 4 years ago

I tried implementing your code but i found an issue.

If you submit the form with an empty password for the currently logged in User $this->getUser()->getPassword() will return an empty string. Therefore in your database the user will no longer have a password.

Edit: to make it work i had to override the entire edit action to save the $currentPassword in a variable. If the $entityInstance->getPassword() call returns null, i reset the password to $currentPassword

rogergerecke commented 4 years ago

In my EA its work? Please show full example off youre code Thanks

morgan-blondellet commented 4 years ago

I don't have the non working code anymore. I had more or less the same thing as yourself but if i edited the current logged in user i would lose the password.

rogergerecke commented 4 years ago

I Fixit found the bug. I Edit my first coment

labgua commented 4 years ago

Hi, I just needed to solve a similar problem today: the password reset management in the admin backend

Actually I also found some problems in the implementation but the way to solve it is correct. So i decided to remake the @rogergerecke 's solution and post it here.

1) Insert in the User entity a not-mapped field (with getter/setter methods): it is used to handle the clear password.

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Entity(repositoryClass=UserRepository::class)
 */
class User implements UserInterface
{

    //...

    /**
     * @var string clear password for backend
     */
    private $clearpassword;

    /**
     * @return string
     */
    public function getClearpassword(): string
    {
        if( $this->clearpassword == null ) return "";
        return $this->clearpassword;
    }

    /**
     * @param string $clearpassword
     */
    public function setClearpassword(string $clearpassword): void
    {
        $this->clearpassword = $clearpassword;
    }

    //...

2) Inject in the User CRUD controller the passwordEncoder and override the updateEntity method to set the encoded password in the entity

MyLog is a utitlity class for debugging, I leave it to you for readability ...

namespace App\Controller\Admin;

use App\Entity\User;
use App\Utils\MyLog;
use Doctrine\ORM\EntityManagerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ArrayField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class UserCrudController extends AbstractCrudController
{
    /**
     * @var UserPasswordEncoderInterface
     */
    private $passwordEncoder;

    /**
     * UserCrudController constructor.
     * @param UserPasswordEncoderInterface $passwordEncoder
     */
    public function __construct(
        UserPasswordEncoderInterface $passwordEncoder
    ) {
        $this->passwordEncoder = $passwordEncoder;
    }

    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureFields(string $pageName): iterable
    {

        $password = TextField::new('clearpassword')
            ->setLabel("New Password")
            ->setFormType(PasswordType::class)
            ->setFormTypeOption('empty_data', '')
            ->setRequired(false)
            ->setHelp('If the right is not given, leave the field blank.')
            ->hideOnIndex();

        return [
            // ...
            $password,
            // ...
        ];
    }

    public function updateEntity(EntityManagerInterface $entityManager, $entityInstance): void
    {
        // set new password with encoder interface
        if (method_exists($entityInstance, 'setPassword')) {

            $clearPassword = trim($this->get('request_stack')->getCurrentRequest()->request->all()['User']['clearpassword']);

            ///MyLog::info("clearPass:" . $clearPassword);

            // save password only if is set a new clearpass
            if ( !empty($clearPassword) ) {
                ////MyLog::info("clearPass not empty! encoding password...");
                $encodedPassword = $this->passwordEncoder->encodePassword($this->getUser(), $clearPassword);
                $entityInstance->setPassword($encodedPassword);
            }
        }

        parent::updateEntity($entityManager, $entityInstance);
    }

}
alexandru-burca commented 4 years ago

@labgua and @rogergerecke your solution works great but will throw an error when there are some AJAX requests in Index, like BooleanField.

A possible solution to avoid this could be to check if it is a Xml request or not.

Update the function updateEntity to:

public function updateEntity(EntityManagerInterface $entityManager, $entityInstance): void
    {
        // set new password with encoder interface
       if (method_exists($entityInstance, 'setPassword') && !$this->get('request_stack')->getCurrentRequest()->isXmlHttpRequest()) {

            $clearPassword = trim($this->get('request_stack')->getCurrentRequest()->request->all()['User']['clearpassword']);

            ///MyLog::info("clearPass:" . $clearPassword);

            // save password only if is set a new clearpass
            if ( !empty($clearPassword) ) {
                ////MyLog::info("clearPass not empty! encoding password...");
                $encodedPassword = $this->passwordEncoder->encodePassword($this->getUser(), $clearPassword);
                $entityInstance->setPassword($encodedPassword);
            }
        }

        parent::updateEntity($entityManager, $entityInstance);
    }
Seb33300 commented 3 years ago

Here is my solution with EasyAdmin v3 and form events. It works even if the password field is mandatory.

<?php

namespace App\Controller\Admin;

use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\KeyValueStore;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class UserCrudController extends AbstractCrudController
{
    /** @var UserPasswordEncoderInterface */
    private $passwordEncoder;

    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureFields(string $pageName): iterable
    {
        return [
            FormField::addPanel('Change password')->setIcon('fa fa-key'),
            Field::new('plainPassword', 'New password')->onlyOnForms()
                ->setFormType(RepeatedType::class)
                ->setFormTypeOptions([
                    'type' => PasswordType::class,
                    'first_options' => ['label' => 'New password'],
                    'second_options' => ['label' => 'Repeat password'],
                ]),
        ];
    }

    public function createEditFormBuilder(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormBuilderInterface
    {
        $formBuilder = parent::createEditFormBuilder($entityDto, $formOptions, $context);

        $this->addEncodePasswordEventListener($formBuilder);

        return $formBuilder;
    }

    public function createNewFormBuilder(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormBuilderInterface
    {
        $formBuilder = parent::createNewFormBuilder($entityDto, $formOptions, $context);

        $this->addEncodePasswordEventListener($formBuilder);

        return $formBuilder;
    }

    /**
     * @required
     */
    public function setEncoder(UserPasswordEncoderInterface $passwordEncoder): void
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    protected function addEncodePasswordEventListener(FormBuilderInterface $formBuilder)
    {
        $formBuilder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) {
            /** @var User $user */
            $user = $event->getData();
            if ($user->getPlainPassword()) {
                $user->setPassword($this->passwordEncoder->encodePassword($user, $user->getPlainPassword()));
            }
        });
    }
}
pibrom commented 3 years ago

Hello,

I have an issue on this getter. When I submit a form with empty passwords, I had an error "Expected argument of type "string", "null" given at property path "plainPassword".

Do you encourter the same issue ?


/**
     * @return string
     */
    public function getPlainPassword()
    {
        if( $this->plainPassword == null ) return "";
        return $this->plainPassword;
    }
morgan-blondellet commented 3 years ago

Hi.

It seems to me that your error is coming from the setter and not the getter. If an argument is expected it would be at the setter level.

Can you share your setter's code please ?

pibrom commented 3 years ago

Thanks for your answer.

Here is my setter, It is configured as described above 👍

   /**
     * @param string $plainPassword
     */
    public function setPlainPassword(string $plainPassword): void
    {
        $this->plainPassword = $plainPassword;
    }

Do you think I should modify something in this setter ?

morgan-blondellet commented 3 years ago

No problem !

Yes, your function's argument is typed as a non nullable string.

In order to be able to send null to the setPlainPassword method you need to type your argument ?string. The question mark specifies that your argument can be null instead of a string.

pibrom commented 3 years ago

Marvelous, it works ! Thanks for your help.

So, for everyone who read this thead, here is my setter

   /**
     * @param string $plainPassword
     */
    public function setPlainPassword(?string $plainPassword): void
    {
        $this->plainPassword = $plainPassword;
    }
BurningDog commented 3 years ago

The solution at https://github.com/EasyCorp/EasyAdminBundle/issues/3349#issuecomment-695214741 works nicely, however, it doesn't allow EasyAdmin to expose any other fields on the User entity.

I don't see a way to do that by changing how the configureFields() function works to return a Symfony form, rather than a list of FieldInterfaces.

For instance, the auto-upgrade from EasyAdmin 2 to 3 gives me this:

public function configureFields(string $pageName): iterable
{
    $email = TextField::new('email');
    $password = TextField::new('password');
    $firstName = TextField::new('firstName');
    $lastName = TextField::new('lastName');
    $created = DateTimeField::new('created');
    $updated = DateTimeField::new('updated');

    if (Crud::PAGE_INDEX === $pageName) {
        return [$id, $email, $firstName, $lastName, $created, $updated];
    } elseif (Crud::PAGE_DETAIL === $pageName) {
        return [$id, $email, $roles, $firstName, $lastName, $created, $updated];
    } elseif (Crud::PAGE_NEW === $pageName) {
        return [$email, $password, $firstName, $lastName, $created, $updated];
    } elseif (Crud::PAGE_EDIT === $pageName) {
        return [$email, $password, $firstName, $lastName, $created, $updated];
    }
}

Is there a way to use the code from https://github.com/EasyCorp/EasyAdminBundle/issues/3349#issuecomment-695214741 here?

Would it not rather make more sense to create a PasswordField which extends FieldInterface?

Seb33300 commented 3 years ago

it doesn't allow EasyAdmin to expose any other fields on the User entity

I am not sure to understand what you mean. I gave the minimal working example.

You can of course add as many fields as you need by adding them to the returned array.

zorn-v commented 3 years ago

I achieved this by using EA events. Like this

<?php

namespace App\Controller\Admin;

use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityPersistedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityUpdatedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class UserCrudController extends AbstractCrudController implements EventSubscriberInterface
{
    /** @var UserPasswordEncoderInterface */
    private $passwordEncoder;

    public function __construct(UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureFields(string $pageName): iterable
    {
        return array_map(function ($f) use ($pageName) {
            if ($f->getAsDto()->getProperty() === 'password') {
                $field = TextField::new('plain_password', Crud::PAGE_NEW === $pageName ? 'Password' : 'Change password')
                    ->setFormType(PasswordType::class);
                if (Crud::PAGE_NEW === $pageName) {
                    $field->setRequired(true);
                }
                return $field;
            }
            return $f;
        }, parent::configureFields($pageName));
    }

    public static function getSubscribedEvents()
    {
        return [
            BeforeEntityPersistedEvent::class => 'encodePassword',
            BeforeEntityUpdatedEvent::class => 'encodePassword',
        ];
    }

    /** @internal */
    public function encodePassword($event)
    {
        $user = $event->getEntityInstance();
        if ($user instanceof User && $user->getPlainPassword()) {
            $user->setPassword($this->passwordEncoder->encodePassword($user, $user->getPlainPassword()));
        }
    }
}
Yeleup commented 3 years ago

I achieved this by using EA events. Like this

<?php

namespace App\Controller\Admin;

use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityPersistedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityUpdatedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class UserCrudController extends AbstractCrudController implements EventSubscriberInterface
{
    /** @var UserPasswordEncoderInterface */
    private $passwordEncoder;

    public function __construct(UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureFields(string $pageName): iterable
    {
        return array_map(function ($f) use ($pageName) {
            if ($f->getAsDto()->getProperty() === 'password') {
                $field = TextField::new('plain_password', Crud::PAGE_NEW === $pageName ? 'Password' : 'Change password')
                    ->setFormType(PasswordType::class);
                if (Crud::PAGE_NEW === $pageName) {
                    $field->setRequired(true);
                }
                return $field;
            }
            return $f;
        }, parent::configureFields($pageName));
    }

    public static function getSubscribedEvents()
    {
        return [
            BeforeEntityPersistedEvent::class => 'encodePassword',
            BeforeEntityUpdatedEvent::class => 'encodePassword',
        ];
    }

    /** @internal */
    public function encodePassword($event)
    {
        $user = $event->getEntityInstance();
        if ($user->getPlainPassword()) {
            $user->setPassword($this->passwordEncoder->encodePassword($user, $user->getPlainPassword()));
        }
    }
}

don't forget to put if (!($user instanceof User)) { return; }

zorn-v commented 3 years ago

Yep. Or

if ($user instanceof User && $user->getPlainPassword()) {

Comment above updated

parijke commented 3 years ago

Is there a special reason to put the subsriber logic in the controller? I normally do this in a seperate subscriber class

zorn-v commented 3 years ago

Is there a special reason to put the subsriber logic in the controller?

No, it is just for example. Feel free to do it as you wish :wink: You can also subscribe to doctrine events for store encoded password from any place of your app.

milosa commented 3 years ago

This really should be in the docs.

parijke commented 3 years ago

@milosa make a PR then

CristinaEsteban97 commented 3 years ago

Here is my solution with EasyAdmin v3 and form events. It works even if the password field is mandatory.

<?php

namespace App\Controller\Admin;

use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\KeyValueStore;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class UserCrudController extends AbstractCrudController
{
    /** @var UserPasswordEncoderInterface */
    private $passwordEncoder;

    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureFields(string $pageName): iterable
    {
        return [
            FormField::addPanel('Change password')->setIcon('fa fa-key'),
            Field::new('plainPassword', 'New password')->onlyOnForms()
                ->setFormType(RepeatedType::class)
                ->setFormTypeOptions([
                    'type' => PasswordType::class,
                    'first_options' => ['label' => 'New password'],
                    'second_options' => ['label' => 'Repeat password'],
                ]),
        ];
    }

    public function createEditFormBuilder(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormBuilderInterface
    {
        $formBuilder = parent::createEditFormBuilder($entityDto, $formOptions, $context);

        $this->addEncodePasswordEventListener($formBuilder);

        return $formBuilder;
    }

    public function createNewFormBuilder(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormBuilderInterface
    {
        $formBuilder = parent::createNewFormBuilder($entityDto, $formOptions, $context);

        $this->addEncodePasswordEventListener($formBuilder);

        return $formBuilder;
    }

    /**
     * @required
     */
    public function setEncoder(UserPasswordEncoderInterface $passwordEncoder): void
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    protected function addEncodePasswordEventListener(FormBuilderInterface $formBuilder)
    {
        $formBuilder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) {
            /** @var User $user */
            $user = $event->getData();
            if ($user->getPlainPassword()) {
                $user->setPassword($this->passwordEncoder->encodePassword($user, $user->getPlainPassword()));
            }
        });
    }
}

@Seb33300 I have tried to do that but I get this mistake "Call to a member function encodePassword() on null". How can I solve that?

Seb33300 commented 3 years ago

@CristinaEsteban97 autowiring needs to be enabled in order to be able to use @required annotation. See https://symfony.com/doc/current/service_container/autowiring.html#autowiring-other-methods-e-g-setters-and-public-typed-properties

CristinaEsteban97 commented 3 years ago

@ CristinaEsteban97 autowiring debe estar habilitado para poder usar la @requiredanotación. Consulte https://symfony.com/doc/current/service_container/autowiring.html#autowiring-other-methods-eg-setters-and-public-typed-properties

@Seb33300 Solved! But I don't undenstand why we need the @required anotation to the method setEncoder() and @var UserPasswordEncoderInterface to the var passwordEncoder. Can you explain me that please?

parijke commented 3 years ago

It looks like a constructor replacement to me. Why not setting it in the constructor?

s-chizhik commented 2 years ago

@CristinaEsteban97 awesome! Your example works perfectly for v4. Thank you.

lDeleted commented 2 years ago

I achieved this by using EA events. Like this

<?php

namespace App\Controller\Admin;

use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityPersistedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityUpdatedEvent;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class UserCrudController extends AbstractCrudController implements EventSubscriberInterface
{
    /** @var UserPasswordEncoderInterface */
    private $passwordEncoder;

    public function __construct(UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureFields(string $pageName): iterable
    {
        return array_map(function ($f) use ($pageName) {
            if ($f->getAsDto()->getProperty() === 'password') {
                $field = TextField::new('plain_password', Crud::PAGE_NEW === $pageName ? 'Password' : 'Change password')
                    ->setFormType(PasswordType::class);
                if (Crud::PAGE_NEW === $pageName) {
                    $field->setRequired(true);
                }
                return $field;
            }
            return $f;
        }, parent::configureFields($pageName));
    }

    public static function getSubscribedEvents()
    {
        return [
            BeforeEntityPersistedEvent::class => 'encodePassword',
            BeforeEntityUpdatedEvent::class => 'encodePassword',
        ];
    }

    /** @internal */
    public function encodePassword($event)
    {
        $user = $event->getEntityInstance();
        if ($user instanceof User && $user->getPlainPassword()) {
            $user->setPassword($this->passwordEncoder->encodePassword($user, $user->getPlainPassword()));
        }
    }
}

Hi! Excuse me, where can I put the rest of the field configuration? (I'm new to EA, sorry)

"Attempted to call an undefined method named "getAsDto" of class "Generator"."

luismisanchez commented 2 years ago

Reading all the previous comments (thanks a lot guys for your examples) I've got it working en EasyAdmin 4 + Symfony 5.4 (php 8.1.1). Works for me on edit user and new user actions. In the New User action password field is required. In the Edit User action it is not. You can pass a blank password and the current one won't be changed.

    <?php
    #Controller/Admin/UserCrudController.php

    namespace App\Controller\Admin;

    use App\Entity\User;
    use EasyCorp\Bundle\EasyAdminBundle\Config\KeyValueStore;
    use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
    use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
    use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
    use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
    use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
    use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
    use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
    use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
    use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
    use Symfony\Component\Form\Extension\Core\Type\PasswordType;
    use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
    use Symfony\Component\Form\FormBuilderInterface;
    use Symfony\Component\Form\FormEvent;
    use Symfony\Component\Form\FormEvents;
    use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

    class UserCrudController extends AbstractCrudController {

        private UserPasswordHasherInterface $passwordEncoder;

        public function __construct( UserPasswordHasherInterface $passwordEncoder ) {
            $this->passwordEncoder = $passwordEncoder;
        }

        public static function getEntityFqcn(): string {
            return User::class;
        }

        public function configureFields( string $pageName ): iterable {
            yield FormField::addPanel( 'User data' )->setIcon( 'fa fa-user' );
            yield EmailField::new( 'email' )->onlyWhenUpdating()->setDisabled();
            yield EmailField::new( 'email' )->onlyWhenCreating();
            yield TextField::new( 'email' )->onlyOnIndex();
            $roles = [ 'ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_USER' ];
            yield ChoiceField::new( 'roles' )
                             ->setChoices( array_combine( $roles, $roles ) )
                             ->allowMultipleChoices()
                             ->renderAsBadges();
            yield FormField::addPanel( 'Change password' )->setIcon( 'fa fa-key' );
            yield Field::new( 'password', 'New password' )->onlyWhenCreating()->setRequired( true )
                       ->setFormType( RepeatedType::class )
                       ->setFormTypeOptions( [
                           'type'            => PasswordType::class,
                           'first_options'   => [ 'label' => 'New password' ],
                           'second_options'  => [ 'label' => 'Repeat password' ],
                           'error_bubbling'  => true,
                           'invalid_message' => 'The password fields do not match.',
                       ] );
            yield Field::new( 'password', 'New password' )->onlyWhenUpdating()->setRequired( false )
                       ->setFormType( RepeatedType::class )
                       ->setFormTypeOptions( [
                           'type'            => PasswordType::class,
                           'first_options'   => [ 'label' => 'New password' ],
                           'second_options'  => [ 'label' => 'Repeat password' ],
                           'error_bubbling'  => true,
                           'invalid_message' => 'The password fields do not match.',
                       ] );
        }

        public function createEditFormBuilder( EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context ): FormBuilderInterface {
            $plainPassword = $entityDto->getInstance()?->getPassword();
            $formBuilder   = parent::createEditFormBuilder( $entityDto, $formOptions, $context );
            $this->addEncodePasswordEventListener( $formBuilder, $plainPassword );

            return $formBuilder;
        }

        public function createNewFormBuilder( EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context ): FormBuilderInterface {
            $formBuilder = parent::createNewFormBuilder( $entityDto, $formOptions, $context );
            $this->addEncodePasswordEventListener( $formBuilder );

            return $formBuilder;
        }

        protected function addEncodePasswordEventListener( FormBuilderInterface $formBuilder, $plainPassword = null ): void {
            $formBuilder->addEventListener( FormEvents::SUBMIT, function ( FormEvent $event ) use ( $plainPassword ) {
                /** @var User $user */
                $user = $event->getData();
                if ( $user->getPassword() !== $plainPassword ) {
                    $user->setPassword( $this->passwordEncoder->hashPassword( $user, $user->getPassword() ) );
                }
            } );
        }
    }

In the User class I've set password as nullable:

    <?php
    #Entity/User.php

    namespace App\Entity;

    use App\Repository\UserRepository;
    use Doctrine\Common\Collections\ArrayCollection;
    use Doctrine\Common\Collections\Collection;
    use Doctrine\ORM\Mapping as ORM;
    use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
    use Symfony\Component\Security\Core\User\UserInterface;

    #[ORM\Entity( repositoryClass: UserRepository::class )]
    class User implements UserInterface, PasswordAuthenticatedUserInterface {

        #[ORM\Column( type: 'string', nullable: true )]
        private ?string $password = null;

        public function getPassword(): ?string {
            return $this->password;
        }

    public function setPassword( ?string $password ): self {
        if (!is_null($password)) {
            $this->password = $password;
        }

        return $this;
    }

And to avoid deprecation messages I've also set this:

    #config/packages/framework.yaml

    framework:
        form:
            legacy_error_messages: false
alejandrogr commented 1 year ago

Hello! I'm trying @luismisanchez solution on Symfony 6.1.5, with EA 4.3 and I'm getting an error. I don't really know what is happening.

The error is as follow:

App\Controller\Admin\UserCrudController::addEncodePasswordEventListener(): Argument #1 ($formBuilder) must be of type Symfony\Component\Form\Test\FormBuilderInterface, Symfony\Component\Form\FormBuilder given, called in /opt/homebrew/var/www/cesida/src/Controller/Admin/UserCrudController.php on line 77

The implementation of the UserCrudController and the User entity is the same as @luismisanchez solution.

Im new in PHP and Symfony and I don't understand why if FormBuilder implements the FormBuilderInterface interface, I'm getting this error.

Any help would be much appreciated :D

parijke commented 1 year ago

You probably have the wrong import in your UserCrudController? image

Should be use Symfony\Component\Form\FormBuilderInterface;

alejandrogr commented 1 year ago

Oh god... I feel stupid now for not seeing it in the error trace :( Good catch! you were right! Thanks!!! 👍

morgan-blondellet commented 1 year ago

Tunnel vision at its best :) We all had it at least once.

Seb33300 commented 1 year ago

Good news guys!

This will be a built-in feature of Symfony 6.2 that will be released next month. PasswordType now has a hash_property_path option for this purpose: https://symfony.com/doc/6.2/reference/forms/types/password.html#hash-property-path

Using a RepeatedType, the only required config will be:

public function configureFields(string $pageName): iterable
{
    return [
        // ...
        Field::new('plainPassword', 'New password')
            ->onlyOnForms()
            ->setFormType(RepeatedType::class)
            ->setFormTypeOptions([
                'type' => PasswordType::class,
                'mapped' => false,
                'first_options' => ['hash_property_path' => 'password', 'label' => 'New password'],
                'second_options' => ['label' => 'Repeat password'],
            ]),
        // ...
    ];
}

And that's all! The password will be automatically hashed and saved into your $user->password.

_Note that you need to replace 'hash_property_path' => 'password' by the property name where you want to store the password on your User's entity (if different from $password)._

OskarStark commented 1 year ago

Thanks for the hint @Seb33300

Lets close then!