diff --git a/src/Attribute/AdminDashboard.php b/src/Attribute/AdminDashboard.php index eef97709d7..dfdda6a268 100644 --- a/src/Attribute/AdminDashboard.php +++ b/src/Attribute/AdminDashboard.php @@ -12,11 +12,11 @@ public function __construct( /** * @var string|null $routePath The path of the Symfony route that will be created for the dashboard (e.g. '/admin) */ - public /* ?string */ $routePath = null, + /* ?string */ public $routePath = null, /** * @var string|null $routeName The name of the Symfony route that will be created for the dashboard (e.g. 'admin') */ - public /* ?string */ $routeName = null, + /* ?string */ public $routeName = null, /** * @var array{ * requirements?: array, diff --git a/src/Controller/AbstractCrudController.php b/src/Controller/AbstractCrudController.php index df7da1c487..47ed17e124 100644 --- a/src/Controller/AbstractCrudController.php +++ b/src/Controller/AbstractCrudController.php @@ -153,6 +153,7 @@ public function index(AdminContext $context) 'pageName' => Crud::PAGE_INDEX, 'templateName' => 'crud/index', 'entities' => $entities, + 'fields' => $fields, 'paginator' => $paginator, 'global_actions' => $actions->getGlobalActions(), 'batch_actions' => $actions->getBatchActions(), diff --git a/src/Dto/FieldDto.php b/src/Dto/FieldDto.php index 32f54f6d68..9f0dbdae38 100644 --- a/src/Dto/FieldDto.php +++ b/src/Dto/FieldDto.php @@ -66,6 +66,7 @@ final class FieldDto private KeyValueStore $displayedOn; /** @var array */ private array $htmlAttributes = []; + private bool $isAccessible = true; public function __construct() { @@ -560,4 +561,14 @@ public function setHtmlAttribute(string $attribute, mixed $value): self return $this; } + + public function markAsInaccessible(): void + { + $this->isAccessible = false; + } + + public function isAccessible(): bool + { + return $this->isAccessible; + } } diff --git a/src/Factory/EntityFactory.php b/src/Factory/EntityFactory.php index cec0d8e907..7b8ad80b1e 100644 --- a/src/Factory/EntityFactory.php +++ b/src/Factory/EntityFactory.php @@ -38,17 +38,20 @@ public function __construct(FieldFactory $fieldFactory, ActionFactory $actionFac $this->eventDispatcher = $eventDispatcher; } - public function processFields(EntityDto $entityDto, FieldCollection $fields): void + public function processFields(EntityDto|EntityCollection $entities, FieldCollection $fields): void { - $this->fieldFactory->processFields($entityDto, $fields); + if ($entities instanceof EntityDto) { + $collection = EntityCollection::new([]); + $collection->set($entities); + $entities = $collection; + } + + $this->fieldFactory->processFields($entities, $fields); } public function processFieldsForAll(EntityCollection $entities, FieldCollection $fields): void { - foreach ($entities as $entity) { - $this->processFields($entity, clone $fields); - $entities->set($entity); - } + $this->processFields($entities, $fields); } public function processActions(EntityDto $entityDto, ActionConfigDto $actionConfigDto): void diff --git a/src/Factory/FieldFactory.php b/src/Factory/FieldFactory.php index 7bd2088e36..928f9ae23b 100644 --- a/src/Factory/FieldFactory.php +++ b/src/Factory/FieldFactory.php @@ -3,6 +3,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Factory; use Doctrine\DBAL\Types\Types; +use EasyCorp\Bundle\EasyAdminBundle\Collection\EntityCollection; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface; @@ -71,8 +72,15 @@ public function __construct( { } - public function processFields(EntityDto $entityDto, FieldCollection $fields): void + public function processFields(EntityCollection $entities, FieldCollection $fields): void { + if (0 === $entities->count()) { + return; + } + + // something that can be and should be done only once for all entities + $entityDto = clone $entities->first(); + $entityDto->setInstance(null); $this->preProcessFields($fields, $entityDto); $context = $this->adminContextProvider->getContext(); @@ -94,14 +102,6 @@ public function processFields(EntityDto $entityDto, FieldCollection $fields): vo continue; } - // when creating new entities with "useEntryCrudForm" on an edit page we must - // explicitly check for the "new" page because $currentPage will be "edit" - if ((null === $entityDto->getInstance()) && !$fieldDto->isDisplayedOn(Crud::PAGE_NEW)) { - $fields->unset($fieldDto); - - continue; - } - foreach ($this->fieldConfigurators as $configurator) { if (!$configurator->supports($fieldDto, $entityDto)) { continue; @@ -123,12 +123,42 @@ public function processFields(EntityDto $entityDto, FieldCollection $fields): vo $fields->set($fieldDto); } + unset($entityDto, $fieldDto); if (!$fields->isEmpty()) { $this->fieldLayoutFactory->createLayout($fields, $this->adminContextProvider->getContext()?->getCrud()?->getCurrentPage() ?? Crud::PAGE_INDEX); } - $entityDto->setFields($fields); + $originalFields = $fields; + foreach ($entities as $entityDto) { + $fields = clone $originalFields; + foreach ($fields as $fieldDto) { + if (false === $this->authorizationChecker->isGranted(Permission::EA_VIEW_FIELD, $fieldDto)) { + $fieldDto->markAsInaccessible(); + + continue; + } + + // when creating new entities with "useEntryCrudForm" on an edit page we must + // explicitly check for the "new" page because $currentPage will be "edit" + if ((null === $entityDto->getInstance()) && !$fieldDto->isDisplayedOn(Crud::PAGE_NEW)) { + $fieldDto->markAsInaccessible(); + + continue; + } + + foreach ($this->fieldConfigurators as $configurator) { + if (!$configurator->supports($fieldDto, $entityDto)) { + continue; + } + + // @phpstan-ignore-next-line argument.type + $configurator->configure($fieldDto, $entityDto, $context); + } + } + + $entityDto->setFields($fields); + } } private function preProcessFields(FieldCollection $fields, EntityDto $entityDto): void diff --git a/src/Field/Configurator/CommonPostConfigurator.php b/src/Field/Configurator/CommonPostConfigurator.php index d3239f3316..fb7571285e 100644 --- a/src/Field/Configurator/CommonPostConfigurator.php +++ b/src/Field/Configurator/CommonPostConfigurator.php @@ -48,7 +48,12 @@ private function buildFormattedValueOption(mixed $value, FieldDto $field, Entity return $value; } - $formatted = $callable($field->getValue(), $entityDto->getInstance()); + $fieldValue = $field->getValue(); + if (null === $fieldValue) { + return $value; + } + + $formatted = $callable($fieldValue, $entityDto->getInstance()); // if the callable returns a string, wrap it in a Twig Markup to render the // HTML and CSS/JS elements that it might contain diff --git a/src/Field/Configurator/CommonPreConfigurator.php b/src/Field/Configurator/CommonPreConfigurator.php index 76aa247222..1c631d82f4 100644 --- a/src/Field/Configurator/CommonPreConfigurator.php +++ b/src/Field/Configurator/CommonPreConfigurator.php @@ -45,12 +45,13 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c // if a field already has set a value, someone has written something to // it (as a virtual field or overwrite); don't modify the value in that case - $isReadable = true; if (null === $value = $field->getValue()) { try { $value = null === $entityDto->getInstance() ? null : $this->propertyAccessor->getValue($entityDto->getInstance(), $field->getProperty()); } catch (AccessException|UnexpectedTypeException) { - $isReadable = false; + if (!$field->isFormLayoutField()) { + $field->markAsInaccessible(); + } } $field->setValue($value); @@ -72,7 +73,7 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c $isVirtual = $this->buildVirtualOption($field, $entityDto); $field->setVirtual($isVirtual); - $templatePath = $this->buildTemplatePathOption($context, $field, $entityDto, $isReadable); + $templatePath = $this->buildTemplatePathOption($context, $field, $entityDto); $field->setTemplatePath($templatePath); $doctrineMetadata = $entityDto->hasProperty($field->getProperty()) ? $entityDto->getPropertyMetadata($field->getProperty())->all() : []; @@ -164,14 +165,10 @@ private function buildVirtualOption(FieldDto $field, EntityDto $entityDto): bool return !$entityDto->hasProperty($field->getProperty()); } - private function buildTemplatePathOption(AdminContext $adminContext, FieldDto $field, EntityDto $entityDto, bool $isReadable): string + private function buildTemplatePathOption(AdminContext $adminContext, FieldDto $field, EntityDto $entityDto): string { - if (null !== $templatePath = $field->getTemplatePath()) { - return $templatePath; - } - // if field has a value set, don't display it as inaccessible (needed e.g. for virtual fields) - if (!$isReadable && null === $field->getValue()) { + if (!$field->isAccessible() && null === $field->getValue()) { return $adminContext->getTemplatePath('label/inaccessible'); } diff --git a/src/Form/Type/CrudFormType.php b/src/Form/Type/CrudFormType.php index fd1384964d..0c709cb9e3 100644 --- a/src/Form/Type/CrudFormType.php +++ b/src/Form/Type/CrudFormType.php @@ -47,6 +47,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void /** @var FieldDto $fieldDto */ foreach ($entityDto->getFields() as $fieldDto) { + if (!$fieldDto->isAccessible()) { + continue; + } + $formFieldOptions = $fieldDto->getFormTypeOptions(); // the names of embedded Doctrine entities contain dots, which are not allowed diff --git a/templates/crud/index.html.twig b/templates/crud/index.html.twig index 269c9b44b4..e7f1b5ef17 100644 --- a/templates/crud/index.html.twig +++ b/templates/crud/index.html.twig @@ -106,7 +106,7 @@ {% set ea_sort_asc = constant('EasyCorp\\Bundle\\EasyAdminBundle\\Config\\Option\\SortOrder::ASC') %} {% set ea_sort_desc = constant('EasyCorp\\Bundle\\EasyAdminBundle\\Config\\Option\\SortOrder::DESC') %} - {% for field in entities|filter(e => e.isAccessible)|first.fields ?? [] %} + {% for field in fields %} {% set is_searchable = null == ea.crud.searchFields or field.property in ea.crud.searchFields %} {% set is_sorting_field = ea.search.isSortingField(field.property) %} {% set next_sort_direction = is_sorting_field ? (ea.search.sortDirection(field.property) == ea_sort_desc ? ea_sort_asc : ea_sort_desc) : ea_sort_desc %} @@ -151,9 +151,13 @@ {% for field in entity.fields %} {% set is_searchable = null == ea.crud.searchFields or field.property in ea.crud.searchFields %} - - {{ include(field.templatePath, { field: field, entity: entity }, with_context = false) }} + {{ dump(field, entity) }} + {% if field.isAccessible %} + {{ include(field.templatePath, { field: field, entity: entity }, with_context = false) }} + {% else %} + + {% endif %} {% endfor %} @@ -193,12 +197,7 @@ {% block table_body_empty %} {% for i in 1..14 %} - - - - - - + {% if 3 == loop.index %} diff --git a/tests/Orm/CustomerSortTest.php b/tests/Orm/CustomerSortTest.php index d8f879faa4..dfcb1d6bd0 100644 --- a/tests/Orm/CustomerSortTest.php +++ b/tests/Orm/CustomerSortTest.php @@ -57,6 +57,7 @@ public function testSorting(array $query, ?\Closure $sortFunction, string $expec // Assert $this->assertResponseIsSuccessful(); + $this->assertSelectorTextSame('th.header-for-field-association > a', 'Bills'); $this->assertSelectorExists('th.header-for-field-association span.icon svg'); diff --git a/tests/Security/VoterTest.php b/tests/Security/VoterTest.php new file mode 100644 index 0000000000..8bea3388d5 --- /dev/null +++ b/tests/Security/VoterTest.php @@ -0,0 +1,60 @@ +client->followRedirects(); + $this->repository = $this->entityManager->getRepository(Website::class); + } + + public function testingVoter(): void + { + // Arrange + $entities = $this->repository->findAll(); + + /** @var CustomVoter $voter */ + $voter = $this->getContainer()->get(CustomVoter::class); + $voter->ban($entities[0], 'name'); + + // UnanimousStrategy + $accessDecisionManager = $this->getContainer()->get('security.access.decision_manager'); + \Closure::bind(function () { + $this->strategy = new UnanimousStrategy(); + }, $accessDecisionManager, $accessDecisionManager)(); + + // Act + $crawler = $this->client->request('GET', $this->generateIndexUrl()); + + // Assert + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists('th[data-column="pages"]'); + $this->assertSelectorExists('th[data-column="name"]'); + } +} diff --git a/tests/TestApplication/src/Security/CustomVoter.php b/tests/TestApplication/src/Security/CustomVoter.php new file mode 100644 index 0000000000..e6716b357c --- /dev/null +++ b/tests/TestApplication/src/Security/CustomVoter.php @@ -0,0 +1,63 @@ +enabled && Permission::exists($attribute); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + { + static $entities = []; + + if ($subject instanceof EntityDto && $subject->getInstance()) { + $entities[] = $subject; + } + + foreach ($entities as $entityDto) { + if ($this->banEntity && $entityDto->getInstance() === $this->banEntity) { + // imitate the behavior of FieldFactory::processFields() + // which removes the field from the entity if it is not granted + if ($subject instanceof FieldDto) { + if ($fieldDto = $entityDto->getFields()?->getByProperty($this->banEntityProperty)) { + $entityDto->getFields()->unset($fieldDto); + } + } + } + } + + return true; + } + + public function ban(object $entity, ?string $propertyName = null): void + { + $this->banEntity = $entity; + $this->banEntityProperty = $propertyName; + $this->enabled = true; + } + + public function removeBan(): void + { + $this->banEntity = null; + $this->banEntityProperty = null; + } +}