@@ -284,80 +284,170 @@ function wp_ajax_oembed_cache() {
284284/**
285285 * Handles user autocomplete via AJAX.
286286 *
287+ * Works on both single-site and multisite installs. On multisite, the 'add'
288+ * type searches the full network and is restricted to network admins; the
289+ * 'search' type is restricted to users already on the current site.
290+ * On single-site, only the 'search' type is supported and requires
291+ * the 'list_users' capability.
292+ *
287293 * @since 3.4.0
294+ * @since 7.1.0 Added single-site support, the `user_id` autocomplete field,
295+ * label template tokens, and the `autocomplete_user_results` filter.
288296 */
289297function wp_ajax_autocomplete_user () {
290- if ( ! is_multisite () || ! current_user_can ( 'promote_users ' ) || wp_is_large_network ( 'users ' ) ) {
291- wp_die ( -1 );
292- }
298+ /*
299+ * Validate the minimum search term length before anything else.
300+ * The same minimum is enforced in JS via the minLength option.
301+ */
302+ $ term = isset ( $ _REQUEST ['term ' ] ) ? sanitize_text_field ( wp_unslash ( $ _REQUEST ['term ' ] ) ) : '' ;
293303
294- /** This filter is documented in wp-admin/user-new.php */
295- if ( ! current_user_can ( 'manage_network_users ' ) && ! apply_filters ( 'autocomplete_users_for_site_admins ' , false ) ) {
304+ /**
305+ * Filters the minimum search term length for user autocomplete.
306+ *
307+ * The same minimum should be mirrored in the JavaScript minLength option.
308+ *
309+ * @since 7.1.0
310+ *
311+ * @param int $length Minimum number of characters required. Default 2.
312+ * @param string $context Context for the autocomplete search. Currently 'users'.
313+ */
314+ if ( strlen ( $ term ) < apply_filters ( 'autocomplete_term_length ' , 2 , 'users ' ) ) {
296315 wp_die ( -1 );
297316 }
298317
299- $ return = array ();
300-
301318 /*
302319 * Check the type of request.
303- * Current allowed values are `add` and `search`.
320+ * Allowed values are `add` (multisite only) and `search`.
304321 */
305- if ( isset ( $ _REQUEST ['autocomplete_type ' ] ) && 'search ' === $ _REQUEST ['autocomplete_type ' ] ) {
306- $ type = $ _REQUEST ['autocomplete_type ' ];
307- } else {
308- $ type = 'add ' ;
309- }
322+ $ type = ( isset ( $ _REQUEST ['autocomplete_type ' ] ) && 'search ' === $ _REQUEST ['autocomplete_type ' ] )
323+ ? 'search '
324+ : 'add ' ;
310325
311326 /*
312- * Check the desired field for value.
313- * Current allowed values are `user_email` and `user_login `.
327+ * Determine the user field to return as the suggestion value.
328+ * Allowed values are `user_email`, `user_login`, and `user_id `.
314329 */
315- if ( isset ( $ _REQUEST ['autocomplete_field ' ] ) && 'user_email ' === $ _REQUEST ['autocomplete_field ' ] ) {
316- $ field = $ _REQUEST ['autocomplete_field ' ];
330+ $ requested_field = isset ( $ _REQUEST ['autocomplete_field ' ] ) ? $ _REQUEST ['autocomplete_field ' ] : '' ;
331+ if ( 'user_email ' === $ requested_field ) {
332+ $ field = 'user_email ' ;
333+ } elseif ( 'user_id ' === $ requested_field ) {
334+ $ field = 'ID ' ;
317335 } else {
318336 $ field = 'user_login ' ;
319337 }
320338
321- // Exclude current users of this blog.
322- if ( isset ( $ _REQUEST ['site_id ' ] ) ) {
323- $ id = absint ( $ _REQUEST ['site_id ' ] );
339+ /*
340+ * Resolve the label template. Supported tokens:
341+ * {{user_login}}, {{user_email}}, {{display_name}}, {{user_id}}.
342+ */
343+ $ label_template = ( isset ( $ _REQUEST ['autocomplete_label ' ] ) && '' !== $ _REQUEST ['autocomplete_label ' ] )
344+ ? sanitize_text_field ( wp_unslash ( $ _REQUEST ['autocomplete_label ' ] ) )
345+ : '{{display_name}} ' ;
346+
347+ $ blog_id = false ;
348+ $ include_blog_users = array ();
349+ $ exclude_blog_users = array ();
350+ $ search_columns = array ( 'user_login ' , 'user_nicename ' , 'display_name ' );
351+
352+ if ( is_multisite () && 'add ' === $ type ) {
353+ /*
354+ * Adding a user to a site requires network-level permissions and should
355+ * not run on very large networks where the search would be slow.
356+ */
357+ if ( ! current_user_can ( 'promote_users ' ) || wp_is_large_network ( 'users ' ) ) {
358+ wp_die ( -1 );
359+ }
360+
361+ /** This filter is documented in wp-admin/user-new.php */
362+ if ( ! current_user_can ( 'manage_network_users ' ) && ! apply_filters ( 'autocomplete_users_for_site_admins ' , false ) ) {
363+ wp_die ( -1 );
364+ }
365+
366+ // Search the full network; exclude users already on the target site.
367+ $ site_id = isset ( $ _REQUEST ['site_id ' ] ) ? absint ( $ _REQUEST ['site_id ' ] ) : get_current_blog_id ();
368+ $ exclude_blog_users = get_users (
369+ array (
370+ 'blog_id ' => $ site_id ,
371+ 'fields ' => 'ID ' ,
372+ )
373+ );
374+
375+ // Super-admins may search by email as well.
376+ $ search_columns [] = 'user_email ' ;
324377 } else {
325- $ id = get_current_blog_id ();
326- }
378+ /*
379+ * Single-site search, or multisite 'search' type.
380+ * Requires the ability to list users on this site.
381+ */
382+ if ( ! current_user_can ( 'list_users ' ) ) {
383+ wp_die ( -1 );
384+ }
327385
328- $ include_blog_users = ( 'search ' === $ type ? get_users (
329- array (
330- 'blog_id ' => $ id ,
331- 'fields ' => 'ID ' ,
332- )
333- ) : array () );
386+ $ blog_id = get_current_blog_id ();
334387
335- $ exclude_blog_users = ( 'add ' === $ type ? get_users (
336- array (
337- 'blog_id ' => $ id ,
338- 'fields ' => 'ID ' ,
339- )
340- ) : array () );
388+ if ( is_multisite () ) {
389+ // Restrict results to users already on this site.
390+ $ include_blog_users = get_users (
391+ array (
392+ 'blog_id ' => $ blog_id ,
393+ 'fields ' => 'ID ' ,
394+ )
395+ );
396+ }
397+
398+ // Include email in search columns only for users who can edit others.
399+ if ( current_user_can ( 'edit_users ' ) ) {
400+ $ search_columns [] = 'user_email ' ;
401+ }
402+ }
403+
404+ // Email tokens in the label should only be visible to users who can edit others.
405+ if ( ! current_user_can ( 'edit_users ' ) ) {
406+ $ label_template = str_replace ( '{{user_email}} ' , '' , $ label_template );
407+ }
341408
342409 $ users = get_users (
343410 array (
344- 'blog_id ' => false ,
345- 'search ' => '* ' . $ _REQUEST [ ' term ' ] . '* ' ,
411+ 'blog_id ' => $ blog_id ,
412+ 'search ' => '* ' . $ term . '* ' ,
346413 'include ' => $ include_blog_users ,
347414 'exclude ' => $ exclude_blog_users ,
348- 'search_columns ' => array ( 'user_login ' , 'user_nicename ' , 'user_email ' ),
415+ 'search_columns ' => $ search_columns ,
416+ 'number ' => 20 ,
349417 )
350418 );
351419
420+ $ return = array ();
421+
352422 foreach ( $ users as $ user ) {
423+ // Replace supported tokens in the label template.
424+ $ label = $ label_template ;
425+ foreach ( array ( 'user_login ' , 'user_email ' , 'display_name ' ) as $ token ) {
426+ $ label = str_replace ( '{{ ' . $ token . '}} ' , $ user ->$ token , $ label );
427+ }
428+ $ label = str_replace ( '{{user_id}} ' , $ user ->ID , $ label );
429+
430+ if ( '' === trim ( $ label ) ) {
431+ $ label = '( ' . $ user ->user_login . ') ' ;
432+ }
433+
353434 $ return [] = array (
354- /* translators: 1: User login, 2: User email address. */
355- 'label ' => sprintf ( _x ( '%1$s (%2$s) ' , 'user autocomplete result ' ), $ user ->user_login , $ user ->user_email ),
356- 'value ' => $ user ->$ field ,
435+ 'label ' => esc_html ( $ label ),
436+ 'value ' => ( 'ID ' === $ field ) ? $ user ->ID : $ user ->$ field ,
357437 );
358438 }
359439
360- wp_die ( wp_json_encode ( $ return ) );
440+ /**
441+ * Filters the user autocomplete results array.
442+ *
443+ * @since 7.1.0
444+ *
445+ * @param array $return Array of result objects with 'label' and 'value' keys.
446+ * @param string $term The sanitized search term.
447+ */
448+ $ return = apply_filters ( 'autocomplete_user_results ' , $ return , $ term );
449+
450+ wp_send_json ( $ return );
361451}
362452
363453/**
0 commit comments