Commit 25d38e013db for woocommerce

commit 25d38e013db3f3f5b36cb61685db662683060698
Author: Mike Jolley <mike.jolley@me.com>
Date:   Thu Jun 25 16:06:46 2026 +0100

    Add admin user-profile control for email confirmation (#65824)

    * Add admin user-profile control for email-confirmation status

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Restrict email-verified field to staff and verify after email change

    * Register profile_update hook in test so email-change verification path runs

    ---------

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/add-admin-email-verified-field b/plugins/woocommerce/changelog/add-admin-email-verified-field
new file mode 100644
index 00000000000..2af673e07ea
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-admin-email-verified-field
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a user profile control so admins can view and set a customer's email-confirmation status.
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/Admin/UserProfileField.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Admin/UserProfileField.php
new file mode 100644
index 00000000000..4518fb925a2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Admin/UserProfileField.php
@@ -0,0 +1,110 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\CustomerEmailVerification\Admin;
+
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\EmailVerificationService;
+use WP_User;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Adds an "Email address verified" control to the wp-admin user profile screen.
+ *
+ * The verification meta is the source of truth; this checkbox simply reflects and edits it.
+ * Ticking it marks the user's current email verified (which also links any matching past guest
+ * orders); unticking it clears the status.
+ *
+ * @since 11.0.0
+ */
+class UserProfileField {
+
+	private const NONCE_ACTION = 'wc_email_verified';
+	private const FIELD        = 'wc_email_verified';
+
+	/**
+	 * Verification service.
+	 *
+	 * @var EmailVerificationService
+	 */
+	private $service;
+
+	/**
+	 * Constructor. Registers hooks.
+	 */
+	public function __construct() {
+		add_action( 'show_user_profile', array( $this, 'render' ) );
+		add_action( 'edit_user_profile', array( $this, 'render' ) );
+		// Saved on profile_update (not the *_profile_update hooks) because that fires after the user
+		// record is written, so an email changed in the same save is already current when we verify it.
+		add_action( 'profile_update', array( $this, 'save' ) );
+	}
+
+	/**
+	 * Inject dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param EmailVerificationService $service Verification service.
+	 */
+	final public function init( EmailVerificationService $service ): void {
+		$this->service = $service;
+	}
+
+	/**
+	 * Render the "Email address verified" checkbox.
+	 *
+	 * @param WP_User|mixed $user The user being edited.
+	 */
+	public function render( $user ): void {
+		if ( ! $user instanceof WP_User || ! current_user_can( 'manage_woocommerce' ) ) {
+			return;
+		}
+
+		wp_nonce_field( self::NONCE_ACTION . '_' . $user->ID, self::NONCE_ACTION . '_nonce' );
+		?>
+		<h2><?php esc_html_e( 'Email confirmation', 'woocommerce' ); ?></h2>
+		<table class="form-table" role="presentation">
+			<tr>
+				<th scope="row"><?php esc_html_e( 'Email address confirmed', 'woocommerce' ); ?></th>
+				<td>
+					<label for="<?php echo esc_attr( self::FIELD ); ?>">
+						<input type="checkbox" name="<?php echo esc_attr( self::FIELD ); ?>" id="<?php echo esc_attr( self::FIELD ); ?>" value="1" <?php checked( $this->service->is_verified( $user->ID ) ); ?> />
+						<?php esc_html_e( 'The customer has confirmed they own this email address.', 'woocommerce' ); ?>
+					</label>
+					<p class="description"><?php esc_html_e( 'Confirming the email address also links any past guest orders placed with it to this account.', 'woocommerce' ); ?></p>
+				</td>
+			</tr>
+		</table>
+		<?php
+	}
+
+	/**
+	 * Persist the "Email address verified" checkbox when a profile is saved.
+	 *
+	 * @param int|mixed $user_id The user being saved.
+	 */
+	public function save( $user_id ): void {
+		$user_id = (int) $user_id;
+
+		if ( ! current_user_can( 'manage_woocommerce' ) ) {
+			return;
+		}
+
+		$nonce = isset( $_POST[ self::NONCE_ACTION . '_nonce' ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_ACTION . '_nonce' ] ) ) : '';
+
+		// profile_update fires on every wp_update_user() call, so the nonce is what scopes us to a
+		// profile-screen submission where our field was actually rendered; without it we would clear
+		// verification on every unrelated user update.
+		if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION . '_' . $user_id ) ) {
+			return;
+		}
+
+		if ( isset( $_POST[ self::FIELD ] ) ) {
+			$this->service->mark_verified( $user_id );
+		} else {
+			$this->service->clear_verification( $user_id );
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/CustomerEmailVerification.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/CustomerEmailVerification.php
index 2e4fd7b5dd9..85e499cf1d6 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/CustomerEmailVerification.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/CustomerEmailVerification.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\CustomerEmailVerification;

+use Automattic\WooCommerce\Internal\CustomerEmailVerification\Admin\UserProfileField;
 use Automattic\WooCommerce\Internal\CustomerEmailVerification\Emails\CustomerVerifyEmail;

 /**
@@ -42,6 +43,10 @@ class CustomerEmailVerification {
 		$container = wc_get_container();
 		$container->get( VerificationController::class );
 		$container->get( VerificationEventListener::class );
+
+		if ( is_admin() ) {
+			$container->get( UserProfileField::class );
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Admin/UserProfileFieldTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Admin/UserProfileFieldTest.php
new file mode 100644
index 00000000000..b2ec6aa23cd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Admin/UserProfileFieldTest.php
@@ -0,0 +1,134 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CustomerEmailVerification\Admin;
+
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\Admin\UserProfileField;
+use Automattic\WooCommerce\Internal\CustomerEmailVerification\EmailVerificationService;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the admin user-profile "Email address verified" checkbox.
+ */
+class UserProfileFieldTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var UserProfileField
+	 */
+	private $sut;
+
+	/**
+	 * The verification service.
+	 *
+	 * @var EmailVerificationService
+	 */
+	private $service;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut     = wc_get_container()->get( UserProfileField::class );
+		$this->service = wc_get_container()->get( EmailVerificationService::class );
+
+		$admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_id );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		wp_set_current_user( 0 );
+		unset( $_POST['wc_email_verified'], $_POST['wc_email_verified_nonce'] );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Saving the profile with the box checked (and a valid nonce) marks the user verified.
+	 */
+	public function test_save_marks_verified_when_checked(): void {
+		$user_id = wc_create_new_customer( 'profile-check@example.com', 'profilecheck', 'pw' );
+
+		$_POST['wc_email_verified_nonce'] = wp_create_nonce( 'wc_email_verified_' . $user_id );
+		$_POST['wc_email_verified']       = '1';
+
+		$this->sut->save( $user_id );
+
+		$this->assertTrue( $this->service->is_verified( $user_id ) );
+	}
+
+	/**
+	 * @testdox Saving the profile with the box unchecked (and a valid nonce) clears verification.
+	 */
+	public function test_save_clears_when_unchecked(): void {
+		$user_id = wc_create_new_customer( 'profile-uncheck@example.com', 'profileuncheck', 'pw' );
+		$this->service->mark_verified( $user_id );
+		$this->assertTrue( $this->service->is_verified( $user_id ) );
+
+		$_POST['wc_email_verified_nonce'] = wp_create_nonce( 'wc_email_verified_' . $user_id );
+		// No 'wc_email_verified' key in POST means the checkbox was unticked.
+
+		$this->sut->save( $user_id );
+
+		$this->assertFalse( $this->service->is_verified( $user_id ) );
+	}
+
+	/**
+	 * @testdox Saving without a valid nonce leaves verification untouched.
+	 */
+	public function test_save_does_nothing_without_valid_nonce(): void {
+		$user_id = wc_create_new_customer( 'profile-nononce@example.com', 'profilenononce', 'pw' );
+
+		$_POST['wc_email_verified'] = '1';
+		// No nonce provided.
+
+		$this->sut->save( $user_id );
+
+		$this->assertFalse( $this->service->is_verified( $user_id ), 'Without a valid nonce the checkbox must not take effect.' );
+	}
+
+	/**
+	 * @testdox A non-privileged user cannot self-verify their own email via a profile save.
+	 */
+	public function test_save_does_not_allow_non_privileged_self_verify(): void {
+		$user_id = wc_create_new_customer( 'self-verify@example.com', 'selfverify', 'pw' );
+		wp_set_current_user( $user_id );
+
+		$_POST['wc_email_verified_nonce'] = wp_create_nonce( 'wc_email_verified_' . $user_id );
+		$_POST['wc_email_verified']       = '1';
+
+		$this->sut->save( $user_id );
+
+		$this->assertFalse( $this->service->is_verified( $user_id ), 'A customer without manage_woocommerce must not be able to verify their own email.' );
+	}
+
+	/**
+	 * @testdox Changing the email and ticking verify in the same save verifies the new address.
+	 */
+	public function test_save_verifies_new_email_when_changed_and_checked_together(): void {
+		$user_id = wc_create_new_customer( 'before-change@example.com', 'emailchange', 'pw' );
+
+		// A fresh instance so its constructor registers the profile_update hook within this test:
+		// the container caches a shared instance, so resolving it again would not re-add the hook.
+		$field = new UserProfileField();
+		$field->init( $this->service );
+
+		$_POST['wc_email_verified_nonce'] = wp_create_nonce( 'wc_email_verified_' . $user_id );
+		$_POST['wc_email_verified']       = '1';
+
+		// Changing the email runs through wp_update_user, whose profile_update hook fires after the
+		// new address is written, so verification must land on the new address, not the old one.
+		wp_update_user(
+			array(
+				'ID'         => $user_id,
+				'user_email' => 'after-change@example.com',
+			)
+		);
+
+		$this->assertTrue( $this->service->is_verified( $user_id ), 'The newly saved email address should be verified.' );
+	}
+}