Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} 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' );
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
);
}

Expand Down Expand Up @@ -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'],
),
)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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();
}
Expand Down Expand Up @@ -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<string, mixed> 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 );
}
}