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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions src/js/_enqueues/lib/user-suggest.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/**
* Suggests users in a multisite environment.
* Suggests users in site admin and multisite environments.
*
* For input fields where the admin can select a user based on email or
* username, this script shows an autocompletion menu for these inputs. Should
* only be used in a multisite environment. Only users in the currently active
* site are shown.
* For input fields where the admin can select a user by searching their name,
* login, or email, this script shows an autocompletion menu. On multisite,
* only users in the currently active site are shown for 'search' type requests.
*
* @since 3.4.0
* @output wp-admin/js/user-suggest.js
Expand Down Expand Up @@ -36,19 +35,29 @@
* - data-autocomplete-type (add, search)
* The action that is going to be performed: search for existing users
* or add a new one. Default: add
* - data-autocomplete-field (user_login, user_email)
* - data-autocomplete-field (user_login, user_email, user_id)
* The field that is returned as the value for the suggestion.
* When set to 'user_id', the input is expected to have an adjacent
* '.wp-suggest-user-helper' hidden input that stores the numeric ID
* while the visible input shows the display label.
* Default: user_login
* - data-autocomplete-label
* A template string with {{tokens}} to build each result's display label.
* Supported tokens: {{user_login}}, {{user_email}}, {{display_name}}, {{user_id}}.
* Default: empty (server returns display_name).
*
* @see wp-admin/includes/admin-actions.php:wp_ajax_autocomplete_user()
* @see wp-admin/includes/ajax-actions.php:wp_ajax_autocomplete_user()
*/
$( '.wp-suggest-user' ).each( function(){
var $this = $( this ),
autocompleteType = ( typeof $this.data( 'autocompleteType' ) !== 'undefined' ) ? $this.data( 'autocompleteType' ) : 'add',
autocompleteField = ( typeof $this.data( 'autocompleteField' ) !== 'undefined' ) ? $this.data( 'autocompleteField' ) : 'user_login';
$( '.wp-suggest-user' ).each( function() {
var $this = $( this ),
autocompleteType = ( typeof $this.data( 'autocompleteType' ) !== 'undefined' ) ? $this.data( 'autocompleteType' ) : 'add',
autocompleteField = ( typeof $this.data( 'autocompleteField' ) !== 'undefined' ) ? $this.data( 'autocompleteField' ) : 'user_login',
autocompleteLabel = ( typeof $this.data( 'autocompleteLabel' ) !== 'undefined' ) ? $this.data( 'autocompleteLabel' ) : '',
// True when using user_id field with a sibling helper input.
hasHelper = ( 'user_id' === autocompleteField && $this.next( '.wp-suggest-user-helper' ).length > 0 );

$this.autocomplete({
source: ajaxurl + '?action=autocomplete-user&autocomplete_type=' + autocompleteType + '&autocomplete_field=' + autocompleteField + id,
source: ajaxurl + '?action=autocomplete-user&autocomplete_type=' + autocompleteType + '&autocomplete_field=' + autocompleteField + '&autocomplete_label=' + encodeURIComponent( autocompleteLabel ) + id,
delay: 500,
minLength: 2,
position: position,
Expand All @@ -57,6 +66,21 @@
},
close: function() {
$( this ).removeClass( 'open' );
},
focus: function( e, ui ) {
if ( hasHelper ) {
// Show the display label while navigating, not the raw ID value.
$( this ).val( ui.item.label );
return false;
}
},
select: function( e, ui ) {
if ( hasHelper ) {
// Store the user ID in the hidden helper; show the label in the text input.
$( this ).next( '.wp-suggest-user-helper' ).val( ui.item.value );
$( this ).val( ui.item.label );
return false;
}
}
});
});
Expand Down
173 changes: 132 additions & 41 deletions src/wp-admin/includes/ajax-actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,80 +284,171 @@ function wp_ajax_oembed_cache() {
/**
* Handles user autocomplete via AJAX.
*
* Works on both single-site and multisite installs. On multisite, the 'add'
* type searches the full network and is restricted to network admins; the
* 'search' type is restricted to users already on the current site.
* On single-site, only the 'search' type is supported and requires
* the 'list_users' capability.
*
* @since 3.4.0
* @since 7.1.0 Added single-site support, the `user_id` autocomplete field,
* label template tokens, and the `autocomplete_user_results` filter.
*/
function wp_ajax_autocomplete_user() {
if ( ! is_multisite() || ! current_user_can( 'promote_users' ) || wp_is_large_network( 'users' ) ) {
wp_die( -1 );
}
/*
* Validate the minimum search term length before anything else. This check is
* authoritative: the JS uses a fixed minLength of 2 to avoid firing requests
* for very short terms, but if the `autocomplete_term_length` filter raises the
* minimum above 2, shorter terms are rejected here rather than in the browser.
*/
$term = isset( $_REQUEST['term'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ) : '';

/** This filter is documented in wp-admin/user-new.php */
if ( ! current_user_can( 'manage_network_users' ) && ! apply_filters( 'autocomplete_users_for_site_admins', false ) ) {
/**
* Filters the minimum search term length for user autocomplete.
*
* This is enforced server-side. The JavaScript uses a fixed minLength of 2;
* raising the minimum above 2 here rejects shorter terms server-side rather
* than preventing the request in the browser.
*
* @since 7.1.0
*
* @param int $length Minimum number of characters required. Default 2.
* @param string $context Context for the autocomplete search. Currently 'users'.
*/
if ( strlen( $term ) < apply_filters( 'autocomplete_term_length', 2, 'users' ) ) {
wp_die( -1 );
}

$return = array();

/*
* Check the type of request.
* Current allowed values are `add` and `search`.
* Allowed values are `add` (multisite only) and `search`.
*/
if ( isset( $_REQUEST['autocomplete_type'] ) && 'search' === $_REQUEST['autocomplete_type'] ) {
$type = $_REQUEST['autocomplete_type'];
} else {
$type = 'add';
}
$type = ( isset( $_REQUEST['autocomplete_type'] ) && 'search' === $_REQUEST['autocomplete_type'] )
? 'search'
: 'add';

/*
* Check the desired field for value.
* Current allowed values are `user_email` and `user_login`.
* Determine the user field to return as the suggestion value.
* Allowed values are `user_email`, `user_login`, and `user_id`.
*/
if ( isset( $_REQUEST['autocomplete_field'] ) && 'user_email' === $_REQUEST['autocomplete_field'] ) {
$field = $_REQUEST['autocomplete_field'];
$requested_field = isset( $_REQUEST['autocomplete_field'] ) ? $_REQUEST['autocomplete_field'] : '';
if ( 'user_email' === $requested_field && current_user_can( 'edit_users' ) ) {
// Returning the email address as the value is limited to users who can edit others.
$field = 'user_email';
} elseif ( 'user_id' === $requested_field ) {
$field = 'ID';
} else {
$field = 'user_login';
}

// Exclude current users of this blog.
if ( isset( $_REQUEST['site_id'] ) ) {
$id = absint( $_REQUEST['site_id'] );
/*
* Resolve the label template. Supported tokens:
* {{user_login}}, {{user_email}}, {{display_name}}, {{user_id}}.
*/
$label_template = ( isset( $_REQUEST['autocomplete_label'] ) && '' !== $_REQUEST['autocomplete_label'] )
? sanitize_text_field( wp_unslash( $_REQUEST['autocomplete_label'] ) )
: '{{display_name}}';

$blog_id = false;
$include_blog_users = array();
$exclude_blog_users = array();
$search_columns = array( 'user_login', 'user_nicename', 'display_name' );

if ( is_multisite() && 'add' === $type ) {
/*
* Adding a user to a site requires network-level permissions and should
* not run on very large networks where the search would be slow.
*/
if ( ! current_user_can( 'promote_users' ) || wp_is_large_network( 'users' ) ) {
wp_die( -1 );
}

/** This filter is documented in wp-admin/user-new.php */
if ( ! current_user_can( 'manage_network_users' ) && ! apply_filters( 'autocomplete_users_for_site_admins', false ) ) {
wp_die( -1 );
}

// Search the full network; exclude users already on the target site.
$site_id = isset( $_REQUEST['site_id'] ) ? absint( $_REQUEST['site_id'] ) : get_current_blog_id();
$exclude_blog_users = get_users(
array(
'blog_id' => $site_id,
'fields' => 'ID',
)
);

// Super-admins may search by email as well.
$search_columns[] = 'user_email';
} else {
$id = get_current_blog_id();
}
/*
* Single-site search, or multisite 'search' type.
* Requires the ability to list users on this site.
*/
if ( ! current_user_can( 'list_users' ) ) {
wp_die( -1 );
}

$include_blog_users = ( 'search' === $type ? get_users(
array(
'blog_id' => $id,
'fields' => 'ID',
)
) : array() );
$blog_id = get_current_blog_id();

$exclude_blog_users = ( 'add' === $type ? get_users(
array(
'blog_id' => $id,
'fields' => 'ID',
)
) : array() );
/*
* On multisite, WP_User_Query already restricts results to members of the
* given site via the 'blog_id' argument (a `{$prefix}capabilities EXISTS`
* meta query), so there is no need to prefetch every member ID here.
*/

// Include email in search columns only for users who can edit others.
if ( current_user_can( 'edit_users' ) ) {
$search_columns[] = 'user_email';
}
}

// Email tokens in the label should only be visible to users who can edit others.
if ( ! current_user_can( 'edit_users' ) ) {
$label_template = str_replace( '{{user_email}}', '', $label_template );
}

$users = get_users(
array(
'blog_id' => false,
'search' => '*' . $_REQUEST['term'] . '*',
'blog_id' => $blog_id,
'search' => '*' . $term . '*',
'include' => $include_blog_users,
'exclude' => $exclude_blog_users,
'search_columns' => array( 'user_login', 'user_nicename', 'user_email' ),
'search_columns' => $search_columns,
'number' => 20,
)
);

$return = array();

foreach ( $users as $user ) {
// Replace supported tokens in the label template.
$label = $label_template;
foreach ( array( 'user_login', 'user_email', 'display_name' ) as $token ) {
$label = str_replace( '{{' . $token . '}}', $user->$token, $label );
}
$label = str_replace( '{{user_id}}', $user->ID, $label );

if ( '' === trim( $label ) ) {
$label = '(' . $user->user_login . ')';
}

$return[] = array(
/* translators: 1: User login, 2: User email address. */
'label' => sprintf( _x( '%1$s (%2$s)', 'user autocomplete result' ), $user->user_login, $user->user_email ),
'value' => $user->$field,
'label' => wp_strip_all_tags( $label ),
'value' => ( 'ID' === $field ) ? $user->ID : $user->$field,
Comment on lines 435 to +437

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8c1157a. user-suggest.js uses jQuery UI's default item renderer (.text(item.label)), so esc_html() made names like Tom & Jerry render as Tom &amp; Jerry. Since .text() is already XSS-safe, the escaping was both wrong and unnecessary — switched to wp_strip_all_tags().

);
}

wp_die( wp_json_encode( $return ) );
/**
* Filters the user autocomplete results array.
*
* @since 7.1.0
*
* @param array $return Array of result objects with 'label' and 'value' keys.
* @param string $term The sanitized search term.
*/
$return = apply_filters( 'autocomplete_user_results', $return, $term );

wp_send_json( $return );
}

/**
Expand Down
7 changes: 6 additions & 1 deletion src/wp-admin/users.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,12 @@
wp_delete_user( $id );
break;
case 'reassign':
wp_delete_user( $id, $_REQUEST['reassign_user'][ $id ] );
$reassign_id = isset( $_REQUEST['reassign_user'][ $id ] ) ? absint( $_REQUEST['reassign_user'][ $id ] ) : 0;
if ( $reassign_id > 0 && $reassign_id !== $id ) {
wp_delete_user( $id, $reassign_id );
} else {
wp_delete_user( $id );
}
break;
}

Expand Down
Loading
Loading