From 9798f2c4b57e20e4536d7085fe09769d8148984a Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 15:08:44 +0200 Subject: [PATCH 01/10] Introduce the ability to switch users from the Command Palette. --- js/command-palette.js | 132 ++++++++++++++++++++++++++++++++++++++++++ readme.txt | 2 +- user-switching.php | 92 +++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 js/command-palette.js diff --git a/js/command-palette.js b/js/command-palette.js new file mode 100644 index 0000000..dc105c9 --- /dev/null +++ b/js/command-palette.js @@ -0,0 +1,132 @@ +/** + * Command Palette integration for User Switching. + */ + +( function () { + const commandsStore = wp.commands.store; + const coreStore = wp.coreData.store; + const dispatch = wp.data.dispatch; + const useSelect = wp.data.useSelect; + const useState = wp.element.useState; + const useEffect = wp.element.useEffect; + const useMemo = wp.element.useMemo; + const useDebounce = wp.compose.useDebounce; + const __ = wp.i18n.__; + const sprintf = wp.i18n.sprintf; + const el = wp.element.createElement; + const SVG = wp.primitives.SVG; + const Path = wp.primitives.Path; + + function useDebouncedValue( value ) { + const [ debouncedValue, setDebouncedValue ] = useState( '' ); + const debounced = useDebounce( setDebouncedValue, 250 ); + useEffect( function () { + debounced( value ); + return function () { + debounced.cancel(); + }; + }, [ debounced, value ] ); + return debouncedValue; + } + + const settings = window.userSwitchingCommands; + + if ( ! settings ) { + return; + } + + const switchIcon = el( + SVG, + { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 24 24', + }, + el( Path, { + d: 'M17.5 9a2 2 0 11-4 0 2 2 0 014 0zm-4.25 8v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM8.5 11a2 2 0 100-4 2 2 0 000 4z', + } ) + ); + + // Static command: Switch Back. + if ( settings.switchBackUrl ) { + dispatch( commandsStore ).registerCommand( { + name: 'user-switching/switch-back', + label: settings.switchBackLabel, + icon: switchIcon, + callback: function ( args ) { + document.location.href = settings.switchBackUrl; + args.close(); + }, + } ); + } + + // Static command: Switch Off. + if ( settings.switchOffUrl ) { + dispatch( commandsStore ).registerCommand( { + name: 'user-switching/switch-off', + label: settings.switchOffLabel, + icon: switchIcon, + callback: function ( args ) { + document.location.href = settings.switchOffUrl; + args.close(); + }, + } ); + } + + // Dynamic command loader for searching users to switch to. + if ( settings.canSwitchUsers ) { + dispatch( commandsStore ).registerCommandLoader( { + name: 'user-switching/switch-to-user', + hook: function useSwitchToUserLoader( options ) { + const search = useDebouncedValue( options.search ); + + const { users, isLoading } = useSelect( function ( select ) { + if ( ! search ) { + return { users: [], isLoading: false }; + } + + const query = { + search: search, + per_page: 10, + context: 'view', + }; + const core = select( coreStore ); + + return { + users: core.getEntityRecords( 'root', 'user', query ) || [], + isLoading: ! core.hasFinishedResolution( 'getEntityRecords', [ 'root', 'user', query ] ), + }; + }, [ search ] ); + + const commands = useMemo( function () { + return users + .filter( function ( user ) { + return user.user_switching_url; + } ) + .map( function ( user ) { + const avatarUrl = user.avatar_urls && ( user.avatar_urls[ '48' ] || user.avatar_urls[ '24' ] ); + + return { + name: `user-switching/switch-to-${ user.id }`, + /* translators: %s: User's display name. */ + label: sprintf( __( 'Switch to %s', 'user-switching' ), user.name ), + searchLabel: `Switch to ${ user.name } ${ user.slug }`, + icon: avatarUrl ? el( 'img', { + src: avatarUrl, + alt: '', + } ) : switchIcon, + callback: function ( args ) { + document.location.href = user.user_switching_url; + args.close(); + }, + }; + } ); + }, [ users ] ); + + return { + commands: commands, + isLoading: isLoading, + }; + }, + } ); + } +} )(); diff --git a/readme.txt b/readme.txt index 86daebe..9f731ec 100644 --- a/readme.txt +++ b/readme.txt @@ -258,7 +258,7 @@ This feature is useful if you have multiple users on your site who may be switch ### Can I switch users directly from the admin toolbar? -Yes, there's a third party add-on plugin for this: [Admin Bar User Switching](https://wordpress.org/plugins/admin-bar-user-switching/). +Yes, in the WordPress Command Palette (WordPress 6.9+) you can search for a user and immediately switch into their account. You can switch off and switch back from the Command Palette too. ### Are any plugin actions called when a user switches account? diff --git a/user-switching.php b/user-switching.php index 2d09c50..934b6aa 100644 --- a/user-switching.php +++ b/user-switching.php @@ -80,6 +80,10 @@ public function init_hooks(): void { add_action( 'admin_bar_menu', [ $this, 'action_admin_bar_menu' ], 11 ); add_action( 'shutdown', [ $this, 'action_shutdown_for_wp_die' ], 1, 0 ); + // Command Palette integration: + add_action( 'rest_api_init', [ $this, 'action_rest_api_init' ] ); + add_action( 'admin_enqueue_scripts', [ $this, 'action_enqueue_command_palette_script' ] ); + // BuddyPress integration: add_action( 'bp_member_header_actions', [ $this, 'action_bp_button' ], 11 ); add_action( 'bp_directory_members_actions', [ $this, 'action_bp_button' ], 11 ); @@ -1144,6 +1148,94 @@ public static function secure_auth_cookie(): bool { return ( is_ssl() && ( 'https' === wp_parse_url( wp_login_url(), PHP_URL_SCHEME ) ) ); } + /** + * Registers a REST API field on the users endpoint containing the "Switch to" URL. + */ + public function action_rest_api_init(): void { + register_rest_field( 'user', 'user_switching_url', [ + 'get_callback' => function ( array $user ): ?string { + if ( ! current_user_can( 'switch_to_user', $user['id'] ) ) { + return null; + } + + $target = get_userdata( $user['id'] ); + + if ( ! $target ) { + return null; + } + + return wp_specialchars_decode( self::switch_to_url( $target ) ); + }, + 'schema' => [ + 'type' => [ + 'string', + 'null', + ], + 'description' => __( 'The URL to switch to this user.', 'user-switching' ), + 'readonly' => true, + ], + ] ); + } + + /** + * Enqueues the command palette integration script. + */ + public function action_enqueue_command_palette_script(): void { + if ( ! wp_script_is( 'wp-commands', 'enqueued' ) ) { + return; + } + + $old_user = self::get_old_user(); + $can_switch_off = current_user_can( 'switch_off' ); + + if ( ! $can_switch_off && ! $old_user ) { + return; + } + + $settings = [ + 'canSwitchUsers' => $can_switch_off, + ]; + + if ( $old_user instanceof WP_User ) { + $switch_back_url = add_query_arg( [ + 'redirect_to' => rawurlencode( self::current_url() ), + ], self::switch_back_url( $old_user ) ); + + $settings['switchBackUrl'] = wp_specialchars_decode( $switch_back_url ); + $settings['switchBackLabel'] = self::switch_back_message( $old_user ); + } + + if ( $can_switch_off ) { + $switch_off_url = self::switch_off_url(); + $redirect_to = is_admin() ? self::get_admin_redirect_to() : [ + 'redirect_to' => rawurlencode( self::current_url() ), + ]; + + if ( is_array( $redirect_to ) ) { + $switch_off_url = add_query_arg( $redirect_to, $switch_off_url ); + } + + $settings['switchOffUrl'] = wp_specialchars_decode( $switch_off_url ); + $settings['switchOffLabel'] = __( 'Switch Off', 'user-switching' ); + } + + wp_enqueue_script( + 'user-switching-commands', + plugins_url( 'js/command-palette.js', __FILE__ ), + [ 'wp-commands', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives' ], + (string) filemtime( __DIR__ . '/js/command-palette.js' ), + true + ); + + wp_set_script_translations( 'user-switching-commands', 'user-switching' ); + + wp_localize_script( + 'user-switching-commands', + 'userSwitchingCommands', + $settings + ); + } + /** * Adds a 'Switch back to {user}' link to the WooCommerce login screen. */ From 7afc60bdbe9d50308228c965fa893537c01c7365 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 15:15:01 +0200 Subject: [PATCH 02/10] Use the old user avatar. --- js/command-palette.js | 5 ++++- user-switching.php | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/js/command-palette.js b/js/command-palette.js index dc105c9..5fce3fe 100644 --- a/js/command-palette.js +++ b/js/command-palette.js @@ -51,7 +51,10 @@ dispatch( commandsStore ).registerCommand( { name: 'user-switching/switch-back', label: settings.switchBackLabel, - icon: switchIcon, + icon: settings.switchBackAvatar ? el( 'img', { + src: settings.switchBackAvatar, + alt: '', + } ) : switchIcon, callback: function ( args ) { document.location.href = settings.switchBackUrl; args.close(); diff --git a/user-switching.php b/user-switching.php index 934b6aa..ecb77c1 100644 --- a/user-switching.php +++ b/user-switching.php @@ -1203,6 +1203,7 @@ public function action_enqueue_command_palette_script(): void { $settings['switchBackUrl'] = wp_specialchars_decode( $switch_back_url ); $settings['switchBackLabel'] = self::switch_back_message( $old_user ); + $settings['switchBackAvatar'] = get_avatar_url( $old_user->ID, [ 'size' => 48 ] ); } if ( $can_switch_off ) { From 72932ad103c514017d25d5e741303ea68457bc83 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 15:23:43 +0200 Subject: [PATCH 03/10] It's called PHPCS, not JSCS. --- phpcs.xml.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 9903923..b6b722c 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,6 +1,7 @@ + From e74655c787da4b898451831dd6c5a7d61fda344d Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 15:40:18 +0200 Subject: [PATCH 04/10] Improve the search handling. --- js/command-palette.js | 21 +++++++++++++++++---- user-switching.php | 1 + 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/js/command-palette.js b/js/command-palette.js index 5fce3fe..db85334 100644 --- a/js/command-palette.js +++ b/js/command-palette.js @@ -51,6 +51,7 @@ dispatch( commandsStore ).registerCommand( { name: 'user-switching/switch-back', label: settings.switchBackLabel, + searchLabel: `Switch back ${ settings.switchBackLabel }`, icon: settings.switchBackAvatar ? el( 'img', { src: settings.switchBackAvatar, alt: '', @@ -67,6 +68,7 @@ dispatch( commandsStore ).registerCommand( { name: 'user-switching/switch-off', label: settings.switchOffLabel, + searchLabel: `Switch off ${ settings.switchOffLabel }`, icon: switchIcon, callback: function ( args ) { document.location.href = settings.switchOffUrl; @@ -80,7 +82,11 @@ dispatch( commandsStore ).registerCommandLoader( { name: 'user-switching/switch-to-user', hook: function useSwitchToUserLoader( options ) { - const search = useDebouncedValue( options.search ); + const trimmedSearch = options.search + .replace( new RegExp( settings.switchToLabel, 'i' ), '' ) + .replace( /Switch To/i, '' ) + .trim(); + const search = useDebouncedValue( trimmedSearch ); const { users, isLoading } = useSelect( function ( select ) { if ( ! search ) { @@ -110,9 +116,16 @@ return { name: `user-switching/switch-to-${ user.id }`, - /* translators: %s: User's display name. */ - label: sprintf( __( 'Switch to %s', 'user-switching' ), user.name ), - searchLabel: `Switch to ${ user.name } ${ user.slug }`, + label: sprintf( + /* translators: %s: User's display name. */ + __( 'Switch to %s', 'user-switching' ), + user.name, + ), + searchLabel: sprintf( + /* translators: %s: User's display name. */ + __( 'Switch to %s', 'user-switching' ), + `${ user.name } ${ user.slug }`, + ), icon: avatarUrl ? el( 'img', { src: avatarUrl, alt: '', diff --git a/user-switching.php b/user-switching.php index ecb77c1..c3c2d40 100644 --- a/user-switching.php +++ b/user-switching.php @@ -1194,6 +1194,7 @@ public function action_enqueue_command_palette_script(): void { $settings = [ 'canSwitchUsers' => $can_switch_off, + 'switchToLabel' => str_replace( ' ', ' ', __( 'Switch To', 'user-switching' ) ), ]; if ( $old_user instanceof WP_User ) { From 2e3179dfa8377994c30abf2699fff35d3f82472e Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 15:42:13 +0200 Subject: [PATCH 05/10] Docs. --- readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.txt b/readme.txt index 9f731ec..1850988 100644 --- a/readme.txt +++ b/readme.txt @@ -258,7 +258,7 @@ This feature is useful if you have multiple users on your site who may be switch ### Can I switch users directly from the admin toolbar? -Yes, in the WordPress Command Palette (WordPress 6.9+) you can search for a user and immediately switch into their account. You can switch off and switch back from the Command Palette too. +Yes, in the [WordPress Command Palette](https://wordpress.org/documentation/article/site-editor-command-palette/) (WordPress 6.9+) you can search for a user and immediately switch into their account. You can switch off and switch back from there too. ### Are any plugin actions called when a user switches account? From 0f09511cb9bd4c0dadd31db02e5df2e297cd64d0 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 15:45:57 +0200 Subject: [PATCH 06/10] Switch to the admin-users dashicon. --- js/command-palette.js | 14 ++------------ user-switching.php | 2 +- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/js/command-palette.js b/js/command-palette.js index db85334..e39ffa8 100644 --- a/js/command-palette.js +++ b/js/command-palette.js @@ -14,8 +14,7 @@ const __ = wp.i18n.__; const sprintf = wp.i18n.sprintf; const el = wp.element.createElement; - const SVG = wp.primitives.SVG; - const Path = wp.primitives.Path; + const Dashicon = wp.components.Dashicon; function useDebouncedValue( value ) { const [ debouncedValue, setDebouncedValue ] = useState( '' ); @@ -35,16 +34,7 @@ return; } - const switchIcon = el( - SVG, - { - xmlns: 'http://www.w3.org/2000/svg', - viewBox: '0 0 24 24', - }, - el( Path, { - d: 'M17.5 9a2 2 0 11-4 0 2 2 0 014 0zm-4.25 8v-2a2.75 2.75 0 00-2.75-2.75h-4A2.75 2.75 0 003.75 15v2h1.5v-2c0-.69.56-1.25 1.25-1.25h4c.69 0 1.25.56 1.25 1.25v2h1.5zm7-2v2h-1.5v-2c0-.69-.56-1.25-1.25-1.25H15v-1.5h2.5A2.75 2.75 0 0120.25 15zM8.5 11a2 2 0 100-4 2 2 0 000 4z', - } ) - ); + const switchIcon = el( Dashicon, { icon: 'admin-users' } ); // Static command: Switch Back. if ( settings.switchBackUrl ) { diff --git a/user-switching.php b/user-switching.php index c3c2d40..0f19e3e 100644 --- a/user-switching.php +++ b/user-switching.php @@ -1224,7 +1224,7 @@ public function action_enqueue_command_palette_script(): void { wp_enqueue_script( 'user-switching-commands', plugins_url( 'js/command-palette.js', __FILE__ ), - [ 'wp-commands', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives' ], + [ 'wp-commands', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n' ], (string) filemtime( __DIR__ . '/js/command-palette.js' ), true ); From 1175bcb14afdf3f108c9534e446237a009642c30 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 15:51:45 +0200 Subject: [PATCH 07/10] More tweaks to search handling. --- js/command-palette.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/js/command-palette.js b/js/command-palette.js index e39ffa8..a270192 100644 --- a/js/command-palette.js +++ b/js/command-palette.js @@ -58,6 +58,7 @@ dispatch( commandsStore ).registerCommand( { name: 'user-switching/switch-off', label: settings.switchOffLabel, + // This facilitates searching either in English or the localized language searchLabel: `Switch off ${ settings.switchOffLabel }`, icon: switchIcon, callback: function ( args ) { @@ -72,9 +73,11 @@ dispatch( commandsStore ).registerCommandLoader( { name: 'user-switching/switch-to-user', hook: function useSwitchToUserLoader( options ) { + // This facilitates searching either in English or the localized language + // and trims the search query to just the name. const trimmedSearch = options.search .replace( new RegExp( settings.switchToLabel, 'i' ), '' ) - .replace( /Switch To/i, '' ) + .replace( /Switch to/i, '' ) .trim(); const search = useDebouncedValue( trimmedSearch ); @@ -111,10 +114,11 @@ __( 'Switch to %s', 'user-switching' ), user.name, ), + // This facilitates searching either in English or the localized language searchLabel: sprintf( /* translators: %s: User's display name. */ __( 'Switch to %s', 'user-switching' ), - `${ user.name } ${ user.slug }`, + `${ user.name } ${ user.slug } Switch to ${ user.name } ${ user.slug }`, ), icon: avatarUrl ? el( 'img', { src: avatarUrl, From df6f6d97681145287587b059c23bdc9cf3e946fd Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 16:03:50 +0200 Subject: [PATCH 08/10] Avoid using `filemtime()`. --- user-switching.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/user-switching.php b/user-switching.php index 0f19e3e..99100b7 100644 --- a/user-switching.php +++ b/user-switching.php @@ -36,6 +36,8 @@ exit; } +define( 'USER_SWITCHING_VERSION', '1.11.2' ); + /** * Main singleton class for the User Switching plugin. */ @@ -1225,7 +1227,7 @@ public function action_enqueue_command_palette_script(): void { 'user-switching-commands', plugins_url( 'js/command-palette.js', __FILE__ ), [ 'wp-commands', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n' ], - (string) filemtime( __DIR__ . '/js/command-palette.js' ), + USER_SWITCHING_VERSION, true ); From 2b3855614b8cc6adea5fc3f716115dd381b8b599 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 16:03:58 +0200 Subject: [PATCH 09/10] Shorten this name. --- user-switching.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/user-switching.php b/user-switching.php index 99100b7..a1cbada 100644 --- a/user-switching.php +++ b/user-switching.php @@ -1224,17 +1224,17 @@ public function action_enqueue_command_palette_script(): void { } wp_enqueue_script( - 'user-switching-commands', + 'user-switching', plugins_url( 'js/command-palette.js', __FILE__ ), [ 'wp-commands', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n' ], USER_SWITCHING_VERSION, true ); - wp_set_script_translations( 'user-switching-commands', 'user-switching' ); + wp_set_script_translations( 'user-switching', 'user-switching' ); wp_localize_script( - 'user-switching-commands', + 'user-switching', 'userSwitchingCommands', $settings ); From 0eb4a0fc34ba953560690a029010f2e69e3b9b96 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sun, 24 May 2026 16:04:02 +0200 Subject: [PATCH 10/10] Docs. --- readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.txt b/readme.txt index 1850988..0a0943b 100644 --- a/readme.txt +++ b/readme.txt @@ -258,7 +258,7 @@ This feature is useful if you have multiple users on your site who may be switch ### Can I switch users directly from the admin toolbar? -Yes, in the [WordPress Command Palette](https://wordpress.org/documentation/article/site-editor-command-palette/) (WordPress 6.9+) you can search for a user and immediately switch into their account. You can switch off and switch back from there too. +Yes, in the [WordPress Command Palette](https://wordpress.org/documentation/article/site-editor-command-palette/) you can search for a user and immediately switch into their account. You can switch off and switch back from there too. ### Are any plugin actions called when a user switches account?