diff -u b/role_delegation.module b/role_delegation.module --- b/role_delegation.module +++ b/role_delegation.module @@ -11,11 +11,19 @@ * without needing access to the user edit form. */ +use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\role_delegation\DelegatableRolesInterface; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; +use Drupal\user\UserInterface; /** * Implements hook_help(). @@ -50,41 +58,154 @@ /** + * Implements hook_ENTITY_TYPE_presave(). + */ +function role_delegation_user_presave(EntityInterface $entity) { + /** @var \Drupal\user\UserInterface $entity */ + + $submitted_roles = []; + foreach ($entity->role_change as $item) { + $submitted_roles[] = $item->target_id; + } + + // Change roles based on the field for role delegation. + if ($submitted_roles != DelegatableRolesInterface::EMPTY_FIELD_VALUE) { + $current_user = \Drupal::currentUser(); + $delegatable_roles = array_keys(\Drupal::service('delegatable_roles') + ->getAssignableRoles($current_user)); + + + // Of the roles that were submitted, only add ones that the user has access + // to use. + $add_roles = array_intersect($delegatable_roles, $submitted_roles); + foreach ($add_roles as $id) { + $entity->addRole($id); + } + // Any roles that the user has access to use and did not include in + // submission are removals. + $remove_roles = array_diff($delegatable_roles, $submitted_roles); + foreach ($remove_roles as $id) { + $entity->removeRole($id); + } + } +} + +/** + * Implements hook_ENTITY_TYPE_load(). + */ +function role_delegation_user_load($entities) { + // This is a workaround for known limitations of computed fields: since they + // are not stored, they are also not loaded with the user, so values must be + // manually supplied. This allows us to later determine that an empty field + // actually means intentional role removals, as opposed to field data not + // being sent/no access to field. + // Things may later with https://www.drupal.org/node/2392845. + foreach ($entities as $user_entity) { + $user_entity->set('role_change', DelegatableRolesInterface::EMPTY_FIELD_VALUE); + } +} + +/** * Implements hook_form_alter(). */ function role_delegation_form_alter(&$form, FormStateInterface $form_state, $form_id) { - $current_user = \Drupal::currentUser(); + // Add an entity builder for the user entity to ensure that it recieves the + // "empty" value when the field is not accessible. + if (in_array($form_id, array('user_register_form', 'user_form'))) { + $form['#entity_builders'][] = 'role_delegation_user_form_builder'; + } +} - // Users with "assign all roles" already get access to the whole set of - // permissions, so we don't need to do anything in that case, but for everyone - // else, adjust the available options for roles. - $is_user_form = in_array($form_id, array('user_register_form', 'user_form')); - if ($is_user_form && !$current_user->hasPermission('administer permissions')) { - $account = $form_state->getFormObject()->getEntity(); - $current_roles = array(); - if ($account) { - $current_roles = $account->getRoles(TRUE); - $current_roles = array_combine($current_roles, $current_roles); - } +/** + * Implements hook_field_widget_form_alter(). + */ +function role_delegation_field_widget_form_alter(&$element, FormStateInterface $form_state, $context) { + /** @var \Drupal\Core\Field\FieldItemListInterface $items */ + /** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */ + $items = $context['items']; + $field_definition = $items->getFieldDefinition(); + + // Since the field is computed, the default value of the form element will be + // empty, so we need to adjust it. + if ($field_definition->getTargetEntityTypeId() == 'user' && $field_definition->getName() == 'role_change' && isset($element['#options'])) { + $roles_current = $items->getEntity()->getRoles(); + $roles_options = array_keys($element['#options']); + $element['#default_value'] = array_intersect($roles_current, $roles_options); + } +} - $delegatable_roles = \Drupal::service('delegatable_roles') +/** + * Implements hook_options_list_alter(). + */ +function role_delegation_options_list_alter(array &$options, array $context) { + /** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */ + $field_definition = $context['fieldDefinition']; + + // By default, ALL the entities for a given type will be used for the options + // on an enity reference field, but we only want the user to be able to choose + // from the roles they can assign. + if ($field_definition->getTargetEntityTypeId() == 'user' && $field_definition->getName() == 'role_change') { + $current_user = \Drupal::currentUser(); + $options = $delegated_roles = \Drupal::service('delegatable_roles') ->getAssignableRoles($current_user); - if (count($delegatable_roles)) { - $form['account']['role_change'] = array( - '#type' => 'checkboxes', - '#title' => t('Roles'), - '#options' => $delegatable_roles, - '#default_value' => $current_roles, - '#description' => t('Change roles assigned to user.'), - ); - $form['actions']['submit']['#submit'][] = 'role_delegation_role_change_submit'; - } } } -function role_delegation_role_change_submit(array &$form, FormStateInterface $form_state) { - /** @var \Drupal\user\UserInterface $account */ - $account = $form_state->getFormObject()->getEntity(); - foreach($form_state->getValue('role_change') as $rid => $value) { - $value === 0 ? $account->removeRole($rid) : $account->addRole($rid); +/** + * Entity builder for the user form with empty field value for "role_change". + * + * @see role_delegation_form_alter() + */ +function role_delegation_user_form_builder($entity_type, UserInterface $user, &$form, FormStateInterface $form_state) { + // If the user has no access to the "role_change" field, then the form will + // submit an empty array for the field, which will make later processing think + // it was intentional. Set it to the empty field value to correct this. + if (!isset($form['role_change']['#access']) || !$form['role_change']['#access']) { + $user->set('role_change', DelegatableRolesInterface::EMPTY_FIELD_VALUE); + } +} + +/** + * Implements hook_entity_base_field_info(). + */ +function role_delegation_entity_base_field_info(EntityTypeInterface $entity_type) { + $fields = []; + + if ($entity_type->id() == 'user') { + $fields['role_change'] = BaseFieldDefinition::create('entity_reference') + ->setLabel('Roles') + ->setSetting('target_type', 'user_role') + ->setCardinality(BaseFieldDefinition::CARDINALITY_UNLIMITED) + ->setComputed(TRUE) + ->setDisplayOptions('form', [ + 'type' => 'options_buttons', + 'weight' => 1, + ]) + ->setDefaultValue(DelegatableRolesInterface::EMPTY_FIELD_VALUE); + } + + return $fields; +} + +/** + * Implements hook_entity_field_access(). + */ +function role_delegation_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) { + if ($operation == 'edit' && $field_definition->getName() == 'role_change' && $field_definition->getTargetEntityTypeId() == 'user') { + // Deny access if the user has access to the normal roles field. + if ($account->hasPermission('administer permissions')) { + return AccessResult::forbidden()->cachePerPermissions(); + } + + // Or if they don't have at least one role that allows them to delegate. + $permissions = \Drupal::service('permission_generator.role_delegation') + ->rolePermissions(); + $permissions = array_merge(['assign all roles'], array_keys($permissions)); + foreach ($permissions as $permission) { + if ($account->hasPermission($permission)) { + return AccessResult::allowed()->cachePerPermissions(); + } + } + return AccessResult::forbidden()->cachePerPermissions(); } - $account->save(); + + return AccessResult::neutral(); } only in patch2: unchanged: --- a/src/Tests/RoleAssignTest.php +++ b/src/Tests/RoleAssignTest.php @@ -27,24 +27,49 @@ class RoleAssignTest extends WebTestBase { $rid2 = $this->drupalCreateRole([]); $rid3 = $this->drupalCreateRole([]); + // Only 2 of the 3 roles appear on the roles edit page. $current_user = $this->drupalCreateUser(["assign $rid1 role", "assign $rid2 role"]); $this->drupalLogin($current_user); - - // Only 2 of the 3 roles appear on any user edit page. $account = $this->drupalCreateUser(); $this->drupalGet(sprintf('/user/%s/roles', $account->id())); - $this->assertFieldByName("role_change[$rid1]"); - $this->assertFieldByName("role_change[$rid2]"); - $this->assertNoFieldByName("role_change[$rid3]"); + $this->assertFieldByName("role_change[$rid1]", NULL, 'Role 1 delegation option found on roles edit page.'); + $this->assertFieldByName("role_change[$rid2]", NULL, 'Role 2 delegation option found on roles edit page.'); + $this->assertNoFieldByName("role_change[$rid3]", NULL, 'Role 3 delegation option not found on roles edit page.'); + + // A user who can access the real roles field should not see the role + // delegation field. + $current_user = $this->drupalCreateUser(['administer users', 'administer permissions', 'assign all roles']); + $this->drupalLogin($current_user); + $this->drupalGet(sprintf('/user/%s/edit', $account->id())); + $this->assertFieldByName("roles[$rid1]", NULL, 'Role field found on user edit page.'); + $this->assertNoFieldByName("role_change[$rid1]", NULL, 'Role delegation field not found on user edit page.'); + + // A user who can edit a user, but does not have access to the real role + // field, but can delegate should see the role delegation field. + $current_user = $this->drupalCreateUser(['administer users', 'assign all roles']); + $this->drupalLogin($current_user); + $this->drupalGet(sprintf('/user/%s/edit', $account->id())); + $this->assertNoFieldByName("roles[$rid1]", NULL, 'Role field not found on user edit page.'); + $this->assertFieldByName("role_change[$rid1]", NULL, 'Role delegation field found on user edit page.'); + + // Similar, but single role permissions rather than assigning all roles. + $current_user = $this->drupalCreateUser(['administer users', "assign $rid1 role"]); + $this->drupalLogin($current_user); + $this->drupalGet(sprintf('/user/%s/edit', $account->id())); + $this->assertNoFieldByName("roles[$rid1]", NULL, 'Role field not found on user edit page when granted an individual permission.'); + $this->assertFieldByName("role_change[$rid1]", NULL, 'Role delegation option found on user edit page when granted the individual permission.'); + $this->assertNoFieldByName("role_change[$rid2]", NULL, 'Role delegation option not found on user edit page when not granted the individual permission.'); + + $access = $account->get('role_change')->access('edit', $current_user); } /** - * Test that we can assign roles we have access to. + * Test that we can assign roles we have access to via the Roles form. */ - public function testRoleAssign() { - // Create a role and login as a user with the permission to assign it. + public function testRoleAssignRolesForm() { $rid1 = $this->drupalCreateRole([]); - $current_user = $this->drupalCreateUser(["assign $rid1 role"]); + $rid2 = $this->drupalCreateRole([]); + $current_user = $this->drupalCreateUser(["assign $rid1 role", "assign $rid2 role"]); $this->drupalLogin($current_user); // Go to the users roles edit page. @@ -56,22 +81,58 @@ class RoleAssignTest extends WebTestBase { $field_name = "role_change[$rid1]"; // Ensure its disabled by default. - $this->assertNoFieldChecked($field_id, 'Role is not assigned by default.'); - $this->assertFalse($account->hasPermission("assign $rid1 role"), 'Role is not assigned by default.'); + $this->assertFalse($account->hasPermission("assign $rid1 role"), "The target user does not have the role by default."); + $this->assertNoFieldChecked($field_id, "Lack of role by default is reflected in checkbox."); + + // Assign the role and ensure its now checked and assigned. + $this->drupalPostForm(NULL, [$field_name => $rid1], 'Save'); + \Drupal::entityTypeManager()->clearCachedDefinitions(); + $account = User::load($account->id()); + $this->assertTrue($account->hasRole($rid1), "The target user has been granted the role."); + $this->assertFieldChecked($field_id, "The role grant is reflected in the checkbox."); + + // Revoke the role. + $this->drupalPostForm(NULL, [$field_name => FALSE], 'Save'); + \Drupal::entityTypeManager()->clearCachedDefinitions(); + $account = User::load($account->id()); + $this->assertFalse($account->hasRole($rid1), "The target user has gotten the role revoked."); + $this->assertNoFieldChecked($field_id, "The role revocation is reflected in the checkbox."); + } + + /** + * Test that we can assign roles we have access to via the user edit form. + */ + public function testRoleAssignUserForm() { + $rid1 = $this->drupalCreateRole([]); + $rid2 = $this->drupalCreateRole([]); + $current_user = $this->drupalCreateUser(['administer users', 'assign all roles']); + $this->drupalLogin($current_user); + + // Go to the users roles edit page. + $account = $this->drupalCreateUser(); + $this->drupalGet(sprintf('/user/%s/edit', $account->id())); + + // The form element field id and name. + $field_id = "edit-role-change-$rid1"; + $field_name = "role_change[$rid1]"; + + // Ensure its disabled by default. + $this->assertFalse($account->hasPermission("assign $rid1 role"), "The target user does not have the role by default."); + $this->assertNoFieldChecked($field_id, "Lack of role by default is reflected in checkbox."); // Assign the role and ensure its now checked and assigned. $this->drupalPostForm(NULL, [$field_name => $rid1], 'Save'); - $this->assertFieldChecked($field_id, 'Role has been granted.'); \Drupal::entityTypeManager()->clearCachedDefinitions(); $account = User::load($account->id()); - $this->assertTrue($account->hasRole($rid1), 'Role has been granted'); + $this->assertTrue($account->hasRole($rid1), "The target user has been granted the role."); + $this->assertFieldChecked($field_id, "The role grant is reflected in the checkbox."); // Revoke the role. $this->drupalPostForm(NULL, [$field_name => FALSE], 'Save'); - $this->assertNoFieldChecked($field_id, 'Role has been revoked.'); \Drupal::entityTypeManager()->clearCachedDefinitions(); $account = User::load($account->id()); - $this->assertFalse($account->hasRole($rid1), 'Role has been revoked.'); + $this->assertFalse($account->hasRole($rid1), "The target user has gotten the role revoked."); + $this->assertNoFieldChecked($field_id, "The role revocation is reflected in the checkbox."); } }