diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-write-on-email-verification-launch-gate b/projects/packages/jetpack-mu-wpcom/changelog/add-write-on-email-verification-launch-gate new file mode 100644 index 000000000000..f7f4c04297fc --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-write-on-email-verification-launch-gate @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Write: require email verification before launch in the post-publish overlay, with inline resend and re-check. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/write/email-verification.php b/projects/packages/jetpack-mu-wpcom/src/features/write/email-verification.php index 766808052bc0..6412ca9281cc 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/write/email-verification.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/write/email-verification.php @@ -39,3 +39,56 @@ function wpcom_write_launch_blocked_for_unverified_email() { return Email_Verification::is_email_unverified(); } + +/** + * Nonce action shared by the checklist's resend and re-check ajax endpoints. + */ +const WPCOM_WRITE_EMAIL_VERIFICATION_NONCE = 'wpcom_write_email_verification'; + +/** + * Admin-ajax: resend the current user's email-verification message. + * + * The post-publish checklist's "confirm your email" step calls this so the author + * can re-request the verification email without leaving the page. admin-ajax is the + * transport because a wpcom Simple site serves no REST API at its own hostname. + * + * Logged-in only (registered on `wp_ajax_`, not `wp_ajax_nopriv_`); Email_Verification + * is a WordPress.com-side class, so off-wpcom this fails closed rather than fatally. + * + * @return void + */ +function wpcom_write_ajax_resend_verification_email() { + check_ajax_referer( WPCOM_WRITE_EMAIL_VERIFICATION_NONCE, 'nonce' ); + + if ( ! class_exists( 'Email_Verification' ) ) { + wp_send_json_error( array( 'message' => 'unavailable' ), 400, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); + } + + // @phan-suppress-next-line PhanUndeclaredStaticMethod -- class_exists guarded above; resend_verification_email() isn't in the generated wpcom stub, unlike is_email_unverified(). + Email_Verification::resend_verification_email(); + + wp_send_json_success( null, 200, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); +} +add_action( 'wp_ajax_wpcom_write_resend_verification_email', 'wpcom_write_ajax_resend_verification_email' ); + +/** + * Admin-ajax: report whether the current user's email is now verified. + * + * Lets the "confirm your email" step re-check status after the author clicks the + * verification link out-of-band, so a user who has verified isn't trapped and one + * who hasn't can't slip past to the launch flow. wpcom's verify_email() clears the + * cached user attribute when the link is clicked, so a fresh request reads current + * state without this endpoint reaching into that cache. + * + * @return void + */ +function wpcom_write_ajax_check_email_verification() { + check_ajax_referer( WPCOM_WRITE_EMAIL_VERIFICATION_NONCE, 'nonce' ); + + if ( ! class_exists( 'Email_Verification' ) ) { + wp_send_json_error( array( 'message' => 'unavailable' ), 400, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); + } + + wp_send_json_success( array( 'verified' => ! Email_Verification::is_email_unverified() ), 200, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); +} +add_action( 'wp_ajax_wpcom_write_check_email_verification', 'wpcom_write_ajax_check_email_verification' ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.css b/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.css index 77e29af135a3..c0f3483b1765 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.css +++ b/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.css @@ -161,6 +161,43 @@ background: #2e49d9; } +/* Secondary action in the confirm-your-email step: resend verification mail. */ +.wpcom-write-ppc__resend { + display: block; + width: 100%; + margin-top: 8px; + padding: 12px 16px; + border: 1px solid #3858e9; + border-radius: 4px; + background: transparent; + color: #3858e9; + font-size: 15px; + font-weight: 600; + cursor: pointer; +} + +.wpcom-write-ppc__resend:hover { + background: #f0f3ff; +} + +.wpcom-write-ppc__launch:disabled, +.wpcom-write-ppc__resend:disabled { + opacity: 0.6; + cursor: default; +} + +/* Feedback line for resend / re-check outcomes in the confirm step. */ +.wpcom-write-ppc__status { + margin: 12px 0 0; + font-size: 13px; + line-height: 1.4; + color: #6d6d6d; +} + +.wpcom-write-ppc__status:empty { + display: none; +} + .wpcom-write-ppc__dismiss { display: block; width: 100%; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.js b/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.js index 72d186300d96..64ac5de1aa45 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.js +++ b/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.js @@ -149,13 +149,38 @@ } } + /** + * POST an email-verification action to admin-ajax. + * + * admin-ajax is the transport because a wpcom Simple site serves no REST API + * at its own hostname. Resolves to the parsed WP ajax envelope + * ({ success, data }); rejects on a network/parse failure. + * + * @param {string} action - The registered `wp_ajax_` action name. + * @return {Promise} The parsed JSON response. + */ + function postVerificationAction( action ) { + const body = new URLSearchParams(); + body.set( 'action', action ); + body.set( 'nonce', config.nonce || '' ); + return fetch( config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + } ).then( function ( response ) { + return response.json(); + } ); + } + /** * Swap the card's body into the inline "confirm your email" step. * - * Replaces the checklist and the launch CTA with an "I've confirmed — launch" - * button that proceeds to the launch flow, where the back-end gate is the real - * enforcement. (Inline resend is deferred to a follow-up — it needs a transport - * that works on a Simple site's front end, e.g. admin-ajax.) + * Replaces the checklist and launch CTA with two controls backed by admin-ajax: + * a "Resend verification email" button, and an "I've confirmed — launch" button + * that re-checks status before proceeding — so a user who has verified isn't + * trapped and one who hasn't can't slip past to the launch flow. The wpcom + * back-end gate remains the authoritative enforcement. */ function showVerifyStep() { const launchBtn = card.querySelector( '.wpcom-write-ppc__launch' ); @@ -175,13 +200,71 @@ list.remove(); } + // Status line for resend/re-check feedback, announced to screen readers. + const status = document.createElement( 'p' ); + status.className = 'wpcom-write-ppc__status'; + status.setAttribute( 'role', 'status' ); + status.setAttribute( 'aria-live', 'polite' ); + const confirm = document.createElement( 'button' ); confirm.type = 'button'; confirm.className = 'wpcom-write-ppc__launch'; confirm.textContent = strings.confirmCta || ''; - confirm.addEventListener( 'click', goToLaunch ); + + const resend = document.createElement( 'button' ); + resend.type = 'button'; + resend.className = 'wpcom-write-ppc__resend'; + resend.textContent = strings.resendCta || ''; + + /** + * Re-check verification and launch only once the email is confirmed. + */ + confirm.addEventListener( 'click', function () { + recordEvent( 'wpcom_write_post_publish_checklist_verify_recheck' ); + confirm.disabled = true; + resend.disabled = true; + status.textContent = strings.checking || ''; + postVerificationAction( 'wpcom_write_check_email_verification' ) + .then( function ( result ) { + if ( result && result.success && result.data && result.data.verified ) { + recordEvent( 'wpcom_write_post_publish_checklist_verify_confirmed' ); + goToLaunch(); + return; + } + recordEvent( 'wpcom_write_post_publish_checklist_verify_still_blocked' ); + status.textContent = strings.stillUnverified || ''; + confirm.disabled = false; + resend.disabled = false; + } ) + .catch( function () { + status.textContent = strings.resendError || ''; + confirm.disabled = false; + resend.disabled = false; + } ); + } ); + + /** + * Re-request the verification email without leaving the page. + */ + resend.addEventListener( 'click', function () { + recordEvent( 'wpcom_write_post_publish_checklist_resend_click' ); + resend.disabled = true; + status.textContent = ''; + postVerificationAction( 'wpcom_write_resend_verification_email' ) + .then( function ( result ) { + status.textContent = + result && result.success ? strings.resendSent || '' : strings.resendError || ''; + resend.disabled = false; + } ) + .catch( function () { + status.textContent = strings.resendError || ''; + resend.disabled = false; + } ); + } ); launchBtn.parentNode.insertBefore( confirm, launchBtn ); + confirm.parentNode.insertBefore( resend, confirm.nextSibling ); + resend.parentNode.insertBefore( status, resend.nextSibling ); launchBtn.remove(); confirm.focus(); } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.php b/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.php index bc63a271a258..c4125c3fce21 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/write/post-publish-checklist.php @@ -81,20 +81,25 @@ function wpcom_write_post_publish_launch_url() { */ function wpcom_write_get_post_publish_checklist_strings() { return array( - 'heading' => __( 'Your post is saved!', 'jetpack-mu-wpcom' ), - 'description' => __( 'Your site is still private, so only you can see this post. Launch your site to share it with the world.', 'jetpack-mu-wpcom' ), - 'launchTitle' => __( 'Launch your site', 'jetpack-mu-wpcom' ), - 'launchDesc' => __( 'Make your site public.', 'jetpack-mu-wpcom' ), - 'shareTitle' => __( 'Share your post', 'jetpack-mu-wpcom' ), - 'shareDesc' => __( 'Available after you launch.', 'jetpack-mu-wpcom' ), - 'launchCta' => __( 'Launch your site', 'jetpack-mu-wpcom' ), - 'dismiss' => __( 'Maybe later', 'jetpack-mu-wpcom' ), - 'close' => __( 'Close', 'jetpack-mu-wpcom' ), + 'heading' => __( 'Your post is saved!', 'jetpack-mu-wpcom' ), + 'description' => __( 'Your site is still private, so only you can see this post. Launch your site to share it with the world.', 'jetpack-mu-wpcom' ), + 'launchTitle' => __( 'Launch your site', 'jetpack-mu-wpcom' ), + 'launchDesc' => __( 'Make your site public.', 'jetpack-mu-wpcom' ), + 'shareTitle' => __( 'Share your post', 'jetpack-mu-wpcom' ), + 'shareDesc' => __( 'Available after you launch.', 'jetpack-mu-wpcom' ), + 'launchCta' => __( 'Launch your site', 'jetpack-mu-wpcom' ), + 'dismiss' => __( 'Maybe later', 'jetpack-mu-wpcom' ), + 'close' => __( 'Close', 'jetpack-mu-wpcom' ), // Inline email-verification step, swapped in when the author tries to // launch with an unverified email (see post-publish-checklist.js). - 'verifyTitle' => __( 'Confirm your email to launch', 'jetpack-mu-wpcom' ), - 'verifyDesc' => __( 'Your site can go public once your email is confirmed. Check your inbox for the confirmation link.', 'jetpack-mu-wpcom' ), - 'confirmCta' => __( "I've confirmed — launch", 'jetpack-mu-wpcom' ), + 'verifyTitle' => __( 'Confirm your email to launch', 'jetpack-mu-wpcom' ), + 'verifyDesc' => __( 'Your site can go public once your email is confirmed. Check your inbox for the confirmation link.', 'jetpack-mu-wpcom' ), + 'confirmCta' => __( "I've confirmed — launch", 'jetpack-mu-wpcom' ), + 'resendCta' => __( 'Resend verification email', 'jetpack-mu-wpcom' ), + 'resendSent' => __( 'Verification email sent. Check your inbox.', 'jetpack-mu-wpcom' ), + 'resendError' => __( 'Something went wrong. Please try again.', 'jetpack-mu-wpcom' ), + 'checking' => __( 'Checking…', 'jetpack-mu-wpcom' ), + 'stillUnverified' => __( "We couldn't confirm your email yet. Click the link in the email, then try again.", 'jetpack-mu-wpcom' ), ); } @@ -136,10 +141,19 @@ function wpcom_write_enqueue_post_publish_checklist_assets() { // Sent as a '1'/'0' string because wp_localize_script() stringifies scalars // (bool true -> '1', false -> ''); the JS compares against '1'. 'blocked' => wpcom_write_launch_blocked_for_unverified_email() ? '1' : '0', + // admin-ajax transport for the inline resend / re-check (a Simple site + // serves no REST at its own host); nonce guards both actions. + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( WPCOM_WRITE_EMAIL_VERIFICATION_NONCE ), 'strings' => array( - 'verifyTitle' => $strings['verifyTitle'], - 'verifyDesc' => $strings['verifyDesc'], - 'confirmCta' => $strings['confirmCta'], + 'verifyTitle' => $strings['verifyTitle'], + 'verifyDesc' => $strings['verifyDesc'], + 'confirmCta' => $strings['confirmCta'], + 'resendCta' => $strings['resendCta'], + 'resendSent' => $strings['resendSent'], + 'resendError' => $strings['resendError'], + 'checking' => $strings['checking'], + 'stillUnverified' => $strings['stillUnverified'], ), ) ); diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/write/Write_Email_Verification_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/write/Write_Email_Verification_Test.php index 89cae879271c..73863cb48b6e 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/write/Write_Email_Verification_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/write/Write_Email_Verification_Test.php @@ -9,7 +9,7 @@ require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/write/email-verification.php'; /** - * Exercises wpcom_write_launch_blocked_for_unverified_email(). + * Exercises the launch gate and its admin-ajax resend / re-check endpoints. */ class Write_Email_Verification_Test extends \WorDBless\BaseTestCase { /** @@ -25,6 +25,7 @@ public function set_up() { */ public function tear_down() { delete_option( 'site_creation_flow' ); + unset( $_REQUEST['nonce'], $_POST['nonce'] ); \Brain\Monkey\tearDown(); parent::tear_down(); } @@ -59,4 +60,96 @@ public function test_not_blocked_on_non_write_on_site() { $this->assertFalse( wpcom_write_launch_blocked_for_unverified_email() ); } + + /** + * The resend endpoint asks Email_Verification to resend and reports success. + */ + public function test_resend_endpoint_resends_and_reports_success() { + $this->set_valid_nonce(); + \Mockery::mock( 'alias:Email_Verification' ) + ->shouldReceive( 'resend_verification_email' ) + ->once(); + + $response = $this->capture_ajax_json( 'wpcom_write_ajax_resend_verification_email' ); + + $this->assertTrue( $response['success'] ); + } + + /** + * The re-check endpoint reports verified=true once the email is confirmed. + */ + public function test_check_endpoint_reports_verified_when_confirmed() { + $this->set_valid_nonce(); + \Mockery::mock( 'alias:Email_Verification' ) + ->shouldReceive( 'is_email_unverified' ) + ->andReturn( false ); + + $response = $this->capture_ajax_json( 'wpcom_write_ajax_check_email_verification' ); + + $this->assertTrue( $response['success'] ); + $this->assertTrue( $response['data']['verified'] ); + } + + /** + * The re-check endpoint reports verified=false while the email is unverified, + * so a still-unverified author can't slip past to the launch flow. + */ + public function test_check_endpoint_reports_unverified_when_still_pending() { + $this->set_valid_nonce(); + \Mockery::mock( 'alias:Email_Verification' ) + ->shouldReceive( 'is_email_unverified' ) + ->andReturn( true ); + + $response = $this->capture_ajax_json( 'wpcom_write_ajax_check_email_verification' ); + + $this->assertTrue( $response['success'] ); + $this->assertFalse( $response['data']['verified'] ); + } + + /** + * Prime a valid nonce for the shared verification action. + */ + private function set_valid_nonce() { + $nonce = wp_create_nonce( WPCOM_WRITE_EMAIL_VERIFICATION_NONCE ); + $_REQUEST['nonce'] = $nonce; + $_POST['nonce'] = $nonce; + } + + /** + * Invoke an ajax handler and return its JSON envelope as an array. + * + * WordPress's wp_send_json_* echoes the response then calls wp_die(); force the + * ajax path and throw from the die handler so we can capture the buffered JSON + * without ending the test process. + * + * @param callable $handler Ajax handler to invoke. + * @return array Decoded response. + */ + private function capture_ajax_json( $handler ) { + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + function () { + // Throw so wp_send_json_*'s wp_die() unwinds back to the test rather + // than ending the process. A `never` return type would break PHP 7.2. + // @phan-suppress-next-line PhanPluginNeverReturnFunction + return function () { + throw new \Exception( 'wp_die' ); + }; + } + ); + + ob_start(); + try { + $handler(); + } catch ( \Exception $e ) { + unset( $e ); // wp_die() from wp_send_json_*; expected. + } + $output = ob_get_clean(); + + remove_all_filters( 'wp_doing_ajax' ); + remove_all_filters( 'wp_die_ajax_handler' ); + + return json_decode( $output, true ); + } }