|false|null */ private $active = null; /** * {@inheritDoc} */ public function id() { return 'switch-user'; } /** * {@inheritDoc} */ public function title() { return __( 'Switch to customer', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function description() { return __( 'Log in as a customer to see what they see, then switch back.', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function category() { return 'customers'; } /** * {@inheritDoc} */ public function icon() { return 'arrow-left-right'; } /** * {@inheritDoc} */ public function location() { return __( 'Users list, order page', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} * * @return array> */ public function fields() { $roles = array(); foreach ( wp_roles()->get_names() as $key => $name ) { if ( 'administrator' === $key ) { continue; } $roles[ $key ] = translate_user_role( $name ); } return array( array( 'id' => 'roles', 'type' => 'checkboxes', 'label' => __( 'Who you can switch to', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Admins are never included.', 'admin-powerups-for-woocommerce' ), 'default' => array( 'customer', 'subscriber' ), 'options' => $roles, ), array( 'id' => 'redirect', 'type' => 'select', 'label' => __( 'After switching, open', 'admin-powerups-for-woocommerce' ), 'default' => 'myaccount', 'options' => array( 'myaccount' => __( 'My account', 'admin-powerups-for-woocommerce' ), 'shop' => __( 'Shop', 'admin-powerups-for-woocommerce' ), 'cart' => __( 'Cart', 'admin-powerups-for-woocommerce' ), 'home' => __( 'Home page', 'admin-powerups-for-woocommerce' ), ), ), array( 'id' => 'show_on_orders', 'type' => 'toggle', 'label' => __( 'Show a switch link on orders', 'admin-powerups-for-woocommerce' ), 'default' => true, ), ); } /** * {@inheritDoc} */ public function unmet_requirement() { if ( class_exists( 'user_switching' ) ) { return __( 'The User Switching plugin is active. Use it instead, or deactivate it to use this.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * {@inheritDoc} */ public function boot() { add_action( 'admin_post_' . self::SWITCH_TO, array( $this, 'handle_switch_to' ) ); add_action( 'init', array( $this, 'maybe_handle_switch_back' ), 20 ); // A real log in or log out always ends a switch (same as User Switching). add_action( 'wp_logout', array( $this, 'forget_switch' ) ); add_action( 'wp_login', array( $this, 'forget_switch' ) ); add_filter( 'user_row_actions', array( $this, 'user_row_action' ), 10, 2 ); add_filter( 'ms_user_row_actions', array( $this, 'user_row_action' ), 10, 2 ); add_action( 'personal_options', array( $this, 'profile_link' ) ); if ( $this->setting( 'show_on_orders' ) ) { add_action( 'woocommerce_admin_order_data_after_order_details', array( $this, 'order_link' ) ); } add_action( 'admin_bar_menu', array( $this, 'admin_bar' ), 5 ); add_action( 'wp_footer', array( $this, 'frontend_banner' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_banner' ) ); add_filter( 'woocommerce_account_menu_items', array( $this, 'account_menu_item' ), 999 ); add_filter( 'woocommerce_get_endpoint_url', array( $this, 'account_menu_url' ), 10, 2 ); } /** * {@inheritDoc} Also forgets the cached switch state. */ public function refresh() { parent::refresh(); $this->active = null; } /** * Why the current user may not switch to $target, or '' when allowed. * * Stricter than User Switching, which allows any user you can edit: we also refuse admins, * anyone holding a capability the switcher lacks, and roles not enabled in settings. * * @since 1.0.0 * @param WP_User|false $target Target user. * @return string */ public function denial_reason( $target ) { if ( ! $target instanceof WP_User || ! $target->exists() ) { return __( 'That user does not exist.', 'admin-powerups-for-woocommerce' ); } if ( get_current_user_id() === $target->ID ) { return __( 'You’re already logged in as this user.', 'admin-powerups-for-woocommerce' ); } if ( $this->active_switch() ) { return __( 'Switch back to your own account first.', 'admin-powerups-for-woocommerce' ); } if ( ! current_user_can( 'manage_woocommerce' ) || ! current_user_can( 'edit_user', $target->ID ) ) { return __( 'You can’t switch to this user.', 'admin-powerups-for-woocommerce' ); } if ( is_super_admin( $target->ID ) || user_can( $target, 'manage_options' ) ) { return __( 'You can’t switch to an admin.', 'admin-powerups-for-woocommerce' ); } // No privilege escalation: the target may not hold any capability the switcher lacks. $current = wp_get_current_user(); foreach ( array_keys( array_filter( (array) $target->allcaps ) ) as $cap ) { // allcaps also lists the user's role names ("customer" => true); those are not permissions. if ( wp_roles()->is_role( $cap ) ) { continue; } if ( empty( $current->allcaps[ $cap ] ) && ! is_super_admin( $current->ID ) ) { return __( 'This user has more access than you.', 'admin-powerups-for-woocommerce' ); } } if ( ! array_intersect( (array) $target->roles, (array) $this->setting( 'roles' ) ) ) { return __( 'Switching to this role is turned off.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * Signed switch URL. * * @since 1.0.0 * @param int $user_id Target. * @param string $redirect_to Optional front-end URL to land on after switching. * @return string */ public function switch_url( $user_id, $redirect_to = '' ) { $args = array( 'action' => self::SWITCH_TO, 'user_id' => (int) $user_id, ); if ( $redirect_to ) { $args['redirect_to'] = rawurlencode( $redirect_to ); } return wp_nonce_url( add_query_arg( $args, admin_url( 'admin-post.php' ) ), self::SWITCH_TO . '_' . (int) $user_id ); } /** * Handle the switch. * * Mirrors User Switching's `switch_to_user()`: the original session is kept (not destroyed) so * switching back resumes it, the new session is tagged with who switched in, WooCommerce's * session is forgotten so carts never cross accounts, and "remember me" carries over. * * @since 1.0.0 * @return void */ public function handle_switch_to() { $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; check_admin_referer( self::SWITCH_TO . '_' . $user_id ); $target = get_userdata( $user_id ); $reason = $this->denial_reason( $target ); if ( '' !== $reason ) { wp_die( esc_html( $reason ), '', array( 'response' => 403, 'back_link' => true, ) ); } $original = wp_get_current_user(); // Another staff member is already switched into this account: confirm first. $duplicate = $this->other_switch_into( $target, $original ); if ( $duplicate && empty( $_GET['force'] ) ) { wp_die( sprintf( '%1$s

%3$s %5$s', esc_html( sprintf( /* translators: 1: staff name, 2: customer name, 3: time, e.g. "5 mins". */ __( '%1$s switched to %2$s %3$s ago and may still be using the account. Switch anyway?', 'admin-powerups-for-woocommerce' ), $duplicate['user']->display_name, $target->display_name, human_time_diff( $duplicate['login'] ) ) ), // Carry the page the switch started from, so switching back never lands on this signed URL. esc_url( add_query_arg( array( 'force' => '1', '_wp_http_referer' => rawurlencode( $this->return_url() ), ) ) ), esc_html__( 'Switch anyway', 'admin-powerups-for-woocommerce' ), esc_url( admin_url( 'users.php' ) ), esc_html__( 'Cancel', 'admin-powerups-for-woocommerce' ) ), '', array( 'response' => 409 ) ); } $this->switch_to( $target, $original, $this->return_url() ); wp_safe_redirect( $this->redirect_target() ); exit; } /** * Admin page to return to on switch back: the page the switch started from (Users list or * order screen). Never a signed switch request: after "Switch anyway" the referer is the * admin-post.php switch URL, and replaying it on switch back would switch in again. * * @since 1.0.0 * @return string */ private function return_url() { $fallback = admin_url( 'users.php' ); $referer = wp_get_referer(); if ( ! $referer || 0 !== strpos( $referer, admin_url() ) ) { return $fallback; } $path = (string) wp_parse_url( $referer, PHP_URL_PATH ); if ( 'admin-post.php' === basename( $path ) || false !== strpos( $referer, self::SWITCH_TO ) ) { return $fallback; } return $referer; } /** * Switch the current request (and browser) from $original to $target. Checks are the caller's job. * * @since 1.0.0 * @param WP_User $target User to switch to. * @param WP_User $original Current user. * @param string $return_url Admin URL to return to on switch back. * @return void */ public function switch_to( WP_User $target, WP_User $original, $return_url ) { $old_token = wp_get_session_token(); $remember = $this->remember(); $token = wp_generate_password( 40, false, false ); set_site_transient( $this->transient_key( $token ), array( 'original' => $original->ID, 'target' => $target->ID, 'return' => $return_url, 'session_hash' => hash( 'sha256', $old_token ), 'remember' => $remember, 'started' => time(), ), self::TTL ); $this->forget_wc_session(); $tag = static function ( $session ) use ( $original ) { $session['apwc_switched_from'] = $original->ID; return $session; }; add_filter( 'attach_session_information', $tag, 99 ); wp_clear_auth_cookie(); unset( $_COOKIE[ LOGGED_IN_COOKIE ] ); // Keep later nonces in this request off the dead session. wp_set_auth_cookie( $target->ID, $remember ); wp_set_current_user( $target->ID ); remove_filter( 'attach_session_information', $tag, 99 ); // The original session token rides in the cookie (never in the database), like User Switching. $this->set_cookie( $token . '.' . $old_token, time() + self::TTL ); $this->active = null; $this->log( sprintf( 'User #%d (%s) switched to user #%d (%s).', $original->ID, $original->user_login, $target->ID, $target->user_login ) ); /** * Fires after a store manager switched to another user. * * @since 1.0.0 * @param int $target_id User switched to. * @param int $original_id User who switched. */ do_action( 'apwc_switched_to_user', $target->ID, $original->ID ); } /** * Switch back to the original user of an active switch. * * @since 1.0.0 * @param array $state Data from active_switch(). * @return void */ public function switch_back( array $state ) { $target_id = get_current_user_id(); // End the temporary customer session for good, then forget the customer's cart session. wp_destroy_current_session(); $this->forget_wc_session(); $this->forget_switch(); wp_clear_auth_cookie(); unset( $_COOKIE[ LOGGED_IN_COOKIE ] ); wp_set_auth_cookie( $state['original'], ! empty( $state['remember'] ), '', $state['session_token'] ); wp_set_current_user( $state['original'] ); $this->log( sprintf( 'User #%d switched back from user #%d.', $state['original'], $target_id ) ); /** * Fires after a store manager switched back to their own account. * * @since 1.0.0 * @param int $original_id Restored user. * @param int $target_id User switched away from. */ do_action( 'apwc_switched_back', $state['original'], $target_id ); } /** * Handle `?apwc_switch_back=1` on any front or admin URL. * * Switching back only works while the original session is still valid, so a password change * or "log out everywhere" on the staff account also cancels the way back. * * @since 1.0.0 * @return void */ public function maybe_handle_switch_back() { if ( empty( $_GET[ self::SWITCH_BACK ] ) ) { return; } check_admin_referer( self::SWITCH_BACK ); $switch = $this->active_switch(); if ( ! $switch ) { wp_die( esc_html__( 'This switch has expired. Log in with your own account.', 'admin-powerups-for-woocommerce' ), '', array( 'response' => 403 ) ); } $this->switch_back( $switch ); wp_safe_redirect( $switch['return'] ); exit; } /** * The active switch for the current user, or false. * * Valid only when: the cookie's switch token matches a stored switch, that switch was into the * current user, the original session token hashes to what was stored, and that session is * still alive for the original user. * * @since 1.0.0 * @return array|false Stored switch data plus `session_token`. */ public function active_switch() { if ( null !== $this->active ) { return $this->active; } $this->active = false; $raw = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) : ''; if ( '' === $raw || ! is_user_logged_in() || false === strpos( $raw, '.' ) ) { return false; } list( $token, $session_token ) = explode( '.', $raw, 2 ); $data = get_site_transient( $this->transient_key( $token ) ); if ( ! is_array( $data ) || get_current_user_id() !== (int) $data['target'] || ! hash_equals( (string) $data['session_hash'], hash( 'sha256', $session_token ) ) || ! get_userdata( (int) $data['original'] ) || ! \WP_Session_Tokens::get_instance( (int) $data['original'] )->verify( $session_token ) ) { return false; } $data['session_token'] = $session_token; $this->active = $data; return $this->active; } /** * Drop the switch state (on log in, log out or switch back). * * @since 1.0.0 * @return void */ public function forget_switch() { $raw = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) : ''; if ( '' !== $raw ) { delete_site_transient( $this->transient_key( strtok( $raw, '.' ) ) ); } $this->set_cookie( '', time() - YEAR_IN_SECONDS ); $this->active = false; } /** * Another user's live switch into $target, if any (from session tags). * * @since 1.0.0 * @param WP_User $target Target user. * @param WP_User $ignore The current switcher. * @return array{user: WP_User, login: int}|null */ private function other_switch_into( WP_User $target, WP_User $ignore ) { foreach ( \WP_Session_Tokens::get_instance( $target->ID )->get_all() as $session ) { if ( ! empty( $session['apwc_switched_from'] ) && (int) $session['apwc_switched_from'] !== $ignore->ID ) { $user = get_userdata( (int) $session['apwc_switched_from'] ); if ( $user ) { return array( 'user' => $user, 'login' => (int) ( $session['login'] ?? time() ), ); } } } return null; } /** * Whether the current login used "Remember me" (same test as User Switching). * * @since 1.0.0 * @return bool */ private function remember() { /** This filter is documented in wp-includes/pluggable.php */ $life = apply_filters( 'auth_cookie_expiration', 2 * DAY_IN_SECONDS, get_current_user_id(), false ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter, applied on purpose so "remember me" is judged exactly as WordPress sets it (same as User Switching). $current = wp_parse_auth_cookie( '', 'logged_in' ); return $current && ( (int) $current['expiration'] - time() > $life ); } /** * Forget WooCommerce's session so a cart never moves between the staff and customer accounts. * * WooCommerce migrates a session cookie that belongs to another user into the logged-in user's * session, so the cookie must go before the next request. * * @since 1.0.0 * @return void */ private function forget_wc_session() { if ( function_exists( 'WC' ) && WC()->session && method_exists( WC()->session, 'forget_session' ) ) { WC()->session->forget_session(); return; } // No session object (e.g. admin-post.php): expire the cookie directly. $cookie = apply_filters( 'woocommerce_cookie', 'wp_woocommerce_session_' . COOKIEHASH ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core WooCommerce filter, applied on purpose to find WooCommerce's own session cookie name. if ( isset( $_COOKIE[ $cookie ] ) && function_exists( 'wc_setcookie' ) ) { wc_setcookie( $cookie, '', time() - YEAR_IN_SECONDS, is_ssl(), true ); unset( $_COOKIE[ $cookie ] ); } } /** * Audit log entry (WooCommerce → Status → Logs, source "apwc-switch-user"). * * @since 1.0.0 * @param string $message Message. * @return void */ private function log( $message ) { if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->info( $message, array( 'source' => 'apwc-switch-user' ) ); } } /** * Signed switch-back URL. * * @since 1.0.0 * @return string */ public function switch_back_url() { return wp_nonce_url( add_query_arg( self::SWITCH_BACK, '1', home_url( '/' ) ), self::SWITCH_BACK ); } /** * "Switch back" item in the My Account menu while switched. * * @since 1.0.0 * @param array $items Menu items. * @return array */ public function account_menu_item( $items ) { $switch = $this->active_switch(); if ( $switch ) { $original = get_userdata( $switch['original'] ); $items['apwc-switch-back'] = sprintf( /* translators: %s: original user display name. */ __( 'Switch back to %s', 'admin-powerups-for-woocommerce' ), $original->display_name ); } return $items; } /** * URL for the My Account "Switch back" item. * * @since 1.0.0 * @param string $url URL. * @param string $endpoint Endpoint. * @return string */ public function account_menu_url( $url, $endpoint ) { return 'apwc-switch-back' === $endpoint && $this->active_switch() ? $this->switch_back_url() : $url; } /** * Users list row action. * * @since 1.0.0 * @param array $actions Actions. * @param WP_User $user User. * @return array */ public function user_row_action( $actions, $user ) { if ( '' === $this->denial_reason( $user ) ) { $actions['apwc_switch'] = sprintf( '%s', esc_url( $this->switch_url( $user->ID ) ), esc_html__( 'Switch to', 'admin-powerups-for-woocommerce' ) ); } return $actions; } /** * Link on the user profile screen. * * @since 1.0.0 * @param WP_User $user User being edited. * @return void */ public function profile_link( $user ) { if ( '' !== $this->denial_reason( $user ) ) { return; } printf( '%s%s', esc_html__( 'Switch user', 'admin-powerups-for-woocommerce' ), esc_url( $this->switch_url( $user->ID ) ), esc_html__( 'Switch to this customer', 'admin-powerups-for-woocommerce' ) ); } /** * Link on the order screen. * * @since 1.0.0 * @param WC_Order $order Order. * @return void */ public function order_link( $order ) { if ( ! $order instanceof WC_Order || ! $order->get_customer_id() ) { return; } $user = get_userdata( $order->get_customer_id() ); if ( '' !== $this->denial_reason( $user ) ) { return; } printf( '', esc_url( $this->switch_url( $user->ID, $order->get_view_order_url() ) ), esc_html__( 'Switch to customer', 'admin-powerups-for-woocommerce' ) ); } /** * Admin bar "Switch back" node. * * @since 1.0.0 * @param \WP_Admin_Bar $bar Admin bar. * @return void */ public function admin_bar( $bar ) { $switch = $this->active_switch(); if ( ! $switch ) { return; } $original = get_userdata( $switch['original'] ); $bar->add_node( array( 'id' => 'apwc-switch-back', /* translators: %s: original user's display name. */ 'title' => esc_html( sprintf( __( 'Switch back to %s', 'admin-powerups-for-woocommerce' ), $original->display_name ) ), 'href' => $this->switch_back_url(), ) ); } /** * Banner styles. * * @since 1.0.0 * @return void */ public function enqueue_banner() { if ( $this->active_switch() ) { wp_enqueue_style( 'apwc-modules', Assets::url( 'assets/css/modules.css' ), array(), Assets::ver( 'assets/css/modules.css' ) ); } } /** * Front-end banner while switched (the admin bar is usually hidden for customers). * * @since 1.0.0 * @return void */ public function frontend_banner() { $switch = $this->active_switch(); if ( ! $switch ) { return; } $original = get_userdata( $switch['original'] ); $current = wp_get_current_user(); ?>
display_name ) ); ?> display_name ) ); ?>
setting( 'redirect' ) ) { case 'shop': $url = wc_get_page_permalink( 'shop' ); break; case 'cart': $url = wc_get_cart_url(); break; case 'home': $url = home_url( '/' ); break; default: $url = wc_get_page_permalink( 'myaccount' ); } return $url ? $url : home_url( '/' ); } /** * Transient key for a token. * * @since 1.0.0 * @param string $token Raw token. * @return string */ private function transient_key( $token ) { return 'apwc_sw_' . substr( hash( 'sha256', $token . wp_salt( 'auth' ) ), 0, 40 ); } /** * Set or clear the switch cookie. * * @since 1.0.0 * @param string $value Value. * @param int $expires Expiry timestamp. * @return void */ private function set_cookie( $value, $expires ) { if ( '' === $value ) { unset( $_COOKIE[ self::COOKIE ] ); } else { $_COOKIE[ self::COOKIE ] = $value; } /** This filter is documented in wp-includes/pluggable.php */ if ( headers_sent() || ! apply_filters( 'send_auth_cookies', true, $expires, 0, 0, '', '' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter, applied on purpose so sites (and tests) that stop auth cookies also stop this one, like User Switching. return; } setcookie( self::COOKIE, $value, array( 'expires' => $expires, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'domain' => COOKIE_DOMAIN, 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); } }