diff --git a/admin/settings/plugins.php b/admin/settings/plugins.php index 8def3d77223..883539b957c 100644 --- a/admin/settings/plugins.php +++ b/admin/settings/plugins.php @@ -401,55 +401,6 @@ if ($hassiteconfig) { /** @var \core\plugininfo\repository $plugin */ $plugin->load_settings($ADMIN, 'repositorysettings', $hassiteconfig); } - -/// Web services - $ADMIN->add('modules', new admin_category('webservicesettings', new lang_string('webservices', 'webservice'))); - - /// overview page - $temp = new admin_settingpage('webservicesoverview', new lang_string('webservicesoverview', 'webservice')); - $temp->add(new admin_setting_webservicesoverview()); - $ADMIN->add('webservicesettings', $temp); - //API documentation - $ADMIN->add('webservicesettings', new admin_externalpage('webservicedocumentation', new lang_string('wsdocapi', 'webservice'), "$CFG->wwwroot/$CFG->admin/webservice/documentation.php", 'moodle/site:config', false)); - /// manage service - $temp = new admin_settingpage('externalservices', new lang_string('externalservices', 'webservice')); - $temp->add(new admin_setting_heading('manageserviceshelpexplaination', new lang_string('information', 'webservice'), new lang_string('servicehelpexplanation', 'webservice'))); - $temp->add(new admin_setting_manageexternalservices()); - $ADMIN->add('webservicesettings', $temp); - $ADMIN->add('webservicesettings', new admin_externalpage('externalservice', new lang_string('editaservice', 'webservice'), "$CFG->wwwroot/$CFG->admin/webservice/service.php", 'moodle/site:config', true)); - $ADMIN->add('webservicesettings', new admin_externalpage('externalservicefunctions', new lang_string('externalservicefunctions', 'webservice'), "$CFG->wwwroot/$CFG->admin/webservice/service_functions.php", 'moodle/site:config', true)); - $ADMIN->add('webservicesettings', new admin_externalpage('externalserviceusers', new lang_string('externalserviceusers', 'webservice'), "$CFG->wwwroot/$CFG->admin/webservice/service_users.php", 'moodle/site:config', true)); - $ADMIN->add('webservicesettings', new admin_externalpage('externalserviceusersettings', new lang_string('serviceusersettings', 'webservice'), "$CFG->wwwroot/$CFG->admin/webservice/service_user_settings.php", 'moodle/site:config', true)); - /// manage protocol page link - $temp = new admin_settingpage('webserviceprotocols', new lang_string('manageprotocols', 'webservice')); - $temp->add(new admin_setting_managewebserviceprotocols()); - if (empty($CFG->enablewebservices)) { - $temp->add(new admin_setting_heading('webservicesaredisabled', '', new lang_string('disabledwarning', 'webservice'))); - } - - // We cannot use $OUTPUT this early, doing so means that we lose the ability - // to set the page layout on all admin pages. - // $wsdoclink = $OUTPUT->doc_link('How_to_get_a_security_key'); - $url = new moodle_url(get_docs_url('How_to_get_a_security_key')); - $wsdoclink = html_writer::tag('a', new lang_string('supplyinfo', 'webservice'), array('href'=>$url)); - $temp->add(new admin_setting_configcheckbox('enablewsdocumentation', new lang_string('enablewsdocumentation', - 'admin'), new lang_string('configenablewsdocumentation', 'admin', $wsdoclink), false)); - $ADMIN->add('webservicesettings', $temp); - /// links to protocol pages - $plugins = core_plugin_manager::instance()->get_plugins_of_type('webservice'); - core_collator::asort_objects_by_property($plugins, 'displayname'); - foreach ($plugins as $plugin) { - /** @var \core\plugininfo\webservice $plugin */ - $plugin->load_settings($ADMIN, 'webservicesettings', $hassiteconfig); - } - /// manage token page link - $ADMIN->add('webservicesettings', new admin_externalpage('addwebservicetoken', new lang_string('managetokens', 'webservice'), "$CFG->wwwroot/$CFG->admin/webservice/tokens.php", 'moodle/site:config', true)); - $temp = new admin_settingpage('webservicetokens', new lang_string('managetokens', 'webservice')); - $temp->add(new admin_setting_managewebservicetokens()); - if (empty($CFG->enablewebservices)) { - $temp->add(new admin_setting_heading('webservicesaredisabled', '', new lang_string('disabledwarning', 'webservice'))); - } - $ADMIN->add('webservicesettings', $temp); } // Question type settings diff --git a/admin/settings/server.php b/admin/settings/server.php index 179cc982abb..75e72ed3a08 100644 --- a/admin/settings/server.php +++ b/admin/settings/server.php @@ -546,4 +546,67 @@ if ($hassiteconfig) { new lang_string('updatenotifybuilds_desc', 'core_admin'), 0)); $ADMIN->add('server', $temp); } + + // Web services. + $ADMIN->add('server', new admin_category('webservicesettings', new lang_string('webservices', 'webservice'))); + + // Web services > Overview. + $temp = new admin_settingpage('webservicesoverview', new lang_string('webservicesoverview', 'webservice')); + $temp->add(new admin_setting_webservicesoverview()); + $ADMIN->add('webservicesettings', $temp); + + // Web services > API documentation. + $ADMIN->add('webservicesettings', new admin_externalpage('webservicedocumentation', new lang_string('wsdocapi', 'webservice'), + "{$CFG->wwwroot}/{$CFG->admin}/webservice/documentation.php", 'moodle/site:config', false)); + + // Web services > External services. + $temp = new admin_settingpage('externalservices', new lang_string('externalservices', 'webservice')); + + $temp->add(new admin_setting_heading('manageserviceshelpexplaination', new lang_string('information', 'webservice'), + new lang_string('servicehelpexplanation', 'webservice'))); + + $temp->add(new admin_setting_manageexternalservices()); + + $ADMIN->add('webservicesettings', $temp); + + $ADMIN->add('webservicesettings', new admin_externalpage('externalservice', new lang_string('editaservice', 'webservice'), + "{$CFG->wwwroot}/{$CFG->admin}/webservice/service.php", 'moodle/site:config', true)); + + $ADMIN->add('webservicesettings', new admin_externalpage('externalservicefunctions', + new lang_string('externalservicefunctions', 'webservice'), "{$CFG->wwwroot}/{$CFG->admin}/webservice/service_functions.php", + 'moodle/site:config', true)); + + $ADMIN->add('webservicesettings', new admin_externalpage('externalserviceusers', + new lang_string('externalserviceusers', 'webservice'), "{$CFG->wwwroot}/{$CFG->admin}/webservice/service_users.php", + 'moodle/site:config', true)); + + $ADMIN->add('webservicesettings', new admin_externalpage('externalserviceusersettings', + new lang_string('serviceusersettings', 'webservice'), "{$CFG->wwwroot}/{$CFG->admin}/webservice/service_user_settings.php", + 'moodle/site:config', true)); + + // Web services > Manage protocols. + $temp = new admin_settingpage('webserviceprotocols', new lang_string('manageprotocols', 'webservice')); + $temp->add(new admin_setting_managewebserviceprotocols()); + if (empty($CFG->enablewebservices)) { + $temp->add(new admin_setting_heading('webservicesaredisabled', '', new lang_string('disabledwarning', 'webservice'))); + } + + // We cannot use $OUTPUT->doc_link() this early, we would lose the ability to set the page layout on all admin pages. + $url = new moodle_url(get_docs_url('How_to_get_a_security_key')); + $wsdoclink = html_writer::link($url, new lang_string('supplyinfo', 'webservice'), ['target' => '_blank']); + $temp->add(new admin_setting_configcheckbox('enablewsdocumentation', new lang_string('enablewsdocumentation', 'admin'), + new lang_string('configenablewsdocumentation', 'admin', $wsdoclink), false)); + + $ADMIN->add('webservicesettings', $temp); + + $plugins = core_plugin_manager::instance()->get_plugins_of_type('webservice'); + core_collator::asort_objects_by_property($plugins, 'displayname'); + foreach ($plugins as $plugin) { + /** @var \core\plugininfo\webservice $plugin */ + $plugin->load_settings($ADMIN, 'webservicesettings', $hassiteconfig); + } + + // Web services > Manage tokens. + $ADMIN->add('webservicesettings', new admin_externalpage('webservicetokens', new lang_string('managetokens', 'webservice'), + new moodle_url('/admin/webservice/tokens.php'))); } diff --git a/admin/tests/behat/manage_tokens.feature b/admin/tests/behat/manage_tokens.feature index dc1cd948a60..e155dedeee0 100644 --- a/admin/tests/behat/manage_tokens.feature +++ b/admin/tests/behat/manage_tokens.feature @@ -1,27 +1,76 @@ @core @core_admin -Feature: Manage tokens - In order to manage webservice usage +Feature: Manage external services tokens + In order to manage external service usage As an admin - I need to be able to create and delete tokens + I need to be able to create, filter and delete tokens Background: Given the following "users" exist: - | username | password | firstname | lastname | - | testuser | testuser | Joe | Bloggs | - | testuser2 | testuser2 | TestFirstname | TestLastname | + | username | password | firstname | lastname | + | user1 | user1 | Firstname1 | Lastname1 | + | user2 | user2 | Firstname2 | Lastname2 | + | user3 | user3 | Firstname3 | Lastname3 | + | user4 | user4 | Firstname4 | Lastname4 | And I change window size to "small" - And I log in as "admin" - And I am on site homepage @javascript - Scenario: Add & delete a token - Given I navigate to "Plugins > Web services > Manage tokens" in site administration - And I follow "Add" - And I set the field "User" to "Joe Bloggs" + Scenario: Add a token to user identified by name and then delete that token + Given I log in as "admin" + And I am on site homepage + And I navigate to "Server > Web services > Manage tokens" in site administration + And I press "Create token" + And I set the field "User" to "Firstname1 Lastname1" + And I set the field "Service" to "Moodle mobile web service" And I set the field "IP restriction" to "127.0.0.1" When I press "Save changes" - Then I should see "Joe Bloggs" - And I should see "127.0.0.1" - And I follow "Delete" + Then I should see "Moodle mobile web service" in the "Firstname1 Lastname1" "table_row" + And I should see "127.0.0.1" in the "Firstname1 Lastname1" "table_row" + And I click on "Delete" "link" in the "Firstname1 Lastname1" "table_row" + And I should see "Do you really want to delete this web service token for Firstname1 Lastname1 on the service Moodle mobile web service?" And I press "Delete" - And I should not see "Joe Bloggs" + And "Firstname1 Lastname1" "table_row" should not exist + + @javascript + Scenario: Tokens can be filtered by user and by service + Given the following "core_webservice > Service" exists: + | name | Site information | + | shortname | siteinfo | + | enabled | 1 | + And the following "core_webservice > Service function" exists: + | service | siteinfo | + | functions | core_webservice_get_site_info | + And the following "core_webservice > Tokens" exist: + | user | service | + | user2 | siteinfo | + | user3 | moodle_mobile_app | + | user4 | siteinfo | + When I log in as "admin" + And I navigate to "Server > Web services > Manage tokens" in site administration + + # All created tokens are shown by default. + And "Firstname1 Lastname1" "table_row" should not exist + And I should see "Site information" in the "Firstname2 Lastname2" "table_row" + And I should see "Moodle mobile web service" in the "Firstname3 Lastname3" "table_row" + And I should see "Site information" in the "Firstname4 Lastname4" "table_row" + + # Filter tokens by user (note we can select the user by the identity field here). + When I click on "Tokens filter" "link" + And I set the field "User" to "user2@example.com" + And I press "Show only matching tokens" + Then "Firstname3 Lastname3" "table_row" should not exist + And "Firstname4 Lastname4" "table_row" should not exist + And I should see "Site information" in the "Firstname2 Lastname2" "table_row" + + # Reset the filter. + And I press "Show all tokens" + And I should see "Site information" in the "Firstname2 Lastname2" "table_row" + And I should see "Moodle mobile web service" in the "Firstname3 Lastname3" "table_row" + And I should see "Site information" in the "Firstname4 Lastname4" "table_row" + + # Filter tokens by service. + And I click on "Tokens filter" "link" + And I set the field "Service" to "Site information" + And I press "Show only matching tokens" + And I should see "Site information" in the "Firstname2 Lastname2" "table_row" + And I should see "Site information" in the "Firstname4 Lastname4" "table_row" + And "Firstname3 Lastname3" "table_row" should not exist diff --git a/admin/upgrade.txt b/admin/upgrade.txt index c3ab30f87e4..6beeb3a0f42 100644 --- a/admin/upgrade.txt +++ b/admin/upgrade.txt @@ -4,6 +4,9 @@ This files describes API changes in /admin/*. * New admin setting admin_setting_encryptedpassword allows passwords in admin settings to be encrypted (with the new \core\encryption API) so that even the admin cannot read them. +* Web services administration has been moved from Plugins into the Server category. If you have + Behat tests containing steps like `Given I navigate to "Plugins > Web services > ..."`, you will + want to replace them with `Given I navigate to "Server > Web services > ..."`. === 3.9 === diff --git a/admin/webservice/forms.php b/admin/webservice/forms.php index c958661e347..3060fd3fab9 100644 --- a/admin/webservice/forms.php +++ b/admin/webservice/forms.php @@ -221,121 +221,3 @@ class external_service_functions_form extends moodleform { } } - -class web_service_token_form extends moodleform { - - function definition() { - global $USER, $DB, $CFG; - - $mform = $this->_form; - $data = $this->_customdata; - - $mform->addElement('header', 'token', get_string('token', 'webservice')); - - if (empty($data->nouserselection)) { - - //check if the number of user is reasonable to be displayed in a select box - $usertotal = $DB->count_records('user', - array('deleted' => 0, 'suspended' => 0, 'confirmed' => 1)); - - if ($usertotal < 500) { - list($sort, $params) = users_order_by_sql('u'); - // User searchable selector - return users who are confirmed, not deleted, not suspended and not a guest. - $userfieldsapi = \core\user_fields::for_name(); - $sql = 'SELECT u.id' . $userfieldsapi->get_sql('u')->selects . ' - FROM {user} u - WHERE u.deleted = 0 - AND u.confirmed = 1 - AND u.suspended = 0 - AND u.id != :siteguestid - ORDER BY ' . $sort; - $params['siteguestid'] = $CFG->siteguest; - $users = $DB->get_records_sql($sql, $params); - $options = array(); - foreach ($users as $userid => $user) { - $options[$userid] = fullname($user); - } - $mform->addElement('searchableselector', 'user', get_string('user'), $options); - $mform->setType('user', PARAM_INT); - } else { - //simple text box for username or user id (if two username exists, a form error is displayed) - $mform->addElement('text', 'user', get_string('usernameorid', 'webservice')); - $mform->setType('user', PARAM_RAW_TRIMMED); - } - $mform->addRule('user', get_string('required'), 'required', null, 'client'); - } - - //service selector - $services = $DB->get_records('external_services'); - $options = array(); - $systemcontext = context_system::instance(); - foreach ($services as $serviceid => $service) { - //check that the user has the required capability - //(only for generation by the profile page) - if (empty($data->nouserselection) - || empty($service->requiredcapability) - || has_capability($service->requiredcapability, $systemcontext, $USER->id)) { - $options[$serviceid] = $service->name; - } - } - $mform->addElement('select', 'service', get_string('service', 'webservice'), $options); - $mform->addRule('service', get_string('required'), 'required', null, 'client'); - $mform->setType('service', PARAM_INT); - - $mform->addElement('text', 'iprestriction', get_string('iprestriction', 'webservice')); - $mform->setType('iprestriction', PARAM_RAW_TRIMMED); - - $mform->addElement('date_selector', 'validuntil', - get_string('validuntil', 'webservice'), array('optional' => true)); - $mform->setType('validuntil', PARAM_INT); - - $mform->addElement('hidden', 'action'); - $mform->setType('action', PARAM_ALPHANUMEXT); - - $this->add_action_buttons(true); - - $this->set_data($data); - } - - function get_data() { - global $DB; - $data = parent::get_data(); - - if (!empty($data) && !is_numeric($data->user)) { - //retrieve username - $user = $DB->get_record('user', array('username' => $data->user), 'id'); - $data->user = $user->id; - } - return $data; - } - - function validation($data, $files) { - global $DB; - - $errors = parent::validation($data, $files); - - if (is_numeric($data['user'])) { - $searchtype = 'id'; - } else { - $searchtype = 'username'; - //check the username is valid - if (clean_param($data['user'], PARAM_USERNAME) != $data['user']) { - $errors['user'] = get_string('invalidusername'); - } - } - - if (!isset($errors['user'])) { - $users = $DB->get_records('user', array($searchtype => $data['user']), '', 'id'); - - //check that the user exists in the database - if (count($users) == 0) { - $errors['user'] = get_string('usernameoridnousererror', 'webservice'); - } else if (count($users) > 1) { //can only be a username search as id are unique - $errors['user'] = get_string('usernameoridoccurenceerror', 'webservice'); - } - } - - return $errors; - } - -} diff --git a/admin/webservice/service_users.php b/admin/webservice/service_users.php index 907a4cc9c1f..3bcc8bcb641 100644 --- a/admin/webservice/service_users.php +++ b/admin/webservice/service_users.php @@ -108,7 +108,7 @@ $usersmissingcaps = $webservicemanager->get_missing_capabilities_by_users($allow //add the missing capabilities to the allowed users object to be displayed by renderer foreach ($allowedusers as &$alloweduser) { if (!is_siteadmin($alloweduser->id) and array_key_exists($alloweduser->id, $usersmissingcaps)) { - $alloweduser->missingcapabilities = implode(', ', $usersmissingcaps[$alloweduser->id]); + $alloweduser->missingcapabilities = $usersmissingcaps[$alloweduser->id]; } } diff --git a/admin/webservice/tokens.php b/admin/webservice/tokens.php index b4d246a2029..7e2599e2c60 100644 --- a/admin/webservice/tokens.php +++ b/admin/webservice/tokens.php @@ -1,6 +1,5 @@ . /** - * Web services tokens admin UI + * Web services / external tokens management UI. * - * @package webservice - * @author Jerome Mouneyrac - * @copyright 2009 Moodle Pty Ltd (http://moodle.com) - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_webservice + * @category admin + * @copyright 2009 Jerome Mouneyrac + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -require_once('../../config.php'); + +require(__DIR__ . '/../../config.php'); require_once($CFG->libdir . '/adminlib.php'); -require_once($CFG->dirroot . '/' . $CFG->admin . '/webservice/forms.php'); require_once($CFG->libdir . '/externallib.php'); +require_once($CFG->dirroot . '/webservice/lib.php'); $action = optional_param('action', '', PARAM_ALPHANUMEXT); $tokenid = optional_param('tokenid', '', PARAM_SAFEDIR); $confirm = optional_param('confirm', 0, PARAM_BOOL); +$ftoken = optional_param('ftoken', '', PARAM_ALPHANUM); +$fusers = optional_param_array('fusers', [], PARAM_INT); +$fservices = optional_param_array('fservices', [], PARAM_INT); -admin_externalpage_setup('addwebservicetoken'); +admin_externalpage_setup('webservicetokens'); -//Deactivate the second 'Manage token' navigation node, and use the main 'Manage token' navigation node -$node = $PAGE->settingsnav->find('addwebservicetoken', navigation_node::TYPE_SETTING); -$newnode = $PAGE->settingsnav->find('webservicetokens', navigation_node::TYPE_SETTING); -if ($node && $newnode) { - $node->display = false; - $newnode->make_active(); +if ($action === 'create') { + $webservicemanager = new webservice(); + $mform = new \core_webservice\token_form(null, ['action' => 'create']); + $data = $mform->get_data(); + + if ($mform->is_cancelled()) { + redirect($PAGE->url); + + } else if ($data) { + ignore_user_abort(true); + + // Check the user is allowed for the service. + $selectedservice = $webservicemanager->get_external_service_by_id($data->service); + + if ($selectedservice->restrictedusers) { + $restricteduser = $webservicemanager->get_ws_authorised_user($data->service, $data->user); + + if (empty($restricteduser)) { + $errormsg = $OUTPUT->notification(get_string('usernotallowed', 'webservice', $selectedservice->name)); + } + } + + $user = \core_user::get_user($data->user, '*', MUST_EXIST); + \core_user::require_active_user($user); + + // Generate the token. + if (empty($errormsg)) { + external_generate_token(EXTERNAL_TOKEN_PERMANENT, $data->service, $data->user, context_system::instance(), + $data->validuntil, $data->iprestriction); + redirect($PAGE->url); + } + } + + echo $OUTPUT->header(); + echo $OUTPUT->heading(get_string('createtoken', 'webservice')); + if (!empty($errormsg)) { + echo $errormsg; + } + $mform->display(); + echo $OUTPUT->footer(); + die(); } +if ($action === 'delete') { + $webservicemanager = new webservice(); + $token = $webservicemanager->get_token_by_id_with_details($tokenid); -$tokenlisturl = new moodle_url("/" . $CFG->admin . "/settings.php", array('section' => 'webservicetokens')); + if ($token->creatorid != $USER->id) { + require_capability('moodle/webservice:managealltokens', context_system::instance()); + } -require_once($CFG->dirroot . "/webservice/lib.php"); -$webservicemanager = new webservice(); + if ($confirm && confirm_sesskey()) { + $webservicemanager->delete_user_ws_token($token->id); + redirect($PAGE->url); + } -switch ($action) { + echo $OUTPUT->header(); - case 'create': - $mform = new web_service_token_form(null, array('action' => 'create')); - $data = $mform->get_data(); - if ($mform->is_cancelled()) { - redirect($tokenlisturl); - } else if ($data and confirm_sesskey()) { - ignore_user_abort(true); + echo $OUTPUT->confirm( + get_string('deletetokenconfirm', 'webservice', [ + 'user' => $token->firstname . ' ' . $token->lastname, + 'service' => $token->name, + ]), + new single_button(new moodle_url('/admin/webservice/tokens.php', [ + 'tokenid' => $token->id, + 'action' => 'delete', + 'confirm' => 1, + 'sesskey' => sesskey(), + ]), get_string('delete')), + $PAGE->url + ); - //check the the user is allowed for the service - $selectedservice = $webservicemanager->get_external_service_by_id($data->service); - if ($selectedservice->restrictedusers) { - $restricteduser = $webservicemanager->get_ws_authorised_user($data->service, $data->user); - if (empty($restricteduser)) { - $allowuserurl = new moodle_url('/' . $CFG->admin . '/webservice/service_users.php', - array('id' => $selectedservice->id)); - $allowuserlink = html_writer::tag('a', $selectedservice->name , array('href' => $allowuserurl)); - $errormsg = $OUTPUT->notification(get_string('usernotallowed', 'webservice', $allowuserlink)); - } - } - - //check if the user is deleted. unconfirmed, suspended or guest - $user = $DB->get_record('user', array('id' => $data->user)); - if ($user->id == $CFG->siteguest or $user->deleted or !$user->confirmed or $user->suspended) { - throw new moodle_exception('forbiddenwsuser', 'webservice'); - } - - //process the creation - if (empty($errormsg)) { - //TODO improvement: either move this function from externallib.php to webservice/lib.php - // either move most of webservicelib.php functions into externallib.php - // (create externalmanager class) MDL-23523 - external_generate_token(EXTERNAL_TOKEN_PERMANENT, $data->service, - $data->user, context_system::instance(), - $data->validuntil, $data->iprestriction); - redirect($tokenlisturl); - } - } - - //OUTPUT: create token form - echo $OUTPUT->header(); - echo $OUTPUT->heading(get_string('createtoken', 'webservice')); - if (!empty($errormsg)) { - echo $errormsg; - } - $mform->display(); - echo $OUTPUT->footer(); - die; - break; - - case 'delete': - $token = $webservicemanager->get_token_by_id_with_details($tokenid); - - if ($token->creatorid != $USER->id) { - require_capability("moodle/webservice:managealltokens", context_system::instance()); - } - - //Delete the token - if ($confirm and confirm_sesskey()) { - $webservicemanager->delete_user_ws_token($token->id); - redirect($tokenlisturl); - } - - ////OUTPUT: display delete token confirmation box - echo $OUTPUT->header(); - $renderer = $PAGE->get_renderer('core', 'webservice'); - echo $renderer->admin_delete_token_confirmation($token); - echo $OUTPUT->footer(); - die; - break; - - default: - //wrong url access - redirect($tokenlisturl); - break; + echo $OUTPUT->footer(); + die(); } + +// Pre-populate the form with the values that come as a part of the URL - typically when using the table_sql control +// links. +$filterdata = (object)[ + 'token' => $ftoken, + 'users' => $fusers, + 'services' => $fservices, +]; + +$filter = new \core_webservice\token_filter($PAGE->url, $filterdata); + +$filter->set_data($filterdata); + +if ($filter->is_submitted()) { + $filterdata = $filter->get_data(); + + if (isset($filterdata->resetbutton)) { + redirect($PAGE->url); + } +} + +echo $OUTPUT->header(); +echo $OUTPUT->heading(get_string('managetokens', 'core_webservice')); + +echo html_writer::div($OUTPUT->render(new single_button(new moodle_url($PAGE->url, ['action' => 'create']), + get_string('createtoken', 'core_webservice'), 'get', true)), 'my-3'); + +$filter->display(); + +$table = new \core_webservice\token_table('webservicetokens', $filterdata); + +// In order to not lose the filter form values by clicking the table control links, make them part of the table's baseurl. +$baseurl = new moodle_url($PAGE->url, ['ftoken' => $filterdata->token]); + +foreach ($filterdata->users as $i => $userid) { + $baseurl->param("fusers[{$i}]", $userid); +} + +foreach ($filterdata->services as $i => $serviceid) { + $baseurl->param("fservices[{$i}]", $serviceid); +} + +$table->define_baseurl($baseurl); + +$table->attributes['class'] = 'admintable generaltable'; +$table->data = []; +$table->out(30, false); + +echo $OUTPUT->footer(); diff --git a/lang/en/deprecated.txt b/lang/en/deprecated.txt index 415402af298..736fb8809aa 100644 --- a/lang/en/deprecated.txt +++ b/lang/en/deprecated.txt @@ -129,3 +129,5 @@ hidepicture,core_group hidepicture,core_moodle sitebackpack,core_badges sitebackpack_help,core_badges +usernameoridnousererror,core_webservice +usernameoridoccurenceerror,core_webservice diff --git a/lang/en/webservice.php b/lang/en/webservice.php index eef36b1e061..0017e1d85dc 100644 --- a/lang/en/webservice.php +++ b/lang/en/webservice.php @@ -118,7 +118,7 @@ $string['loginrequired'] = 'Restricted to logged-in users'; $string['manageprotocols'] = 'Manage protocols'; $string['managetokens'] = 'Manage tokens'; $string['missingcaps'] = 'Missing capabilities'; -$string['missingcaps_help'] = 'List of required capabilities for the service which the selected user does not have. Missing capabilities must be added to the user\'s role in order to use the service.'; +$string['missingcaps_help'] = 'List of capabilities declared by the service which the user does not have. Some service functionality may not be available without these capabilities.'; $string['missingpassword'] = 'Missing password'; $string['missingrequiredcapability'] = 'The capability {$a} is required.'; $string['missingusername'] = 'Missing username'; @@ -130,7 +130,7 @@ $string['norequiredcapability'] = 'No required capability'; $string['notoken'] = 'The token list is empty.'; $string['onesystemcontrolling'] = 'Allow an external system to control Moodle'; $string['onesystemcontrollingdescription'] = 'The following steps help you to set up the Moodle web services to allow an external system to interact with Moodle. This includes setting up a token (security key) authentication method.'; -$string['onlyseecreatedtokens'] = 'Any token can be deleted, though you can only view tokens that you created.'; +$string['onlyseecreatedtokens'] = 'You can only view tokens that you created.'; $string['operation'] = 'Operation'; $string['optional'] = 'Optional'; $string['passwordisexpired'] = 'Password is expired.'; @@ -205,6 +205,9 @@ $string['token'] = 'Token'; $string['tokenauthlog'] = 'Token authentication'; $string['tokencreatedbyadmin'] = 'Can only be reset by administrator (*)'; $string['tokencreator'] = 'Creator'; +$string['tokenfilter'] = 'Tokens filter'; +$string['tokenfiltersubmit'] = 'Show only matching tokens'; +$string['tokenfilterreset'] = 'Show all tokens'; $string['unknownoptionkey'] = 'Unknown option key ({$a})'; $string['unnamedstringparam'] = 'A string parameter is unnamed.'; $string['updateusersettings'] = 'Update'; @@ -215,8 +218,6 @@ $string['userasclientsdescription'] = 'The following steps help you to set up th $string['usermissingcaps'] = 'Missing capabilities: {$a}'; $string['usernameorid'] = 'Username / User id'; $string['usernameorid_help'] = 'Enter a username or a user id.'; -$string['usernameoridnousererror'] = 'No users were found with this username/user id.'; -$string['usernameoridoccurenceerror'] = 'More than one user was found with this username. Please enter the user id.'; $string['usernotallowed'] = 'The user is not allowed for this service. First you need to allow this user on the {$a}\'s allowed users administration page.'; $string['userservices'] = 'User services: {$a}'; $string['usersettingssaved'] = 'User settings saved'; @@ -243,3 +244,7 @@ $string['wsusername'] = 'Web service username'; // Deprecated since Moodle 3.9. $string['documentation'] = 'web service documentation'; + +// Deprecated since Moodle 3.11. +$string['usernameoridnousererror'] = 'No users were found with this username/user id.'; +$string['usernameoridoccurenceerror'] = 'More than one user was found with this username. Please enter the user id.'; diff --git a/lib/adminlib.php b/lib/adminlib.php index 3b348afcb04..72a3c33a42a 100644 --- a/lib/adminlib.php +++ b/lib/adminlib.php @@ -10395,91 +10395,6 @@ class admin_setting_managewebserviceprotocols extends admin_setting { } } - -/** - * Special class for web service token administration. - * - * @author Jerome Mouneyrac - */ -class admin_setting_managewebservicetokens extends admin_setting { - - /** - * Calls parent::__construct with specific arguments - */ - public function __construct() { - $this->nosave = true; - parent::__construct('webservicestokenui', get_string('managetokens', 'webservice'), '', ''); - } - - /** - * Always returns true, does nothing - * - * @return true - */ - public function get_setting() { - return true; - } - - /** - * Always returns true, does nothing - * - * @return true - */ - public function get_defaultsetting() { - return true; - } - - /** - * Always returns '', does not write anything - * - * @return string Always returns '' - */ - public function write_setting($data) { - // do not write any setting - return ''; - } - - /** - * Builds the XHTML to display the control - * - * @param string $data Unused - * @param string $query - * @return string - */ - public function output_html($data, $query='') { - global $CFG, $OUTPUT; - - require_once($CFG->dirroot . '/webservice/classes/token_table.php'); - $baseurl = new moodle_url('/' . $CFG->admin . '/settings.php?section=webservicetokens'); - - $return = $OUTPUT->box_start('generalbox webservicestokenui'); - - if (has_capability('moodle/webservice:managealltokens', context_system::instance())) { - $return .= \html_writer::div(get_string('onlyseecreatedtokens', 'webservice')); - } - - $table = new \webservice\token_table('webservicetokens'); - $table->define_baseurl($baseurl); - $table->attributes['class'] = 'admintable generaltable'; // Any need changing? - $table->data = array(); - ob_start(); - $table->out(10, false); - $tablehtml = ob_get_contents(); - ob_end_clean(); - $return .= $tablehtml; - - $tokenpageurl = "$CFG->wwwroot/$CFG->admin/webservice/tokens.php?sesskey=" . sesskey(); - - $return .= $OUTPUT->box_end(); - // add a token to the table - $return .= ""; - $return .= get_string('add').""; - - return highlight($query, $return); - } -} - - /** * Colour picker * diff --git a/lib/db/services.php b/lib/db/services.php index 75d858d6609..adec2e98912 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -1740,12 +1740,21 @@ $functions = array( 'methodname' => 'get_users_by_field', 'classpath' => 'user/externallib.php', 'description' => 'Retrieve users\' information for a specified unique field - If you want to do a user search, use ' - . 'core_user_get_users()', + . 'core_user_get_users() or core_user_search_identity().', 'type' => 'read', 'capabilities' => 'moodle/user:viewdetails, moodle/user:viewhiddendetails, moodle/course:useremail, moodle/user:update', 'ajax' => true, 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE), ), + 'core_user_search_identity' => array( + 'classname' => '\core_user\external\search_identity', + 'methodname' => 'execute', + 'description' => 'Return list of users identities matching the given criteria in their name or other identity fields.', + 'type' => 'read', + 'capabilities' => 'moodle/user:viewalldetails', + 'ajax' => true, + 'loginrequired' => true, + ), 'core_user_remove_user_device' => array( 'classname' => 'core_user_external', 'methodname' => 'remove_user_device', diff --git a/user/amd/build/form_user_selector.min.js b/user/amd/build/form_user_selector.min.js new file mode 100644 index 00000000000..41c53a6abc6 --- /dev/null +++ b/user/amd/build/form_user_selector.min.js @@ -0,0 +1,2 @@ +define ("core_user/form_user_selector",["exports","core/ajax","core/templates","core/str"],function(a,b,c,d){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.transport=g;a.processResults=function(a,b){if(!Array.isArray(b)){return b}else{return b.map(function(a){return{value:a.id,label:a.label}})}};b=function(a){return a&&a.__esModule?a:{default:a}}(b);function e(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function f(a){return function(){var b=this,c=arguments;return new Promise(function(d,f){var i=a.apply(b,c);function g(a){e(i,d,f,g,h,"next",a)}function h(a){e(i,d,f,g,h,"throw",a)}g(void 0)})}}function g(){return h.apply(this,arguments)}function h(){h=f(regeneratorRuntime.mark(function a(e,f,g,h){var i,j,k,l;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:i={methodname:"core_user_search_identity",args:{query:f}};a.prev=1;a.next=4;return b.default.call([i])[0];case 4:j=a.sent;if(!j.overflow){a.next=12;break}a.next=8;return(0,d.get_string)("toomanyuserstoshow","core",">"+j.maxusersperpage);case 8:k=a.sent;g(k);a.next=19;break;case 12:l=[];j.list.forEach(function(a){l.push((0,c.render)("core_user/form_user_selector_suggestion",a))});a.next=16;return Promise.all(l);case 16:l=a.sent;j.list.forEach(function(a,b){a.label=l[b]});g(j.list);case 19:a.next=24;break;case 21:a.prev=21;a.t0=a["catch"](1);h(a.t0);case 24:case"end":return a.stop();}}},a,null,[[1,21]])}));return h.apply(this,arguments)}}); +//# sourceMappingURL=form_user_selector.min.js.map diff --git a/user/amd/build/form_user_selector.min.js.map b/user/amd/build/form_user_selector.min.js.map new file mode 100644 index 00000000000..0b7ebcb9a1a --- /dev/null +++ b/user/amd/build/form_user_selector.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/form_user_selector.js"],"names":["selector","results","Array","isArray","map","result","value","id","label","transport","query","callback","failure","request","methodname","args","Ajax","call","response","overflow","maxusersperpage","msg","labels","list","forEach","user","push","Promise","all","index"],"mappings":"0MA8EO,SAAwBA,CAAxB,CAAkCC,CAAlC,CAA2C,CAE9C,GAAI,CAACC,KAAK,CAACC,OAAN,CAAcF,CAAd,CAAL,CAA6B,CACzB,MAAOA,CAAAA,CAEV,CAHD,IAGO,CACH,MAAOA,CAAAA,CAAO,CAACG,GAAR,CAAY,SAAAC,CAAM,QAAK,CAACC,KAAK,CAAED,CAAM,CAACE,EAAf,CAAmBC,KAAK,CAAEH,CAAM,CAACG,KAAjC,CAAL,CAAlB,CACV,CACJ,C,CA9DD,uD,uUAYsBC,CAAAA,C,2EAAf,WAAyBT,CAAzB,CAAmCU,CAAnC,CAA0CC,CAA1C,CAAoDC,CAApD,+FAEGC,CAFH,CAEa,CACZC,UAAU,CAAE,2BADA,CAEZC,IAAI,CAAE,CACFL,KAAK,CAAEA,CADL,CAFM,CAFb,yBAUwBM,WAAKC,IAAL,CAAU,CAACJ,CAAD,CAAV,EAAqB,CAArB,CAVxB,QAUOK,CAVP,YAYKA,CAAQ,CAACC,QAZd,iCAauB,iBAAU,oBAAV,CAAgC,MAAhC,CAAwC,IAAMD,CAAQ,CAACE,eAAvD,CAbvB,QAaWC,CAbX,QAcKV,CAAQ,CAACU,CAAD,CAAR,CAdL,wBAiBSC,CAjBT,CAiBkB,EAjBlB,CAkBKJ,CAAQ,CAACK,IAAT,CAAcC,OAAd,CAAsB,SAAAC,CAAI,CAAI,CAC1BH,CAAM,CAACI,IAAP,CAAY,aAAe,yCAAf,CAA0DD,CAA1D,CAAZ,CACH,CAFD,EAlBL,gBAqBoBE,CAAAA,OAAO,CAACC,GAAR,CAAYN,CAAZ,CArBpB,SAqBKA,CArBL,QAuBKJ,CAAQ,CAACK,IAAT,CAAcC,OAAd,CAAsB,SAACC,CAAD,CAAOI,CAAP,CAAiB,CACnCJ,CAAI,CAACjB,KAAL,CAAac,CAAM,CAACO,CAAD,CACtB,CAFD,EAIAlB,CAAQ,CAACO,CAAQ,CAACK,IAAV,CAAR,CA3BL,6DA+BCX,CAAO,MAAP,CA/BD,uD","sourcesContent":["// This file is part of Moodle - https://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Provides the required functionality for an autocomplete element to select a user.\n *\n * @module core_user/form_user_selector\n * @package core_webservice\n * @copyright 2020 David Mudrák \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\nimport {render as renderTemplate} from 'core/templates';\nimport {get_string as getString} from 'core/str';\n\n/**\n * Load the list of users matching the query and render the selector labels for them.\n *\n * @param {String} selector The selector of the auto complete element.\n * @param {String} query The query string.\n * @param {Function} callback A callback function receiving an array of results.\n * @param {Function} failure A function to call in case of failure, receiving the error message.\n */\nexport async function transport(selector, query, callback, failure) {\n\n const request = {\n methodname: 'core_user_search_identity',\n args: {\n query: query\n }\n };\n\n try {\n const response = await Ajax.call([request])[0];\n\n if (response.overflow) {\n const msg = await getString('toomanyuserstoshow', 'core', '>' + response.maxusersperpage);\n callback(msg);\n\n } else {\n let labels = [];\n response.list.forEach(user => {\n labels.push(renderTemplate('core_user/form_user_selector_suggestion', user));\n });\n labels = await Promise.all(labels);\n\n response.list.forEach((user, index) => {\n user.label = labels[index];\n });\n\n callback(response.list);\n }\n\n } catch (e) {\n failure(e);\n }\n}\n\n/**\n * Process the results for auto complete elements.\n *\n * @param {String} selector The selector of the auto complete element.\n * @param {Array} results An array or results returned by {@see transport()}.\n * @return {Array} New array of the selector options.\n */\nexport function processResults(selector, results) {\n\n if (!Array.isArray(results)) {\n return results;\n\n } else {\n return results.map(result => ({value: result.id, label: result.label}));\n }\n}\n"],"file":"form_user_selector.min.js"} \ No newline at end of file diff --git a/user/amd/src/form_user_selector.js b/user/amd/src/form_user_selector.js new file mode 100644 index 00000000000..07df01c03be --- /dev/null +++ b/user/amd/src/form_user_selector.js @@ -0,0 +1,87 @@ +// This file is part of Moodle - https://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Provides the required functionality for an autocomplete element to select a user. + * + * @module core_user/form_user_selector + * @package core_webservice + * @copyright 2020 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Ajax from 'core/ajax'; +import {render as renderTemplate} from 'core/templates'; +import {get_string as getString} from 'core/str'; + +/** + * Load the list of users matching the query and render the selector labels for them. + * + * @param {String} selector The selector of the auto complete element. + * @param {String} query The query string. + * @param {Function} callback A callback function receiving an array of results. + * @param {Function} failure A function to call in case of failure, receiving the error message. + */ +export async function transport(selector, query, callback, failure) { + + const request = { + methodname: 'core_user_search_identity', + args: { + query: query + } + }; + + try { + const response = await Ajax.call([request])[0]; + + if (response.overflow) { + const msg = await getString('toomanyuserstoshow', 'core', '>' + response.maxusersperpage); + callback(msg); + + } else { + let labels = []; + response.list.forEach(user => { + labels.push(renderTemplate('core_user/form_user_selector_suggestion', user)); + }); + labels = await Promise.all(labels); + + response.list.forEach((user, index) => { + user.label = labels[index]; + }); + + callback(response.list); + } + + } catch (e) { + failure(e); + } +} + +/** + * Process the results for auto complete elements. + * + * @param {String} selector The selector of the auto complete element. + * @param {Array} results An array or results returned by {@see transport()}. + * @return {Array} New array of the selector options. + */ +export function processResults(selector, results) { + + if (!Array.isArray(results)) { + return results; + + } else { + return results.map(result => ({value: result.id, label: result.label})); + } +} diff --git a/user/classes/external/search_identity.php b/user/classes/external/search_identity.php new file mode 100644 index 00000000000..694f3920594 --- /dev/null +++ b/user/classes/external/search_identity.php @@ -0,0 +1,131 @@ +. + +namespace core_user\external; + +/** + * Provides the core_user_search_identity external function. + * + * @package core_user + * @category external + * @copyright 2021 David Mudrák + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class search_identity extends \external_api { + + /** + * Describes the external function parameters. + * + * @return external_function_parameters + */ + public static function execute_parameters(): \external_function_parameters { + + return new \external_function_parameters([ + 'query' => new \external_value(PARAM_TEXT, 'The search query', VALUE_REQUIRED), + ]); + } + + /** + * Finds users with the identity matching the given query. + * + * @param string $query The search request. + * @return array + */ + public static function execute(string $query): array { + global $DB, $CFG; + + $params = \external_api::validate_parameters(self::execute_parameters(), [ + 'query' => $query, + ]); + $query = $params['query']; + + // Validate context. + $context = \context_system::instance(); + self::validate_context($context); + require_capability('moodle/user:viewalldetails', $context); + + $hasviewfullnames = has_capability('moodle/site:viewfullnames', $context); + + $fields = \core\user_fields::for_name()->with_identity($context, false); + $extrafields = $fields->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]); + + list($searchsql, $searchparams) = users_search_sql($query, '', true, $extrafields); + list($sortsql, $sortparams) = users_order_by_sql('', $query, $context); + $params = array_merge($searchparams, $sortparams); + + $rs = $DB->get_recordset_select('user', $searchsql, $params, $sortsql, + 'id' . $fields->get_sql()->selects, 0, $CFG->maxusersperpage + 1); + + $count = 0; + $list = []; + + foreach ($rs as $record) { + $user = (object)[ + 'id' => $record->id, + 'fullname' => fullname($record, $hasviewfullnames), + 'extrafields' => [], + ]; + + foreach ($extrafields as $extrafield) { + // Sanitize the extra fields to prevent potential XSS exploit. + $user->extrafields[] = (object)[ + 'name' => $extrafield, + 'value' => s($record->$extrafield) + ]; + } + + $count++; + + if ($count <= $CFG->maxusersperpage) { + $list[$record->id] = $user; + } + } + + $rs->close(); + + return [ + 'list' => $list, + 'maxusersperpage' => $CFG->maxusersperpage, + 'overflow' => ($count > $CFG->maxusersperpage), + ]; + } + + /** + * Describes the external function result value. + * + * @return external_description + */ + public static function execute_returns(): \external_description { + + return new \external_single_structure([ + 'list' => new \external_multiple_structure( + new \external_single_structure([ + 'id' => new \external_value(\core_user::get_property_type('id'), 'ID of the user'), + // The output of the {@see fullname()} can contain formatting HTML such as tags. + // So we need PARAM_RAW here and the caller is supposed to render it appropriately. + 'fullname' => new \external_value(PARAM_RAW, 'The fullname of the user'), + 'extrafields' => new \external_multiple_structure( + new \external_single_structure([ + 'name' => new \external_value(PARAM_TEXT, 'Name of the extrafield.'), + 'value' => new \external_value(PARAM_TEXT, 'Value of the extrafield.'), + ]), 'List of extra fields', VALUE_OPTIONAL) + ]) + ), + 'maxusersperpage' => new \external_value(PARAM_INT, 'Configured maximum users per page.'), + 'overflow' => new \external_value(PARAM_BOOL, 'Were there more records than maxusersperpage found?'), + ]); + } +} diff --git a/user/templates/form_user_selector_suggestion.mustache b/user/templates/form_user_selector_suggestion.mustache new file mode 100644 index 00000000000..abec063a0a7 --- /dev/null +++ b/user/templates/form_user_selector_suggestion.mustache @@ -0,0 +1,54 @@ +{{! + This file is part of Moodle - https://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_user/form_user_selector_suggestion + + Moodle template for the list of valid options in an user selector autocomplate form element. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * fullname - string Users full name + * extrafields - list + + Example context (json): + { + "fullname": "Admin User", + "extrafields": [ + { + "name": "email", + "value": "admin@example.com" + }, + { + "name": "phone1", + "value": "0123456789" + } + ] + } +}} + + {{fullname}} + + {{#extrafields}} + {{{value}}} + {{/extrafields}} + + diff --git a/user/tests/externallib_test.php b/user/tests/externallib_test.php index 2e179df56eb..3c6b4de806d 100644 --- a/user/tests/externallib_test.php +++ b/user/tests/externallib_test.php @@ -679,7 +679,7 @@ class core_user_externallib_testcase extends externallib_advanced_testcase { } /** - * Data provider for {@link self::test_create_users_invalid_parameter()}. + * Data provider for {@see self::test_create_users_invalid_parameter()}. * * @return array */ @@ -1538,4 +1538,149 @@ class core_user_externallib_testcase extends externallib_advanced_testcase { // Try to retrieve other user private files info. core_user_external::get_private_files_info($user2->id); } + + /** + * Test the functionality of the {@see \core_user\external\search_identity} class. + */ + public function test_external_search_identity() { + global $CFG; + + $this->resetAfterTest(true); + $this->setAdminUser(); + + $user1 = self::getDataGenerator()->create_user([ + 'firstname' => 'Firstone', + 'lastname' => 'Lastone', + 'username' => 'usernameone', + 'idnumber' => 'idnumberone', + 'email' => 'userone@example.com', + 'phone1' => 'tel1', + 'phone2' => 'tel2', + 'department' => 'Department Foo', + 'institution' => 'Institution Foo', + 'city' => 'City One', + 'country' => 'AU', + ]); + + $user2 = self::getDataGenerator()->create_user([ + 'firstname' => 'Firsttwo', + 'lastname' => 'Lasttwo', + 'username' => 'usernametwo', + 'idnumber' => 'idnumbertwo', + 'email' => 'usertwo@example.com', + 'phone1' => 'tel1', + 'phone2' => 'tel2', + 'department' => 'Department Foo', + 'institution' => 'Institution Foo', + 'city' => 'City One', + 'country' => 'AU', + ]); + + $user3 = self::getDataGenerator()->create_user([ + 'firstname' => 'Firstthree', + 'lastname' => 'Lastthree', + 'username' => 'usernamethree', + 'idnumber' => 'idnumberthree', + 'email' => 'userthree@example.com', + 'phone1' => 'tel1', + 'phone2' => 'tel2', + 'department' => 'Department Foo', + 'institution' => 'Institution Foo', + 'city' => 'City One', + 'country' => 'AU', + ]); + + $CFG->showuseridentity = 'email,idnumber,city'; + $CFG->maxusersperpage = 3; + + $result = \core_user\external\search_identity::execute('Lastt'); + $result = external_api::clean_returnvalue(\core_user\external\search_identity::execute_returns(), $result); + + $this->assertEquals(2, count($result['list'])); + $this->assertEquals(3, $result['maxusersperpage']); + $this->assertEquals(false, $result['overflow']); + + foreach ($result['list'] as $user) { + $this->assertEquals(3, count($user['extrafields'])); + $this->assertEquals('email', $user['extrafields'][0]['name']); + $this->assertEquals('idnumber', $user['extrafields'][1]['name']); + $this->assertEquals('city', $user['extrafields'][2]['name']); + } + + $CFG->showuseridentity = 'username'; + $CFG->maxusersperpage = 2; + + $result = \core_user\external\search_identity::execute('Firstt'); + $result = external_api::clean_returnvalue(\core_user\external\search_identity::execute_returns(), $result); + + $this->assertEquals(2, count($result['list'])); + $this->assertEquals(2, $result['maxusersperpage']); + $this->assertEquals(false, $result['overflow']); + + foreach ($result['list'] as $user) { + $this->assertEquals(1, count($user['extrafields'])); + $this->assertEquals('username', $user['extrafields'][0]['name']); + } + + $CFG->showuseridentity = 'email'; + $CFG->maxusersperpage = 2; + + $result = \core_user\external\search_identity::execute('City One'); + $result = external_api::clean_returnvalue(\core_user\external\search_identity::execute_returns(), $result); + + $this->assertEquals(0, count($result['list'])); + $this->assertEquals(2, $result['maxusersperpage']); + $this->assertEquals(false, $result['overflow']); + + $CFG->showuseridentity = 'city'; + $CFG->maxusersperpage = 2; + + foreach ($result['list'] as $user) { + $this->assertEquals(1, count($user['extrafields'])); + $this->assertEquals('username', $user['extrafields'][0]['name']); + } + + $result = \core_user\external\search_identity::execute('City One'); + $result = external_api::clean_returnvalue(\core_user\external\search_identity::execute_returns(), $result); + + $this->assertEquals(2, count($result['list'])); + $this->assertEquals(2, $result['maxusersperpage']); + $this->assertEquals(true, $result['overflow']); + } + + /** + * Test functionality of the {@see \core_user\external\search_identity} class with alternativefullnameformat defined. + */ + public function test_external_search_identity_with_alternativefullnameformat() { + global $CFG; + + $this->resetAfterTest(true); + $this->setAdminUser(); + + $user1 = self::getDataGenerator()->create_user([ + 'lastname' => '小柳', + 'lastnamephonetic' => 'Koyanagi', + 'firstname' => '秋', + 'firstnamephonetic' => 'Aki', + 'email' => 'koyanagiaki@example.com', + 'country' => 'JP', + ]); + + $CFG->showuseridentity = 'email'; + $CFG->maxusersperpage = 3; + $CFG->alternativefullnameformat = + 'lastname firstname (lastnamephonetic firstnamephonetic)'; + + $result = \core_user\external\search_identity::execute('Ak'); + $result = external_api::clean_returnvalue(\core_user\external\search_identity::execute_returns(), $result); + + $this->assertEquals(1, count($result['list'])); + $this->assertEquals(3, $result['maxusersperpage']); + $this->assertEquals(false, $result['overflow']); + + foreach ($result['list'] as $user) { + $this->assertEquals(1, count($user['extrafields'])); + $this->assertEquals('email', $user['extrafields'][0]['name']); + } + } } diff --git a/user/upgrade.txt b/user/upgrade.txt index 85531920a8d..d20af1afffd 100644 --- a/user/upgrade.txt +++ b/user/upgrade.txt @@ -1,5 +1,13 @@ This files describes API changes for code that uses the user API. +=== 3.11 === + +* Added new core_user/form_user_selector JS module that can be used as the 'ajax' handler for the autocomplete form + element implementing the user selector. +* Added new external function core_user_external::search_identity(). The main purpose of this external function is to + provide data for asynchronous user selectors and similar widgets. It allows to search users matching the given query + in their name or other available identity fields. + === 3.9 === * The unified filter has been replaced by the participants filter. The following have therefore been deprecated: diff --git a/version.php b/version.php index 56afbad5b80..8b558c60aa7 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021052500.68; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021052500.69; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20210312)'; // Human-friendly version name diff --git a/webservice/classes/token_filter.php b/webservice/classes/token_filter.php new file mode 100644 index 00000000000..8144fe62843 --- /dev/null +++ b/webservice/classes/token_filter.php @@ -0,0 +1,100 @@ +. + +/** + * Provides the {@see core_webservice\token_filter} class. + * + * @package core_webservice + * @copyright 2020 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_webservice; + +use moodleform; + +/** + * Form allowing to filter displayed tokens. + * + * @copyright 2020 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class token_filter extends moodleform { + + /** + * Defines the form fields. + */ + public function definition() { + global $DB; + + $mform = $this->_form; + $presetdata = $this->_customdata; + + $mform->addElement('header', 'tokenfilter', get_string('tokenfilter', 'webservice')); + + if (empty($presetdata->token) && empty($presetdata->users) && empty($presetdata->services)) { + $mform->setExpanded('tokenfilter', false); + } else { + $mform->setExpanded('tokenfilter', true); + } + + // Token. + $mform->addElement('text', 'token', get_string('token', 'core_webservice'), ['size' => 32]); + $mform->setType('token', PARAM_ALPHANUM); + + // User selector. + $attributes = [ + 'multiple' => true, + 'ajax' => 'core_user/form_user_selector', + 'valuehtmlcallback' => function($userid) { + global $DB, $OUTPUT; + + $context = \context_system::instance(); + $fields = \core\user_fields::for_name()->with_identity($context, false); + $record = \core_user::get_user($userid, 'id' . $fields->get_sql()->selects, MUST_EXIST); + + $user = (object)[ + 'id' => $record->id, + 'fullname' => fullname($record, has_capability('moodle/site:viewfullnames', $context)), + 'extrafields' => [], + ]; + + foreach ($fields->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]) as $extrafield) { + $user->extrafields[] = (object)[ + 'name' => $extrafield, + 'value' => s($record->$extrafield) + ]; + } + + return $OUTPUT->render_from_template('core_user/form_user_selector_suggestion', $user); + }, + ]; + $mform->addElement('autocomplete', 'users', get_string('user'), [], $attributes); + + // Service selector. + $options = $DB->get_records_menu('external_services', null, '', 'id, name'); + $attributes = [ + 'multiple' => true, + ]; + $mform->addElement('autocomplete', 'services', get_string('service', 'webservice'), $options, $attributes); + + // Action buttons. + $mform->addGroup([ + $mform->createElement('submit', 'submitbutton', get_string('tokenfiltersubmit', 'core_webservice')), + $mform->createElement('submit', 'resetbutton', get_string('tokenfilterreset', 'core_webservice'), [], false), + ], 'actionbuttons', '', ' ', false); + } +} diff --git a/webservice/classes/token_form.php b/webservice/classes/token_form.php new file mode 100644 index 00000000000..5331e96db05 --- /dev/null +++ b/webservice/classes/token_form.php @@ -0,0 +1,119 @@ +. + +/** + * Provides the {@see \core_webservice\token_form} class. + * + * @package core_webservice + * @category admin + * @copyright 2020 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_webservice; + +/** + * Form to create and edit a web service token. + * + * Tokens allow users call external functions provided by selected web services. They can optionally have IP restriction + * and date validity defined. + * + * @copyright 2010 Jerome Mouneyrac + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class token_form extends \moodleform { + + /** + * Defines the form fields. + */ + public function definition() { + global $DB; + + $mform = $this->_form; + $data = $this->_customdata; + + $mform->addElement('header', 'token', get_string('token', 'webservice')); + + // User selector. + $attributes = [ + 'multiple' => false, + 'ajax' => 'core_user/form_user_selector', + 'valuehtmlcallback' => function($userid) { + global $DB, $OUTPUT; + + $context = \context_system::instance(); + $fields = \core\user_fields::for_name()->with_identity($context, false); + $record = $DB->get_record('user', ['id' => $userid], $fields, MUST_EXIST); + + $user = (object)[ + 'id' => $record->id, + 'fullname' => fullname($record, has_capability('moodle/site:viewfullnames', $context)), + 'extrafields' => [], + ]; + + foreach ($fields->get_required_fields([\core\user_fields::PURPOSE_IDENTITY]) as $extrafield) { + $user->extrafields[] = (object)[ + 'name' => $extrafield, + 'value' => s($record->$extrafield) + ]; + } + + return $OUTPUT->render_from_template('core_user/form_user_selector_suggestion', $user); + }, + ]; + $mform->addElement('autocomplete', 'user', get_string('user'), [], $attributes); + $mform->addRule('user', get_string('required'), 'required', null, 'client'); + + // Service selector. + $options = $DB->get_records_menu('external_services', null, '', 'id, name'); + $mform->addElement('select', 'service', get_string('service', 'webservice'), $options); + $mform->addRule('service', get_string('required'), 'required', null, 'client'); + $mform->setType('service', PARAM_INT); + + $mform->addElement('text', 'iprestriction', get_string('iprestriction', 'webservice')); + $mform->setType('iprestriction', PARAM_RAW_TRIMMED); + + $mform->addElement('date_selector', 'validuntil', + get_string('validuntil', 'webservice'), array('optional' => true)); + $mform->setType('validuntil', PARAM_INT); + + $mform->addElement('hidden', 'action'); + $mform->setType('action', PARAM_ALPHANUMEXT); + + $this->add_action_buttons(true); + + $this->set_data($data); + } + + /** + * Validate the submitted data. + * + * @param array $data Submitted data. + * @param array $files Submitted files. + * @return array Validation errors. + */ + public function validation($data, $files) { + global $DB; + + $errors = parent::validation($data, $files); + + if ($DB->get_field('user', 'suspended', ['id' => $data['user']], MUST_EXIST)) { + $errors['user'] = get_string('suspended', 'core') . ' - ' . get_string('forbiddenwsuser', 'core_webservice'); + } + + return $errors; + } +} diff --git a/webservice/classes/token_table.php b/webservice/classes/token_table.php index f6220570031..deb8a7a816f 100644 --- a/webservice/classes/token_table.php +++ b/webservice/classes/token_table.php @@ -22,7 +22,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -namespace webservice; +namespace core_webservice; defined('MOODLE_INTERNAL') || die; @@ -44,11 +44,22 @@ class token_table extends \table_sql { */ protected $showalltokens; + /** @var bool $hasviewfullnames Does the user have the viewfullnames capability. */ + protected $hasviewfullnames; + + /** @var array */ + protected $userextrafields; + + /** @var object */ + protected $filterdata; + /** * Sets up the table. + * * @param int $id The id of the table + * @param object $filterdata The data submitted by the {@see token_filter}. */ - public function __construct($id) { + public function __construct($id, $filterdata = null) { parent::__construct($id); // Get the context. @@ -56,6 +67,13 @@ class token_table extends \table_sql { // Can we see tokens created by all users? $this->showalltokens = has_capability('moodle/webservice:managealltokens', $context); + $this->hasviewfullnames = has_capability('moodle/site:viewfullnames', $context); + + // List of user identity fields. + $this->userextrafields = \core\user_fields::get_identity_fields(\context_system::instance(), false); + + // Filter form values. + $this->filterdata = $filterdata; // Define the headers and columns. $headers = []; @@ -66,7 +84,7 @@ class token_table extends \table_sql { $headers[] = get_string('user'); $columns[] = 'fullname'; $headers[] = get_string('service', 'webservice'); - $columns[] = 'name'; + $columns[] = 'servicename'; $headers[] = get_string('iprestriction', 'webservice'); $columns[] = 'iprestriction'; $headers[] = get_string('validuntil', 'webservice'); @@ -130,20 +148,33 @@ class token_table extends \table_sql { public function col_fullname($data) { global $OUTPUT; + $identity = []; + + foreach ($this->userextrafields as $userextrafield) { + $identity[] = $data->$userextrafield; + } + $userprofilurl = new \moodle_url('/user/profile.php', ['id' => $data->userid]); - $content = \html_writer::link($userprofilurl, fullname($data)); + $content = \html_writer::link($userprofilurl, fullname($data, $this->hasviewfullnames)); + + if ($identity) { + $content .= \html_writer::div('' . implode(', ', $identity) . '', 'useridentity text-muted'); + } // Make up list of capabilities that the user is missing for the given webservice. $webservicemanager = new \webservice(); $usermissingcaps = $webservicemanager->get_missing_capabilities_by_users([['id' => $data->userid]], $data->serviceid); - if (!is_siteadmin($data->userid) && array_key_exists($data->userid, $usermissingcaps)) { - $missingcapabilities = implode(', ', $usermissingcaps[$data->userid]); - if (!empty($missingcapabilities)) { - $capabilitiesstring = get_string('usermissingcaps', 'webservice', $missingcapabilities) . ' ' . - $OUTPUT->help_icon('missingcaps', 'webservice'); - $content .= \html_writer::div($capabilitiesstring, 'missingcaps'); - } + if ($data->serviceshortname <> MOODLE_OFFICIAL_MOBILE_SERVICE && !is_siteadmin($data->userid) + && array_key_exists($data->userid, $usermissingcaps)) { + $count = \html_writer::span(count($usermissingcaps[$data->userid]), 'badge badge-danger'); + $links = array_map(function($capname) { + return get_capability_docs_link((object)['name' => $capname]) . \html_writer::div($capname, 'text-muted'); + }, $usermissingcaps[$data->userid]); + $list = \html_writer::alist($links); + $help = $OUTPUT->help_icon('missingcaps', 'webservice'); + $content .= print_collapsible_region(\html_writer::div($list . $help, 'missingcaps'), 'small mt-2', + \html_writer::random_id('usermissingcaps'), get_string('usermissingcaps', 'webservice', $count), '', true, true); } return $content; @@ -159,7 +190,7 @@ class token_table extends \table_sql { global $USER; // Hide the token if it wasn't created by the current user. if ($data->creatorid != $USER->id) { - return '-'; + return \html_writer::tag('small', get_string('onlyseecreatedtokens', 'core_webservice'), ['class' => 'text-muted']); } return $data->token; @@ -183,7 +214,17 @@ class token_table extends \table_sql { } $creatorprofileurl = new \moodle_url('/user/profile.php', ['id' => $data->creatorid]); - return \html_writer::link($creatorprofileurl, fullname((object)$user)); + return \html_writer::link($creatorprofileurl, fullname((object)$user, $this->hasviewfullnames)); + } + + /** + * Format the service name column. + * + * @param \stdClass $data + * @return string + */ + public function col_servicename($data) { + return \html_writer::div(s($data->servicename)) . \html_writer::div(s($data->serviceshortname), 'small text-muted'); } /** @@ -221,41 +262,59 @@ class token_table extends \table_sql { $usernamefields = $userfieldsapi->get_sql('u', false, '', '', false)->selects; $creatorfields = $userfieldsapi->get_sql('c', false, 'creator', '', false)->selects; - $params = ["tokenmode" => EXTERNAL_TOKEN_PERMANENT]; + if (!empty($this->userextrafields)) { + $usernamefields .= ',u.' . implode(',u.', $this->userextrafields); + } - // TODO: in order to let the administrator delete obsolete token, split the request in multiple request or use LEFT JOIN. + $params = ['tokenmode' => EXTERNAL_TOKEN_PERMANENT]; - if ($this->showalltokens) { - // Show all tokens. - $sql = "SELECT t.id, t.token, u.id AS userid, $usernamefields, s.name, t.iprestriction, t.validuntil, s.id AS serviceid, - t.creatorid, $creatorfields - FROM {external_tokens} t, {user} u, {external_services} s, {user} c - WHERE t.tokentype = :tokenmode AND s.id = t.externalserviceid AND t.userid = u.id AND c.id = t.creatorid"; - $countsql = "SELECT COUNT(t.id) - FROM {external_tokens} t, {user} u, {external_services} s, {user} c - WHERE t.tokentype = :tokenmode AND s.id = t.externalserviceid AND t.userid = u.id AND c.id = t.creatorid"; - } else { + $selectfields = "SELECT t.id, t.token, t.iprestriction, t.validuntil, t.creatorid, + u.id AS userid, $usernamefields, + s.id AS serviceid, s.name AS servicename, s.shortname AS serviceshortname, + $creatorfields "; + + $selectcount = "SELECT COUNT(t.id) "; + + $sql = " FROM {external_tokens} t + JOIN {user} u ON u.id = t.userid + JOIN {external_services} s ON s.id = t.externalserviceid + JOIN {user} c ON c.id = t.creatorid + WHERE t.tokentype = :tokenmode"; + + if (!$this->showalltokens) { // Only show tokens created by the current user. - $sql = "SELECT t.id, t.token, u.id AS userid, $usernamefields, s.name, t.iprestriction, t.validuntil, s.id AS serviceid, - t.creatorid, $creatorfields - FROM {external_tokens} t, {user} u, {external_services} s, {user} c - WHERE t.creatorid=:userid AND t.tokentype = :tokenmode AND s.id = t.externalserviceid AND t.userid = u.id AND - c.id = t.creatorid"; - $countsql = "SELECT COUNT(t.id) - FROM {external_tokens} t, {user} u, {external_services} s, {user} c - WHERE t.creatorid=:userid AND t.tokentype = :tokenmode AND s.id = t.externalserviceid AND - t.userid = u.id AND c.id = t.creatorid"; - $params["userid"] = $USER->id; + $sql .= " AND t.creatorid = :userid"; + $params['userid'] = $USER->id; + } + + if ($this->filterdata->token !== '') { + $sql .= " AND " . $DB->sql_like("t.token", ":token"); + $params['token'] = "%" . $DB->sql_like_escape($this->filterdata->token) . "%"; + } + + if (!empty($this->filterdata->users)) { + list($sqlusers, $paramsusers) = $DB->get_in_or_equal($this->filterdata->users, SQL_PARAMS_NAMED, 'user'); + $sql .= " AND t.userid {$sqlusers}"; + $params += $paramsusers; + } + + if (!empty($this->filterdata->services)) { + list($sqlservices, $paramsservices) = $DB->get_in_or_equal($this->filterdata->services, SQL_PARAMS_NAMED, 'service'); + $sql .= " AND s.id {$sqlservices}"; + $params += $paramsservices; } $sort = $this->get_sql_sort(); + $sortsql = ''; + if ($sort) { - $sql = $sql . ' ORDER BY ' . $sort; + $sortsql = " ORDER BY {$sort}"; } - $total = $DB->count_records_sql($countsql, $params); + $total = $DB->count_records_sql($selectcount . $sql, $params); $this->pagesize($pagesize, $total); - $this->rawdata = $DB->get_recordset_sql($sql, $params, $this->get_page_start(), $this->get_page_size()); + $this->rawdata = $DB->get_recordset_sql($selectfields . $sql . $sortsql, $params, $this->get_page_start(), + $this->get_page_size()); } } diff --git a/webservice/lib.php b/webservice/lib.php index 5257640e944..d8bd2cd34f6 100644 --- a/webservice/lib.php +++ b/webservice/lib.php @@ -285,9 +285,16 @@ class webservice { */ public function get_ws_authorised_users($serviceid) { global $DB, $CFG; + $params = array($CFG->siteguest, $serviceid); - $sql = " SELECT u.id as id, esu.id as serviceuserid, u.email as email, u.firstname as firstname, - u.lastname as lastname, + + $namefields = get_all_user_name_fields(true, 'u'); + + foreach (get_extra_user_fields(context_system::instance()) as $extrafield) { + $namefields .= ',u.' . $extrafield; + } + + $sql = " SELECT u.id as id, esu.id as serviceuserid, {$namefields}, esu.iprestriction as iprestriction, esu.validuntil as validuntil, esu.timecreated as timecreated FROM {user} u, {external_services_users} esu @@ -296,6 +303,7 @@ class webservice { AND esu.externalserviceid = ?"; $users = $DB->get_records_sql($sql, $params); + return $users; } @@ -623,11 +631,16 @@ class webservice { * as the front end does not display it itself. In pratice, * admins would like the info, for more info you can follow: MDL-29962 * + * @deprecated since Moodle 3.11 in MDL-67748 without a replacement. + * @todo MDL-70187 Please delete this method completely in Moodle 4.3, thank you. * @param int $userid user id * @return array */ public function get_user_capabilities($userid) { global $DB; + + debugging('webservice::get_user_capabilities() has been deprecated.', DEBUG_DEVELOPER); + //retrieve the user capabilities $sql = "SELECT DISTINCT rc.id, rc.capability FROM {role_capabilities} rc, {role_assignments} ra WHERE rc.roleid=ra.roleid AND ra.userid= ? AND rc.permission = ?"; @@ -640,45 +653,97 @@ class webservice { } /** - * Get missing user capabilities for a given service - * WARNING: do not use this "broken" function. It was created in the goal to display some capabilities - * required by users. In theory we should not need to display this kind of information - * as the front end does not display it itself. In pratice, - * admins would like the info, for more info you can follow: MDL-29962 + * Get missing user capabilities for the given service's functions. * - * @param array $users users - * @param int $serviceid service id - * @return array of missing capabilities, keys being the user ids + * Every external function can declare some required capabilities to allow for easier setup of the web services. + * However, that is supposed to be used for informational admin report only. There is no automatic evaluation of + * the declared capabilities and the context of the capability evaluation is ignored. Also, actual capability + * evaluation is much more complex as it allows for overrides etc. + * + * Returned are capabilities that the given users do not seem to have assigned anywhere at the site and that should + * be checked by the admin. + * + * Do not use this method for anything else, particularly not for any security related checks. See MDL-29962 for the + * background of why we have this - there are arguments for dropping this feature completely. + * + * @param array $users List of users to check, consisting of objects, arrays or integer ids. + * @param int $serviceid The id of the external service to check. + * @return array List of missing capabilities: (int)userid => array of (string)capabilitynames */ - public function get_missing_capabilities_by_users($users, $serviceid) { + public function get_missing_capabilities_by_users(array $users, int $serviceid): array { global $DB; - $usersmissingcaps = array(); - //retrieve capabilities required by the service - $servicecaps = $this->get_service_required_capabilities($serviceid); + // The following are default capabilities for all authenticated users and we will assume them granted. + $commoncaps = get_default_capabilities('user'); - //retrieve users missing capabilities - foreach ($users as $user) { - //cast user array into object to be a bit more flexible - if (is_array($user)) { - $user = (object) $user; - } - $usercaps = $this->get_user_capabilities($user->id); - - //detect the missing capabilities - foreach ($servicecaps as $functioname => $functioncaps) { - foreach ($functioncaps as $functioncap) { - if (!array_key_exists($functioncap, $usercaps)) { - if (!isset($usersmissingcaps[$user->id]) - or array_search($functioncap, $usersmissingcaps[$user->id]) === false) { - $usersmissingcaps[$user->id][] = $functioncap; - } - } + // Get the list of additional capabilities required by the service. + $servicecaps = []; + foreach ($this->get_service_required_capabilities($serviceid) as $service => $caps) { + foreach ($caps as $cap) { + if (empty($commoncaps[$cap])) { + $servicecaps[$cap] = true; } } } - return $usersmissingcaps; + if (empty($servicecaps)) { + return []; + } + + // Prepare a list of user ids we want to check. + $userids = []; + foreach ($users as $user) { + if (is_object($user) && isset($user->id)) { + $userids[$user->id] = true; + } else if (is_array($user) && isset($user['id'])) { + $userids[$user['id']] = true; + } else { + throw new coding_exception('Unexpected format of users list in webservice::get_missing_capabilities_by_users().'); + } + } + + // Prepare a matrix of missing capabilities x users - consider them all missing by default. + foreach (array_keys($userids) as $userid) { + foreach (array_keys($servicecaps) as $capname) { + $matrix[$userid][$capname] = true; + } + } + + list($capsql, $capparams) = $DB->get_in_or_equal(array_keys($servicecaps), SQL_PARAMS_NAMED, 'paramcap'); + list($usersql, $userparams) = $DB->get_in_or_equal(array_keys($userids), SQL_PARAMS_NAMED, 'paramuser'); + + $sql = "SELECT c.name AS capability, u.id AS userid + FROM {capabilities} c + JOIN {role_capabilities} rc ON c.name = rc.capability + JOIN {role_assignments} ra ON ra.roleid = rc.roleid + JOIN {user} u ON ra.userid = u.id + WHERE rc.permission = :capallow + AND c.name {$capsql} + AND u.id {$usersql}"; + + $params = $capparams + $userparams + [ + 'capallow' => CAP_ALLOW, + ]; + + $rs = $DB->get_recordset_sql($sql, $params); + + foreach ($rs as $record) { + // If there was a potential role assignment found that might grant the user the given capability, + // remove it from the matrix. Again, we ignore all the contexts, prohibits, prevents and other details + // of the permissions evaluations. See the function docblock for details. + unset($matrix[$record->userid][$record->capability]); + } + + $rs->close(); + + foreach ($matrix as $userid => $caps) { + $matrix[$userid] = array_keys($caps); + if (empty($matrix[$userid])) { + unset($matrix[$userid]); + } + } + + return $matrix; } /** @@ -1416,7 +1481,7 @@ abstract class webservice_base_server extends webservice_server { 7. The function is called with username/password (no user token is sent) and none of the services has the function to allow the user. These settings can be found in Administration > Site administration - > Plugins > Web services > External services and Manage tokens.'); + > Server > Web services > External services and Manage tokens.'); } // we have all we need now diff --git a/webservice/renderer.php b/webservice/renderer.php index f97c03c866d..ebccbb9156e 100644 --- a/webservice/renderer.php +++ b/webservice/renderer.php @@ -96,7 +96,7 @@ class core_webservice_renderer extends plugin_renderer_base { } /** - * Display list of authorised users + * Display list of authorised users for the given external service. * * @param array $users authorised users * @param int $serviceid service id @@ -104,25 +104,43 @@ class core_webservice_renderer extends plugin_renderer_base { */ public function admin_authorised_user_list($users, $serviceid) { global $CFG; - $html = $this->output->box_start('generalbox', 'alloweduserlist'); + + $listitems = []; + $extrafields = get_extra_user_fields(context_system::instance()); + foreach ($users as $user) { - $modifiedauthoriseduserurl = new moodle_url('/' . $CFG->admin . '/webservice/service_user_settings.php', - array('userid' => $user->id, 'serviceid' => $serviceid)); - $html .= html_writer::tag('a', $user->firstname . " " - . $user->lastname . ", " . s($user->email), - array('href' => $modifiedauthoriseduserurl)); - //add missing capabilities - if (!empty($user->missingcapabilities)) { - $html .= html_writer::tag('div', - get_string('usermissingcaps', 'webservice', $user->missingcapabilities) - . ' ' . $this->output->help_icon('missingcaps', 'webservice'), - array('class' => 'missingcaps', 'id' => 'usermissingcaps')); - $html .= html_writer::empty_tag('br'); - } else { - $html .= html_writer::empty_tag('br') . html_writer::empty_tag('br'); + $settingsurl = new moodle_url('/admin/webservice/service_user_settings.php', + ['userid' => $user->id, 'serviceid' => $serviceid]); + + $identity = []; + foreach ($extrafields as $extrafield) { + if (isset($user->{$extrafield})) { + $identity[] = s($user->{$extrafield}); + } } + $identity = $identity ? html_writer::div(implode(', ', $identity), 'small') : ''; + + $link = html_writer::link($settingsurl, fullname($user)); + + if (!empty($user->missingcapabilities)) { + $count = html_writer::span(count($user->missingcapabilities), 'badge badge-danger'); + $links = array_map(function($capname) { + return get_capability_docs_link((object)['name' => $capname]) . html_writer::div($capname, 'text-muted'); + }, $user->missingcapabilities); + $list = html_writer::alist($links); + $help = $this->output->help_icon('missingcaps', 'webservice'); + $missingcaps = print_collapsible_region(html_writer::div($list . $help, 'missingcaps'), 'small', + html_writer::random_id('usermissingcaps'), get_string('usermissingcaps', 'webservice', $count), '', true, true); + + } else { + $missingcaps = ''; + } + + $listitems[] = $link . $identity . $missingcaps; } - $html .= $this->output->box_end(); + + $html = html_writer::div(html_writer::alist($listitems), 'alloweduserlist'); + return $html; } @@ -166,29 +184,6 @@ class core_webservice_renderer extends plugin_renderer_base { $formcontinue, $formcancel); } - /** - * Display a confirmation page to delete a token - * - * @param stdClass $token Required properties: id (token id), firstname (user firstname), lastname (user lastname), name (service name) - * @return string html - */ - public function admin_delete_token_confirmation($token) { - global $CFG; - $optionsyes = array('tokenid' => $token->id, 'action' => 'delete', - 'confirm' => 1, 'sesskey' => sesskey()); - $optionsno = array('section' => 'webservicetokens', 'sesskey' => sesskey()); - $formcontinue = new single_button( - new moodle_url('/' . $CFG->admin . '/webservice/tokens.php', $optionsyes), - get_string('delete')); - $formcancel = new single_button( - new moodle_url('/' . $CFG->admin . '/settings.php', $optionsno), - get_string('cancel'), 'get'); - return $this->output->confirm(get_string('deletetokenconfirm', 'webservice', - (object) array('user' => $token->firstname . " " - . $token->lastname, 'service' => $token->name)), - $formcontinue, $formcancel); - } - /** * Display a list of functions for a given service * If the service is built-in, do not display remove/add operation (read-only) diff --git a/webservice/tests/generator/behat_core_webservice_generator.php b/webservice/tests/generator/behat_core_webservice_generator.php new file mode 100644 index 00000000000..cf6f3744303 --- /dev/null +++ b/webservice/tests/generator/behat_core_webservice_generator.php @@ -0,0 +1,57 @@ +. + +/** + * Behat data generator for core_webservice. + * + * @package core_webservice + * @category test + * @copyright 2021 Andrew Nicols + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_core_webservice_generator extends behat_generator_base { + + /** + * Get the list of creatable entities for a web service. + * + * @return array + */ + protected function get_creatable_entities(): array { + + return [ + 'Services' => [ + 'singular' => 'Service', + 'datagenerator' => 'service', + 'required' => ['name'], + ], + + 'Service functions' => [ + 'singular' => 'Service function', + 'datagenerator' => 'service_functions', + 'required' => ['service', 'functions'], + ], + + 'Tokens' => [ + 'singular' => 'Token', + 'datagenerator' => 'token', + 'required' => ['user'], + 'switchids' => [ + 'user' => 'userid', + ], + ], + ]; + } +} diff --git a/webservice/tests/generator/lib.php b/webservice/tests/generator/lib.php new file mode 100644 index 00000000000..9d877abc28b --- /dev/null +++ b/webservice/tests/generator/lib.php @@ -0,0 +1,139 @@ +. + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once(__DIR__ . '/../../lib.php'); + +/** + * Data generator for core_webservice plugin. + * + * @package core_webservice + * @category test + * @copyright 2021 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_webservice_generator extends component_generator_base { + /** + * Create a new webservice service. + * + * @param array $data + * @return stdClass + */ + public function create_service(array $data): \stdClass { + $webservicemanager = new webservice(); + + $requiredfields = [ + 'name', + 'shortname', + ]; + + foreach ($requiredfields as $fieldname) { + if (!array_key_exists($fieldname, $data)) { + throw new \coding_exception("Field '{$fieldname}' missing when creating new service"); + } + } + + $optionalfields = [ + 'requiredcapability' => '', + 'restrictedusers' => 0, + 'component' => '', + 'timemodified' => time(), + ]; + + foreach ($optionalfields as $fieldname => $value) { + if (!array_key_exists($fieldname, $data)) { + $data[$fieldname] = $value; + } + } + + $serviceid = $webservicemanager->add_external_service((object) $data); + + return $webservicemanager->get_external_service_by_id($serviceid); + } + + /** + * Associate a webservice function with service. + * + * @param array $data + */ + public function create_service_functions(array $data): void { + $webservicemanager = new webservice(); + + $requiredfields = [ + 'service', + 'functions', + ]; + + foreach ($requiredfields as $fieldname) { + if (!array_key_exists($fieldname, $data)) { + throw new \coding_exception("Field '{$fieldname}' missing when creating new service"); + } + } + + $service = $webservicemanager->get_external_service_by_shortname($data['service']); + + $functions = explode(',', $data['functions']); + foreach ($functions as $functionname) { + $functionname = trim($functionname); + $webservicemanager->add_external_function_to_service($functionname, $service->id); + } + } + + /** + * Create a new webservice token. + * + * @param array $data + */ + public function create_token(array $data): void { + $webservicemanager = new webservice(); + + $requiredfields = [ + 'userid', + 'service', + ]; + + foreach ($requiredfields as $fieldname) { + if (!array_key_exists($fieldname, $data)) { + throw new \coding_exception("Field '{$fieldname}' missing when creating new service"); + } + } + + $optionalfields = [ + 'context' => context_system::instance(), + 'validuntil' => 0, + 'iprestriction' => '', + ]; + + foreach ($optionalfields as $fieldname => $value) { + if (!array_key_exists($fieldname, $data)) { + $data[$fieldname] = $value; + } + } + + $service = $webservicemanager->get_external_service_by_shortname($data['service']); + + external_generate_token( + EXTERNAL_TOKEN_PERMANENT, + $service->id, + $data['userid'], + $data['context'], + $data['validuntil'], + $data['iprestriction'] + ); + } +} diff --git a/webservice/tests/lib_test.php b/webservice/tests/lib_test.php index 407556b0dda..97784590e69 100644 --- a/webservice/tests/lib_test.php +++ b/webservice/tests/lib_test.php @@ -193,6 +193,65 @@ class webservice_test extends advanced_testcase { $this->assertEquals($before + 60, $token->lastaccess); } + /** + * Tests for the {@see webservice::get_missing_capabilities_by_users()} implementation. + */ + public function test_get_missing_capabilities_by_users() { + global $DB; + + $this->resetAfterTest(true); + $wsman = new webservice(); + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + + // Add a test web service. + $serviceid = $wsman->add_external_service((object)[ + 'name' => 'Test web service', + 'enabled' => 1, + 'requiredcapability' => '', + 'restrictedusers' => false, + 'component' => 'moodle', + 'downloadfiles' => false, + 'uploadfiles' => false, + ]); + + // Add a function to the service that does not declare any capability as required. + $wsman->add_external_function_to_service('core_webservice_get_site_info', $serviceid); + + // Users can be provided as an array of objects, arrays or integers (ids). + $this->assertEmpty($wsman->get_missing_capabilities_by_users([$user1, array($user2), $user3->id], $serviceid)); + + // Add a function to the service that declares some capability as required, but that capability is common for + // any user. Here we use 'core_message_delete_conversation' which declares 'moodle/site:deleteownmessage' which + // in turn is granted to the authenticated user archetype by default. + $wsman->add_external_function_to_service('core_message_delete_conversation', $serviceid); + + // So all three users should have this capability implicitly. + $this->assertEmpty($wsman->get_missing_capabilities_by_users([$user1, $user2, $user3], $serviceid)); + + // Add a function to the service that declares some non-common capability. Here we use + // 'core_group_add_group_members' that wants 'moodle/course:managegroups'. + $wsman->add_external_function_to_service('core_group_add_group_members', $serviceid); + + // Make it so that the $user1 has the capability in some course. + $course1 = $this->getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'editingteacher'); + + // Check that no missing capability is reported for the $user1. We don't care at what actual context the + // external function call will evaluate the permission. We just check that there is a chance that the user has + // the capability somewhere. + $this->assertEmpty($wsman->get_missing_capabilities_by_users([$user1], $serviceid)); + + // But there is no place at the site where the capability would be granted to the other two users, so it is + // reported as missing. + $missing = $wsman->get_missing_capabilities_by_users([$user1, $user2, $user3], $serviceid); + $this->assertArrayNotHasKey($user1->id, $missing); + $this->assertContains('moodle/course:managegroups', $missing[$user2->id]); + $this->assertContains('moodle/course:managegroups', $missing[$user3->id]); + } + /** * Utility method that tests the parameter type of a method info's input/output parameter. * diff --git a/webservice/upgrade.txt b/webservice/upgrade.txt index ec06f9f00ad..2463b78b83d 100644 --- a/webservice/upgrade.txt +++ b/webservice/upgrade.txt @@ -3,6 +3,12 @@ information provided here is intended especially for developers. This information is intended for authors of webservices, not people writing webservice clients. +=== 3.11 === + +* The method webservice::get_user_capabilities() is deprecated now without a replacement. It has been used + internally only to populate the list of missing capabilities. That functionality has been improved so that + it no longer needs this standalone method. + === 3.10 === * The class externallib_advanced_testcase, used in unit tests, has a new function called "configure_filters" to easily configure filters for external functions testing.