Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.96% covered (warning)
82.96%
112 / 135
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Users_Connection_Admin
82.96% covered (warning)
82.96%
112 / 135
68.75% covered (warning)
68.75%
11 / 16
63.86
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 keep_connected_view_on_submit
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 is_connected_view
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_connected_view_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_connected_user_ids
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
10
 filter_query_to_connected_users
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 add_connected_view
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 count_connected_users
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 add_connection_column
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 render_connection_column
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_scripts
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 enqueue_connection_column_styles
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 add_connection_column_styles
n/a
0 / 0
n/a
0 / 0
1
 get_connection_column_styles
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_column_tooltip_text
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
6.32
 get_column_id
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Handles the WordPress.com account column in the users list table.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Status\Host;
12
13/**
14 * Class Users_Connection_Admin
15 */
16class Users_Connection_Admin {
17    /**
18     * The column ID used for the WordPress.com account column.
19     *
20     * @var string
21     */
22    const COLUMN_ID = 'user_jetpack';
23
24    /**
25     * The handle used for the users list table column styles.
26     *
27     * @var string
28     */
29    const STYLE_HANDLE = 'jetpack-connection-users-column';
30
31    /**
32     * Query argument that filters the users list to connected users.
33     *
34     * @var string
35     */
36    const VIEW_QUERY_ARG = 'jetpack_connection';
37
38    /**
39     * Value of VIEW_QUERY_ARG that selects the connected view. Doubles as the view's
40     * key, which WP_List_Table::views() renders as the list item's class.
41     *
42     * @var string
43     */
44    const VIEW_CONNECTED = 'connected';
45
46    /**
47     * Constructor.
48     */
49    public function __construct() {
50        // Only set up hooks if we're in the admin area and user has proper permissions
51        add_action( 'init', array( $this, 'init' ) );
52    }
53
54    /**
55     * Initialize the admin functionality if conditions are met.
56     */
57    public function init() {
58        if ( ! is_admin() || ! current_user_can( 'manage_options' ) || ( new Host() )->is_wpcom_simple() ) {
59            return;
60        }
61
62        add_filter( 'manage_users_columns', array( $this, 'add_connection_column' ) );
63        add_filter( 'manage_users_custom_column', array( $this, 'render_connection_column' ), 9, 3 ); // Priority 9 to run before SSO
64        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
65        // Static callbacks: a request runs several instances of this class, and WP keys
66        // callbacks by object hash, so instance callbacks here would stack up and print the
67        // hidden field once each. The column callbacks above stack harmlessly.
68        add_filter( 'views_users', array( self::class, 'add_connected_view' ) );
69        add_filter( 'users_list_table_query_args', array( self::class, 'filter_query_to_connected_users' ) );
70        add_action( 'restrict_manage_users', array( self::class, 'keep_connected_view_on_submit' ) );
71    }
72
73    /**
74     * Carry the connected view through the list table's form submissions.
75     *
76     * The search box and bulk actions submit a GET form that only replays the parameters
77     * it carries, so without this, searching from the connected view silently searches
78     * every user while the view still looks active. Core preserves `role` the same way.
79     *
80     * @since 9.10.0
81     *
82     * @param string $which Which tablenav is being rendered, 'top' or 'bottom'.
83     */
84    public static function keep_connected_view_on_submit( $which ) {
85        // Both tablenavs sit in the same form, so only one copy of the field is needed.
86        if ( 'top' !== $which || ! self::is_connected_view() ) {
87            return;
88        }
89
90        printf(
91            '<input type="hidden" name="%1$s" value="%2$s" />',
92            esc_attr( self::VIEW_QUERY_ARG ),
93            esc_attr( self::VIEW_CONNECTED )
94        );
95    }
96
97    /**
98     * Whether the users list is currently filtered to connected users.
99     *
100     * Only the per-site Users screen offers the view. Network admin shares the same
101     * list-table hooks but never shows the link, so honouring the argument there would
102     * narrow those lists silently, by this site's tokens, with no way to clear it.
103     *
104     * @since 9.10.0
105     *
106     * @return bool
107     */
108    public static function is_connected_view() {
109        if ( is_network_admin() ) {
110            return false;
111        }
112
113        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list filter, compared against a fixed value.
114        $view = isset( $_GET[ self::VIEW_QUERY_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::VIEW_QUERY_ARG ] ) ) : '';
115
116        return self::VIEW_CONNECTED === $view;
117    }
118
119    /**
120     * URL of the users list filtered to connected users.
121     *
122     * @since 9.10.0
123     *
124     * @return string
125     */
126    public static function get_connected_view_url() {
127        return add_query_arg( self::VIEW_QUERY_ARG, self::VIEW_CONNECTED, admin_url( 'users.php' ) );
128    }
129
130    /**
131     * Local user IDs holding a valid WordPress.com user token.
132     *
133     * There is no meta to query: the tokens live in the `user_tokens` grouped option,
134     * keyed by local user ID. Read it once rather than calling `is_user_connected()` per
135     * key â€” that re-reads the option, and on managed hosts external storage serves it
136     * with an uncached query. An ID here need not resolve to a user; see the count.
137     *
138     * @since 9.10.0
139     *
140     * @return int[]
141     */
142    public static function get_connected_user_ids() {
143        $tokens_api = ( new Manager() )->get_tokens();
144
145        // A locked site has no usable tokens, which is what the column reports too.
146        if ( $tokens_api->is_locked() ) {
147            return array();
148        }
149
150        $tokens = $tokens_api->get_user_tokens();
151
152        if ( ! is_array( $tokens ) ) {
153            return array();
154        }
155
156        $ids = array();
157
158        foreach ( $tokens as $id => $token ) {
159            // PHP leaves a non-canonical key like "01" a string, and get_access_token() looks
160            // the token up by the integer ID, so normalising here would claim a user it cannot find.
161            if ( ! is_int( $id ) || $id <= 0 ) {
162                continue;
163            }
164
165            $chunks = is_string( $token ) ? explode( '.', $token ) : array();
166
167            // Mirrors Tokens::get_access_token(): three parts, and the token names its own user.
168            if ( ! empty( $chunks[1] ) && ! empty( $chunks[2] ) && (string) $id === $chunks[2] ) {
169                $ids[] = $id;
170            }
171        }
172
173        return $ids;
174    }
175
176    /**
177     * Narrow the users list table query to connected users when the view is active.
178     *
179     * @since 9.10.0
180     *
181     * @param array $args Query arguments for the list table's WP_User_Query.
182     * @return array
183     */
184    public static function filter_query_to_connected_users( $args ) {
185        if ( ! self::is_connected_view() ) {
186            return $args;
187        }
188
189        $connected = self::get_connected_user_ids();
190
191        // A pre-existing `include` means something else already narrowed the list, so both
192        // constraints are kept rather than overwriting theirs. Tested with isset(), not
193        // empty(): core sets an empty `include` for `role=none` when every user has a role,
194        // and that already means "nobody" â€” reading it as "unset" would list everyone.
195        if ( isset( $args['include'] ) ) {
196            $existing  = wp_parse_id_list( $args['include'] );
197            $connected = $existing ? array_intersect( $existing, $connected ) : array();
198        }
199
200        // WP_User_Query only honours `exclude` when `include` is empty, so setting `include`
201        // below would silently un-exclude whoever another filter had removed.
202        if ( ! empty( $args['exclude'] ) ) {
203            $connected = array_diff( $connected, wp_parse_id_list( $args['exclude'] ) );
204        }
205
206        // An empty `include` is ignored by WP_User_Query, which would list every user. No
207        // user has ID 0, so it is the way to express "match nothing".
208        $args['include'] = empty( $connected ) ? array( 0 ) : array_values( $connected );
209
210        return $args;
211    }
212
213    /**
214     * Add a "Connected" view to the users list table.
215     *
216     * @since 9.10.0
217     *
218     * @param string[] $views View links keyed by view name.
219     * @return string[]
220     */
221    public static function add_connected_view( $views ) {
222        $count = self::count_connected_users();
223
224        if ( ! $count ) {
225            return $views;
226        }
227
228        $is_current = self::is_connected_view();
229
230        // Core marks "All" as current whenever no role is selected, which stays true here.
231        // Matches WP_List_Table::get_views_links(); a core change makes this a no-op and
232        // two links look active, so it fails cosmetically rather than breaking the view.
233        if ( $is_current && isset( $views['all'] ) ) {
234            $views['all'] = str_replace( ' class="current" aria-current="page"', '', $views['all'] );
235        }
236
237        // Count inside the translated string, as core's own views do, so the word order and
238        // plural form are the translator's to choose.
239        $label = sprintf(
240            /* translators: %s: number of users with a linked WordPress.com account. */
241            _n(
242                'Connected to WordPress.com <span class="count">(%s)</span>',
243                'Connected to WordPress.com <span class="count">(%s)</span>',
244                $count,
245                'jetpack-connection'
246            ),
247            esc_html( number_format_i18n( $count ) )
248        );
249
250        $views[ self::VIEW_CONNECTED ] = sprintf(
251            '<a href="%1$s"%2$s>%3$s</a>',
252            esc_url( self::get_connected_view_url() ),
253            $is_current ? ' class="current" aria-current="page"' : '',
254            $label
255        );
256
257        return $views;
258    }
259
260    /**
261     * Number of connected users the list will actually show.
262     *
263     * Queried, not counted off the option: a token can outlive its user, and the query
264     * drops those IDs and scopes to the current site. Ordered by ID so it does not
265     * filesort on `user_login`, the default, which a count never needs.
266     *
267     * @since 9.10.0
268     *
269     * @return int
270     */
271    private static function count_connected_users() {
272        $ids = self::get_connected_user_ids();
273
274        if ( ! $ids ) {
275            return 0;
276        }
277
278        $query = new \WP_User_Query(
279            array(
280                'include'     => $ids,
281                'fields'      => 'ID',
282                'number'      => -1,
283                'orderby'     => 'ID',
284                'count_total' => false,
285            )
286        );
287
288        return count( $query->get_results() );
289    }
290
291    /**
292     * Add the connection column to the users list table.
293     *
294     * @param array $columns The current columns.
295     * @return array Modified columns.
296     */
297    public function add_connection_column( $columns ) {
298        $columns[ self::COLUMN_ID ] = sprintf(
299            '<span class="jetpack-connection-tooltip-icon" role="tooltip" tabindex="0" aria-label="%2$s: %1$s">
300                %1$s
301                <span class="jetpack-connection-tooltip"></span>
302            </span>',
303            esc_html__( 'WordPress.com account', 'jetpack-connection' ),
304            esc_attr__( 'Tooltip', 'jetpack-connection' )
305        );
306        return $columns;
307    }
308
309    /**
310     * Render the connection column content.
311     *
312     * @param string $output      Custom column output.
313     * @param string $column_name Column name.
314     * @param int    $user_id     ID of the currently-listed user.
315     * @return string
316     */
317    public function render_connection_column( $output, $column_name, $user_id ) {
318        if ( self::COLUMN_ID !== $column_name ) {
319            return $output;
320        }
321
322        if ( ( new Manager() )->is_user_connected( $user_id ) ) {
323            $logo_url = Jetpack_Connector::get_inline_connector_logo_url();
324
325            return sprintf(
326                '<span title="%1$s" class="jetpack-connection-status"><img src="%2$s" alt="" class="jetpack-connection-status__logo" height="18" decoding="async" loading="lazy" />%3$s</span>',
327                esc_attr__( 'This user has connected their WordPress.com account.', 'jetpack-connection' ),
328                esc_url( $logo_url ),
329                esc_html__( 'Connected', 'jetpack-connection' )
330            );
331        }
332
333        return $output;
334    }
335
336    /**
337     * Enqueue scripts and styles.
338     *
339     * @param string $hook The current admin page.
340     */
341    public function enqueue_scripts( $hook ) {
342        if ( 'users.php' !== $hook ) {
343            return;
344        }
345
346        self::enqueue_connection_column_styles();
347
348        Assets::register_script(
349            'jetpack-users-connection',
350            '../dist/jetpack-users-connection.js',
351            __FILE__,
352            array(
353                'strategy'  => 'defer',
354                'in_footer' => true,
355                'enqueue'   => true,
356                'version'   => Package_Version::PACKAGE_VERSION,
357                'deps'      => array( 'wp-i18n' ),
358
359            )
360        );
361
362        wp_localize_script(
363            'jetpack-users-connection',
364            'jetpackConnectionTooltips',
365            array(
366                'columnTooltip' => esc_html( self::get_column_tooltip_text() ),
367            )
368        );
369    }
370
371    /**
372     * Enqueue the styles for the connection column as inline CSS on a source-less handle.
373     */
374    private static function enqueue_connection_column_styles() {
375        // A request can run several instances of this class, and wp_add_inline_style() appends, so the CSS is added once per handle.
376        if ( ! wp_style_is( self::STYLE_HANDLE, 'registered' ) ) {
377            wp_register_style( self::STYLE_HANDLE, false, array(), Package_Version::PACKAGE_VERSION );
378            wp_add_inline_style( self::STYLE_HANDLE, self::get_connection_column_styles() );
379        }
380        wp_enqueue_style( self::STYLE_HANDLE );
381    }
382
383    /**
384     * Add the styles for the connection column.
385     *
386     * @deprecated 8.12.0 The CSS is enqueued on the `jetpack-connection-users-column` style handle by enqueue_scripts().
387     */
388    public function add_connection_column_styles() {
389        _deprecated_function( __METHOD__, 'connection-8.12.0', __CLASS__ . '::enqueue_scripts' );
390        self::enqueue_connection_column_styles();
391    }
392
393    /**
394     * Get the styles for the connection column.
395     *
396     * @return string CSS rules.
397     */
398    private static function get_connection_column_styles() {
399        return '
400        .jetpack-connection-tooltip-icon {
401            position: relative;
402            cursor: pointer;
403        }
404        /* Add [?] icon using pseudo-element, only in column header */
405        th.manage-column .jetpack-connection-tooltip-icon::after {
406            content: \'[?]\';
407            color: #3c434a;
408            font-size: 1em;
409            margin-left: 4px;
410        }
411        .jetpack-connection-tooltip {
412            position: absolute;
413            background: #f6f7f7;
414            top: -85px;
415            width: 250px;
416            padding: 7px;
417            color: #3c434a;
418            font-size: .75rem;
419            line-height: 17px;
420            text-align: left;
421            margin: 0;
422            display: none;
423            border-radius: 4px;
424            font-family: sans-serif;
425            box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.1);
426            left: -170px;
427        }
428        .column-user_jetpack {
429            width: 190px;
430        }
431        @media screen and (max-width: 1100px) {
432            .column-user_jetpack {
433                width: auto;
434            }
435        }
436        .jetpack-connection-status {
437            display: inline-flex;
438            align-items: center;
439            column-gap: 6px;
440        }
441        .jetpack-connection-status__logo {
442            display: block;
443            flex-shrink: 0;
444        }
445        /* Show tooltip on hover and focus */
446        .jetpack-connection-tooltip-icon:hover .jetpack-connection-tooltip,
447        .jetpack-connection-tooltip-icon:focus-within .jetpack-connection-tooltip {
448            display: block;
449        }
450        ';
451    }
452
453    /**
454     * Build the column header tooltip text based on which plugin families use the connection.
455     *
456     * @return string Tooltip text.
457     */
458    private static function get_column_tooltip_text() {
459        $families = Jetpack_Connector::get_connected_plugin_families();
460
461        if ( $families['has_woo'] && $families['has_a4a'] ) {
462            return __( 'Connecting a WordPress.com account unlocks features for Jetpack, WooCommerce, and Automattic for Agencies including secure logins.', 'jetpack-connection' );
463        }
464
465        if ( $families['has_woo'] ) {
466            return __( 'Connecting a WordPress.com account unlocks features for Jetpack and WooCommerce including secure logins.', 'jetpack-connection' );
467        }
468
469        if ( $families['has_a4a'] ) {
470            return __( 'Connecting a WordPress.com account unlocks features for Jetpack and Automattic for Agencies including secure logins.', 'jetpack-connection' );
471        }
472
473        return __( 'Connecting a WordPress.com account unlocks Jetpack features including secure logins.', 'jetpack-connection' );
474    }
475
476    /**
477     * Get the column ID. Allows other classes to reference the same column.
478     *
479     * @return string
480     */
481    public static function get_column_id() {
482        return self::COLUMN_ID;
483    }
484}