PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-connection / src / sso / class-user-admin.php

class-user-admin.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-connection/src/sso/class-user-admin.php

1,337 lines 39.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Extra UI elements added to the User Menu for the SSO feature.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8 namespace Automattic\Jetpack\Connection\SSO;
9
10 use Automattic\Jetpack\Assets;
11 use Automattic\Jetpack\Connection\Client;
12 use Automattic\Jetpack\Connection\Manager;
13 use Automattic\Jetpack\Connection\Package_Version;
14 use Automattic\Jetpack\Connection\Users_Connection_Admin as Base_Admin;
15 use Automattic\Jetpack\Roles;
16 use Automattic\Jetpack\Status\Host;
17 use Automattic\Jetpack\Tracking;
18 use WP_Error;
19 use WP_User;
20 use WP_User_Query;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit( 0 );
24 }
25
26 /**
27 * Jetpack sso user admin class.
28 *
29 * @phan-constructor-used-for-side-effects
30 */
31 class User_Admin extends Base_Admin {
32 /**
33 * Instance of WP_User_Query.
34 *
35 * @var $user_search
36 */
37 private static $user_search = null;
38 /**
39 * Array of cached invites.
40 *
41 * @var $cached_invites
42 */
43 private static $cached_invites = null;
44
45 /**
46 * Instance of Jetpack Tracking.
47 *
48 * @var $instance
49 */
50 private static $tracking = null;
51
52 /**
53 * Constructor function.
54 */
55 public function __construct() {
56 add_action( 'delete_user', array( Helpers::class, 'delete_connection_for_user' ) );
57 // If the user has no errors on creation, send an invite to WordPress.com.
58 add_filter( 'user_profile_update_errors', array( $this, 'send_wpcom_mail_user_invite' ), 10, 3 );
59 add_filter( 'wp_send_new_user_notification_to_user', array( $this, 'should_send_wp_mail_new_user' ) );
60 add_action( 'user_new_form', array( $this, 'render_invitation_email_message' ) );
61 add_action( 'user_new_form', array( $this, 'render_wpcom_invite_checkbox' ), 1 );
62 add_action( 'user_new_form', array( $this, 'render_wpcom_external_user_checkbox' ), 1 );
63 add_action( 'user_new_form', array( $this, 'render_custom_email_message_form_field' ), 1 );
64 add_action( 'delete_user_form', array( $this, 'render_invitations_notices_for_deleted_users' ) );
65 add_action( 'delete_user', array( $this, 'revoke_user_invite' ) );
66 add_filter( 'manage_users_custom_column', array( $this, 'jetpack_show_connection_status' ), 10, 3 );
67 add_action( 'user_row_actions', array( $this, 'jetpack_user_table_row_actions' ), 10, 2 );
68
69 if ( isset( $_GET['jetpack-sso-invite-user'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
70 add_action( 'admin_notices', array( $this, 'handle_invitation_results' ) );
71 }
72
73 add_action( 'admin_post_jetpack_invite_user_to_wpcom', array( $this, 'invite_user_to_wpcom' ) );
74 add_action( 'admin_post_jetpack_revoke_invite_user_to_wpcom', array( $this, 'handle_request_revoke_invite' ) );
75 add_action( 'admin_post_jetpack_resend_invite_user_to_wpcom', array( $this, 'handle_request_resend_invite' ) );
76 add_filter( 'users_list_table_query_args', array( $this, 'set_user_query' ), 100, 1 );
77 add_action( 'admin_print_styles-user-new.php', array( $this, 'jetpack_new_users_styles' ) );
78 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
79
80 self::$tracking = new Tracking();
81 }
82
83 /**
84 * Enqueue assets for user-new.php.
85 */
86 public function jetpack_new_users_styles() {
87 Assets::register_script(
88 'jetpack-sso-admin-create-user',
89 '../../dist/jetpack-sso-admin-create-user.js',
90 __FILE__,
91 array(
92 'strategy' => 'defer',
93 'in_footer' => true,
94 'enqueue' => true,
95 )
96 );
97 }
98
99 /**
100 * Intercept the arguments for building the table, and create WP_User_Query instance
101 *
102 * @param array $args The search arguments.
103 *
104 * @return array
105 */
106 public function set_user_query( $args ) {
107 self::$user_search = new WP_User_Query( $args );
108 return $args;
109 }
110
111 /**
112 * Revokes WordPress.com invitation.
113 *
114 * @param int $user_id The user ID.
115 * @return mixed Response from the API call or false on failure.
116 */
117 public function revoke_user_invite( $user_id ) {
118 try {
119 $has_pending_invite = self::has_pending_wpcom_invite( $user_id );
120
121 if ( $has_pending_invite ) {
122 $response = self::send_revoke_wpcom_invite( $has_pending_invite );
123 $event = 'sso_user_invite_revoked';
124
125 if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
126 $body = json_decode( wp_remote_retrieve_body( $response ) );
127 $tracking_event_data = array(
128 'success' => 'false',
129 'error_code' => 'invalid-revoke-api-error',
130 );
131
132 if ( ! empty( $body ) && ! empty( $body->message ) ) {
133 $tracking_event_data['error_message'] = $body->message;
134 }
135 self::$tracking->record_user_event(
136 $event,
137 $tracking_event_data
138 );
139 return $response;
140 }
141
142 $body = json_decode( $response['body'] );
143
144 if ( ! $body->deleted ) {
145 self::$tracking->record_user_event(
146 $event,
147 array(
148 'success' => 'false',
149 'error_message' => 'invalid-invite-revoke',
150 )
151 );
152 } else {
153 self::$tracking->record_user_event( $event, array( 'success' => 'true' ) );
154 }
155
156 return $response;
157 } else {
158 // Delete external contributor if it exists.
159 $wpcom_user_data = ( new Manager() )->get_connected_user_data( $user_id );
160 if ( isset( $wpcom_user_data['ID'] ) ) {
161 return self::delete_external_contributor( $wpcom_user_data['ID'] );
162 }
163 }
164 } catch ( \Exception $e ) {
165 return false;
166 }
167 }
168
169 /**
170 * Renders invitations errors/success messages in users.php.
171 */
172 public function handle_invitation_results() {
173 $valid_nonce = isset( $_GET['_wpnonce'] )
174 ? wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'jetpack-sso-invite-user' )
175 : false;
176
177 if ( ! $valid_nonce || ! isset( $_GET['jetpack-sso-invite-user'] ) ) {
178 return;
179 }
180 if ( $_GET['jetpack-sso-invite-user'] === 'success' ) {
181 return wp_admin_notice( __( 'User was invited successfully!', 'jetpack-connection' ), array( 'type' => 'success' ) );
182 }
183 if ( $_GET['jetpack-sso-invite-user'] === 'reinvited-success' ) {
184 return wp_admin_notice( __( 'User was re-invited successfully!', 'jetpack-connection' ), array( 'type' => 'success' ) );
185 }
186
187 if ( $_GET['jetpack-sso-invite-user'] === 'successful-revoke' ) {
188 return wp_admin_notice( __( 'User invite revoked successfully.', 'jetpack-connection' ), array( 'type' => 'success' ) );
189 }
190
191 if ( $_GET['jetpack-sso-invite-user'] === 'failed' && isset( $_GET['jetpack-sso-api-error-message'] ) ) {
192 return wp_admin_notice( wp_kses( wp_unslash( $_GET['jetpack-sso-api-error-message'] ), array() ), array( 'type' => 'error' ) );
193 }
194
195 if ( $_GET['jetpack-sso-invite-user'] === 'failed' && isset( $_GET['jetpack-sso-invite-error'] ) ) {
196 switch ( $_GET['jetpack-sso-invite-error'] ) {
197 case 'invalid-user':
198 return wp_admin_notice( __( 'Tried to invite a user that doesn&#8217;t exist.', 'jetpack-connection' ), array( 'type' => 'error' ) );
199 case 'invalid-email':
200 return wp_admin_notice( __( 'Tried to invite a user that doesn&#8217;t have an email address.', 'jetpack-connection' ), array( 'type' => 'error' ) );
201 case 'invalid-user-permissions':
202 return wp_admin_notice( __( 'You don&#8217;t have permission to invite users.', 'jetpack-connection' ), array( 'type' => 'error' ) );
203 case 'invalid-user-revoke':
204 return wp_admin_notice( __( 'Tried to revoke an invite for a user that doesn&#8217;t exist.', 'jetpack-connection' ), array( 'type' => 'error' ) );
205 case 'invalid-invite-revoke':
206 return wp_admin_notice( __( 'Tried to revoke an invite that doesn&#8217;t exist.', 'jetpack-connection' ), array( 'type' => 'error' ) );
207 case 'invalid-revoke-permissions':
208 return wp_admin_notice( __( 'You don&#8217;t have permission to revoke invites.', 'jetpack-connection' ), array( 'type' => 'error' ) );
209 case 'empty-invite':
210 return wp_admin_notice( __( 'There is no previous invite for this user', 'jetpack-connection' ), array( 'type' => 'error' ) );
211 case 'invalid-invite':
212 return wp_admin_notice( __( 'Attempted to send a new invitation to a user using an invite that doesn&#8217;t exist.', 'jetpack-connection' ), array( 'type' => 'error' ) );
213 case 'error-revoke':
214 return wp_admin_notice( __( 'An error has occurred when revoking the invite for the user.', 'jetpack-connection' ), array( 'type' => 'error' ) );
215 case 'invalid-revoke-api-error':
216 return wp_admin_notice( __( 'An error has occurred when revoking the user invite.', 'jetpack-connection' ), array( 'type' => 'error' ) );
217 default:
218 return wp_admin_notice( __( 'An error has occurred when inviting the user to the site.', 'jetpack-connection' ), array( 'type' => 'error' ) );
219 }
220 }
221 }
222
223 /**
224 * Invites a user to connect to WordPress.com to allow them to log in via SSO.
225 */
226 public function invite_user_to_wpcom() {
227 check_admin_referer( 'jetpack-sso-invite-user', 'invite_nonce' );
228 $nonce = wp_create_nonce( 'jetpack-sso-invite-user' );
229 $event = 'sso_user_invite_sent';
230
231 if ( ! current_user_can( 'create_users' ) ) {
232 $error = 'invalid-user-permissions';
233 $query_params = array(
234 'jetpack-sso-invite-user' => 'failed',
235 'jetpack-sso-invite-error' => $error,
236 '_wpnonce' => $nonce,
237 );
238 return self::create_error_notice_and_redirect( $query_params );
239 } elseif ( isset( $_GET['user_id'] ) ) {
240 $user_id = intval( wp_unslash( $_GET['user_id'] ) );
241 $user = get_user_by( 'id', $user_id );
242 $user_email = $user->user_email;
243
244 if ( ! $user || ! $user_email ) {
245 $reason = ! $user ? 'invalid-user' : 'invalid-email';
246 $query_params = array(
247 'jetpack-sso-invite-user' => 'failed',
248 'jetpack-sso-invite-error' => $reason,
249 '_wpnonce' => $nonce,
250 );
251
252 self::$tracking->record_user_event(
253 $event,
254 array(
255 'success' => 'false',
256 'error_message' => $reason,
257 )
258 );
259 return self::create_error_notice_and_redirect( $query_params );
260 }
261
262 $blog_id = Manager::get_site_id( true );
263 $roles = new Roles();
264 $user_role = $roles->translate_user_to_role( $user );
265
266 $url = '/sites/' . $blog_id . '/invites/new';
267 $response = Client::wpcom_json_api_request_as_user(
268 $url,
269 'v2',
270 array(
271 'method' => 'POST',
272 ),
273 array(
274 'invitees' => array(
275 array(
276 'email_or_username' => $user_email,
277 'role' => $user_role,
278 ),
279 ),
280 ),
281 'wpcom'
282 );
283
284 if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
285 $error_code = 'invalid-invite-api-error';
286 $query_params = array(
287 'jetpack-sso-invite-user' => 'failed',
288 'jetpack-sso-invite-error' => $error_code,
289 '_wpnonce' => $nonce,
290 );
291
292 $tracking_event_data = array(
293 'success' => 'false',
294 'error_code' => $error_code,
295 );
296
297 $body = json_decode( wp_remote_retrieve_body( $response ) );
298 if ( ! empty( $body ) && ! empty( $body->message ) ) {
299 $query_params['jetpack-sso-api-error-message'] = $body->message;
300 $tracking_event_data['error_message'] = $body->message;
301 }
302
303 self::$tracking->record_user_event(
304 $event,
305 $tracking_event_data
306 );
307 return self::create_error_notice_and_redirect( $query_params );
308 }
309
310 $body = json_decode( wp_remote_retrieve_body( $response ) );
311
312 // access the first item since we're inviting one user.
313 if ( is_array( $body ) && ! empty( $body ) ) {
314 $body = $body[0];
315 }
316
317 $query_params = array(
318 'jetpack-sso-invite-user' => $body->success ? 'success' : 'failed',
319 '_wpnonce' => $nonce,
320 );
321
322 if ( ! $body->success && $body->errors ) {
323 $response_error = array_keys( (array) $body->errors );
324 $query_params['jetpack-sso-invite-error'] = $response_error[0];
325 self::$tracking->record_user_event(
326 $event,
327 array(
328 'success' => 'false',
329 'error_message' => $response_error[0],
330 )
331 );
332 } else {
333 self::$tracking->record_user_event( $event, array( 'success' => 'true' ) );
334 }
335
336 return self::create_error_notice_and_redirect( $query_params );
337 } else {
338 $error = 'invalid-user';
339 $query_params = array(
340 'jetpack-sso-invite-user' => 'failed',
341 'jetpack-sso-invite-error' => $error,
342 '_wpnonce' => $nonce,
343 );
344 self::$tracking->record_user_event(
345 $event,
346 array(
347 'success' => 'false',
348 'error_message' => $error,
349 )
350 );
351 return self::create_error_notice_and_redirect( $query_params );
352 }
353 wp_die();
354 }
355
356 /**
357 * Revokes a user's invitation to connect to WordPress.com.
358 *
359 * @param string $invite_id The ID of the invite to revoke.
360 */
361 public function send_revoke_wpcom_invite( $invite_id ) {
362 $blog_id = Manager::get_site_id( true );
363
364 $url = '/sites/' . $blog_id . '/invites/delete';
365 return Client::wpcom_json_api_request_as_user(
366 $url,
367 'v2',
368 array(
369 'method' => 'POST',
370 ),
371 array(
372 'invite_ids' => array( $invite_id ),
373 ),
374 'wpcom'
375 );
376 }
377
378 /**
379 * Handles logic to revoke user invite.
380 */
381 public function handle_request_revoke_invite() {
382 check_admin_referer( 'jetpack-sso-revoke-user-invite', 'revoke_invite_nonce' );
383 $nonce = wp_create_nonce( 'jetpack-sso-invite-user' );
384 $event = 'sso_user_invite_revoked';
385 if ( ! current_user_can( 'promote_users' ) ) {
386 $error = 'invalid-revoke-permissions';
387 $query_params = array(
388 'jetpack-sso-invite-user' => 'failed',
389 'jetpack-sso-invite-error' => $error,
390 '_wpnonce' => $nonce,
391 );
392
393 return self::create_error_notice_and_redirect( $query_params );
394 } elseif ( isset( $_GET['user_id'] ) ) {
395 $user_id = intval( wp_unslash( $_GET['user_id'] ) );
396 $user = get_user_by( 'id', $user_id );
397 if ( ! $user ) {
398 $error = 'invalid-user-revoke';
399 $query_params = array(
400 'jetpack-sso-invite-user' => 'failed',
401 'jetpack-sso-invite-error' => $error,
402 '_wpnonce' => $nonce,
403 );
404
405 self::$tracking->record_user_event(
406 $event,
407 array(
408 'success' => 'false',
409 'error_message' => $error,
410 )
411 );
412 return self::create_error_notice_and_redirect( $query_params );
413 }
414
415 if ( ! isset( $_GET['invite_id'] ) ) {
416 $error = 'invalid-invite-revoke';
417 $query_params = array(
418 'jetpack-sso-invite-user' => 'failed',
419 'jetpack-sso-invite-error' => $error,
420 '_wpnonce' => $nonce,
421 );
422 self::$tracking->record_user_event(
423 $event,
424 array(
425 'success' => 'false',
426 'error_message' => $error,
427 )
428 );
429 return self::create_error_notice_and_redirect( $query_params );
430 }
431
432 $invite_id = sanitize_text_field( wp_unslash( $_GET['invite_id'] ) );
433 $response = self::send_revoke_wpcom_invite( $invite_id );
434
435 if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
436 $error = 'invalid-revoke-api-error';
437 $query_params = array(
438 'jetpack-sso-invite-user' => 'failed',
439 'jetpack-sso-invite-error' => $error, // general error message
440 '_wpnonce' => $nonce,
441 );
442
443 $tracking_event_data = array(
444 'success' => 'false',
445 'error_code' => $error,
446 );
447
448 $body = json_decode( wp_remote_retrieve_body( $response ) );
449 if ( ! empty( $body ) && ! empty( $body->message ) ) {
450 $query_params['jetpack-sso-api-error-message'] = $body->message;
451 $tracking_event_data['error_message'] = $body->message;
452 }
453
454 self::$tracking->record_user_event(
455 $event,
456 $tracking_event_data
457 );
458 return self::create_error_notice_and_redirect( $query_params );
459 }
460
461 $body = json_decode( $response['body'] );
462 $query_params = array(
463 'jetpack-sso-invite-user' => $body->deleted ? 'successful-revoke' : 'failed',
464 '_wpnonce' => $nonce,
465 );
466 if ( ! $body->deleted ) { // no invite was deleted, probably it does not exist
467 $error = 'invalid-invite-revoke';
468 $query_params['jetpack-sso-invite-error'] = $error;
469 self::$tracking->record_user_event(
470 $event,
471 array(
472 'success' => 'false',
473 'error_message' => $error,
474 )
475 );
476 } else {
477 self::$tracking->record_user_event( $event, array( 'success' => 'true' ) );
478 }
479 return self::create_error_notice_and_redirect( $query_params );
480 } else {
481 $error = 'invalid-user-revoke';
482 $query_params = array(
483 'jetpack-sso-invite-user' => 'failed',
484 'jetpack-sso-invite-error' => $error,
485 '_wpnonce' => $nonce,
486 );
487 self::$tracking->record_user_event(
488 $event,
489 array(
490 'success' => 'false',
491 'error_message' => $error,
492 )
493 );
494 return self::create_error_notice_and_redirect( $query_params );
495 }
496
497 wp_die();
498 }
499
500 /**
501 * Handles resend user invite.
502 */
503 public function handle_request_resend_invite() {
504 check_admin_referer( 'jetpack-sso-resend-user-invite', 'resend_invite_nonce' );
505 $nonce = wp_create_nonce( 'jetpack-sso-invite-user' );
506 $event = 'sso_user_invite_resend';
507 if ( ! current_user_can( 'create_users' ) ) {
508 $query_params = array(
509 'jetpack-sso-invite-user' => 'failed',
510 'jetpack-sso-invite-error' => 'invalid-user-permissions',
511 '_wpnonce' => $nonce,
512 );
513 return self::create_error_notice_and_redirect( $query_params );
514 } elseif ( isset( $_GET['invite_id'] ) ) {
515 $invite_slug = sanitize_text_field( wp_unslash( $_GET['invite_id'] ) );
516 $blog_id = Manager::get_site_id( true );
517 $url = '/sites/' . $blog_id . '/invites/resend';
518 $response = Client::wpcom_json_api_request_as_user(
519 $url,
520 'v2',
521 array(
522 'method' => 'POST',
523 ),
524 array(
525 'invite_slug' => $invite_slug,
526 ),
527 'wpcom'
528 );
529
530 $status_code = wp_remote_retrieve_response_code( $response );
531
532 if ( 200 !== $status_code ) {
533 $message_type = $status_code === 404 ? 'invalid-invite' : ''; // empty is the general error message
534 $query_params = array(
535 'jetpack-sso-invite-user' => 'failed',
536 'jetpack-sso-invite-error' => $message_type,
537 '_wpnonce' => $nonce,
538 );
539 self::$tracking->record_user_event(
540 $event,
541 array(
542 'success' => 'false',
543 'error_message' => $message_type,
544 )
545 );
546 return self::create_error_notice_and_redirect( $query_params );
547 }
548
549 $body = json_decode( $response['body'] );
550 $invite_response_message = $body->success ? 'reinvited-success' : 'failed';
551 $query_params = array(
552 'jetpack-sso-invite-user' => $invite_response_message,
553 '_wpnonce' => $nonce,
554 );
555
556 if ( ! $body->success ) {
557 self::$tracking->record_user_event(
558 $event,
559 array(
560 'success' => 'false',
561 'error_message' => $invite_response_message,
562 )
563 );
564 } else {
565 self::$tracking->record_user_event( $event, array( 'success' => 'true' ) );
566 }
567
568 return self::create_error_notice_and_redirect( $query_params );
569 } else {
570 $error = 'empty-invite';
571 $query_params = array(
572 'jetpack-sso-invite-user' => 'failed',
573 'jetpack-sso-invite-error' => 'empty-invite',
574 '_wpnonce' => $nonce,
575 );
576 self::$tracking->record_user_event(
577 $event,
578 array(
579 'success' => 'false',
580 'error_message' => $error,
581 )
582 );
583 return self::create_error_notice_and_redirect( $query_params );
584 }
585 }
586
587 /**
588 * Adds 'Revoke invite' and 'Resend invite' link to user table row actions.
589 * Removes 'Reset password' link.
590 *
591 * @param array $actions - User row actions.
592 * @param WP_User $user_object - User object.
593 */
594 public function jetpack_user_table_row_actions( $actions, $user_object ) {
595 $user_id = $user_object->ID;
596 $has_pending_invite = self::has_pending_wpcom_invite( $user_id );
597
598 if ( current_user_can( 'promote_users' ) && $has_pending_invite ) {
599 $nonce = wp_create_nonce( 'jetpack-sso-revoke-user-invite' );
600 $actions['sso_revoke_invite'] = sprintf(
601 '<a class="jetpack-sso-revoke-invite-action" href="%s">%s</a>',
602 add_query_arg(
603 array(
604 'action' => 'jetpack_revoke_invite_user_to_wpcom',
605 'user_id' => $user_id,
606 'revoke_invite_nonce' => $nonce,
607 'invite_id' => $has_pending_invite,
608 ),
609 admin_url( 'admin-post.php' )
610 ),
611 esc_html__( 'Revoke invite', 'jetpack-connection' )
612 );
613 }
614 if ( current_user_can( 'promote_users' ) && $has_pending_invite ) {
615 $nonce = wp_create_nonce( 'jetpack-sso-resend-user-invite' );
616 $actions['sso_resend_invite'] = sprintf(
617 '<a class="jetpack-sso-resend-invite-action" href="%s">%s</a>',
618 add_query_arg(
619 array(
620 'action' => 'jetpack_resend_invite_user_to_wpcom',
621 'user_id' => $user_id,
622 'resend_invite_nonce' => $nonce,
623 'invite_id' => $has_pending_invite,
624 ),
625 admin_url( 'admin-post.php' )
626 ),
627 esc_html__( 'Resend invite', 'jetpack-connection' )
628 );
629 }
630
631 if (
632 current_user_can( 'promote_users' )
633 && (
634 $has_pending_invite
635 || ( new Manager() )->is_user_connected( $user_id )
636 )
637 ) {
638 unset( $actions['resetpassword'] );
639 }
640
641 return $actions;
642 }
643
644 /**
645 * Render the invitation email message.
646 */
647 public function render_invitation_email_message() {
648 $message = wp_kses(
649 __(
650 'We highly recommend inviting users to join WordPress.com and log in securely using <a class="jetpack-sso-admin-create-user-invite-message-link-sso" rel="noopener noreferrer" target="_blank" href="https://jetpack.com/support/sso/">Secure Sign On</a> to ensure maximum security and efficiency.',
651 'jetpack-connection'
652 ),
653 array(
654 'a' => array(
655 'class' => array(),
656 'href' => array(),
657 'rel' => array(),
658 'target' => array(),
659 ),
660 )
661 );
662 wp_admin_notice(
663 $message,
664 array(
665 'id' => 'invitation_message',
666 'type' => 'info',
667 'dismissible' => false,
668 'additional_classes' => array( 'jetpack-sso-admin-create-user-invite-message' ),
669 )
670 );
671 }
672
673 /**
674 * Render a note that wp.com invites will be automatically revoked.
675 */
676 public function render_invitations_notices_for_deleted_users() {
677 check_admin_referer( 'bulk-users' );
678
679 // When one user is deleted, the param is `user`, when multiple users are deleted, the param is `users`.
680 // We start with `users` and fallback to `user`.
681 $user_id = isset( $_GET['user'] ) ? intval( wp_unslash( $_GET['user'] ) ) : null;
682 $user_ids = isset( $_GET['users'] ) ? array_map( 'intval', wp_unslash( $_GET['users'] ) ) : array( $user_id );
683
684 $users_with_invites = array_filter(
685 $user_ids,
686 function ( $user_id ) {
687 return $user_id !== null && self::has_pending_wpcom_invite( $user_id );
688 }
689 );
690
691 $users_with_invites = array_map(
692 function ( $user_id ) {
693 $user = get_user_by( 'id', $user_id );
694 return $user->user_login;
695 },
696 $users_with_invites
697 );
698
699 $invites_count = count( $users_with_invites );
700 if ( $invites_count > 0 ) {
701 $users_with_invites = implode( ', ', $users_with_invites );
702 $message = wp_kses(
703 sprintf(
704 /* translators: %s is a comma-separated list of user logins. */
705 _n(
706 'WordPress.com invitation will be automatically revoked for user: <strong>%s</strong>.',
707 'WordPress.com invitations will be automatically revoked for users: <strong>%s</strong>.',
708 $invites_count,
709 'jetpack-connection'
710 ),
711 $users_with_invites
712 ),
713 array( 'strong' => true )
714 );
715 wp_admin_notice(
716 $message,
717 array(
718 'id' => 'invitation_message',
719 'type' => 'info',
720 'dismissible' => false,
721 'additional_classes' => array( 'jetpack-sso-admin-create-user-invite-message' ),
722 )
723 );
724 }
725 }
726
727 /**
728 * Render WordPress.com invite checkbox for new user registration.
729 *
730 * @param string $type The type of new user form the hook follows.
731 */
732 public function render_wpcom_invite_checkbox( $type ) {
733 /*
734 * Only check this box by default on WordPress.com sites
735 * that do not use the WooCommerce plugin.
736 */
737 $is_checked = ( new Host() )->is_wpcom_platform() && ! class_exists( 'WooCommerce' );
738
739 if ( $type === 'add-new-user' ) {
740 ?>
741 <table class="form-table">
742 <tr class="form-field">
743 <th scope="row">
744 <label for="invite_user_wpcom"><?php esc_html_e( 'Invite user', 'jetpack-connection' ); ?></label>
745 </th>
746 <td>
747 <fieldset>
748 <legend class="screen-reader-text">
749 <span><?php esc_html_e( 'Invite user', 'jetpack-connection' ); ?></span>
750 </legend>
751 <label for="invite_user_wpcom">
752 <input
753 name="invite_user_wpcom"
754 type="checkbox"
755 id="invite_user_wpcom"
756 <?php checked( $is_checked ); ?>
757 >
758 <?php esc_html_e( 'Invite user to WordPress.com', 'jetpack-connection' ); ?>
759 </label>
760 </fieldset>
761 </td>
762 </tr>
763 </table>
764 <?php
765 }
766 }
767
768 /**
769 * Render a checkbox to differentiate if a user is external.
770 *
771 * @param string $type The type of new user form the hook follows.
772 */
773 public function render_wpcom_external_user_checkbox( $type ) {
774 // Only enable this feature on WordPress.com sites.
775 if ( ! ( new Host() )->is_wpcom_platform() ) {
776 return;
777 }
778
779 if ( $type === 'add-new-user' ) {
780 ?>
781 <table class="form-table">
782 <tr class="form-field">
783 <th scope="row">
784 <label for="user_external_contractor"><?php esc_html_e( 'External User', 'jetpack-connection' ); ?></label>
785 </th>
786 <td>
787 <fieldset>
788 <legend class="screen-reader-text">
789 <span><?php esc_html_e( 'Invite user', 'jetpack-connection' ); ?></span>
790 </legend>
791 <label for="user_external_contractor">
792 <input
793 name="user_external_contractor"
794 type="checkbox"
795 id="user_external_contractor"
796 >
797 <?php esc_html_e( 'Mark as external collaborator', 'jetpack-connection' ); ?>
798 </label>
799 </fieldset>
800 </td>
801 </tr>
802 </table>
803 <?php
804 }
805 }
806
807 /**
808 * Render the custom email message form field for new user registration.
809 *
810 * @param string $type The type of new user form the hook follows.
811 */
812 public function render_custom_email_message_form_field( $type ) {
813 if ( $type === 'add-new-user' ) {
814 $valid_nonce = isset( $_POST['_wpnonce_create-user'] )
815 ? wp_verify_nonce( sanitize_key( $_POST['_wpnonce_create-user'] ), 'create-user' )
816 : false;
817 $custom_email_message = ( $valid_nonce && isset( $_POST['custom_email_message'] ) ) ? sanitize_text_field( wp_unslash( $_POST['custom_email_message'] ) ) : '';
818 ?>
819 <table class="form-table" id="custom_email_message_block">
820 <tr class="form-field">
821 <th scope="row">
822 <label for="custom_email_message"><?php esc_html_e( 'Custom Message', 'jetpack-connection' ); ?></label>
823 </th>
824 <td>
825 <label for="custom_email_message">
826 <textarea aria-describedby="custom_email_message_description" rows="3" maxlength="500" id="custom_email_message" name="custom_email_message"><?php echo esc_html( $custom_email_message ); ?></textarea>
827 <p id="custom_email_message_description">
828 <?php
829 esc_html_e( 'This user will be invited to WordPress.com. You can include a personalized welcome message with the invitation.', 'jetpack-connection' );
830 ?>
831 </label>
832 </td>
833 </tr>
834 </table>
835 <?php
836 }
837 }
838
839 /**
840 * Conditionally disable the core invitation email.
841 * It should be sent when SSO is disabled or when admins opt-out of WordPress.com invites intentionally.
842 * If the "Send User Notification" checkbox is checked, the core invitation email should be sent.
843 *
844 * @param boolean $send_wp_email Whether the core invitation email should be sent.
845 *
846 * @return boolean Indicating if the core invitation main should be sent.
847 */
848 public function should_send_wp_mail_new_user( $send_wp_email ) {
849 if ( ! isset( $_POST['invite_user_wpcom'] ) && isset( $_POST['send_user_notification'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Hooked to 'wp_send_new_user_notification_to_user' to conditionally disable the core invitation email. At this point nonces should be checked already.
850 return $send_wp_email;
851 }
852 return false;
853 }
854
855 /**
856 * Send user invitation to WordPress.com if user has no errors.
857 *
858 * @param WP_Error $errors The WP_Error object.
859 * @param bool $update Whether the user is being updated or not.
860 * @param \stdClass $user The User object about to be created.
861 * @return WP_Error The modified or not WP_Error object.
862 */
863 public function send_wpcom_mail_user_invite( $errors, $update, $user ) {
864 // Only admins should be able to invite new users.
865 if ( ! current_user_can( 'create_users' ) ) {
866 return $errors;
867 }
868
869 if ( $update ) {
870 return $errors;
871 }
872
873 // check for a valid nonce.
874 if (
875 ! isset( $_POST['_wpnonce_create-user'] )
876 || ! wp_verify_nonce( sanitize_key( $_POST['_wpnonce_create-user'] ), 'create-user' )
877 ) {
878 return $errors;
879 }
880
881 // Check if the user is being invited to WordPress.com.
882 if ( ! isset( $_POST['invite_user_wpcom'] ) ) {
883 return $errors;
884 }
885
886 // check if the custom email message is too long.
887 if (
888 ! empty( $_POST['custom_email_message'] )
889 && strlen( sanitize_text_field( wp_unslash( $_POST['custom_email_message'] ) ) ) > 500
890 ) {
891 $errors->add(
892 'custom_email_message',
893 wp_kses(
894 __( '<strong>Error</strong>: The custom message is too long. Please keep it under 500 characters.', 'jetpack-connection' ),
895 array(
896 'strong' => array(),
897 )
898 )
899 );
900 }
901
902 $site_id = Manager::get_site_id( true );
903 if ( ! $site_id ) {
904 $errors->add(
905 'invalid_site_id',
906 wp_kses(
907 __( '<strong>Error</strong>: Invalid site ID.', 'jetpack-connection' ),
908 array(
909 'strong' => array(),
910 )
911 )
912 );
913 }
914
915 // Bail if there are any errors.
916 if ( $errors->has_errors() ) {
917 return $errors;
918 }
919
920 $new_user_request = array(
921 'email_or_username' => sanitize_email( $user->user_email ),
922 'role' => sanitize_key( $user->role ),
923 );
924
925 if (
926 isset( $_POST['custom_email_message'] )
927 && strlen( sanitize_text_field( wp_unslash( $_POST['custom_email_message'] ) ) ) > 0
928 ) {
929 $new_user_request['message'] = sanitize_text_field( wp_unslash( $_POST['custom_email_message'] ) );
930 }
931
932 if ( isset( $_POST['user_external_contractor'] ) ) {
933 $new_user_request['is_external'] = true;
934 }
935
936 $response = Client::wpcom_json_api_request_as_user(
937 sprintf(
938 '/sites/%d/invites/new',
939 (int) $site_id
940 ),
941 '2', // Api version
942 array(
943 'method' => 'POST',
944 ),
945 array(
946 'invitees' => array( $new_user_request ),
947 )
948 );
949
950 $event_name = 'sso_new_user_invite_sent';
951 $custom_message_sent = isset( $new_user_request['message'] ) ? 'true' : 'false';
952
953 if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
954 $errors->add(
955 'invitation_not_sent',
956 wp_kses(
957 __( '<strong>Error</strong>: The user invitation email could not be sent, the user account was not created.', 'jetpack-connection' ),
958 array(
959 'strong' => array(),
960 )
961 )
962 );
963 self::$tracking->record_user_event(
964 $event_name,
965 array(
966 'success' => 'false',
967 'error' => wp_remote_retrieve_body( $response ), // Get as much information as possible.
968 )
969 );
970 } else {
971 self::$tracking->record_user_event(
972 $event_name,
973 array(
974 'success' => 'true',
975 'custom_message_sent' => $custom_message_sent,
976 )
977 );
978 }
979
980 return $errors;
981 }
982
983 /**
984 * Executed when our WP_User_Query instance is set, and we don't have cached invites.
985 * This function uses the user emails and the 'are-users-invited' endpoint to build the cache.
986 *
987 * @return void
988 */
989 private static function rebuild_invite_cache() {
990 $blog_id = Manager::get_site_id( true );
991
992 if ( self::$cached_invites === null && self::$user_search !== null ) {
993
994 self::$cached_invites = array();
995
996 $results = self::$user_search->get_results();
997
998 $user_emails = array_reduce(
999 $results,
1000 function ( $current, $item ) {
1001 if ( ! ( new Manager() )->is_user_connected( $item->ID ) ) {
1002 $current[] = rawurlencode( $item->user_email );
1003 } else {
1004 self::$cached_invites[] = array(
1005 'email_or_username' => $item->user_email,
1006 'invited' => false,
1007 'invite_code' => '',
1008 );
1009 }
1010 return $current;
1011 },
1012 array()
1013 );
1014
1015 if ( ! empty( $user_emails ) ) {
1016 $url = '/sites/' . $blog_id . '/invites/are-users-invited';
1017
1018 $response = Client::wpcom_json_api_request_as_user(
1019 $url,
1020 'v2',
1021 array(
1022 'method' => 'POST',
1023 ),
1024 array( 'users' => $user_emails ),
1025 'wpcom'
1026 );
1027
1028 if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
1029 $body = json_decode( $response['body'], true );
1030
1031 // ensure array_merge happens with the right parameters
1032 if ( empty( $body ) ) {
1033 $body = array();
1034 }
1035
1036 self::$cached_invites = array_merge( self::$cached_invites, $body );
1037 }
1038 }
1039 }
1040 }
1041
1042 /**
1043 * Check if there is cached invite for a user email.
1044 *
1045 * @access private
1046 * @static
1047 *
1048 * @param string $email The user email.
1049 *
1050 * @return array|void Returns the cached invite if found.
1051 */
1052 public static function get_pending_cached_wpcom_invite( $email ) {
1053 if ( self::$cached_invites === null ) {
1054 self::rebuild_invite_cache();
1055 }
1056
1057 if ( ! empty( self::$cached_invites ) && is_array( self::$cached_invites ) ) {
1058 $index = array_search( $email, array_column( self::$cached_invites, 'email_or_username' ), true );
1059 if ( $index !== false ) {
1060 return self::$cached_invites[ $index ];
1061 }
1062 }
1063 }
1064
1065 /**
1066 * Check if a given user is invited to the site.
1067 *
1068 * @access private
1069 * @static
1070 * @param int $user_id The user ID.
1071 *
1072 * @return false|string returns the user invite code if the user is invited, false otherwise.
1073 */
1074 private static function has_pending_wpcom_invite( $user_id ) {
1075 $user = get_user_by( 'id', $user_id );
1076 if ( ! $user instanceof \WP_User ) {
1077 return false;
1078 }
1079
1080 $blog_id = Manager::get_site_id( true );
1081 $cached_invite = self::get_pending_cached_wpcom_invite( $user->user_email );
1082
1083 if ( $cached_invite ) {
1084 return $cached_invite['invite_code'];
1085 }
1086
1087 $url = '/sites/' . $blog_id . '/invites/is-invited';
1088 $url = add_query_arg(
1089 array(
1090 'email_or_username' => rawurlencode( $user->user_email ),
1091 ),
1092 $url
1093 );
1094 $response = Client::wpcom_json_api_request_as_user(
1095 $url,
1096 'v2',
1097 array(),
1098 null,
1099 'wpcom'
1100 );
1101
1102 if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
1103 return false;
1104 }
1105
1106 $body_response = wp_remote_retrieve_body( $response );
1107 if ( empty( $body_response ) ) {
1108 return false;
1109 }
1110
1111 $body = json_decode( $body_response, true );
1112 if ( ! empty( $body['invite_code'] ) ) {
1113 return $body['invite_code'];
1114 }
1115
1116 return false;
1117 }
1118
1119 /**
1120 * Delete an external contributor from the site.
1121 *
1122 * @access private
1123 * @static
1124 * @param int $user_id The user ID.
1125 *
1126 * @return bool Returns true if the user was successfully deleted, false otherwise.
1127 */
1128 private static function delete_external_contributor( $user_id ) {
1129 $blog_id = Manager::get_site_id( true );
1130 $url = '/sites/' . $blog_id . '/external-contributors/remove';
1131 $response = Client::wpcom_json_api_request_as_user(
1132 $url,
1133 'v2',
1134 array(
1135 'method' => 'POST',
1136 ),
1137 array(
1138 'user_id' => $user_id,
1139 ),
1140 'wpcom'
1141 );
1142
1143 if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
1144 return false;
1145 }
1146
1147 return true;
1148 }
1149
1150 /**
1151 * Show Jetpack SSO user connection status.
1152 *
1153 * @param string $val HTML for the column.
1154 * @param string $col User list table column.
1155 * @param int $user_id User ID.
1156 * @return string Modified column content.
1157 */
1158 public function jetpack_show_connection_status( $val, $col, $user_id ) {
1159 if ( 'user_jetpack' !== $col ) {
1160 return $val;
1161 }
1162
1163 // Get base connection status from parent
1164 $connection_status = parent::render_connection_column( '', $col, $user_id );
1165
1166 // If user is not connected, check for pending invite
1167 if ( ! $connection_status ) {
1168 $has_pending_invite = self::has_pending_wpcom_invite( $user_id );
1169 if ( $has_pending_invite ) {
1170 return sprintf(
1171 '<span title="%1$s" class="jetpack-sso-invitation sso-pending-invite">%2$s</span>',
1172 esc_attr__( 'This user didn&#8217;t accept the invitation to join this site yet.', 'jetpack-connection' ),
1173 esc_html__( 'Pending invite', 'jetpack-connection' )
1174 );
1175 }
1176
1177 // Show invite button for non-connected users
1178 $nonce = wp_create_nonce( 'jetpack-sso-invite-user' );
1179 return sprintf(
1180 '<span tabindex="0" role="tooltip" aria-label="%4$s: %3$s" class="jetpack-sso-invitation-tooltip-icon sso-disconnected-user">
1181 <span class="sso-disconnected-user-icon dashicons dashicons-warning">
1182 <span class="jetpack-sso-invitation-tooltip jetpack-sso-td-tooltip">%3$s</span>
1183 </span>
1184 <a href="%1$s" class="jetpack-sso-invitation sso-disconnected-user">%2$s</a>
1185 </span>',
1186 add_query_arg(
1187 array(
1188 'user_id' => $user_id,
1189 'invite_nonce' => $nonce,
1190 'action' => 'jetpack_invite_user_to_wpcom',
1191 ),
1192 admin_url( 'admin-post.php' )
1193 ),
1194 esc_html__( 'Send invite', 'jetpack-connection' ),
1195 esc_attr__( 'This user doesn&#8217;t have a Jetpack SSO connection to WordPress.com. Invite them to the site to increase security and improve their experience.', 'jetpack-connection' ),
1196 esc_attr__( 'Tooltip', 'jetpack-connection' )
1197 );
1198 }
1199
1200 return $connection_status;
1201 }
1202
1203 /**
1204 * Creates error notices and redirects the user to the previous page.
1205 *
1206 * @param array $query_params - query parameters added to redirection URL.
1207 * @phan-suppress PhanPluginNeverReturnMethod
1208 */
1209 public function create_error_notice_and_redirect( $query_params ) {
1210 $ref = wp_get_referer();
1211 if ( empty( $ref ) ) {
1212 $ref = network_admin_url( 'users.php' );
1213 }
1214
1215 $url = add_query_arg(
1216 $query_params,
1217 $ref
1218 );
1219 wp_safe_redirect( $url );
1220 exit;
1221 }
1222
1223 /**
1224 * Enqueue the styles for the Jetpack user rows and columns.
1225 */
1226 public function jetpack_user_table_styles() {
1227 $handle = 'jetpack-sso-users-styles';
1228
1229 // No src: the handle only carries the inline CSS below.
1230 wp_register_style( $handle, false, array(), Package_Version::PACKAGE_VERSION );
1231 wp_enqueue_style( $handle );
1232
1233 $css = <<<'CSS'
1234 #the-list tr:has(.sso-disconnected-user) {
1235 background: #F5F1E1;
1236 }
1237
1238 #the-list tr:has(.sso-pending-invite) {
1239 background: #E9F0F5;
1240 }
1241
1242 .jetpack-sso-invitation {
1243 background: none;
1244 border: none;
1245 color: #50575e;
1246 padding: 0;
1247 text-align: unset;
1248 }
1249
1250 .jetpack-sso-invitation.sso-disconnected-user {
1251 color: #0073aa;
1252 cursor: pointer;
1253 text-decoration: underline;
1254 }
1255
1256 .jetpack-sso-invitation.sso-disconnected-user:hover,
1257 .jetpack-sso-invitation.sso-disconnected-user:focus,
1258 .jetpack-sso-invitation.sso-disconnected-user:active {
1259 color: #0096dd;
1260 }
1261
1262 .sso-disconnected-user-icon {
1263 cursor: pointer;
1264 background: gray;
1265 border-radius: 10px;
1266 }
1267
1268 .sso-disconnected-user-icon.dashicons {
1269 font-size: 1rem;
1270 height: 1rem;
1271 width: 1rem;
1272 background-color: #9D6E00;
1273 color: #F5F1E1;
1274 }
1275
1276 .jetpack-sso-invitation-tooltip-icon {
1277 position: relative;
1278 cursor: pointer;
1279 display: inline-flex;
1280 align-items: center;
1281 column-gap: 6px;
1282 }
1283
1284 .jetpack-sso-td-tooltip {
1285 left: -256px;
1286 }
1287
1288 .jetpack-sso-invitation-tooltip {
1289 position: absolute;
1290 background: #f6f7f7;
1291 top: -85px;
1292 width: 250px;
1293 padding: 7px;
1294 color: #3c434a;
1295 font-size: .75rem;
1296 line-height: 17px;
1297 text-align: left;
1298 margin: 0;
1299 display: none;
1300 border-radius: 4px;
1301 font-family: sans-serif;
1302 box-shadow: 5px 10px 10px rgba(0, 0, 0, 0.1);
1303 }
1304 CSS;
1305
1306 wp_add_inline_style( $handle, $css );
1307 }
1308
1309 /**
1310 * Enqueue SSO-specific scripts.
1311 *
1312 * @param string $hook The current admin page.
1313 */
1314 public function enqueue_scripts( $hook ) {
1315 if ( 'users.php' !== $hook ) {
1316 return;
1317 }
1318
1319 parent::enqueue_scripts( $hook );
1320
1321 $this->jetpack_user_table_styles();
1322
1323 // Enqueue the SSO users script.
1324 Assets::register_script(
1325 'jetpack-sso-users',
1326 '../../dist/jetpack-sso-users.js',
1327 __FILE__,
1328 array(
1329 'strategy' => 'defer',
1330 'in_footer' => true,
1331 'enqueue' => true,
1332 'version' => Package_Version::PACKAGE_VERSION,
1333 )
1334 );
1335 }
1336 }
1337