settings = $settings; $this->tokens = $tokens; $this->mailer = $mailer; add_filter( 'manage_users_columns', array( $this, 'register_column' ) ); add_filter( 'manage_users_custom_column', array( $this, 'render_column' ), 10, 3 ); add_filter( 'manage_users_sortable_columns', array( $this, 'mark_sortable' ) ); add_action( 'pre_get_users', array( $this, 'apply_query' ) ); add_action( 'restrict_manage_users', array( $this, 'render_filter' ) ); add_filter( 'bulk_actions-users', array( $this, 'register_bulk_actions' ) ); add_filter( 'handle_bulk_actions-users', array( $this, 'handle_bulk_actions' ), 10, 3 ); add_action( 'admin_notices', array( $this, 'render_bulk_notices' ) ); add_filter( 'user_row_actions', array( $this, 'register_row_actions' ), 10, 2 ); add_action( 'admin_post_wpcev_user_action', array( $this, 'handle_row_action' ) ); // Priority 20: after WooCommerce 11's own "Email confirmation" box (10), which // is the one switch when it exists; see render_profile_widget(). add_action( 'show_user_profile', array( $this, 'render_profile_widget' ), 20 ); add_action( 'edit_user_profile', array( $this, 'render_profile_widget' ), 20 ); // WooCommerce's box ticked or cleared on the profile: follow it. add_action( 'profile_update', array( $this, 'remember_wc_state' ), 5 ); add_action( 'profile_update', array( $this, 'follow_wc_box' ), 20 ); // Pending-count badge on the Users menu (mirrors the "Comments // awaiting moderation" badge WP itself ships). add_action( 'admin_menu', array( $this, 'inject_users_menu_badge' ), 999 ); } /* ─── Pending-count menu badge ───────────────────────────────────────── */ public function inject_users_menu_badge() { if ( ! current_user_can( 'list_users' ) ) { return; } if ( ! class_exists( 'WPCEV_Stats' ) ) { return; } $counts = WPCEV_Stats::counts(); $pending = (int) $counts['unverified']; if ( $pending < 1 ) { return; } global $menu, $submenu; $badge = sprintf( ' %2$s', $pending, number_format_i18n( $pending ) ); // Top-level "Users" entry. Mutating $menu / $submenu in-place is // the documented WP pattern for menu badges (same as core does for // comments awaiting moderation). if ( is_array( $menu ) ) { foreach ( $menu as $key => $item ) { if ( isset( $item[2] ) && 'users.php' === $item[2] ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- documented pattern: append to $menu[$key][0]. $menu[ $key ][0] .= $badge; break; } } } // Submenu "All Users" entry, so the badge also appears when the // Users menu is expanded. if ( isset( $submenu['users.php'] ) && is_array( $submenu['users.php'] ) ) { foreach ( $submenu['users.php'] as $key => $sub ) { if ( isset( $sub[2] ) && 'users.php' === $sub[2] ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- documented pattern: append to $submenu[$parent][$key][0]. $submenu['users.php'][ $key ][0] .= $badge; break; } } } } /* ─── Column ─────────────────────────────────────────────────────────── */ public function register_column( $cols ) { $cols[ self::COLUMN_ID ] = __( 'Verified', 'wpcoretools-email-verify' ); return $cols; } public function render_column( $value, $column_name, $user_id ) { if ( self::COLUMN_ID !== $column_name ) { return $value; } return $this->badge_html( $this->tokens->status( $user_id ) ); } /** * Status badge markup for a status from WPCEV_Tokens::status(). Shared by * the Users list, the profile widget and the WooCommerce Orders list so the * labels cannot drift. Returns escaped HTML. * * @param string $status verified|unverified|exempt|grandfathered. * @return string */ public function badge_html( $status ) { switch ( $status ) { case 'verified': return '' . esc_html__( 'Verified', 'wpcoretools-email-verify' ) . ''; case 'unverified': return '' . esc_html__( 'Pending', 'wpcoretools-email-verify' ) . ''; case 'exempt': return '' . esc_html__( 'Exempt', 'wpcoretools-email-verify' ) . ''; case 'grandfathered': default: return '—'; } } public function mark_sortable( $cols ) { $cols[ self::COLUMN_ID ] = WPCEV_Tokens::META_VERIFIED; return $cols; } public function apply_query( $query ) { if ( ! is_admin() ) { return; } $meta_query = $query->get( 'meta_query' ); if ( ! is_array( $meta_query ) ) { $meta_query = array(); } /* * Sorting by the Verified column. * * Setting `meta_key` would make WP_User_Query INNER JOIN usermeta, which * silently drops every grandfathered user — they have no wpcev_verified * row at all, so sorting the column hid exactly the users an admin sorts * in order to find. Instead order by a named meta_query clause whose * relation is OR'd with NOT EXISTS: that produces a LEFT JOIN, so * grandfathered users are kept and sort together on the NULL side. */ $orderby = $query->get( 'orderby' ); if ( WPCEV_Tokens::META_VERIFIED === $orderby ) { $meta_query['wpcev_sort'] = array( 'relation' => 'OR', 'wpcev_has' => array( 'key' => WPCEV_Tokens::META_VERIFIED, 'compare' => 'EXISTS', ), 'wpcev_none' => array( 'key' => WPCEV_Tokens::META_VERIFIED, 'compare' => 'NOT EXISTS', ), ); $query->set( 'orderby', 'wpcev_has' ); } // Filter dropdown. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter form. $filter = isset( $_GET['wpcev_filter'] ) ? sanitize_key( wp_unslash( $_GET['wpcev_filter'] ) ) : ''; switch ( $filter ) { case 'unverified': $meta_query[] = array( 'key' => WPCEV_Tokens::META_VERIFIED, 'value' => '0', ); break; case 'verified': $meta_query[] = array( 'key' => WPCEV_Tokens::META_VERIFIED, 'value' => '1', ); break; case 'grandfathered': $meta_query[] = array( 'key' => WPCEV_Tokens::META_VERIFIED, 'compare' => 'NOT EXISTS', ); break; } if ( $meta_query ) { // Top-level AND so a filter still narrows the sorted set. if ( ! isset( $meta_query['relation'] ) ) { $meta_query['relation'] = 'AND'; } $query->set( 'meta_query', $meta_query ); } } public function render_filter( $which ) { if ( 'top' !== $which ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $current = isset( $_GET['wpcev_filter'] ) ? sanitize_key( wp_unslash( $_GET['wpcev_filter'] ) ) : ''; // Nonce for our bulk actions (see handle_bulk_actions()): it is in the list form // whichever Apply button is used. wp_nonce_field( self::BULK_NONCE_ACTION, self::BULK_NONCE_FIELD, false ); ?> 403 ) ); } $counts = array( 'verify' => 0, 'unverify' => 0, 'resend' => 0, 'fail' => 0, ); foreach ( (array) $user_ids as $uid ) { $uid = (int) $uid; if ( ! $uid ) { continue; } switch ( $action ) { case 'wpcev_verify': $this->tokens->mark_verified( $uid, 'admin' ); ++$counts['verify']; break; case 'wpcev_unverify': $this->tokens->unmark_verified( $uid, 'admin' ); $result = $this->mailer->send_verification( $uid, false ); if ( 'sent' === $result ) { ++$counts['unverify']; } else { ++$counts['fail']; } break; case 'wpcev_resend': $result = $this->mailer->send_verification( $uid, true ); if ( 'sent' === $result ) { ++$counts['resend']; } elseif ( 'throttled' !== $result ) { ++$counts['fail']; } break; } } return add_query_arg( array( 'wpcev_bulk_verify' => $counts['verify'], 'wpcev_bulk_unverify' => $counts['unverify'], 'wpcev_bulk_resend' => $counts['resend'], 'wpcev_bulk_fail' => $counts['fail'], ), $redirect ); } public function render_bulk_notices() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only flags from our own redirect. $verify = isset( $_GET['wpcev_bulk_verify'] ) ? (int) $_GET['wpcev_bulk_verify'] : 0; $unverify = isset( $_GET['wpcev_bulk_unverify'] ) ? (int) $_GET['wpcev_bulk_unverify'] : 0; $resend = isset( $_GET['wpcev_bulk_resend'] ) ? (int) $_GET['wpcev_bulk_resend'] : 0; $fail = isset( $_GET['wpcev_bulk_fail'] ) ? (int) $_GET['wpcev_bulk_fail'] : 0; // phpcs:enable WordPress.Security.NonceVerification.Recommended $messages = array(); if ( $verify ) { /* translators: %d: number of users */ $messages[] = sprintf( _n( '%d user marked verified.', '%d users marked verified.', $verify, 'wpcoretools-email-verify' ), $verify ); } if ( $unverify ) { /* translators: %d: number of users */ $messages[] = sprintf( _n( '%d user reset and re-emailed.', '%d users reset and re-emailed.', $unverify, 'wpcoretools-email-verify' ), $unverify ); } if ( $resend ) { /* translators: %d: number of users */ $messages[] = sprintf( _n( '%d verification email sent.', '%d verification emails sent.', $resend, 'wpcoretools-email-verify' ), $resend ); } if ( $messages ) { echo '

' . esc_html( implode( ' ', $messages ) ) . '

'; } if ( $fail ) { /* translators: %d: number of failures */ echo '

' . esc_html( sprintf( _n( '%d email failed to send.', '%d emails failed to send.', $fail, 'wpcoretools-email-verify' ), $fail ) ) . '

'; } } /* ─── Row actions ────────────────────────────────────────────────────── */ public function register_row_actions( $actions, $user ) { if ( ! current_user_can( 'edit_users' ) ) { return $actions; } if ( ! ( $user instanceof WP_User ) ) { return $actions; } $status = $this->tokens->status( $user->ID ); if ( 'unverified' === $status ) { $actions['wpcev_resend'] = '' . esc_html__( 'Resend verification', 'wpcoretools-email-verify' ) . ''; $actions['wpcev_verify'] = '' . esc_html__( 'Mark verified', 'wpcoretools-email-verify' ) . ''; } elseif ( 'verified' === $status ) { $actions['wpcev_unverify'] = '' . esc_html__( 'Mark unverified', 'wpcoretools-email-verify' ) . ''; } return $actions; } private function action_url( $action, $user_id ) { return wp_nonce_url( add_query_arg( array( 'action' => 'wpcev_user_action', 'wpcev' => $action, 'user_id' => (int) $user_id, ), admin_url( 'admin-post.php' ) ), 'wpcev_user_action_' . $user_id ); } public function handle_row_action() { if ( ! current_user_can( 'edit_users' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'wpcoretools-email-verify' ) ); } $user_id = isset( $_GET['user_id'] ) ? (int) $_GET['user_id'] : 0; check_admin_referer( 'wpcev_user_action_' . $user_id ); $action = isset( $_GET['wpcev'] ) ? sanitize_key( wp_unslash( $_GET['wpcev'] ) ) : ''; $flag = ''; switch ( $action ) { case 'verify': $this->tokens->mark_verified( $user_id, 'admin' ); $flag = 'verified'; break; case 'unverify': $this->tokens->unmark_verified( $user_id, 'admin' ); $result = $this->mailer->send_verification( $user_id, false ); $flag = 'sent' === $result ? 'unverified_emailed' : 'unverified_no_email'; break; case 'resend': $result = $this->mailer->send_verification( $user_id, false ); $flag = 'sent' === $result ? 'resent' : 'resend_failed'; break; } wp_safe_redirect( add_query_arg( 'wpcev_action_done', $flag, admin_url( 'users.php' ) ) ); exit; } /* ─── User profile widget ────────────────────────────────────────────── */ /** * The profile's email verification rows. * * With WooCommerce 11's built-in verification (and a viewer who sees its * "Email confirmation" box) that box is the one switch: this plugin follows it * both ways and only adds how the address was verified, or the pending * confirmation email with its resend button, right under it. Without it, this * plugin's own section with status and actions. * * @param WP_User $user */ public function render_profile_widget( $user ) { if ( ! current_user_can( 'edit_users' ) && get_current_user_id() !== (int) $user->ID ) { return; } $status = $this->tokens->status( $user->ID ); $sent_at = (int) get_user_meta( $user->ID, WPCEV_Tokens::META_SENT_AT, true ); $wc_box = class_exists( 'WPCEV_WC_Sync' ) && WPCEV_WC_Sync::available() && current_user_can( 'manage_woocommerce' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers manage_woocommerce; available() is only true with WooCommerce 11. ?>

General). * * @param int $ts Unix time. * @return string */ public static function site_time( $ts ) { return (string) wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $ts ); } /** * How a verified address was verified, in words (the reason stored since 1.0.3). * * @param int $user_id * @return string */ public static function verified_via_label( $user_id ) { $via = (string) get_user_meta( (int) $user_id, WPCEV_Tokens::META_VERIFIED_VIA, true ); $labels = array( 'link' => __( 'Confirmation link in the email', 'wpcoretools-email-verify' ), 'password_reset' => __( 'Password reset email', 'wpcoretools-email-verify' ), 'multisite' => __( 'Multisite activation email', 'wpcoretools-email-verify' ), 'woocommerce' => __( 'WooCommerce email confirmation', 'wpcoretools-email-verify' ), 'admin' => __( 'Marked verified by an admin', 'wpcoretools-email-verify' ), 'backfill' => __( 'Sync to WooCommerce (Tools tab)', 'wpcoretools-email-verify' ), 'exempt' => __( 'Exempt role, not asked', 'wpcoretools-email-verify' ), 'filter' => __( 'Another plugin', 'wpcoretools-email-verify' ), ); $label = isset( $labels[ $via ] ) ? $labels[ $via ] : ( '' === $via || 'none' === $via ? __( 'Not recorded', 'wpcoretools-email-verify' ) : ucwords( str_replace( array( '_', '-' ), ' ', $via ) ) ); /** * How the address was verified, as shown on the profile. Plugins that verify * through mark_verified( $user_id, '' ) name their reason here. * * @since 1.0.4 * * @param string $label * @param string $via The stored reason ('' before 1.0.3). * @param int $user_id */ return (string) apply_filters( 'wpcev_verified_via_label', $label, $via, (int) $user_id ); } /** @var array WooCommerce's verification before a profile save, by user. */ private $wc_before = array(); /** * profile_update, before WooCommerce saves its box (10): remember its state. * * @param int $user_id */ public function remember_wc_state( $user_id ) { $user_id = (int) $user_id; if ( $user_id && class_exists( 'WPCEV_WC_Sync' ) && WPCEV_WC_Sync::available() ) { $this->wc_before[ $user_id ] = WPCEV_WC_Sync::is_verified_in_woocommerce( $user_id ); } } /** * profile_update, after WooCommerce saved its box: an admin who cleared it * made the address unverified here too. Only a change counts, so saving a * profile for anything else never touches verification. (Ticking it fires * woocommerce_customer_email_verified, which WPCEV_Integrations follows.) * * @param int $user_id */ public function follow_wc_box( $user_id ) { $user_id = (int) $user_id; if ( ! isset( $this->wc_before[ $user_id ] ) ) { return; } $before = $this->wc_before[ $user_id ]; unset( $this->wc_before[ $user_id ] ); if ( $before && ! WPCEV_WC_Sync::is_verified_in_woocommerce( $user_id ) && 'verified' === $this->tokens->status( $user_id ) ) { $this->tokens->unmark_verified( $user_id, 'admin' ); } } }