diff --git a/core/modules/user/src/AccountForm.php b/core/modules/user/src/AccountForm.php index d695968..5e20de2 100644 --- a/core/modules/user/src/AccountForm.php +++ b/core/modules/user/src/AccountForm.php @@ -68,8 +68,17 @@ public function form(array $form, FormStateInterface $form_state) { $form['#cache']['tags'] = $config->getCacheTags(); $language_interface = \Drupal::languageManager()->getCurrentLanguage(); + + // Check for new account. $register = $account->isAnonymous(); - $admin = $user->hasPermission('administer users'); + + // There are two sub-cases. + // - Self-register (route = 'user.register'). + // - Admin-create (route = 'user.admin_create'). + // If the current user has permission to create users then it must be the + // second case. + $admin_create = $register && $account->access('create'); + $self_register = $register && !$admin_create; // Account information. $form['account'] = [ @@ -78,14 +87,14 @@ public function form(array $form, FormStateInterface $form_state) { ]; // The mail field is NOT required if account originally had no mail set - // and the user performing the edit has 'administer users' permission. + // and the user performing the edit has appropriate permission. // This allows users without email address to be edited and deleted. // Also see \Drupal\user\Plugin\Validation\Constraint\UserMailRequired. $form['account']['mail'] = [ '#type' => 'email', '#title' => $this->t('Email address'), '#description' => $this->t('A valid email address. All emails from the system will be sent to this address. The email address is not made public and will only be used if you wish to receive a new password or wish to receive certain news or notifications by email.'), - '#required' => !(!$account->getEmail() && $admin), + '#required' => $account->getEmail() || user_mail_required($user), '#default_value' => (!$register ? $account->getEmail() : ''), ]; @@ -103,7 +112,7 @@ public function form(array $form, FormStateInterface $form_state) { 'spellcheck' => 'false', ], '#default_value' => (!$register ? $account->getAccountName() : ''), - '#access' => ($register || ($user->id() == $account->id() && $user->hasPermission('change own username')) || $admin), + '#access' => $account->name->access('edit'), ]; // Display password field only for existing users or when user is allowed to @@ -150,7 +159,7 @@ public function form(array $form, FormStateInterface $form_state) { } } } - elseif (!$config->get('verify_mail') || $admin) { + elseif (!$config->get('verify_mail') || $admin_create) { $form['account']['pass'] = [ '#type' => 'password_confirm', '#size' => 25, @@ -169,7 +178,7 @@ public function form(array $form, FormStateInterface $form_state) { } } - if ($admin || !$register) { + if (!$self_register) { $status = $account->get('status')->value; } else { @@ -181,7 +190,7 @@ public function form(array $form, FormStateInterface $form_state) { '#title' => $this->t('Status'), '#default_value' => $status, '#options' => [$this->t('Blocked'), $this->t('Active')], - '#access' => $admin, + '#access' => $account->status->access('edit'), ]; $roles = array_map(['\Drupal\Component\Utility\Html', 'escape'], user_role_names(TRUE)); @@ -203,7 +212,7 @@ public function form(array $form, FormStateInterface $form_state) { $form['account']['notify'] = [ '#type' => 'checkbox', '#title' => $this->t('Notify user of new account'), - '#access' => $register && $admin, + '#access' => $admin_create, ]; $user_preferred_langcode = $register ? $language_interface->getId() : $account->getPreferredLangcode(); @@ -222,7 +231,7 @@ public function form(array $form, FormStateInterface $form_state) { '#open' => TRUE, // Display language selector when either creating a user on the admin // interface or editing a user account. - '#access' => !$register || $admin, + '#access' => !$self_register, ]; $form['language']['preferred_langcode'] = [ diff --git a/core/modules/user/src/Form/UserCancelForm.php b/core/modules/user/src/Form/UserCancelForm.php index da7a5f4..1cb99e8 100644 --- a/core/modules/user/src/Form/UserCancelForm.php +++ b/core/modules/user/src/Form/UserCancelForm.php @@ -20,6 +20,13 @@ class UserCancelForm extends ContentEntityConfirmFormBase { protected $cancelMethods; /** + * Whether allowed to select cancellation method. + * + * @var boolean + */ + protected $selectCancel; + + /** * The user being cancelled. * * @var \Drupal\user\UserInterface @@ -49,14 +56,19 @@ public function getCancelUrl() { public function getDescription() { $description = ''; $default_method = $this->config('user.settings')->get('cancel_method'); - if ($this->currentUser()->hasPermission('administer users') || $this->currentUser()->hasPermission('select account cancellation method')) { + $own_account = $this->entity->id() == $this->currentUser()->id(); + if ($this->selectCancel) { $description = $this->t('Select the method to cancel the account above.'); } // Options supplied via user_cancel_methods() can have a custom // #confirm_description property for the confirmation form description. - elseif (isset($this->cancelMethods[$default_method]['#confirm_description'])) { + // This text refers to "Your account" so only user it if cancelling own account. + elseif ($own_account && isset($this->cancelMethods[$default_method]['#confirm_description'])) { $description = $this->cancelMethods[$default_method]['#confirm_description']; } + else { + $description = $this->cancelMethods['#options'][$default_method]; + } return $description . ' ' . $this->t('This action cannot be undone.'); } @@ -75,18 +87,19 @@ public function buildForm(array $form, FormStateInterface $form_state) { $this->cancelMethods = user_cancel_methods(); // Display account cancellation method selection, if allowed. - $admin_access = $user->hasPermission('administer users'); + $own_account = $this->entity->id() == $user->id(); + $this->selectCancel = $user->hasPermission('administer users') || $user->hasPermission('select account cancellation method'); + $form['user_cancel_method'] = [ '#type' => 'radios', - '#title' => ($this->entity->id() == $user->id() ? $this->t('When cancelling your account') : $this->t('When cancelling the account')), - '#access' => $admin_access || $user->hasPermission('select account cancellation method'), + '#title' => $own_account ? $this->t('When cancelling your account') : $this->t('When cancelling the account'), + '#access' => $this->selectCancel, ]; $form['user_cancel_method'] += $this->cancelMethods; - // Allow user administrators to skip the account cancellation confirmation - // mail (by default), as long as they do not attempt to cancel their own - // account. - $override_access = $admin_access && ($this->entity->id() != $user->id()); + // When managing another user, can skip the account cancellation + // confirmation mail (by default). + $override_access = !$own_account; $form['user_cancel_confirm'] = [ '#type' => 'checkbox', '#title' => $this->t('Require email confirmation to cancel account'), @@ -111,7 +124,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { // if desired. $form['access'] = [ '#type' => 'value', - '#value' => $user->hasPermission('administer users'), + '#value' => !$own_account, ]; $form = parent::buildForm($form, $form_state); diff --git a/core/modules/user/src/Form/UserMultipleCancelConfirm.php b/core/modules/user/src/Form/UserMultipleCancelConfirm.php index b4487c2..f84f5d5 100644 --- a/core/modules/user/src/Form/UserMultipleCancelConfirm.php +++ b/core/modules/user/src/Form/UserMultipleCancelConfirm.php @@ -141,13 +141,28 @@ public function buildForm(array $form, FormStateInterface $form_state) { $form['operation'] = ['#type' => 'hidden', '#value' => 'cancel']; + // Display account cancellation method selection, if allowed. + $user = $this->currentUser(); + $selectCancel = $user->hasPermission('administer users') || $user->hasPermission('select account cancellation method'); + $form['user_cancel_method'] = [ '#type' => 'radios', '#title' => $this->t('When cancelling these accounts'), + '#access' => $selectCancel, ]; $form['user_cancel_method'] += user_cancel_methods(); + if (!$selectCancel) { + // Display an item to inform the user of the setting. + $default_method = $form['user_cancel_method']['#default_value']; + $form['user_cancel_method_show'] = [ + '#type' => 'item', + '#title' => $this->t('When cancelling these accounts'), + '#plain_text' => $form['user_cancel_method']['#options'][$default_method], + ]; + } + // Allow to send the account cancellation confirmation mail. $form['user_cancel_confirm'] = [ '#type' => 'checkbox', diff --git a/core/modules/user/src/Plugin/Validation/Constraint/UserMailRequiredValidator.php b/core/modules/user/src/Plugin/Validation/Constraint/UserMailRequiredValidator.php index 7e20b7a..3fc9368 100644 --- a/core/modules/user/src/Plugin/Validation/Constraint/UserMailRequiredValidator.php +++ b/core/modules/user/src/Plugin/Validation/Constraint/UserMailRequiredValidator.php @@ -9,7 +9,7 @@ * Checks if the user's email address is provided if required. * * The user mail field is NOT required if account originally had no mail set - * and the user performing the edit has 'administer users' permission. + * and the user performing the edit has appropriate permission. * This allows users without email address to be edited and deleted. */ class UserMailRequiredValidator extends ConstraintValidator { @@ -29,7 +29,7 @@ public function validate($items, Constraint $constraint) { $existing_value = $account_unchanged->getEmail(); } - $required = !(!$existing_value && \Drupal::currentUser()->hasPermission('administer users')); + $required = $existing_value || user_mail_required(\Drupal::currentUser()); if ($required && (!isset($items) || $items->isEmpty())) { $this->context->addViolation($constraint->message, ['@name' => $account->getFieldDefinition('mail')->getLabel()]); diff --git a/core/modules/user/src/ProfileForm.php b/core/modules/user/src/ProfileForm.php index c856b75..9a35772 100644 --- a/core/modules/user/src/ProfileForm.php +++ b/core/modules/user/src/ProfileForm.php @@ -20,12 +20,10 @@ protected function actions(array $form, FormStateInterface $form_state) { // The user account being edited. $account = $this->entity; - // The user doing the editing. - $user = $this->currentUser(); $element['delete']['#type'] = 'submit'; $element['delete']['#value'] = $this->t('Cancel account'); $element['delete']['#submit'] = ['::editCancelSubmit']; - $element['delete']['#access'] = $account->id() > 1 && (($account->id() == $user->id() && $user->hasPermission('cancel account')) || $user->hasPermission('administer users')); + $element['delete']['#access'] = $account->id() > 1 && $account->access('delete'); return $element; } diff --git a/core/modules/user/src/RegisterForm.php b/core/modules/user/src/RegisterForm.php index 4974e69..0a2e61d 100644 --- a/core/modules/user/src/RegisterForm.php +++ b/core/modules/user/src/RegisterForm.php @@ -18,7 +18,14 @@ public function form(array $form, FormStateInterface $form_state) { $user = $this->currentUser(); /** @var \Drupal\user\UserInterface $account */ $account = $this->entity; - $admin = $user->hasPermission('administer users'); + + // This form is used for two cases: + // - Self-register (route = 'user.register'). + // - Admin-create (route = 'user.admin_create'). + // If the current user has permission to create users then it must be the + // second case. + $admin = $account->access('create'); + // Pass access information to the submit handler. Running an access check // inside the submit function interferes with form processing and breaks // hook_form_alter(). diff --git a/core/modules/user/src/Tests/UserSubAdminTest.php b/core/modules/user/src/Tests/UserSubAdminTest.php new file mode 100644 index 0000000..1102a32 --- /dev/null +++ b/core/modules/user/src/Tests/UserSubAdminTest.php @@ -0,0 +1,66 @@ +drupalCreateUser(['sub-admin']); + $this->drupalLogin($user); + + // Test that the create user page has admin fields. + $this->drupalGet('admin/people/create'); + $this->assertField("edit-name", "Name field exists."); + $this->assertField("edit-notify", "Notify field exists."); + + // Not 'status' or 'roles' as they require extra permission. + $this->assertNoField("edit-status-0", "Status field missing."); + $this->assertNoField("edit-role", "Role field missing."); + + // Test that create user gives an admin style message. + $edit = [ + 'name' => $this->randomMachineName(), + 'mail' => $this->randomMachineName() . '@example.com', + 'pass[pass1]' => $pass = $this->randomString(), + 'pass[pass2]' => $pass, + 'notify' => FALSE, + ]; + $this->drupalPostForm('admin/people/create', $edit, t('Create new account')); + $this->assertText(t('Created a new user account for @name. No email has been sent.', ['@name' => $edit['name']]), 'User created'); + + // Test that the cancel user page has admin fields. + $cancel_user = $this->createUser(); + $this->drupalGet('user/' . $cancel_user->id() . '/cancel'); + $this->assertRaw(t('Are you sure you want to cancel the account %name?', ['%name' => $cancel_user->getUsername()]), 'Confirmation form to cancel account displayed.'); + $this->assertRaw(t('Disable the account and keep its content.') . ' ' . t('This action cannot be undone.'), 'Cannot select account cancellation method.'); + + // Test that cancel confirmation gives an admin style message. + $this->drupalPostForm(NULL, NULL, t('Cancel account')); + $this->assertRaw(t('%name has been disabled.', ['%name' => $cancel_user->getUsername()]), "Confirmation message displayed to user."); + + // Repeat with permission to select account cancellation method. + $user->addRole($this->drupalCreateRole(['select account cancellation method'])); + $user->save(); + $cancel_user = $this->createUser(); + $this->drupalGet('user/' . $cancel_user->id() . '/cancel'); + $this->assertText(t('Select the method to cancel the account above.'), 'Allows to select account cancellation method.'); + } + +} diff --git a/core/modules/user/src/UserAccessControlHandler.php b/core/modules/user/src/UserAccessControlHandler.php index 8ff01d1..1a2042e 100644 --- a/core/modules/user/src/UserAccessControlHandler.php +++ b/core/modules/user/src/UserAccessControlHandler.php @@ -93,11 +93,9 @@ protected function checkFieldAccess($operation, FieldDefinitionInterface $field_ $is_own_account = $items ? $items->getEntity()->id() == $account->id() : FALSE; switch ($field_definition->getName()) { case 'name': - // Allow view access to anyone with access to the entity. Anonymous - // users should be able to access the username field during the - // registration process, otherwise the username and email constraints - // are not checked. - if ($operation == 'view' || ($items && $account->isAnonymous() && $items->getEntity()->isAnonymous())) { + // Allow view access to anyone with access to the entity. + // The username field is editable during the registration process. + if ($operation == 'view' || ($items && $items->getEntity()->isAnonymous())) { return AccessResult::allowed()->cachePerPermissions(); } // Allow edit access for the own user name if the permission is @@ -106,7 +104,7 @@ protected function checkFieldAccess($operation, FieldDefinitionInterface $field_ return AccessResult::allowed()->cachePerPermissions()->cachePerUser(); } else { - return AccessResult::forbidden(); + return AccessResult::neutral(); } case 'preferred_langcode': @@ -116,7 +114,7 @@ protected function checkFieldAccess($operation, FieldDefinitionInterface $field_ // Allow view access to own mail address and other personalization // settings. if ($operation == 'view') { - return $is_own_account ? AccessResult::allowed()->cachePerUser() : AccessResult::forbidden(); + return $is_own_account ? AccessResult::allowed()->cachePerUser() : AccessResult::neutral(); } // Anyone that can edit the user can also edit this field. return AccessResult::allowed()->cachePerPermissions(); @@ -127,14 +125,14 @@ protected function checkFieldAccess($operation, FieldDefinitionInterface $field_ case 'created': // Allow viewing the created date, but not editing it. - return ($operation == 'view') ? AccessResult::allowed() : AccessResult::forbidden(); + return ($operation == 'view') ? AccessResult::allowed() : AccessResult::neutral(); case 'roles': case 'status': case 'access': case 'login': case 'init': - return AccessResult::forbidden(); + return AccessResult::neutral(); } return parent::checkFieldAccess($operation, $field_definition, $account, $items); diff --git a/core/modules/user/tests/modules/user_access_test/user_access_test.module b/core/modules/user/tests/modules/user_access_test/user_access_test.module index 470a76a..4369ac8 100644 --- a/core/modules/user/tests/modules/user_access_test/user_access_test.module +++ b/core/modules/user/tests/modules/user_access_test/user_access_test.module @@ -6,6 +6,9 @@ */ use Drupal\Core\Access\AccessResult; +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Session\AccountInterface; use Drupal\user\Entity\User; /** @@ -20,5 +23,39 @@ function user_access_test_user_access(User $entity, $operation, $account) { // Deny delete access. return AccessResult::forbidden(); } + + // Account with role sub-admin can manage users with no roles. + if (count($entity->getRoles()) == 1) { + return AccessResult::allowedIfHasPermission($account, 'sub-admin'); + } + + return AccessResult::neutral(); +} + +/** + * Implements hook_entity_create_access(). + */ +function user_access_test_entity_create_access(AccountInterface $account, array $context, $entity_bundle) { + if ($context['entity_type_id'] != 'user') { + return AccessResult::neutral(); + } + + // Account with role sub-admin can create users. + return AccessResult::allowedIfHasPermission($account, 'sub-admin'); +} + +/** + * Implements hook_entity_field_access(). + */ +function user_access_test_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) { + if ($field_definition->getTargetEntityTypeId() != 'user') { + return AccessResult::neutral(); + } + + // Allow view access for status, init and mail fields. + if ($operation === 'view' && in_array($field_definition->getName(), ['status', 'init', 'mail'])) { + return AccessResult::allowedIfHasPermission($account, 'sub-admin'); + } + return AccessResult::neutral(); } diff --git a/core/modules/user/tests/modules/user_access_test/user_access_test.permissions.yml b/core/modules/user/tests/modules/user_access_test/user_access_test.permissions.yml new file mode 100644 index 0000000..dadf7ba --- /dev/null +++ b/core/modules/user/tests/modules/user_access_test/user_access_test.permissions.yml @@ -0,0 +1,2 @@ +sub-admin: + title: 'Administer users with no roles' diff --git a/core/modules/user/user.api.php b/core/modules/user/user.api.php index 364dd1a..3b2b46d 100644 --- a/core/modules/user/user.api.php +++ b/core/modules/user/user.api.php @@ -128,6 +128,28 @@ function hook_user_format_name_alter(&$name, $account) { } /** + * Alter whether the user mail field is required. + * + * By default a user with permission 'administer users' may create and edit + * a user account without setting the mail field, but a user without that + * permission must set the mail field. This hook can be user to allow + * other users to omit the mail field, or to require the mail field for + * all users. + * + * @param boolean $required + * True if the mail field is required. + * + * @param \Drupal\user\UserInterface $user + * User editing the account. + */ +function hook_user_mail_required_alter(&$required, $user) { + // Check for a new permission that is defined in custom code. + if ($user->hasPermission('allow empty user mail')) { + $required = FALSE; + } +} + +/** * The user just logged in. * * @param object $account diff --git a/core/modules/user/user.module b/core/modules/user/user.module index cabe189..b85d251 100644 --- a/core/modules/user/user.module +++ b/core/modules/user/user.module @@ -1421,3 +1421,18 @@ function template_preprocess_user(&$variables) { $variables['content'][$key] = $variables['elements'][$key]; } } + +/** + * Determine if the user account 'mail' field is required. + * + * @param \Drupal\user\UserInterface $user + * User editing the account. + * + * @return boolean True if the mail field is required. + */ +function user_mail_required(AccountInterface $user) { + $required = !$user->hasPermission('administer users'); + \Drupal::moduleHandler()->alter('user_mail_required', $required, $user); + + return $required; +} diff --git a/core/modules/user/user.permissions.yml b/core/modules/user/user.permissions.yml index 810583c..a295b1f 100644 --- a/core/modules/user/user.permissions.yml +++ b/core/modules/user/user.permissions.yml @@ -14,7 +14,7 @@ access user profiles: change own username: title: 'Change own username' select account cancellation method: - title: 'Select method for cancelling own account' + title: 'Select method for cancelling account' restrict access: true cancel account: title: 'Cancel own user account' diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml index e38bb7a..bfacec2 100644 --- a/core/modules/user/user.routing.yml +++ b/core/modules/user/user.routing.yml @@ -43,7 +43,7 @@ user.admin_create: _entity_form: 'user.register' _title: 'Add user' requirements: - _permission: 'administer users' + _entity_create_access: 'user' user.admin_permissions: path: '/admin/people/permissions'