Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Attribute/AdminDashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
Expand Down
1 change: 1 addition & 0 deletions src/Controller/AbstractCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
11 changes: 11 additions & 0 deletions src/Dto/FieldDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ final class FieldDto
private KeyValueStore $displayedOn;
/** @var array<string, bool|int|float|string> */
private array $htmlAttributes = [];
private bool $isAccessible = true;

public function __construct()
{
Expand Down Expand Up @@ -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;
}
}
15 changes: 9 additions & 6 deletions src/Factory/EntityFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 40 additions & 10 deletions src/Factory/FieldFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/Field/Configurator/CommonPostConfigurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 6 additions & 9 deletions src/Field/Configurator/CommonPreConfigurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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() : [];
Expand Down Expand Up @@ -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');
}

Expand Down
4 changes: 4 additions & 0 deletions src/Form/Type/CrudFormType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 8 additions & 9 deletions templates/crud/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down Expand Up @@ -151,9 +151,13 @@

{% for field in entity.fields %}
{% set is_searchable = null == ea.crud.searchFields or field.property in ea.crud.searchFields %}

<td data-column="{{ field.property }}" data-label="{{ field.label|trans|e('html') }}" class="{{ is_searchable ? 'searchable' }} {{ field.property == sort_field_name ? 'sorted' }} text-{{ field.textAlign }} {{ field.cssClass }}" dir="{{ ea.i18n.textDirection }}" {% for name, value in field.htmlAttributes %}{{ name }}="{{ value|e('html_attr') }}" {% endfor %}>
{{ 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 %}
<twig:ea:Icon name="internal:lock" class="mr-1" />
{% endif %}
</td>
{% endfor %}

Expand Down Expand Up @@ -193,12 +197,7 @@
{% block table_body_empty %}
{% for i in 1..14 %}
<tr class="empty-row">
<td><span></span></td>
<td><span></span></td>
<td><span></span></td>
<td><span></span></td>
<td><span></span></td>
<td><span></span></td>
<td colspan="6"><span></span></td>
</tr>

{% if 3 == loop.index %}
Expand Down
1 change: 1 addition & 0 deletions tests/Orm/CustomerSortTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
60 changes: 60 additions & 0 deletions tests/Security/VoterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Security;

use Doctrine\ORM\EntityRepository;
use EasyCorp\Bundle\EasyAdminBundle\Test\AbstractCrudTestCase;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\DashboardController;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\Sort\WebsiteCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\Website;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Security\CustomVoter;
use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy;

class VoterTest extends AbstractCrudTestCase
{
/**
* @var EntityRepository
*/
private $repository;

protected function getControllerFqcn(): string
{
return WebsiteCrudController::class;
}

protected function getDashboardFqcn(): string
{
return DashboardController::class;
}

protected function setUp(): void
{
parent::setUp();
$this->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"]');
}
}
63 changes: 63 additions & 0 deletions tests/TestApplication/src/Security/CustomVoter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Security;

use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
use EasyCorp\Bundle\EasyAdminBundle\Security\Permission;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/**
* Custom voter that allows to ban an entity and its property from being processed.
*
* This is only for testing purposes, to simulate the behavior of the FieldFactory::processFields() method when
* EA_VIEW_FIELD will return FALSE, on first entity, which is used to build table theaders.
*/
class CustomVoter extends Voter
{
private ?object $banEntity = null;
private ?string $banEntityProperty = null;
private bool $enabled = false;

protected function supports(string $attribute, mixed $subject): bool
{
return $this->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;
}
}
Loading