diff --git a/projects/plugins/jetpack/changelog/Add column tooltip b/projects/plugins/jetpack/changelog/Add column tooltip new file mode 100644 index 0000000000000..eaf8f0319d40b --- /dev/null +++ b/projects/plugins/jetpack/changelog/Add column tooltip @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Rename status column to sso status and add tooltip diff --git a/projects/plugins/jetpack/changelog/Improve column b/projects/plugins/jetpack/changelog/Improve column new file mode 100644 index 0000000000000..a31f898fa711a --- /dev/null +++ b/projects/plugins/jetpack/changelog/Improve column @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Improve column by renaming and adding icon diff --git a/projects/plugins/jetpack/changelog/Improve question mark icon b/projects/plugins/jetpack/changelog/Improve question mark icon new file mode 100644 index 0000000000000..48020c9692aab --- /dev/null +++ b/projects/plugins/jetpack/changelog/Improve question mark icon @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Replace question mark icon diff --git a/projects/plugins/jetpack/changelog/add-new-user-additional-email-message b/projects/plugins/jetpack/changelog/add-new-user-additional-email-message new file mode 100644 index 0000000000000..88b9200284912 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-new-user-additional-email-message @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Added custom message textarea to send a message via email when adding new users diff --git a/projects/plugins/jetpack/changelog/add-sso-cached-invites b/projects/plugins/jetpack/changelog/add-sso-cached-invites new file mode 100644 index 0000000000000..bafd1b5be8879 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-sso-cached-invites @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Add caching for user invites diff --git a/projects/plugins/jetpack/changelog/add-sso-move-to-admin-class b/projects/plugins/jetpack/changelog/add-sso-move-to-admin-class new file mode 100644 index 0000000000000..db0210bf486df --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-sso-move-to-admin-class @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Move user customization to seperate file diff --git a/projects/plugins/jetpack/changelog/add-sso-users-table-revoke-invite b/projects/plugins/jetpack/changelog/add-sso-users-table-revoke-invite new file mode 100644 index 0000000000000..c27ee599f5211 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-sso-users-table-revoke-invite @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +SSO: Add user invite revoke row action in users table diff --git a/projects/plugins/jetpack/changelog/fix-registration-user-invite b/projects/plugins/jetpack/changelog/fix-registration-user-invite new file mode 100644 index 0000000000000..fd0944f49ab9c --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-registration-user-invite @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Trigger user invitation for new users diff --git a/projects/plugins/jetpack/changelog/fix-translations b/projects/plugins/jetpack/changelog/fix-translations new file mode 100644 index 0000000000000..da549f7ebc073 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-translations @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Fix message translation content diff --git a/projects/plugins/jetpack/changelog/improve-binding-local-remote-users b/projects/plugins/jetpack/changelog/improve-binding-local-remote-users new file mode 100644 index 0000000000000..0c66cb9580a7b --- /dev/null +++ b/projects/plugins/jetpack/changelog/improve-binding-local-remote-users @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +SSO: improve messaging and account binding between local and wp.com users diff --git a/projects/plugins/jetpack/changelog/improve_invite_form b/projects/plugins/jetpack/changelog/improve_invite_form new file mode 100644 index 0000000000000..070491429e8f6 --- /dev/null +++ b/projects/plugins/jetpack/changelog/improve_invite_form @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +SSO: When creating a new users, mail the users with an invitation to WPCom. diff --git a/projects/plugins/jetpack/changelog/revoke-deleted-users b/projects/plugins/jetpack/changelog/revoke-deleted-users new file mode 100644 index 0000000000000..39682160b4ddd --- /dev/null +++ b/projects/plugins/jetpack/changelog/revoke-deleted-users @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +SSO: revoke invites sent to users upon users deletion diff --git a/projects/plugins/jetpack/changelog/update-improve-users-table b/projects/plugins/jetpack/changelog/update-improve-users-table new file mode 100644 index 0000000000000..df3e90478e918 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-improve-users-table @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +SSO: Updated column heading and row background color when invitation is pending. diff --git a/projects/plugins/jetpack/changelog/update-user-new-save-form-data b/projects/plugins/jetpack/changelog/update-user-new-save-form-data new file mode 100644 index 0000000000000..41ad15adbcb42 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-user-new-save-form-data @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Persist user-new.php custom message form field after submission with errors diff --git a/projects/plugins/jetpack/changelog/update-users-table-sso-background-colors b/projects/plugins/jetpack/changelog/update-users-table-sso-background-colors new file mode 100644 index 0000000000000..336db0aa5a0e9 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-users-table-sso-background-colors @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Update users table ssorow background colors diff --git a/projects/plugins/jetpack/class.jetpack.php b/projects/plugins/jetpack/class.jetpack.php index ed3f0cd155c05..7750ec98e9c36 100644 --- a/projects/plugins/jetpack/class.jetpack.php +++ b/projects/plugins/jetpack/class.jetpack.php @@ -8,7 +8,6 @@ */ use Automattic\Jetpack\Assets; -use Automattic\Jetpack\Assets\Logo as Jetpack_Logo; use Automattic\Jetpack\Boost_Speed_Score\Speed_Score; use Automattic\Jetpack\Config; use Automattic\Jetpack\Connection\Client; @@ -3363,11 +3362,6 @@ public function admin_init() { // Artificially throw errors in certain specific cases during plugin activation. add_action( 'activate_plugin', array( $this, 'throw_error_on_activate_plugin' ) ); } - - // Add custom column in wp-admin/users.php to show whether user is linked. - add_filter( 'manage_users_columns', array( $this, 'jetpack_icon_user_connected' ) ); - add_action( 'manage_users_custom_column', array( $this, 'jetpack_show_user_connected_icon' ), 10, 3 ); - add_action( 'admin_print_styles', array( $this, 'jetpack_user_col_style' ) ); } /** @@ -6410,65 +6404,6 @@ public function get_user_option_meta_box_order_dashboard( $sorted ) { return $sorted; } - - /** - * Adds a "blank" column in the user admin table to display indication of user connection. - * - * @param array $columns User list table columns. - * - * @return array - */ - public function jetpack_icon_user_connected( $columns ) { - $columns['user_jetpack'] = ''; - return $columns; - } - - /** - * Show Jetpack icon if the user is linked. - * - * @param string $val HTML for the icon. - * @param string $col User list table column. - * @param int $user_id User ID. - * - * @return string - */ - public function jetpack_show_user_connected_icon( $val, $col, $user_id ) { - if ( 'user_jetpack' === $col && self::connection()->is_user_connected( $user_id ) ) { - $jetpack_logo = new Jetpack_Logo(); - $emblem_html = sprintf( - '%2$s', - esc_attr__( 'This user is linked and ready to fly with Jetpack.', 'jetpack' ), - $jetpack_logo->get_jp_emblem() - ); - return $emblem_html; - } - - return $val; - } - - /** - * Style the Jetpack user column - */ - public function jetpack_user_col_style() { - global $current_screen; - if ( ! empty( $current_screen->base ) && 'users' === $current_screen->base ) { - ?> - - errors['loggedout'] ) ) { $logout_message = wp_kses( sprintf( - /* translators: %1$s is a link to the WordPress.com account settings page. */ + /* translators: %1$s is a link to the WordPress.com account settings page. */ __( 'If you are on a shared computer, remember to also log out of WordPress.com.', 'jetpack' ), 'https://wordpress.com/me' ), @@ -143,7 +145,7 @@ public function maybe_logout_user() { if ( 1 === (int) $current_user->jetpack_force_logout ) { delete_user_meta( $current_user->ID, 'jetpack_force_logout' ); - self::delete_connection_for_user( $current_user->ID ); + Jetpack_SSO_Helpers::delete_connection_for_user( $current_user->ID ); wp_logout(); wp_safe_redirect( wp_login_url() ); exit; @@ -180,7 +182,7 @@ public function xmlrpc_user_disconnect( $user_id ) { if ( $user instanceof WP_User ) { $user = wp_set_current_user( $user->ID ); update_user_meta( $user->ID, 'jetpack_force_logout', '1' ); - self::delete_connection_for_user( $user->ID ); + Jetpack_SSO_Helpers::delete_connection_for_user( $user->ID ); return true; } return false; @@ -320,10 +322,10 @@ public function render_require_two_step() { - + + > - + - + + > - +
- +

- %s', 'jetpack' ), esc_html( $display_name ) ), - array( 'span' => true ) - ); - ?> + %s', 'jetpack' ), esc_html( $display_name ) ), + array( 'span' => true ) + ); + ?>

- +
- build_sso_button( array(), 'is_primary' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaping done in build_sso_button() ?> + build_sso_button( array(), 'is_primary' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaping done in build_sso_button() ?> - + @@ -610,18 +612,18 @@ public function login_form() {
- +
@@ -637,39 +639,9 @@ public function login_form() { esc_html_e( 'Log in with WordPress.com', 'jetpack' ) ?> - +
- is_user_connected() ) { - static::delete_connection_for_user( get_current_user_id() ); + Jetpack_SSO_Helpers::delete_connection_for_user( get_current_user_id() ); } } - /** - * Remove an SSO connection for a user. - * - * @param int $user_id The local user id. - */ - public static function delete_connection_for_user( $user_id ) { - $wpcom_user_id = get_user_meta( $user_id, 'wpcom_user_id', true ); - if ( ! $wpcom_user_id ) { - return; - } - - $xml = new Jetpack_IXR_Client( - array( - 'wpcom_user_id' => $user_id, - ) - ); - $xml->query( 'jetpack.sso.removeUser', $wpcom_user_id ); - - if ( $xml->isError() ) { - return false; - } - - // Clean up local data stored for SSO. - delete_user_meta( $user_id, 'wpcom_user_id' ); - delete_user_meta( $user_id, 'wpcom_user_data' ); - self::clear_wpcom_profile_cookies(); - - return $xml->getResponse(); - } - /** * Retrieves nonce used for SSO form. */ public static function request_initial_nonce() { $nonce = ! empty( $_COOKIE['jetpack_sso_nonce'] ) - ? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) ) - : false; + ? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) ) + : false; if ( ! $nonce ) { $xml = new Jetpack_IXR_Client(); @@ -904,8 +846,8 @@ public function handle_login() { } $user_found_with = $new_user_override_role - ? 'user_created_new_user_override' - : 'user_created_users_can_register'; + ? 'user_created_new_user_override' + : 'user_created_users_can_register'; } else { $tracking->record_user_event( 'sso_login_failed', @@ -991,7 +933,7 @@ public function handle_login() { add_filter( 'allowed_redirect_hosts', array( 'Jetpack_SSO_Helpers', 'allowed_redirect_hosts' ) ); wp_safe_redirect( - /** This filter is documented in core/src/wp-login.php */ + /** This filter is documented in core/src/wp-login.php */ apply_filters( 'login_redirect', $redirect_to, $_request_redirect_to, $user ) ); exit; @@ -1016,7 +958,7 @@ public function handle_login() { } /** - * Retreive the admin profile page URL. + * Retrieve the admin profile page URL. */ public static function profile_page_url() { return admin_url( 'profile.php' ); @@ -1032,8 +974,8 @@ public static function profile_page_url() { public function build_sso_button( $args = array(), $is_primary = false ) { $url = $this->build_sso_button_url( $args ); $classes = $is_primary - ? 'jetpack-sso button button-primary' - : 'jetpack-sso button'; + ? 'jetpack-sso button button-primary' + : 'jetpack-sso button'; return sprintf( '%3$s %4$s', @@ -1080,7 +1022,7 @@ public function get_sso_url_or_die( $reauth = false, $args = array() ) { if ( empty( $reauth ) ) { $sso_redirect = $this->build_sso_url( $args ); } else { - self::clear_wpcom_profile_cookies(); + Jetpack_SSO_Helpers::clear_wpcom_profile_cookies(); $sso_redirect = $this->build_reauth_and_sso_url( $args ); } diff --git a/projects/plugins/jetpack/modules/sso/class.jetpack-sso-helpers.php b/projects/plugins/jetpack/modules/sso/class.jetpack-sso-helpers.php index d2e3053754122..24ffd1a3b9f71 100644 --- a/projects/plugins/jetpack/modules/sso/class.jetpack-sso-helpers.php +++ b/projects/plugins/jetpack/modules/sso/class.jetpack-sso-helpers.php @@ -366,6 +366,66 @@ public static function get_custom_login_url() { // Extracting the "path" part of the URL, because we don't need the `site_url` part. return str_ireplace( $site_url, '', $login_url ); } + + /** + * Clear the cookies that store the profile information for the last + * WPCOM user to connect. + */ + public static function clear_wpcom_profile_cookies() { + if ( isset( $_COOKIE[ 'jetpack_sso_wpcom_name_' . COOKIEHASH ] ) ) { + setcookie( + 'jetpack_sso_wpcom_name_' . COOKIEHASH, + ' ', + time() - YEAR_IN_SECONDS, + COOKIEPATH, + COOKIE_DOMAIN, + is_ssl(), + true + ); + } + + if ( isset( $_COOKIE[ 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH ] ) ) { + setcookie( + 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH, + ' ', + time() - YEAR_IN_SECONDS, + COOKIEPATH, + COOKIE_DOMAIN, + is_ssl(), + true + ); + } + } + + /** + * Remove an SSO connection for a user. + * + * @param int $user_id The local user id. + */ + public static function delete_connection_for_user( $user_id ) { + $wpcom_user_id = get_user_meta( $user_id, 'wpcom_user_id', true ); + if ( ! $wpcom_user_id ) { + return; + } + + $xml = new Jetpack_IXR_Client( + array( + 'wpcom_user_id' => $user_id, + ) + ); + $xml->query( 'jetpack.sso.removeUser', $wpcom_user_id ); + + if ( $xml->isError() ) { + return false; + } + + // Clean up local data stored for SSO. + delete_user_meta( $user_id, 'wpcom_user_id' ); + delete_user_meta( $user_id, 'wpcom_user_data' ); + self::clear_wpcom_profile_cookies(); + + return $xml->getResponse(); + } } endif; diff --git a/projects/plugins/jetpack/modules/sso/class.jetpack-sso-user-admin.php b/projects/plugins/jetpack/modules/sso/class.jetpack-sso-user-admin.php new file mode 100644 index 0000000000000..63fae88071697 --- /dev/null +++ b/projects/plugins/jetpack/modules/sso/class.jetpack-sso-user-admin.php @@ -0,0 +1,722 @@ + 'success' ) ); + } + + if ( $_GET['jetpack-sso-invite-user'] === 'successful-revoke' ) { + return wp_admin_notice( __( 'User invite revoked successfully.', 'jetpack' ), array( 'type' => 'success' ) ); + } + + if ( $_GET['jetpack-sso-invite-user'] === 'failed' && isset( $_GET['jetpack-sso-invite-error'] ) ) { + switch ( $_GET['jetpack-sso-invite-error'] ) { + case 'invalid-user': + return wp_admin_notice( __( 'Tried to invite a user that doesn’t exist.', 'jetpack' ), array( 'type' => 'error' ) ); + case 'invalid-email': + return wp_admin_notice( __( 'Tried to invite a user that doesn’t have an email address.', 'jetpack' ), array( 'type' => 'error' ) ); + case 'invalid-user-permissions': + return wp_admin_notice( __( 'You don’t have permission to invite users.', 'jetpack' ), array( 'type' => 'error' ) ); + case 'invalid-user-revoke': + return wp_admin_notice( __( 'Tried to revoke an invite for a user that doesn’t exist.', 'jetpack' ), array( 'type' => 'error' ) ); + case 'invalid-invite-revoke': + return wp_admin_notice( __( 'Tried to revoke an invite that doesn’t exist.', 'jetpack' ), array( 'type' => 'error' ) ); + case 'invalid-revoke-permissions': + return wp_admin_notice( __( 'You don’t have permission to revoke invites.', 'jetpack' ), array( 'type' => 'error' ) ); + default: + return wp_admin_notice( __( 'An error has occurred when inviting the user to the site.', 'jetpack' ), array( 'type' => 'error' ) ); + } + } + } + + /** + * Invites a user to connect to WordPress.com to allow them to log in via SSO. + */ + public function invite_user_to_wpcom() { + check_admin_referer( 'jetpack-sso-invite-user', 'invite_nonce' ); + $nonce = wp_create_nonce( 'jetpack-sso-invite-user' ); + + if ( ! current_user_can( 'create_users' ) ) { + $query_params = array( + 'jetpack-sso-invite-user' => 'failed', + 'jetpack-sso-invite-error' => 'invalid-user-permissions', + '_wpnonce' => $nonce, + ); + return self::create_error_notice_and_redirect( $query_params ); + } elseif ( isset( $_GET['user_id'] ) ) { + $user_id = intval( wp_unslash( $_GET['user_id'] ) ); + $user = get_user_by( 'id', $user_id ); + $user_email = $user->user_email; + + if ( ! $user || ! $user_email ) { + $reason = ! $user ? 'invalid-user' : 'invalid-email'; + $query_params = array( + 'jetpack-sso-invite-user' => 'failed', + 'jetpack-sso-invite-error' => $reason, + '_wpnonce' => $nonce, + ); + + return self::create_error_notice_and_redirect( $query_params ); + } + + $blog_id = Jetpack_Options::get_option( 'id' ); + $roles = new Roles(); + $user_role = $roles->translate_user_to_role( $user ); + + $url = '/sites/' . $blog_id . '/invites/new'; + $response = Client::wpcom_json_api_request_as_user( + $url, + 'v2', + array( + 'method' => 'POST', + ), + array( + 'invitees' => array( + array( + 'email_or_username' => $user_email, + 'role' => $user_role, + ), + ), + ), + 'wpcom' + ); + + // access the first item since we're inviting one user. + $body = json_decode( $response['body'] )[0]; + + $query_params = array( + 'jetpack-sso-invite-user' => $body->success ? 'success' : 'failed', + '_wpnonce' => $nonce, + ); + + if ( ! $body->success ) { + $query_params = array( + 'jetpack-sso-invite-error' => $body->errors[0], + ); + } + return self::create_error_notice_and_redirect( $query_params ); + } else { + $query_params = array( + 'jetpack-sso-invite-user' => 'failed', + 'jetpack-sso-invite-error' => 'invalid-user', + '_wpnonce' => $nonce, + ); + return self::create_error_notice_and_redirect( $query_params ); + } + wp_die(); + } + + /** + * Revokes a user's invitation to connect to WordPress.com. + * + * @param string $invite_id The ID of the invite to revoke. + */ + public function revoke_wpcom_invite( $invite_id ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + + $url = '/sites/' . $blog_id . '/invites/delete'; + $response = Client::wpcom_json_api_request_as_user( + $url, + 'v2', + array( + 'method' => 'POST', + ), + array( + 'invite_ids' => array( $invite_id ), + ), + 'wpcom' + ); + + return json_decode( $response['body'] ); + } + + /** + * Handles logic to revoke user invite. + */ + public function handle_request_revoke_invite() { + check_admin_referer( 'jetpack-sso-revoke-user-invite', 'revoke_invite_nonce' ); + $nonce = wp_create_nonce( 'jetpack-sso-invite-user' ); + + if ( ! current_user_can( 'promote_users' ) ) { + $query_params = array( + 'jetpack-sso-invite-user' => 'failed', + 'jetpack-sso-invite-error' => 'invalid-revoke-permissions', + '_wpnonce' => $nonce, + ); + + return self::create_error_notice_and_redirect( $query_params ); + } elseif ( isset( $_GET['user_id'] ) ) { + $user_id = intval( wp_unslash( $_GET['user_id'] ) ); + $user = get_user_by( 'id', $user_id ); + + if ( ! $user ) { + + $query_params = array( + 'jetpack-sso-invite-user' => 'failed', + 'jetpack-sso-invite-error' => 'invalid-user-revoke', + '_wpnonce' => $nonce, + ); + + return self::create_error_notice_and_redirect( $query_params ); + } + + if ( ! isset( $_GET['invite_id'] ) ) { + $query_params = array( + 'jetpack-sso-invite-user' => 'failed', + 'jetpack-sso-invite-error' => 'invalid-invite-revoke', + '_wpnonce' => $nonce, + ); + return self::create_error_notice_and_redirect( $query_params ); + } + + $invite_id = sanitize_text_field( wp_unslash( $_GET['invite_id'] ) ); + $body = self::revoke_wpcom_invite( $invite_id ); + $query_params = array( + 'jetpack-sso-invite-user' => $body->deleted ? 'successful-revoke' : 'failed', + '_wpnonce' => $nonce, + ); + + if ( ! $body->deleted ) { + $query_params['jetpack-sso-invite-error'] = 'invalid-invite-revoke'; + } + return self::create_error_notice_and_redirect( $query_params ); + } else { + $query_params = array( + 'jetpack-sso-invite-user' => 'failed', + 'jetpack-sso-invite-error' => 'invalid-user-revoke', + '_wpnonce' => $nonce, + ); + return self::create_error_notice_and_redirect( $query_params ); + } + wp_die(); + } + + /** + * Adds 'Revoke invite' link to user table row actions. + * Removes 'Reset password' link. + * + * @param array $actions - User row actions. + * @param WP_User $user_object - User object. + */ + public function jetpack_user_table_row_actions( $actions, $user_object ) { + $user_id = $user_object->ID; + $has_pending_invite = self::has_pending_wpcom_invite( $user_id ); + + if ( current_user_can( 'promote_users' ) && $has_pending_invite ) { + $nonce = wp_create_nonce( 'jetpack-sso-revoke-user-invite' ); + $actions['sso_revoke_invite'] = sprintf( + '%s', + add_query_arg( + array( + 'action' => 'jetpack_revoke_invite_user_to_wpcom', + 'user_id' => $user_id, + 'revoke_invite_nonce' => $nonce, + 'invite_id' => $has_pending_invite, + ), + admin_url( 'admin-post.php' ) + ), + esc_html__( 'Revoke invite', 'jetpack' ) + ); + } + + unset( $actions['resetpassword'] ); + + return $actions; + } + + /** + * Render the invitation email message. + */ + public function render_invitation_email_message() { + $message = wp_kses( + __( + 'New users will receive an invite to join WordPress.com, so they can log in securely using Secure Sign On.', + 'jetpack' + ), + array( + 'a' => array( + 'class' => array(), + 'href' => array(), + 'rel' => array(), + 'target' => array(), + ), + ) + ); + wp_admin_notice( + $message, + array( + 'id' => 'invitation_message', + 'type' => 'info', + 'dismissible' => false, + 'additional_classes' => array( 'jetpack-sso-admin-create-user-invite-message' ), + ) + ); + } + + /** + * Render a note that wp.com invites will be automatically revoked. + */ + public function render_invitations_notices_for_deleted_users() { + check_admin_referer( 'bulk-users' ); + + // When one user is deleted, the param is `user`, when multiple users are deleted, the param is `users`. + // We start with `users` and fallback to `user`. + $user_id = isset( $_GET['user'] ) ? intval( wp_unslash( $_GET['user'] ) ) : null; + $user_ids = isset( $_GET['users'] ) ? array_map( 'intval', wp_unslash( $_GET['users'] ) ) : array( $user_id ); + + $users_with_invites = array_filter( + $user_ids, + function ( $user_id ) { + return $user_id !== null && self::has_pending_wpcom_invite( $user_id ); + } + ); + + $users_with_invites = array_map( + function ( $user_id ) { + $user = get_user_by( 'id', $user_id ); + return $user->user_login; + }, + $users_with_invites + ); + + $invites_count = count( $users_with_invites ); + if ( $invites_count > 0 ) { + $users_with_invites = implode( ', ', $users_with_invites ); + $message = wp_kses( + sprintf( + /* translators: %s is a comma-separated list of user logins. */ + _n( + 'WordPress.com invitation will be automatically revoked for user: %s.', + 'WordPress.com invitations will be automatically revoked for users: %s.', + $invites_count, + 'jetpack' + ), + $users_with_invites + ), + array( 'strong' => true ) + ); + wp_admin_notice( + $message, + array( + 'id' => 'invitation_message', + 'type' => 'info', + 'dismissible' => false, + 'additional_classes' => array( 'jetpack-sso-admin-create-user-invite-message' ), + ) + ); + } + } + + /** + * Render the custom email message form field for new user registration. + * + * @param string $type The type of new user form the hook follows. + */ + public function render_custom_email_message_form_field( $type ) { + if ( $type === 'add-new-user' ) { + $valid_nonce = isset( $_POST['_wpnonce_create-user'] ) ? wp_verify_nonce( $_POST['_wpnonce_create-user'], 'create-user' ) : false; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- WP core doesn't pre-sanitize nonces either. + $custom_email_message = ( $valid_nonce && isset( $_POST['custom_email_message'] ) ) ? sanitize_text_field( wp_unslash( $_POST['custom_email_message'] ) ) : ''; + ?> + + + + + +
+ + +
+ 500 ) { + $errors->add( 'custom_email_message', __( 'Error: The custom message is too long. Please keep it under 500 characters.', 'jetpack' ) ); + } + + if ( $errors->has_errors() ) { + return $errors; + } + + $email = $user->user_email; + $role = $user->role; + $blog_id = Jetpack_Options::get_option( 'id' ); + $url = '/sites/' . $blog_id . '/invites/new'; + + $new_user_request = array( + 'email_or_username' => $email, + 'role' => $role, + ); + + if ( $valid_nonce && isset( $_POST['custom_email_message'] ) && strlen( sanitize_text_field( wp_unslash( $_POST['custom_email_message'] ) ) > 0 ) ) { + $new_user_request['message'] = sanitize_text_field( wp_unslash( $_POST['custom_email_message'] ) ); + } + + $response = Client::wpcom_json_api_request_as_user( + $url, + '2', // Api version + array( + 'method' => 'POST', + ), + array( + 'invitees' => array( $new_user_request ), + ) + ); + + if ( 200 !== $response['response']['code'] ) { + $errors->add( 'invitation_not_sent', __( 'Error: The user invitation email could not be sent, the user account was not created.', 'jetpack' ) ); + } + } + + return $errors; + } + + /** + * Adds a column in the user admin table to display user connection status and actions. + * + * @param array $columns User list table columns. + * + * @return array + */ + public function jetpack_user_connected_th( $columns ) { + $columns['user_jetpack'] = sprintf( + '%2$s [?]', + esc_attr__( 'Jetpack SSO is required for a seamless and secure experience on WordPress.com. Join millions of WordPress users who trust us to keep their accounts safe.', 'jetpack' ), + esc_html__( 'SSO Status', 'jetpack' ) + ); + return $columns; + } + + /** + * Executed when our WP_User_Query instance is set, and we don't have cached invites. + * This function uses the user emails and the 'are-users-invited' endpoint to build the cache. + * + * @return void + */ + private static function rebuild_invite_cache() { + $blog_id = Jetpack_Options::get_option( 'id' ); + + if ( self::$cached_invites === null && self::$user_search !== null ) { + + self::$cached_invites = array(); + + $results = self::$user_search->get_results(); + + $user_emails = array_reduce( + $results, + function ( $current, $item ) { + if ( ! Jetpack::connection()->is_user_connected( $item->ID ) ) { + $current[] = rawurlencode( $item->user_email ); + } else { + self::$cached_invites[] = array( + 'email_or_username' => $item->user_email, + 'invited' => false, + 'invite_code' => '', + ); + } + return $current; + }, + array() + ); + + if ( ! empty( $user_emails ) ) { + $url = '/sites/' . $blog_id . '/invites/are-users-invited'; + + $response = Client::wpcom_json_api_request_as_user( + $url, + 'v2', + array( + 'method' => 'POST', + ), + array( 'users' => $user_emails ), + 'wpcom' + ); + + if ( ! is_wp_error( $response ) && 200 === $response['response']['code'] ) { + $body = json_decode( $response['body'], true ); + self::$cached_invites = array_merge( self::$cached_invites, $body ); + } + } + } + } + + /** + * Check if there is cached invite for a user email. + * + * @access private + * @static + * + * @param string $email The user email. + * + * @return array|void Returns the cached invite if found. + */ + public static function get_pending_cached_wpcom_invite( $email ) { + if ( self::$cached_invites === null ) { + self::rebuild_invite_cache(); + } + + if ( ! empty( self::$cached_invites ) ) { + $index = array_search( $email, array_column( self::$cached_invites, 'email_or_username' ), true ); + if ( $index !== false ) { + return self::$cached_invites[ $index ]; + } + } + } + + /** + * Check if a given user is invited to the site. + * + * @access private + * @static + * @param int $user_id The user ID. + * + * @return {false|string} returns the user invite code if the user is invited, false otherwise. + */ + private static function has_pending_wpcom_invite( $user_id ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $user = get_user_by( 'id', $user_id ); + $cached_invite = self::get_pending_cached_wpcom_invite( $user->user_email ); + + if ( $cached_invite ) { + return $cached_invite['invite_code']; + } + + $url = '/sites/' . $blog_id . '/invites/is-invited'; + $url = add_query_arg( + array( + 'email_or_username' => rawurlencode( $user->user_email ), + ), + $url + ); + $response = Client::wpcom_json_api_request_as_user( + $url, + 'v2', + array(), + null, + 'wpcom' + ); + if ( is_wp_error( $response ) ) { + return false; + } + + if ( 200 !== $response['response']['code'] ) { + return false; + } + + return json_decode( $response['body'], true )['invite_code']; + } + + /** + * Show Jetpack SSO user connection status. + * + * @param string $val HTML for the column. + * @param string $col User list table column. + * @param int $user_id User ID. + * + * @return string + */ + public function jetpack_show_connection_status( $val, $col, $user_id ) { + if ( 'user_jetpack' === $col && Jetpack::connection()->is_user_connected( $user_id ) ) { + $connection_html = sprintf( + '%2$s', + esc_attr__( 'This user is connected and can log-in to this site.', 'jetpack' ), + esc_html__( 'Connected', 'jetpack' ) + ); + return $connection_html; + } else { + $has_pending_invite = self::has_pending_wpcom_invite( $user_id ); + if ( $has_pending_invite ) { + $connection_html = sprintf( + '%2$s', + esc_attr__( 'This user didn’t accept the invitation to join this site yet.', 'jetpack' ), + esc_html__( 'Pending invite', 'jetpack' ) + ); + return $connection_html; + } + $nonce = wp_create_nonce( 'jetpack-sso-invite-user' ); + $connection_html = sprintf( + // Using formmethod and formaction because we can't nest forms and have to submit using the main form. + '%2$s', + add_query_arg( + array( + 'user_id' => $user_id, + 'invite_nonce' => $nonce, + 'action' => 'jetpack_invite_user_to_wpcom', + ), + admin_url( 'admin-post.php' ) + ), + esc_html__( 'Send invite', 'jetpack' ), + esc_attr__( 'This user doesn’t have a WP.com account and, with your current site settings, won’t be able to log in. Request them to create a WP.com account to be able to function normally.', 'jetpack' ) + ); + return $connection_html; + } + } + + /** + * Creates error notices and redirects the user to the previous page. + * + * @param array $query_params - query parameters added to redirection URL. + */ + public function create_error_notice_and_redirect( $query_params ) { + $ref = wp_get_referer(); + $url = add_query_arg( + $query_params, + $ref + ); + return wp_safe_redirect( $url ); + } + + /** + * Style the Jetpack user rows and columns. + */ + public function jetpack_user_table_styles() { + ?> + +