From 6d9111f1a1ee9a1d7b54cb5d56dfb2c0f44a1601 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 4 Feb 2026 13:15:14 -0300 Subject: [PATCH 01/27] feat(reader-registration-block): password and OTP authentication flows --- src/blocks/reader-registration/block.json | 6 +- src/blocks/reader-registration/edit.js | 18 +- src/blocks/reader-registration/index.php | 97 +++++- src/blocks/reader-registration/style.scss | 153 +++++++++ src/blocks/reader-registration/view.js | 320 ++++++++++++++++--- src/reader-activation-auth/auth-form.js | 46 +-- src/reader-activation-auth/auth-utils.js | 367 ++++++++++++++++++++++ src/reader-activation-auth/otp-input.js | 214 +++++++------ 8 files changed, 1013 insertions(+), 208 deletions(-) create mode 100644 src/reader-activation-auth/auth-utils.js diff --git a/src/blocks/reader-registration/block.json b/src/blocks/reader-registration/block.json index e7302bcc6c..aa29353a5f 100644 --- a/src/blocks/reader-registration/block.json +++ b/src/blocks/reader-registration/block.json @@ -53,13 +53,9 @@ "type": "object", "default": {} }, - "signInLabel": { - "type": "string", - "default": "Sign in to an existing account" - }, "signedInLabel": { "type": "string", - "default": "An account was already registered with this email. Please check your inbox for an authentication link." + "default": "Success! You're signed in." } }, "supports": { diff --git a/src/blocks/reader-registration/edit.js b/src/blocks/reader-registration/edit.js index 9c4d95598a..e4187ce595 100644 --- a/src/blocks/reader-registration/edit.js +++ b/src/blocks/reader-registration/edit.js @@ -42,7 +42,6 @@ export default function ReaderRegistrationEdit( { displayListDescription, hideSubscriptionInput, newsletterLabel, - signInLabel, signedInLabel, lists, listsCheckboxes, @@ -303,7 +302,7 @@ export default function ReaderRegistrationEdit( { + + + +
+ + +
+

+

+
+ +
+
+ + + +
+
+
+ +
+ + + +

+ +
$email, - 'authenticated' => $user_logged_in, - 'existing_user' => ! $user_logged_in, - 'metadata' => $metadata, - ] - ); + // For existing users, determine if they need password or OTP authentication. + $response = [ + 'email' => $email, + 'authenticated' => $user_logged_in, + 'existing_user' => ! $user_logged_in, + 'metadata' => $metadata, + ]; + + if ( ! $user_logged_in ) { + $existing_user = \get_user_by( 'email', $email ); + if ( $existing_user && Reader_Activation::is_user_reader( $existing_user ) ) { + if ( Reader_Activation::is_reader_without_password( $existing_user ) ) { + $response['action'] = 'otp'; + } else { + $response['action'] = 'pwd'; + } + } + } + + return send_form_response( $response ); } add_action( 'template_redirect', __NAMESPACE__ . '\\process_form' ); diff --git a/src/blocks/reader-registration/style.scss b/src/blocks/reader-registration/style.scss index 6891e0f98d..b9540100b2 100644 --- a/src/blocks/reader-registration/style.scss +++ b/src/blocks/reader-registration/style.scss @@ -270,6 +270,159 @@ input[type="email"].nphp { @include mixins.visuallyHidden; } + + // OTP state modifiers + &--otp { + .newspack-registration__form-content, + .newspack-registration__have-account, + .newspack-registration__help-text, + .newspack-registration__login-success, + .newspack-registration__registration-success, + .newspack-registration__password { + display: none !important; + } + + .newspack-registration__otp { + display: block !important; + } + + form { + display: block; + } + } + + // Password state modifiers + &--pwd { + .newspack-registration__form-content, + .newspack-registration__have-account, + .newspack-registration__help-text, + .newspack-registration__login-success, + .newspack-registration__registration-success, + .newspack-registration__otp { + display: none !important; + } + + .newspack-registration__password { + display: block !important; + } + + form { + display: block; + } + } + + // OTP UI styles + &__otp { + text-align: center; + + &-title { + font-size: var(--newspack-ui-font-size-m); + margin-bottom: var(--newspack-ui-spacer-2); + } + + &-email { + font-weight: 600; + margin-bottom: var(--newspack-ui-spacer-5); + } + + &-actions { + display: flex; + flex-direction: column; + gap: var(--newspack-ui-spacer-2); + margin-top: var(--newspack-ui-spacer-5); + } + + &-response { + color: var(--newspack-ui-color-error-50); + font-size: var(--newspack-ui-font-size-xs); + margin-top: var(--newspack-ui-spacer-2); + min-height: 1.5em; + + &:empty { + visibility: hidden; + } + } + } + + // Code input styling (match auth form pattern) + .newspack-ui__code-input { + display: flex; + justify-content: center; + gap: var(--newspack-ui-spacer-2); + + input[type="text"] { + width: 2.5rem; + height: 3rem; + text-align: center; + font-size: var(--newspack-ui-font-size-l); + font-weight: 600; + padding: 0; + border: 1px solid var(--newspack-ui-color-border); + border-radius: var(--newspack-ui-border-radius-s); + + &:focus { + border-color: var(--newspack-ui-color-primary-50); + outline: 1px solid var(--newspack-ui-color-primary-50); + } + } + } + + // Password UI styles + &__password { + text-align: center; + + &-title { + font-size: var(--newspack-ui-font-size-m); + margin-bottom: var(--newspack-ui-spacer-2); + } + + &-email { + font-weight: 600; + margin-bottom: var(--newspack-ui-spacer-5); + } + + &-input { + margin-bottom: var(--newspack-ui-spacer-2); + + input[type="password"] { + width: 100%; + padding: var(--newspack-ui-spacer-2) var(--newspack-ui-spacer-3); + border: 1px solid var(--newspack-ui-color-border); + border-radius: var(--newspack-ui-border-radius-s); + font-size: var(--newspack-ui-font-size-s); + + &:focus { + border-color: var(--newspack-ui-color-primary-50); + outline: 1px solid var(--newspack-ui-color-primary-50); + } + } + } + + &-actions { + display: flex; + flex-direction: column; + gap: var(--newspack-ui-spacer-2); + margin-top: var(--newspack-ui-spacer-3); + } + + &-response { + color: var(--newspack-ui-color-error-50); + font-size: var(--newspack-ui-font-size-xs); + margin-top: var(--newspack-ui-spacer-2); + min-height: 1.5em; + + &:empty { + visibility: hidden; + } + } + } + + // Pending verification state + &__pending-verification { + p { + margin: var(--newspack-ui-spacer-2) 0 var(--newspack-ui-spacer-4); + } + } } @container registration ( width > 568px ) { diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index fdf4642b11..c529188f6a 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -2,29 +2,14 @@ * Internal dependencies */ import './style.scss'; - -/** - * Specify a function to execute when the DOM is fully loaded. - * - * @see https://github.com/WordPress/gutenberg/blob/trunk/packages/dom-ready/ - * - * @param {Function} callback A function to execute after the DOM is ready. - * @return {void} - */ -function domReady( callback ) { - if ( typeof document === 'undefined' ) { - return; - } - if ( - document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly. - document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly. - ) { - return void callback(); - } - // DOMContentLoaded has not fired yet, delay callback until then. - document.addEventListener( 'DOMContentLoaded', callback ); -} - +import { domReady } from '../../utils'; +import { initOTPInput } from '../../reader-activation-auth/otp-input'; +import { + createOTPTimerHandler, + createOTPSubmitHandler, + createPasswordSubmitHandler, + createSendLinkHandler, +} from '../../reader-activation-auth/auth-utils'; window.newspackRAS = window.newspackRAS || []; window.newspackRAS.push( function ( readerActivation ) { @@ -40,7 +25,254 @@ window.newspackRAS.push( function ( readerActivation ) { const submitElement = form.querySelector( 'button[type="submit"]' ); const spinner = document.createElement( 'span' ); spinner.classList.add( 'spinner' ); - let successElement = container.querySelector( '.newspack-registration__registration-success' ); + + // OTP elements + const otpEmailElement = container.querySelector( '.newspack-registration__otp-email' ); + const otpSubmitButton = container.querySelector( '[data-otp-submit]' ); + const otpResendButton = container.querySelector( '[data-otp-resend]' ); + const otpBackButton = container.querySelector( '[data-otp-back]' ); + const otpResponseElement = container.querySelector( '.newspack-registration__otp-response' ); + + // Password elements + const pwdEmailElement = container.querySelector( '.newspack-registration__password-email' ); + const pwdInput = container.querySelector( '.newspack-registration__password-input input[name="password"]' ); + const pwdSubmitButton = container.querySelector( '[data-pwd-submit]' ); + const pwdLinkButton = container.querySelector( '[data-pwd-link]' ); + const pwdBackButton = container.querySelector( '[data-pwd-back]' ); + const pwdResponseElement = container.querySelector( '.newspack-registration__password-response' ); + + // Pending verification elements + const resendVerificationButton = container.querySelector( '[data-resend-verification]' ); + + // Initialize OTP input lazily + let otpCodeInput = null; + const ensureOtpInputInitialized = () => { + if ( otpCodeInput ) { + return true; + } + const originalInput = container.querySelector( '.newspack-ui__code-input input[name="otp_code"]' ); + if ( originalInput ) { + otpCodeInput = initOTPInput( originalInput ); + } + return !! otpCodeInput; + }; + + // Store the email for auth flows + let currentEmail = ''; + + // Get form action URL + const getActionUrl = () => form.getAttribute( 'action' ) || window.location.pathname; + + // Create OTP timer handler using shared utility + const handleOTPTimer = createOTPTimerHandler( readerActivation, otpResendButton ); + + /** + * Clear error message. + * + * @param {HTMLElement} element Response element. + */ + const clearError = element => { + if ( element ) { + element.textContent = ''; + } + }; + + /** + * Set the current form state. + * + * @param {string} state State name: 'form', 'otp', 'pwd'. + * @param {string} email Email address. + */ + const setFormState = ( state, email = '' ) => { + // Remove all state classes + container.classList.remove( 'newspack-registration--otp', 'newspack-registration--pwd' ); + + if ( email ) { + currentEmail = email; + readerActivation.setReaderEmail( email ); + } + + if ( state === 'otp' ) { + ensureOtpInputInitialized(); + container.classList.add( 'newspack-registration--otp' ); + if ( otpEmailElement ) { + otpEmailElement.textContent = currentEmail; + } + clearError( otpResponseElement ); + // Focus first OTP digit + const firstInput = container.querySelector( '.newspack-ui__code-input input[data-index="0"]' ); + if ( firstInput ) { + firstInput.focus(); + } + readerActivation.setOTPTimer(); + handleOTPTimer(); + } else if ( state === 'pwd' ) { + container.classList.add( 'newspack-registration--pwd' ); + if ( pwdEmailElement ) { + pwdEmailElement.textContent = currentEmail; + } + clearError( pwdResponseElement ); + if ( pwdInput ) { + pwdInput.value = ''; + pwdInput.focus(); + } + } + }; + + // Create handlers using shared utilities + const handleOtpSubmit = createOTPSubmitHandler( + readerActivation, + { + getOtpCode: () => otpCodeInput?.value, + submitButton: otpSubmitButton, + responseElement: otpResponseElement, + }, + { + onSuccess: data => { + setFormState( 'form' ); + // OTP flow is always for existing users + form.endLoginFlow( data.message, 200, { ...data, existing_user: true } ); + }, + onExpired: () => setFormState( 'form' ), + } + ); + + const handlePwdSubmit = createPasswordSubmitHandler( + { + getEmail: () => currentEmail, + passwordInput: pwdInput, + submitButton: pwdSubmitButton, + responseElement: pwdResponseElement, + }, + getActionUrl(), + { + onSuccess: ( message, data ) => { + setFormState( 'form' ); + // Password flow is always for existing users + form.endLoginFlow( message, 200, { ...data, existing_user: true } ); + }, + } + ); + + const handleSendLink = createSendLinkHandler( + { + getEmail: () => currentEmail, + linkButton: pwdLinkButton, + responseElement: pwdResponseElement, + }, + getActionUrl(), + { + onSuccess: () => setFormState( 'otp', currentEmail ), + } + ); + + /** + * Handle OTP resend. + */ + const handleOtpResend = () => { + if ( ! currentEmail ) { + return; + } + clearError( otpResponseElement ); + if ( otpResendButton ) { + otpResendButton.disabled = true; + } + + const resendBody = new FormData(); + resendBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); + resendBody.set( 'npe', currentEmail ); + + fetch( getActionUrl(), { + method: 'POST', + headers: { Accept: 'application/json' }, + body: resendBody, + } ) + .then( res => { + if ( res.status === 200 ) { + readerActivation.setOTPTimer(); + handleOTPTimer(); + } else { + res.json().then( ( { message } ) => { + if ( otpResponseElement ) { + otpResponseElement.textContent = message || 'Failed to resend code.'; + } + } ); + } + } ) + .catch( () => { + if ( otpResponseElement ) { + otpResponseElement.textContent = 'Failed to resend code.'; + } + } ); + }; + + // Attach OTP event listeners + if ( otpSubmitButton ) { + otpSubmitButton.addEventListener( 'click', handleOtpSubmit ); + } + if ( otpResendButton ) { + otpResendButton.addEventListener( 'click', handleOtpResend ); + } + if ( otpBackButton ) { + otpBackButton.addEventListener( 'click', () => setFormState( 'form' ) ); + } + + // Attach password event listeners + if ( pwdSubmitButton ) { + pwdSubmitButton.addEventListener( 'click', handlePwdSubmit ); + } + if ( pwdLinkButton ) { + pwdLinkButton.addEventListener( 'click', handleSendLink ); + } + if ( pwdBackButton ) { + pwdBackButton.addEventListener( 'click', () => setFormState( 'form' ) ); + } + if ( pwdInput ) { + pwdInput.addEventListener( 'keydown', ev => { + if ( ev.key === 'Enter' ) { + ev.preventDefault(); + handlePwdSubmit(); + } + } ); + } + + // Handle pending verification resend + if ( resendVerificationButton ) { + resendVerificationButton.addEventListener( 'click', () => { + resendVerificationButton.disabled = true; + const reader = readerActivation.getReader(); + const email = reader?.email; + + if ( ! email ) { + resendVerificationButton.disabled = false; + return; + } + + const verifyBody = new FormData(); + verifyBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); + verifyBody.set( 'npe', email ); + + fetch( getActionUrl(), { + method: 'POST', + headers: { Accept: 'application/json' }, + body: verifyBody, + } ) + .then( res => { + if ( res.status === 200 ) { + resendVerificationButton.textContent = 'Email sent!'; + setTimeout( () => { + resendVerificationButton.textContent = 'Resend verification email'; + resendVerificationButton.disabled = false; + }, 3000 ); + } else { + resendVerificationButton.disabled = false; + } + } ) + .catch( () => { + resendVerificationButton.disabled = false; + } ); + } ); + } form.startLoginFlow = () => { messageElement.classList.add( 'newspack-registration--hidden' ); @@ -53,10 +285,27 @@ window.newspackRAS.push( function ( readerActivation ) { form.endLoginFlow = ( message = null, status = 500, data = null ) => { let messageNode; - if ( data?.existing_user ) { - successElement = container.querySelector( '.newspack-registration__login-success' ); + // Handle auth flow for existing users based on action + if ( data?.existing_user && ! data?.authenticated && data?.action ) { + const email = data.email || form.npe?.value; + setFormState( data.action, email ); + if ( submitElement.contains( spinner ) ) { + submitElement.removeChild( spinner ); + } + submitElement.disabled = false; + container.classList.remove( 'newspack-registration--in-progress' ); + return; } + // Determine which success element to show + const registrationSuccessEl = container.querySelector( '.newspack-registration__registration-success' ); + const loginSuccessEl = container.querySelector( '.newspack-registration__login-success' ); + const successElement = data?.existing_user ? loginSuccessEl : registrationSuccessEl; + + // Hide both success elements first to ensure only one shows + registrationSuccessEl?.classList.add( 'newspack-registration--hidden' ); + loginSuccessEl?.classList.add( 'newspack-registration--hidden' ); + if ( message ) { messageNode = document.createElement( 'p' ); messageNode.textContent = message; @@ -74,7 +323,6 @@ window.newspackRAS.push( function ( readerActivation ) { if ( data?.email ) { body = new FormData( form ); readerActivation.setReaderEmail( data.email ); - // Set authenticated only if email is set, otherwise an error will be thrown. readerActivation.setAuthenticated( data?.authenticated ); if ( data.authenticated ) { @@ -126,33 +374,25 @@ window.newspackRAS.push( function ( readerActivation ) { form.startLoginFlow(); if ( ! form.npe?.value ) { - return form.endLoginFlow( 'Please enter a vaild email address.', 400 ); + return form.endLoginFlow( 'Please enter a valid email address.', 400 ); } body = new FormData( form ); if ( ! body.has( 'npe' ) || ! body.get( 'npe' ) ) { - return form.endFlow( 'Please enter a vaild email address.', 400 ); + return form.endLoginFlow( 'Please enter a valid email address.', 400 ); } - fetch( form.getAttribute( 'action' ) || window.location.pathname, { + fetch( getActionUrl(), { method: 'POST', - headers: { - Accept: 'application/json', - }, + headers: { Accept: 'application/json' }, body, } ) .then( res => { res.json().then( ( { message, data } ) => form.endLoginFlow( message, res.status, data ) ); } ) .catch( e => { - form.endLoginFlow( e, 400 ); + form.endLoginFlow( e?.message || 'An error occurred.', 400 ); } ); } ); - - readerActivation.on( 'reader', ( { detail: { authenticated } } ) => { - if ( authenticated ) { - form.endLoginFlow( null, 200 ); - } - } ); } ); } ); } ); diff --git a/src/reader-activation-auth/auth-form.js b/src/reader-activation-auth/auth-form.js index 16c2a53c58..d304d44599 100644 --- a/src/reader-activation-auth/auth-form.js +++ b/src/reader-activation-auth/auth-form.js @@ -3,9 +3,10 @@ /** * Internal dependencies. */ -import { domReady, formatTime } from '../utils'; +import { domReady } from '../utils'; import { getPendingCheckout } from '../reader-activation/checkout'; import { openNewslettersSignupModal } from '../reader-activation-newsletters/newsletters-modal'; +import { createOTPTimerHandler, verifyOTP } from './auth-utils'; import './google-oauth'; import './otp-input'; @@ -192,29 +193,9 @@ window.newspackRAS.push( function ( readerActivation ) { } ); /** - * Handle OTP Timer. + * Handle OTP Timer using shared utility. */ - const handleOTPTimer = () => { - if ( ! resendCodeButton ) { - return; - } - resendCodeButton.originalButtonText = resendCodeButton.textContent.replace( /\s\(\d{1,}:\d{2}\)/, '' ); - const updateButton = () => { - const remaining = readerActivation.getOTPTimeRemaining(); - if ( remaining ) { - resendCodeButton.textContent = `${ resendCodeButton.originalButtonText } (${ formatTime( remaining ) })`; - } else { - resendCodeButton.textContent = resendCodeButton.originalButtonText; - clearInterval( resendCodeButton.otpTimerInterval ); - } - resendCodeButton.disabled = !! remaining; - }; - const remaining = readerActivation.getOTPTimeRemaining(); - if ( remaining ) { - resendCodeButton.otpTimerInterval = setInterval( updateButton, 1000 ); - updateButton(); - } - }; + const handleOTPTimer = createOTPTimerHandler( readerActivation, resendCodeButton ); if ( sendCodeButton || resendCodeButton ) { [ sendCodeButton, resendCodeButton ].forEach( button => { @@ -432,17 +413,18 @@ window.newspackRAS.push( function ( readerActivation ) { } if ( 'otp' === action ) { - readerActivation - .authenticateOTP( body.get( 'otp_code' ) ) - .then( data => { + verifyOTP( readerActivation, body.get( 'otp_code' ), { + onSuccess: data => { form.endLoginFlow( data.message, 200, data ); - } ) - .catch( data => { - if ( data.expired ) { - container.setFormAction( 'signin' ); - } + }, + onExpired: ( errorMessage, data ) => { + container.setFormAction( 'signin' ); form.endLoginFlow( data.message, 400 ); - } ); + }, + onError: ( errorMessage, data ) => { + form.endLoginFlow( data?.message || errorMessage, 400 ); + }, + } ); } else { fetch( form.getAttribute( 'action' ) || window.location.pathname, { method: 'POST', diff --git a/src/reader-activation-auth/auth-utils.js b/src/reader-activation-auth/auth-utils.js new file mode 100644 index 0000000000..10bbe36ed6 --- /dev/null +++ b/src/reader-activation-auth/auth-utils.js @@ -0,0 +1,367 @@ +/** + * Shared utilities for authentication flows. + * + * Used by both the auth form modal and the reader registration block. + */ + +import { formatTime } from '../utils'; + +/** + * Create an OTP timer handler for a resend button. + * + * @param {Object} readerActivation The readerActivation API. + * @param {HTMLButtonElement} resendButton The resend button element. + * + * @return {Function} A function to start/update the OTP timer. + */ +export function createOTPTimerHandler( readerActivation, resendButton ) { + if ( ! resendButton ) { + return () => {}; + } + + return () => { + resendButton.originalButtonText = resendButton.textContent.replace( /\s\(\d{1,}:\d{2}\)/, '' ); + + const updateButton = () => { + const remaining = readerActivation.getOTPTimeRemaining(); + if ( remaining ) { + resendButton.textContent = `${ resendButton.originalButtonText } (${ formatTime( remaining ) })`; + resendButton.disabled = true; + } else { + resendButton.textContent = resendButton.originalButtonText; + resendButton.disabled = false; + clearInterval( resendButton.otpTimerInterval ); + } + }; + + const remaining = readerActivation.getOTPTimeRemaining(); + if ( remaining ) { + resendButton.otpTimerInterval = setInterval( updateButton, 1000 ); + updateButton(); + } + }; +} + +/** + * Handle OTP verification. + * + * @param {Object} readerActivation The readerActivation API. + * @param {string} code The OTP code to verify. + * @param {Object} options Options object. + * @param {Function} options.onSuccess Callback on successful verification. + * @param {Function} options.onError Callback on error. + * @param {Function} options.onExpired Callback when OTP has expired. + * @param {Function} options.onFinally Callback that always runs after verification. + * + * @return {Promise} The authentication promise. + */ +export function verifyOTP( readerActivation, code, { onSuccess, onError, onExpired, onFinally } = {} ) { + return readerActivation + .authenticateOTP( code ) + .then( data => { + if ( onSuccess ) { + onSuccess( data ); + } + } ) + .catch( data => { + const errorMessage = data?.message || 'Invalid code. Please try again.'; + if ( data?.expired ) { + if ( onExpired ) { + onExpired( errorMessage, data ); + } else if ( onError ) { + onError( errorMessage, data ); + } + } else if ( onError ) { + onError( errorMessage, data ); + } + } ) + .finally( () => { + if ( onFinally ) { + onFinally(); + } + } ); +} + +/** + * Send authentication link (magic link) to an email address. + * + * @param {string} email The email address. + * @param {string} actionUrl The form action URL. + * @param {Object} options Options object. + * @param {string} options.formId Optional form identifier (e.g., 'reader-activation-auth-form'). + * @param {string} options.redirectUrl Optional redirect URL after authentication. + * @param {Function} options.onSuccess Callback on success. + * @param {Function} options.onError Callback on error. + * @param {Function} options.onFinally Callback that always runs. + * + * @return {Promise} The fetch promise. + */ +export function sendAuthLink( email, actionUrl, { formId = 'reader-activation-auth-form', redirectUrl, onSuccess, onError, onFinally } = {} ) { + const body = new FormData(); + body.set( formId, '1' ); + body.set( 'npe', email ); + body.set( 'action', 'link' ); + + if ( redirectUrl ) { + body.set( 'redirect_url', redirectUrl ); + } + + return fetch( actionUrl || window.location.pathname, { + method: 'POST', + headers: { Accept: 'application/json' }, + body, + } ) + .then( res => { + if ( res.status === 200 ) { + if ( onSuccess ) { + onSuccess( res ); + } + } else { + res.json().then( ( { message } ) => { + if ( onError ) { + onError( message || 'Failed to send authentication link.' ); + } + } ); + } + } ) + .catch( () => { + if ( onError ) { + onError( 'Failed to send authentication link.' ); + } + } ) + .finally( () => { + if ( onFinally ) { + onFinally(); + } + } ); +} + +/** + * Authenticate with password. + * + * @param {string} email The email address. + * @param {string} password The password. + * @param {string} actionUrl The form action URL. + * @param {Object} options Options object. + * @param {string} options.formId Optional form identifier. + * @param {Function} options.onSuccess Callback on success with data. + * @param {Function} options.onError Callback on error with message. + * @param {Function} options.onFinally Callback that always runs. + * + * @return {Promise} The fetch promise. + */ +export function authenticateWithPassword( + email, + password, + actionUrl, + { formId = 'reader-activation-auth-form', onSuccess, onError, onFinally } = {} +) { + const body = new FormData(); + body.set( formId, '1' ); + body.set( 'npe', email ); + body.set( 'password', password ); + body.set( 'action', 'pwd' ); + + return fetch( actionUrl || window.location.pathname, { + method: 'POST', + headers: { Accept: 'application/json' }, + body, + } ) + .then( res => { + res.json().then( ( { message, data } ) => { + if ( res.status === 200 && data?.authenticated ) { + if ( onSuccess ) { + onSuccess( message, data ); + } + } else if ( onError ) { + onError( message || 'Password not recognized, try again.' ); + } + } ); + } ) + .catch( () => { + if ( onError ) { + onError( 'An error occurred. Please try again.' ); + } + } ) + .finally( () => { + if ( onFinally ) { + onFinally(); + } + } ); +} + +/** + * Create an OTP submission handler. + * + * @param {Object} readerActivation The readerActivation API. + * @param {Object} elements DOM elements. + * @param {Function} elements.getOtpCode Function that returns the OTP code value. + * @param {HTMLElement} elements.submitButton The submit button element. + * @param {HTMLElement} elements.responseElement The response/error element. + * @param {Object} callbacks Callback functions. + * @param {Function} callbacks.onSuccess Callback on successful verification. + * @param {Function} callbacks.onExpired Callback when OTP has expired. + * + * @return {Function} The submit handler function. + */ +export function createOTPSubmitHandler( readerActivation, { getOtpCode, submitButton, responseElement }, { onSuccess, onExpired } = {} ) { + const showError = message => { + if ( responseElement ) { + responseElement.textContent = message; + } + }; + + const clearError = () => { + if ( responseElement ) { + responseElement.textContent = ''; + } + }; + + return () => { + const code = getOtpCode(); + if ( ! code || code.length !== 6 ) { + showError( 'Please enter the 6-digit code.' ); + return; + } + clearError(); + if ( submitButton ) { + submitButton.disabled = true; + } + + verifyOTP( readerActivation, code, { + onSuccess: data => { + if ( onSuccess ) { + onSuccess( data ); + } + }, + onExpired: errorMessage => { + showError( errorMessage ); + if ( onExpired ) { + setTimeout( onExpired, 2000 ); + } + }, + onError: errorMessage => { + showError( errorMessage ); + }, + onFinally: () => { + if ( submitButton ) { + submitButton.disabled = false; + } + }, + } ); + }; +} + +/** + * Create a password submission handler. + * + * @param {Object} elements DOM elements. + * @param {Function} elements.getEmail Function that returns the current email. + * @param {HTMLElement} elements.passwordInput The password input element. + * @param {HTMLElement} elements.submitButton The submit button element. + * @param {HTMLElement} elements.responseElement The response/error element. + * @param {string} actionUrl The form action URL. + * @param {Object} callbacks Callback functions. + * @param {Function} callbacks.onSuccess Callback on successful authentication. + * + * @return {Function} The submit handler function. + */ +export function createPasswordSubmitHandler( { getEmail, passwordInput, submitButton, responseElement }, actionUrl, { onSuccess } = {} ) { + const showError = message => { + if ( responseElement ) { + responseElement.textContent = message; + } + }; + + const clearError = () => { + if ( responseElement ) { + responseElement.textContent = ''; + } + }; + + return () => { + const email = getEmail(); + if ( ! passwordInput || ! email ) { + return; + } + const password = passwordInput.value; + if ( ! password ) { + showError( 'Please enter your password.' ); + return; + } + clearError(); + if ( submitButton ) { + submitButton.disabled = true; + } + + authenticateWithPassword( email, password, actionUrl, { + onSuccess: ( message, data ) => { + if ( onSuccess ) { + onSuccess( message, data ); + } + }, + onError: errorMessage => { + showError( errorMessage ); + }, + onFinally: () => { + if ( submitButton ) { + submitButton.disabled = false; + } + }, + } ); + }; +} + +/** + * Create a "send link" handler that sends a magic link and transitions to OTP state. + * + * @param {Object} elements DOM elements. + * @param {Function} elements.getEmail Function that returns the current email. + * @param {HTMLElement} elements.linkButton The send link button element. + * @param {HTMLElement} elements.responseElement The response/error element. + * @param {string} actionUrl The form action URL. + * @param {Object} callbacks Callback functions. + * @param {Function} callbacks.onSuccess Callback on success (to transition to OTP state). + * + * @return {Function} The handler function. + */ +export function createSendLinkHandler( { getEmail, linkButton, responseElement }, actionUrl, { onSuccess } = {} ) { + const showError = message => { + if ( responseElement ) { + responseElement.textContent = message; + } + }; + + const clearError = () => { + if ( responseElement ) { + responseElement.textContent = ''; + } + }; + + return () => { + const email = getEmail(); + if ( ! email ) { + return; + } + clearError(); + if ( linkButton ) { + linkButton.disabled = true; + } + + sendAuthLink( email, actionUrl, { + onSuccess: () => { + if ( onSuccess ) { + onSuccess(); + } + }, + onError: errorMessage => { + showError( errorMessage ); + }, + onFinally: () => { + if ( linkButton ) { + linkButton.disabled = false; + } + }, + } ); + }; +} diff --git a/src/reader-activation-auth/otp-input.js b/src/reader-activation-auth/otp-input.js index ae282f98b1..46496fbe79 100644 --- a/src/reader-activation-auth/otp-input.js +++ b/src/reader-activation-auth/otp-input.js @@ -3,112 +3,128 @@ */ import { domReady } from '../utils'; -domReady( function () { - /** - * OTP Input - */ - const otpInputs = document.querySelectorAll( 'input[name="otp_code"]' ); - otpInputs.forEach( originalInput => { - const length = parseInt( originalInput.getAttribute( 'maxlength' ) ); - if ( ! length ) { - return; - } - const inputContainer = originalInput.parentNode; - inputContainer.removeChild( originalInput ); - const values = []; - const otpCodeInput = document.createElement( 'input' ); - otpCodeInput.setAttribute( 'type', 'hidden' ); - otpCodeInput.setAttribute( 'name', 'otp_code' ); - inputContainer.appendChild( otpCodeInput ); - for ( let i = 0; i < length; i++ ) { - const digit = document.createElement( 'input' ); - digit.setAttribute( 'type', 'text' ); - digit.setAttribute( 'pattern', '[0-9]' ); - digit.setAttribute( 'autocomplete', 0 === i ? 'one-time-code' : 'off' ); - digit.setAttribute( 'inputmode', 'numeric' ); - digit.setAttribute( 'data-index', i ); - digit.addEventListener( 'keydown', ev => { - const prev = inputContainer.querySelector( `[data-index="${ i - 1 }"]` ); - const next = inputContainer.querySelector( `[data-index="${ i + 1 }"]` ); - switch ( ev.key ) { - case 'Backspace': - ev.preventDefault(); - ev.target.value = ''; - if ( prev ) { - prev.focus(); - } - values[ i ] = ''; - otpCodeInput.value = values.join( '' ); - break; - case 'ArrowLeft': - ev.preventDefault(); - if ( prev ) { - prev.focus(); - } - break; - case 'ArrowRight': +/** + * Initialize OTP input - transforms a single input into multiple digit inputs. + * + * @param {HTMLInputElement} originalInput The original input element with name="otp_code". + * + * @return {HTMLInputElement|null} The hidden input holding the OTP code value, or null if initialization failed. + */ +export function initOTPInput( originalInput ) { + if ( ! originalInput ) { + return null; + } + const length = parseInt( originalInput.getAttribute( 'maxlength' ) ); + if ( ! length ) { + return originalInput; + } + const inputContainer = originalInput.parentNode; + inputContainer.removeChild( originalInput ); + const values = []; + const otpCodeInput = document.createElement( 'input' ); + otpCodeInput.setAttribute( 'type', 'hidden' ); + otpCodeInput.setAttribute( 'name', 'otp_code' ); + inputContainer.appendChild( otpCodeInput ); + for ( let i = 0; i < length; i++ ) { + const digit = document.createElement( 'input' ); + digit.setAttribute( 'type', 'text' ); + digit.setAttribute( 'pattern', '[0-9]' ); + digit.setAttribute( 'autocomplete', 0 === i ? 'one-time-code' : 'off' ); + digit.setAttribute( 'inputmode', 'numeric' ); + digit.setAttribute( 'data-index', i ); + digit.addEventListener( 'keydown', ev => { + const prev = inputContainer.querySelector( `[data-index="${ i - 1 }"]` ); + const next = inputContainer.querySelector( `[data-index="${ i + 1 }"]` ); + switch ( ev.key ) { + case 'Backspace': + ev.preventDefault(); + ev.target.value = ''; + if ( prev ) { + prev.focus(); + } + values[ i ] = ''; + otpCodeInput.value = values.join( '' ); + break; + case 'ArrowLeft': + ev.preventDefault(); + if ( prev ) { + prev.focus(); + } + break; + case 'ArrowRight': + ev.preventDefault(); + if ( next ) { + next.focus(); + } + break; + default: + if ( ev.key.match( /^[0-9]$/ ) ) { ev.preventDefault(); + ev.target.value = ev.key; + ev.target.dispatchEvent( + new Event( 'input', { + bubbles: true, + cancelable: true, + } ) + ); if ( next ) { next.focus(); } - break; - default: - if ( ev.key.match( /^[0-9]$/ ) ) { - ev.preventDefault(); - ev.target.value = ev.key; - ev.target.dispatchEvent( - new Event( 'input', { - bubbles: true, - cancelable: true, - } ) - ); - if ( next ) { - next.focus(); - } - } - break; - } - } ); - digit.addEventListener( 'input', ev => { - const otpInput = ev.target.value.trim(); - if ( length === otpInput.length ) { - for ( let index = 0; index < length; index++ ) { - const char = otpInput[ index ]; - if ( /^[0-9]$/.test( char ) ) { - const input = inputContainer.querySelector( `[data-index="${ index }"]` ); - input.value = char; - values[ index ] = char; - } } - otpCodeInput.value = values.join( '' ); - return; - } else if ( otpInput.match( /^[0-9]$/ ) ) { - values[ i ] = otpInput; - const next = inputContainer.querySelector( `[data-index="${ i + 1 }"]` ); - if ( next ) { - next.focus(); + break; + } + } ); + digit.addEventListener( 'input', ev => { + const otpInput = ev.target.value.trim(); + if ( length === otpInput.length ) { + for ( let index = 0; index < length; index++ ) { + const char = otpInput[ index ]; + if ( /^[0-9]$/.test( char ) ) { + const input = inputContainer.querySelector( `[data-index="${ index }"]` ); + input.value = char; + values[ index ] = char; } - } else { - ev.target.value = ''; } otpCodeInput.value = values.join( '' ); - } ); - digit.addEventListener( 'paste', ev => { - ev.preventDefault(); - const paste = ( ev.clipboardData || window.clipboardData ).getData( 'text' ); - if ( paste.length !== length ) { - return; + return; + } else if ( otpInput.match( /^[0-9]$/ ) ) { + values[ i ] = otpInput; + const next = inputContainer.querySelector( `[data-index="${ i + 1 }"]` ); + if ( next ) { + next.focus(); } - for ( let j = 0; j < length; j++ ) { - if ( paste[ j ].match( /^[0-9]$/ ) ) { - const digitInput = inputContainer.querySelector( `[data-index="${ j }"]` ); - digitInput.value = paste[ j ]; - values[ j ] = paste[ j ]; - } + } else { + ev.target.value = ''; + } + otpCodeInput.value = values.join( '' ); + } ); + digit.addEventListener( 'paste', ev => { + ev.preventDefault(); + const paste = ( ev.clipboardData || window.clipboardData ).getData( 'text' ); + if ( paste.length !== length ) { + return; + } + for ( let j = 0; j < length; j++ ) { + if ( paste[ j ].match( /^[0-9]$/ ) ) { + const digitInput = inputContainer.querySelector( `[data-index="${ j }"]` ); + digitInput.value = paste[ j ]; + values[ j ] = paste[ j ]; } - otpCodeInput.value = values.join( '' ); - } ); - inputContainer.appendChild( digit ); - } - } ); -} ); + } + otpCodeInput.value = values.join( '' ); + } ); + inputContainer.appendChild( digit ); + } + return otpCodeInput; +} + +/** + * Initialize all OTP inputs on the page. + */ +export function initAllOTPInputs() { + const otpInputs = document.querySelectorAll( 'input[name="otp_code"]' ); + otpInputs.forEach( initOTPInput ); +} + +// Auto-initialize on DOM ready for backwards compatibility +domReady( initAllOTPInputs ); From 4d64fb609780c81605e3aced88da9573e02e4f59 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 4 Feb 2026 15:16:23 -0300 Subject: [PATCH 02/27] refactor: streamline authentication flow --- src/blocks/reader-registration/index.php | 52 +-- src/blocks/reader-registration/style.scss | 146 --------- src/blocks/reader-registration/view.js | 324 ++++--------------- src/reader-activation-auth/auth-form.js | 46 ++- src/reader-activation-auth/auth-utils.js | 367 ---------------------- 5 files changed, 107 insertions(+), 828 deletions(-) delete mode 100644 src/reader-activation-auth/auth-utils.js diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index 602828e02f..3df5606a7c 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -310,46 +310,6 @@ class="newspack-ui__button newspack-ui__button--primary" - -
-

-

-
- -
-
- - - -
-
-
- -
-

-

-
- -
-
- - - -
-
-
@@ -516,7 +476,17 @@ function process_form() { $existing_user = \get_user_by( 'email', $email ); if ( $existing_user && Reader_Activation::is_user_reader( $existing_user ) ) { if ( Reader_Activation::is_reader_without_password( $existing_user ) ) { - $response['action'] = 'otp'; + // Check if there's already an active token. + if ( \Newspack\Magic_Link::has_active_token( $existing_user ) ) { + $response['action'] = 'otp'; + } else { + // Send the magic link email which also sets the OTP hash cookie. + $sent = \Newspack\Magic_Link::send_email( $existing_user ); + if ( true === $sent ) { + $response['action'] = 'otp'; + } + // If sending failed, don't set action - let the auth modal handle it. + } } else { $response['action'] = 'pwd'; } diff --git a/src/blocks/reader-registration/style.scss b/src/blocks/reader-registration/style.scss index b9540100b2..56adf17549 100644 --- a/src/blocks/reader-registration/style.scss +++ b/src/blocks/reader-registration/style.scss @@ -271,152 +271,6 @@ @include mixins.visuallyHidden; } - // OTP state modifiers - &--otp { - .newspack-registration__form-content, - .newspack-registration__have-account, - .newspack-registration__help-text, - .newspack-registration__login-success, - .newspack-registration__registration-success, - .newspack-registration__password { - display: none !important; - } - - .newspack-registration__otp { - display: block !important; - } - - form { - display: block; - } - } - - // Password state modifiers - &--pwd { - .newspack-registration__form-content, - .newspack-registration__have-account, - .newspack-registration__help-text, - .newspack-registration__login-success, - .newspack-registration__registration-success, - .newspack-registration__otp { - display: none !important; - } - - .newspack-registration__password { - display: block !important; - } - - form { - display: block; - } - } - - // OTP UI styles - &__otp { - text-align: center; - - &-title { - font-size: var(--newspack-ui-font-size-m); - margin-bottom: var(--newspack-ui-spacer-2); - } - - &-email { - font-weight: 600; - margin-bottom: var(--newspack-ui-spacer-5); - } - - &-actions { - display: flex; - flex-direction: column; - gap: var(--newspack-ui-spacer-2); - margin-top: var(--newspack-ui-spacer-5); - } - - &-response { - color: var(--newspack-ui-color-error-50); - font-size: var(--newspack-ui-font-size-xs); - margin-top: var(--newspack-ui-spacer-2); - min-height: 1.5em; - - &:empty { - visibility: hidden; - } - } - } - - // Code input styling (match auth form pattern) - .newspack-ui__code-input { - display: flex; - justify-content: center; - gap: var(--newspack-ui-spacer-2); - - input[type="text"] { - width: 2.5rem; - height: 3rem; - text-align: center; - font-size: var(--newspack-ui-font-size-l); - font-weight: 600; - padding: 0; - border: 1px solid var(--newspack-ui-color-border); - border-radius: var(--newspack-ui-border-radius-s); - - &:focus { - border-color: var(--newspack-ui-color-primary-50); - outline: 1px solid var(--newspack-ui-color-primary-50); - } - } - } - - // Password UI styles - &__password { - text-align: center; - - &-title { - font-size: var(--newspack-ui-font-size-m); - margin-bottom: var(--newspack-ui-spacer-2); - } - - &-email { - font-weight: 600; - margin-bottom: var(--newspack-ui-spacer-5); - } - - &-input { - margin-bottom: var(--newspack-ui-spacer-2); - - input[type="password"] { - width: 100%; - padding: var(--newspack-ui-spacer-2) var(--newspack-ui-spacer-3); - border: 1px solid var(--newspack-ui-color-border); - border-radius: var(--newspack-ui-border-radius-s); - font-size: var(--newspack-ui-font-size-s); - - &:focus { - border-color: var(--newspack-ui-color-primary-50); - outline: 1px solid var(--newspack-ui-color-primary-50); - } - } - } - - &-actions { - display: flex; - flex-direction: column; - gap: var(--newspack-ui-spacer-2); - margin-top: var(--newspack-ui-spacer-3); - } - - &-response { - color: var(--newspack-ui-color-error-50); - font-size: var(--newspack-ui-font-size-xs); - margin-top: var(--newspack-ui-spacer-2); - min-height: 1.5em; - - &:empty { - visibility: hidden; - } - } - } - // Pending verification state &__pending-verification { p { diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index c529188f6a..77967506e2 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -3,13 +3,8 @@ */ import './style.scss'; import { domReady } from '../../utils'; -import { initOTPInput } from '../../reader-activation-auth/otp-input'; -import { - createOTPTimerHandler, - createOTPSubmitHandler, - createPasswordSubmitHandler, - createSendLinkHandler, -} from '../../reader-activation-auth/auth-utils'; +import { openAuthModal } from '../../reader-activation-auth/auth-modal'; + window.newspackRAS = window.newspackRAS || []; window.newspackRAS.push( function ( readerActivation ) { @@ -26,254 +21,6 @@ window.newspackRAS.push( function ( readerActivation ) { const spinner = document.createElement( 'span' ); spinner.classList.add( 'spinner' ); - // OTP elements - const otpEmailElement = container.querySelector( '.newspack-registration__otp-email' ); - const otpSubmitButton = container.querySelector( '[data-otp-submit]' ); - const otpResendButton = container.querySelector( '[data-otp-resend]' ); - const otpBackButton = container.querySelector( '[data-otp-back]' ); - const otpResponseElement = container.querySelector( '.newspack-registration__otp-response' ); - - // Password elements - const pwdEmailElement = container.querySelector( '.newspack-registration__password-email' ); - const pwdInput = container.querySelector( '.newspack-registration__password-input input[name="password"]' ); - const pwdSubmitButton = container.querySelector( '[data-pwd-submit]' ); - const pwdLinkButton = container.querySelector( '[data-pwd-link]' ); - const pwdBackButton = container.querySelector( '[data-pwd-back]' ); - const pwdResponseElement = container.querySelector( '.newspack-registration__password-response' ); - - // Pending verification elements - const resendVerificationButton = container.querySelector( '[data-resend-verification]' ); - - // Initialize OTP input lazily - let otpCodeInput = null; - const ensureOtpInputInitialized = () => { - if ( otpCodeInput ) { - return true; - } - const originalInput = container.querySelector( '.newspack-ui__code-input input[name="otp_code"]' ); - if ( originalInput ) { - otpCodeInput = initOTPInput( originalInput ); - } - return !! otpCodeInput; - }; - - // Store the email for auth flows - let currentEmail = ''; - - // Get form action URL - const getActionUrl = () => form.getAttribute( 'action' ) || window.location.pathname; - - // Create OTP timer handler using shared utility - const handleOTPTimer = createOTPTimerHandler( readerActivation, otpResendButton ); - - /** - * Clear error message. - * - * @param {HTMLElement} element Response element. - */ - const clearError = element => { - if ( element ) { - element.textContent = ''; - } - }; - - /** - * Set the current form state. - * - * @param {string} state State name: 'form', 'otp', 'pwd'. - * @param {string} email Email address. - */ - const setFormState = ( state, email = '' ) => { - // Remove all state classes - container.classList.remove( 'newspack-registration--otp', 'newspack-registration--pwd' ); - - if ( email ) { - currentEmail = email; - readerActivation.setReaderEmail( email ); - } - - if ( state === 'otp' ) { - ensureOtpInputInitialized(); - container.classList.add( 'newspack-registration--otp' ); - if ( otpEmailElement ) { - otpEmailElement.textContent = currentEmail; - } - clearError( otpResponseElement ); - // Focus first OTP digit - const firstInput = container.querySelector( '.newspack-ui__code-input input[data-index="0"]' ); - if ( firstInput ) { - firstInput.focus(); - } - readerActivation.setOTPTimer(); - handleOTPTimer(); - } else if ( state === 'pwd' ) { - container.classList.add( 'newspack-registration--pwd' ); - if ( pwdEmailElement ) { - pwdEmailElement.textContent = currentEmail; - } - clearError( pwdResponseElement ); - if ( pwdInput ) { - pwdInput.value = ''; - pwdInput.focus(); - } - } - }; - - // Create handlers using shared utilities - const handleOtpSubmit = createOTPSubmitHandler( - readerActivation, - { - getOtpCode: () => otpCodeInput?.value, - submitButton: otpSubmitButton, - responseElement: otpResponseElement, - }, - { - onSuccess: data => { - setFormState( 'form' ); - // OTP flow is always for existing users - form.endLoginFlow( data.message, 200, { ...data, existing_user: true } ); - }, - onExpired: () => setFormState( 'form' ), - } - ); - - const handlePwdSubmit = createPasswordSubmitHandler( - { - getEmail: () => currentEmail, - passwordInput: pwdInput, - submitButton: pwdSubmitButton, - responseElement: pwdResponseElement, - }, - getActionUrl(), - { - onSuccess: ( message, data ) => { - setFormState( 'form' ); - // Password flow is always for existing users - form.endLoginFlow( message, 200, { ...data, existing_user: true } ); - }, - } - ); - - const handleSendLink = createSendLinkHandler( - { - getEmail: () => currentEmail, - linkButton: pwdLinkButton, - responseElement: pwdResponseElement, - }, - getActionUrl(), - { - onSuccess: () => setFormState( 'otp', currentEmail ), - } - ); - - /** - * Handle OTP resend. - */ - const handleOtpResend = () => { - if ( ! currentEmail ) { - return; - } - clearError( otpResponseElement ); - if ( otpResendButton ) { - otpResendButton.disabled = true; - } - - const resendBody = new FormData(); - resendBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); - resendBody.set( 'npe', currentEmail ); - - fetch( getActionUrl(), { - method: 'POST', - headers: { Accept: 'application/json' }, - body: resendBody, - } ) - .then( res => { - if ( res.status === 200 ) { - readerActivation.setOTPTimer(); - handleOTPTimer(); - } else { - res.json().then( ( { message } ) => { - if ( otpResponseElement ) { - otpResponseElement.textContent = message || 'Failed to resend code.'; - } - } ); - } - } ) - .catch( () => { - if ( otpResponseElement ) { - otpResponseElement.textContent = 'Failed to resend code.'; - } - } ); - }; - - // Attach OTP event listeners - if ( otpSubmitButton ) { - otpSubmitButton.addEventListener( 'click', handleOtpSubmit ); - } - if ( otpResendButton ) { - otpResendButton.addEventListener( 'click', handleOtpResend ); - } - if ( otpBackButton ) { - otpBackButton.addEventListener( 'click', () => setFormState( 'form' ) ); - } - - // Attach password event listeners - if ( pwdSubmitButton ) { - pwdSubmitButton.addEventListener( 'click', handlePwdSubmit ); - } - if ( pwdLinkButton ) { - pwdLinkButton.addEventListener( 'click', handleSendLink ); - } - if ( pwdBackButton ) { - pwdBackButton.addEventListener( 'click', () => setFormState( 'form' ) ); - } - if ( pwdInput ) { - pwdInput.addEventListener( 'keydown', ev => { - if ( ev.key === 'Enter' ) { - ev.preventDefault(); - handlePwdSubmit(); - } - } ); - } - - // Handle pending verification resend - if ( resendVerificationButton ) { - resendVerificationButton.addEventListener( 'click', () => { - resendVerificationButton.disabled = true; - const reader = readerActivation.getReader(); - const email = reader?.email; - - if ( ! email ) { - resendVerificationButton.disabled = false; - return; - } - - const verifyBody = new FormData(); - verifyBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); - verifyBody.set( 'npe', email ); - - fetch( getActionUrl(), { - method: 'POST', - headers: { Accept: 'application/json' }, - body: verifyBody, - } ) - .then( res => { - if ( res.status === 200 ) { - resendVerificationButton.textContent = 'Email sent!'; - setTimeout( () => { - resendVerificationButton.textContent = 'Resend verification email'; - resendVerificationButton.disabled = false; - }, 3000 ); - } else { - resendVerificationButton.disabled = false; - } - } ) - .catch( () => { - resendVerificationButton.disabled = false; - } ); - } ); - } - form.startLoginFlow = () => { messageElement.classList.add( 'newspack-registration--hidden' ); messageElement.innerHTML = ''; @@ -285,15 +32,27 @@ window.newspackRAS.push( function ( readerActivation ) { form.endLoginFlow = ( message = null, status = 500, data = null ) => { let messageNode; - // Handle auth flow for existing users based on action + // For existing users, open the auth modal with the appropriate state if ( data?.existing_user && ! data?.authenticated && data?.action ) { const email = data.email || form.npe?.value; - setFormState( data.action, email ); if ( submitElement.contains( spinner ) ) { submitElement.removeChild( spinner ); } submitElement.disabled = false; container.classList.remove( 'newspack-registration--in-progress' ); + + // Set the reader email before opening the modal + readerActivation.setReaderEmail( email ); + + // Open auth modal with the appropriate initial state (otp or pwd) + openAuthModal( { + initialState: data.action, + closeOnSuccess: true, + onSuccess: () => { + // Refresh the page on successful authentication + window.location.reload(); + }, + } ); return; } @@ -310,7 +69,7 @@ window.newspackRAS.push( function ( readerActivation ) { messageNode = document.createElement( 'p' ); messageNode.textContent = message; - const defaultMessage = successElement.querySelector( 'p' ); + const defaultMessage = successElement?.querySelector( 'p' ); if ( defaultMessage && data?.sso ) { defaultMessage.replaceWith( messageNode ); } @@ -319,7 +78,7 @@ window.newspackRAS.push( function ( readerActivation ) { const isSuccess = status === 200; container.classList.add( `newspack-registration--${ isSuccess ? 'success' : 'error' }` ); if ( isSuccess ) { - successElement.classList.remove( 'newspack-registration--hidden' ); + successElement?.classList.remove( 'newspack-registration--hidden' ); if ( data?.email ) { body = new FormData( form ); readerActivation.setReaderEmail( data.email ); @@ -381,7 +140,7 @@ window.newspackRAS.push( function ( readerActivation ) { if ( ! body.has( 'npe' ) || ! body.get( 'npe' ) ) { return form.endLoginFlow( 'Please enter a valid email address.', 400 ); } - fetch( getActionUrl(), { + fetch( form.getAttribute( 'action' ) || window.location.pathname, { method: 'POST', headers: { Accept: 'application/json' }, body, @@ -393,6 +152,51 @@ window.newspackRAS.push( function ( readerActivation ) { form.endLoginFlow( e?.message || 'An error occurred.', 400 ); } ); } ); + + readerActivation.on( 'reader', ( { detail: { authenticated } } ) => { + if ( authenticated ) { + form.endLoginFlow( null, 200 ); + } + } ); + + // Handle pending verification resend button + const resendVerificationButton = container.querySelector( '[data-resend-verification]' ); + if ( resendVerificationButton ) { + resendVerificationButton.addEventListener( 'click', () => { + resendVerificationButton.disabled = true; + const reader = readerActivation.getReader(); + const email = reader?.email; + + if ( ! email ) { + resendVerificationButton.disabled = false; + return; + } + + const verifyBody = new FormData(); + verifyBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); + verifyBody.set( 'npe', email ); + + fetch( form.getAttribute( 'action' ) || window.location.pathname, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: verifyBody, + } ) + .then( res => { + if ( res.status === 200 ) { + resendVerificationButton.textContent = 'Email sent!'; + setTimeout( () => { + resendVerificationButton.textContent = 'Resend verification email'; + resendVerificationButton.disabled = false; + }, 3000 ); + } else { + resendVerificationButton.disabled = false; + } + } ) + .catch( () => { + resendVerificationButton.disabled = false; + } ); + } ); + } } ); } ); } ); diff --git a/src/reader-activation-auth/auth-form.js b/src/reader-activation-auth/auth-form.js index d304d44599..16c2a53c58 100644 --- a/src/reader-activation-auth/auth-form.js +++ b/src/reader-activation-auth/auth-form.js @@ -3,10 +3,9 @@ /** * Internal dependencies. */ -import { domReady } from '../utils'; +import { domReady, formatTime } from '../utils'; import { getPendingCheckout } from '../reader-activation/checkout'; import { openNewslettersSignupModal } from '../reader-activation-newsletters/newsletters-modal'; -import { createOTPTimerHandler, verifyOTP } from './auth-utils'; import './google-oauth'; import './otp-input'; @@ -193,9 +192,29 @@ window.newspackRAS.push( function ( readerActivation ) { } ); /** - * Handle OTP Timer using shared utility. + * Handle OTP Timer. */ - const handleOTPTimer = createOTPTimerHandler( readerActivation, resendCodeButton ); + const handleOTPTimer = () => { + if ( ! resendCodeButton ) { + return; + } + resendCodeButton.originalButtonText = resendCodeButton.textContent.replace( /\s\(\d{1,}:\d{2}\)/, '' ); + const updateButton = () => { + const remaining = readerActivation.getOTPTimeRemaining(); + if ( remaining ) { + resendCodeButton.textContent = `${ resendCodeButton.originalButtonText } (${ formatTime( remaining ) })`; + } else { + resendCodeButton.textContent = resendCodeButton.originalButtonText; + clearInterval( resendCodeButton.otpTimerInterval ); + } + resendCodeButton.disabled = !! remaining; + }; + const remaining = readerActivation.getOTPTimeRemaining(); + if ( remaining ) { + resendCodeButton.otpTimerInterval = setInterval( updateButton, 1000 ); + updateButton(); + } + }; if ( sendCodeButton || resendCodeButton ) { [ sendCodeButton, resendCodeButton ].forEach( button => { @@ -413,18 +432,17 @@ window.newspackRAS.push( function ( readerActivation ) { } if ( 'otp' === action ) { - verifyOTP( readerActivation, body.get( 'otp_code' ), { - onSuccess: data => { + readerActivation + .authenticateOTP( body.get( 'otp_code' ) ) + .then( data => { form.endLoginFlow( data.message, 200, data ); - }, - onExpired: ( errorMessage, data ) => { - container.setFormAction( 'signin' ); + } ) + .catch( data => { + if ( data.expired ) { + container.setFormAction( 'signin' ); + } form.endLoginFlow( data.message, 400 ); - }, - onError: ( errorMessage, data ) => { - form.endLoginFlow( data?.message || errorMessage, 400 ); - }, - } ); + } ); } else { fetch( form.getAttribute( 'action' ) || window.location.pathname, { method: 'POST', diff --git a/src/reader-activation-auth/auth-utils.js b/src/reader-activation-auth/auth-utils.js deleted file mode 100644 index 10bbe36ed6..0000000000 --- a/src/reader-activation-auth/auth-utils.js +++ /dev/null @@ -1,367 +0,0 @@ -/** - * Shared utilities for authentication flows. - * - * Used by both the auth form modal and the reader registration block. - */ - -import { formatTime } from '../utils'; - -/** - * Create an OTP timer handler for a resend button. - * - * @param {Object} readerActivation The readerActivation API. - * @param {HTMLButtonElement} resendButton The resend button element. - * - * @return {Function} A function to start/update the OTP timer. - */ -export function createOTPTimerHandler( readerActivation, resendButton ) { - if ( ! resendButton ) { - return () => {}; - } - - return () => { - resendButton.originalButtonText = resendButton.textContent.replace( /\s\(\d{1,}:\d{2}\)/, '' ); - - const updateButton = () => { - const remaining = readerActivation.getOTPTimeRemaining(); - if ( remaining ) { - resendButton.textContent = `${ resendButton.originalButtonText } (${ formatTime( remaining ) })`; - resendButton.disabled = true; - } else { - resendButton.textContent = resendButton.originalButtonText; - resendButton.disabled = false; - clearInterval( resendButton.otpTimerInterval ); - } - }; - - const remaining = readerActivation.getOTPTimeRemaining(); - if ( remaining ) { - resendButton.otpTimerInterval = setInterval( updateButton, 1000 ); - updateButton(); - } - }; -} - -/** - * Handle OTP verification. - * - * @param {Object} readerActivation The readerActivation API. - * @param {string} code The OTP code to verify. - * @param {Object} options Options object. - * @param {Function} options.onSuccess Callback on successful verification. - * @param {Function} options.onError Callback on error. - * @param {Function} options.onExpired Callback when OTP has expired. - * @param {Function} options.onFinally Callback that always runs after verification. - * - * @return {Promise} The authentication promise. - */ -export function verifyOTP( readerActivation, code, { onSuccess, onError, onExpired, onFinally } = {} ) { - return readerActivation - .authenticateOTP( code ) - .then( data => { - if ( onSuccess ) { - onSuccess( data ); - } - } ) - .catch( data => { - const errorMessage = data?.message || 'Invalid code. Please try again.'; - if ( data?.expired ) { - if ( onExpired ) { - onExpired( errorMessage, data ); - } else if ( onError ) { - onError( errorMessage, data ); - } - } else if ( onError ) { - onError( errorMessage, data ); - } - } ) - .finally( () => { - if ( onFinally ) { - onFinally(); - } - } ); -} - -/** - * Send authentication link (magic link) to an email address. - * - * @param {string} email The email address. - * @param {string} actionUrl The form action URL. - * @param {Object} options Options object. - * @param {string} options.formId Optional form identifier (e.g., 'reader-activation-auth-form'). - * @param {string} options.redirectUrl Optional redirect URL after authentication. - * @param {Function} options.onSuccess Callback on success. - * @param {Function} options.onError Callback on error. - * @param {Function} options.onFinally Callback that always runs. - * - * @return {Promise} The fetch promise. - */ -export function sendAuthLink( email, actionUrl, { formId = 'reader-activation-auth-form', redirectUrl, onSuccess, onError, onFinally } = {} ) { - const body = new FormData(); - body.set( formId, '1' ); - body.set( 'npe', email ); - body.set( 'action', 'link' ); - - if ( redirectUrl ) { - body.set( 'redirect_url', redirectUrl ); - } - - return fetch( actionUrl || window.location.pathname, { - method: 'POST', - headers: { Accept: 'application/json' }, - body, - } ) - .then( res => { - if ( res.status === 200 ) { - if ( onSuccess ) { - onSuccess( res ); - } - } else { - res.json().then( ( { message } ) => { - if ( onError ) { - onError( message || 'Failed to send authentication link.' ); - } - } ); - } - } ) - .catch( () => { - if ( onError ) { - onError( 'Failed to send authentication link.' ); - } - } ) - .finally( () => { - if ( onFinally ) { - onFinally(); - } - } ); -} - -/** - * Authenticate with password. - * - * @param {string} email The email address. - * @param {string} password The password. - * @param {string} actionUrl The form action URL. - * @param {Object} options Options object. - * @param {string} options.formId Optional form identifier. - * @param {Function} options.onSuccess Callback on success with data. - * @param {Function} options.onError Callback on error with message. - * @param {Function} options.onFinally Callback that always runs. - * - * @return {Promise} The fetch promise. - */ -export function authenticateWithPassword( - email, - password, - actionUrl, - { formId = 'reader-activation-auth-form', onSuccess, onError, onFinally } = {} -) { - const body = new FormData(); - body.set( formId, '1' ); - body.set( 'npe', email ); - body.set( 'password', password ); - body.set( 'action', 'pwd' ); - - return fetch( actionUrl || window.location.pathname, { - method: 'POST', - headers: { Accept: 'application/json' }, - body, - } ) - .then( res => { - res.json().then( ( { message, data } ) => { - if ( res.status === 200 && data?.authenticated ) { - if ( onSuccess ) { - onSuccess( message, data ); - } - } else if ( onError ) { - onError( message || 'Password not recognized, try again.' ); - } - } ); - } ) - .catch( () => { - if ( onError ) { - onError( 'An error occurred. Please try again.' ); - } - } ) - .finally( () => { - if ( onFinally ) { - onFinally(); - } - } ); -} - -/** - * Create an OTP submission handler. - * - * @param {Object} readerActivation The readerActivation API. - * @param {Object} elements DOM elements. - * @param {Function} elements.getOtpCode Function that returns the OTP code value. - * @param {HTMLElement} elements.submitButton The submit button element. - * @param {HTMLElement} elements.responseElement The response/error element. - * @param {Object} callbacks Callback functions. - * @param {Function} callbacks.onSuccess Callback on successful verification. - * @param {Function} callbacks.onExpired Callback when OTP has expired. - * - * @return {Function} The submit handler function. - */ -export function createOTPSubmitHandler( readerActivation, { getOtpCode, submitButton, responseElement }, { onSuccess, onExpired } = {} ) { - const showError = message => { - if ( responseElement ) { - responseElement.textContent = message; - } - }; - - const clearError = () => { - if ( responseElement ) { - responseElement.textContent = ''; - } - }; - - return () => { - const code = getOtpCode(); - if ( ! code || code.length !== 6 ) { - showError( 'Please enter the 6-digit code.' ); - return; - } - clearError(); - if ( submitButton ) { - submitButton.disabled = true; - } - - verifyOTP( readerActivation, code, { - onSuccess: data => { - if ( onSuccess ) { - onSuccess( data ); - } - }, - onExpired: errorMessage => { - showError( errorMessage ); - if ( onExpired ) { - setTimeout( onExpired, 2000 ); - } - }, - onError: errorMessage => { - showError( errorMessage ); - }, - onFinally: () => { - if ( submitButton ) { - submitButton.disabled = false; - } - }, - } ); - }; -} - -/** - * Create a password submission handler. - * - * @param {Object} elements DOM elements. - * @param {Function} elements.getEmail Function that returns the current email. - * @param {HTMLElement} elements.passwordInput The password input element. - * @param {HTMLElement} elements.submitButton The submit button element. - * @param {HTMLElement} elements.responseElement The response/error element. - * @param {string} actionUrl The form action URL. - * @param {Object} callbacks Callback functions. - * @param {Function} callbacks.onSuccess Callback on successful authentication. - * - * @return {Function} The submit handler function. - */ -export function createPasswordSubmitHandler( { getEmail, passwordInput, submitButton, responseElement }, actionUrl, { onSuccess } = {} ) { - const showError = message => { - if ( responseElement ) { - responseElement.textContent = message; - } - }; - - const clearError = () => { - if ( responseElement ) { - responseElement.textContent = ''; - } - }; - - return () => { - const email = getEmail(); - if ( ! passwordInput || ! email ) { - return; - } - const password = passwordInput.value; - if ( ! password ) { - showError( 'Please enter your password.' ); - return; - } - clearError(); - if ( submitButton ) { - submitButton.disabled = true; - } - - authenticateWithPassword( email, password, actionUrl, { - onSuccess: ( message, data ) => { - if ( onSuccess ) { - onSuccess( message, data ); - } - }, - onError: errorMessage => { - showError( errorMessage ); - }, - onFinally: () => { - if ( submitButton ) { - submitButton.disabled = false; - } - }, - } ); - }; -} - -/** - * Create a "send link" handler that sends a magic link and transitions to OTP state. - * - * @param {Object} elements DOM elements. - * @param {Function} elements.getEmail Function that returns the current email. - * @param {HTMLElement} elements.linkButton The send link button element. - * @param {HTMLElement} elements.responseElement The response/error element. - * @param {string} actionUrl The form action URL. - * @param {Object} callbacks Callback functions. - * @param {Function} callbacks.onSuccess Callback on success (to transition to OTP state). - * - * @return {Function} The handler function. - */ -export function createSendLinkHandler( { getEmail, linkButton, responseElement }, actionUrl, { onSuccess } = {} ) { - const showError = message => { - if ( responseElement ) { - responseElement.textContent = message; - } - }; - - const clearError = () => { - if ( responseElement ) { - responseElement.textContent = ''; - } - }; - - return () => { - const email = getEmail(); - if ( ! email ) { - return; - } - clearError(); - if ( linkButton ) { - linkButton.disabled = true; - } - - sendAuthLink( email, actionUrl, { - onSuccess: () => { - if ( onSuccess ) { - onSuccess(); - } - }, - onError: errorMessage => { - showError( errorMessage ); - }, - onFinally: () => { - if ( linkButton ) { - linkButton.disabled = false; - } - }, - } ); - }; -} From 59d4284dd171652a308d811065460cc14f41be64 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 4 Feb 2026 18:07:02 -0300 Subject: [PATCH 03/27] refactor(reader-registration): simplify authentication handling for OTP and password actions --- src/blocks/reader-registration/index.php | 17 +------ src/blocks/reader-registration/view.js | 57 +++++++++++++++++++----- src/reader-activation-auth/auth-form.js | 1 + 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index 3df5606a7c..cfb5db1a36 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -475,21 +475,8 @@ function process_form() { if ( ! $user_logged_in ) { $existing_user = \get_user_by( 'email', $email ); if ( $existing_user && Reader_Activation::is_user_reader( $existing_user ) ) { - if ( Reader_Activation::is_reader_without_password( $existing_user ) ) { - // Check if there's already an active token. - if ( \Newspack\Magic_Link::has_active_token( $existing_user ) ) { - $response['action'] = 'otp'; - } else { - // Send the magic link email which also sets the OTP hash cookie. - $sent = \Newspack\Magic_Link::send_email( $existing_user ); - if ( true === $sent ) { - $response['action'] = 'otp'; - } - // If sending failed, don't set action - let the auth modal handle it. - } - } else { - $response['action'] = 'pwd'; - } + // Return the action type - frontend will check OTP hash validity and request fresh OTP if needed. + $response['action'] = Reader_Activation::is_reader_without_password( $existing_user ) ? 'otp' : 'pwd'; } } diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index 77967506e2..47cb3aef3f 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -44,15 +44,48 @@ window.newspackRAS.push( function ( readerActivation ) { // Set the reader email before opening the modal readerActivation.setReaderEmail( email ); - // Open auth modal with the appropriate initial state (otp or pwd) - openAuthModal( { - initialState: data.action, - closeOnSuccess: true, - onSuccess: () => { - // Refresh the page on successful authentication - window.location.reload(); - }, - } ); + // Helper to open the modal + const openModal = initialState => { + openAuthModal( { + initialState, + closeOnSuccess: true, + onSuccess: () => window.location.reload(), + } ); + }; + + // For OTP action, check if we have a valid OTP hash cookie + if ( data.action === 'otp' ) { + if ( readerActivation.getOTPHash() ) { + // Valid OTP hash exists, just open the modal + readerActivation.setOTPTimer(); + openModal( 'otp' ); + } else { + // No valid OTP hash, request a fresh one using the email we already have + const otpBody = new FormData(); + otpBody.set( 'reader-activation-auth-form', '1' ); + otpBody.set( 'npe', email ); + otpBody.set( 'action', 'link' ); + + fetch( form.getAttribute( 'action' ) || window.location.pathname, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: otpBody, + } ) + .then( res => { + if ( res.status === 200 ) { + readerActivation.setOTPTimer(); + openModal( 'otp' ); + } else { + openModal( 'signin' ); + } + } ) + .catch( () => openModal( 'signin' ) ); + } + return; + } + + // For password or other actions, just open the modal + openModal( data.action ); return; } @@ -153,9 +186,9 @@ window.newspackRAS.push( function ( readerActivation ) { } ); } ); - readerActivation.on( 'reader', ( { detail: { authenticated } } ) => { - if ( authenticated ) { - form.endLoginFlow( null, 200 ); + readerActivation.on( 'reader', ( { detail } ) => { + if ( detail.authenticated ) { + form.endLoginFlow( null, 200, { existing_user: true } ); } } ); diff --git a/src/reader-activation-auth/auth-form.js b/src/reader-activation-auth/auth-form.js index 16c2a53c58..fcbc2573e1 100644 --- a/src/reader-activation-auth/auth-form.js +++ b/src/reader-activation-auth/auth-form.js @@ -127,6 +127,7 @@ window.newspackRAS.push( function ( readerActivation ) { } if ( 'otp' === action ) { if ( ! readerActivation.getOTPHash() ) { + console.warn( 'No OTP hash found.' ); // eslint-disable-line no-console return; } const emailAddressElements = container.querySelectorAll( '.email-address' ); From 4889ffcc6f193ddc6ddf79154e46ff62d80c962e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 5 Feb 2026 14:47:54 -0300 Subject: [PATCH 04/27] chore: move inline gate content methods to trait for better organization --- includes/content-gate/class-content-gate.php | 16 ---------------- .../content-gate/trait-content-gate-layout.php | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/includes/content-gate/class-content-gate.php b/includes/content-gate/class-content-gate.php index 9115acf11f..c3b88ba826 100644 --- a/includes/content-gate/class-content-gate.php +++ b/includes/content-gate/class-content-gate.php @@ -692,22 +692,6 @@ public static function handle_edit_gate_layout() { } } - /** - * Get the inline gate content. - */ - public static function get_inline_gate_content() { - return self::get_inline_gate_content_for_post( self::get_gate_layout_id() ); - } - - /** - * Get the inline gate HTML for rendering. - * - * @return string - */ - public static function get_inline_gate_html() { - return apply_filters( 'newspack_gate_content', self::get_inline_gate_content() ); - } - /** * Get the post excerpt to be displayed in the gate. * diff --git a/includes/content-gate/trait-content-gate-layout.php b/includes/content-gate/trait-content-gate-layout.php index cf2cb23530..397353e59c 100644 --- a/includes/content-gate/trait-content-gate-layout.php +++ b/includes/content-gate/trait-content-gate-layout.php @@ -264,6 +264,22 @@ public static function get_restricted_post_excerpt_for_gate( $post, $gate_layout return $content; } + /** + * Get the inline gate content. + */ + public static function get_inline_gate_content() { + return self::get_inline_gate_content_for_post( self::get_gate_layout_id() ); + } + + /** + * Get the inline gate HTML for rendering. + * + * @return string + */ + public static function get_inline_gate_html() { + return apply_filters( 'newspack_gate_content', self::get_inline_gate_content() ); + } + /** * Render the overlay gate HTML. * From 0971c9c6632783b23205ec8492fa568dc58b161a Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 5 Feb 2026 14:55:27 -0300 Subject: [PATCH 05/27] fix: enhance pending verification handling in registration block --- src/blocks/reader-registration/index.php | 60 ++++++++++-------------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index cfb5db1a36..47784d7ede 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -94,9 +94,11 @@ function render_block( $attrs, $content ) { return ''; } - $registered = false; - $my_account_url = function_exists( 'wc_get_account_endpoint_url' ) ? \wc_get_account_endpoint_url( 'dashboard' ) : false; - $message = ''; + $registered = false; + $show_pending_verification = false; + + $my_account_url = function_exists( 'wc_get_account_endpoint_url' ) ? \wc_get_account_endpoint_url( 'dashboard' ) : false; + $message = ''; $success_message = __( 'Success! Your account was created and you’re signed in.', 'newspack-plugin' ) . '
'; if ( $my_account_url ) { @@ -120,11 +122,6 @@ function render_block( $attrs, $content ) { } } - $sign_in_url = \wp_login_url(); - if ( function_exists( 'wc_get_account_endpoint_url' ) ) { - $sign_in_url = $my_account_url; - } - /** Setup list subscription */ $lists = []; if ( $attrs['newsletterSubscription'] && method_exists( 'Newspack_Newsletters_Subscription', 'get_lists_config' ) ) { @@ -141,34 +138,24 @@ function render_block( $attrs, $content ) { $is_admin_preview = method_exists( 'Newspack_Popups', 'is_user_admin' ) && \Newspack_Popups::is_user_admin(); - // Check if logged-in user has an unverified email (for pending verification state). - $show_pending_verification = false; - if ( - ! \is_preview() && - ! $is_admin_preview && - \is_user_logged_in() && - ! Reader_Activation::is_reader_verified( \wp_get_current_user() ) - ) { - $show_pending_verification = true; - } - - // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! \is_preview() && ! $is_admin_preview && ( ! method_exists( '\Newspack_Popups', 'is_preview_request' ) || ! \Newspack_Popups::is_preview_request() ) && ( \is_user_logged_in() || - ( isset( $_GET['newspack_reader'] ) && absint( $_GET['newspack_reader'] ) ) + ( isset( $_GET['newspack_reader'] ) && absint( $_GET['newspack_reader'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended ) ) { $registered = true; - $message = $success_message; + if ( ! Reader_Activation::is_reader_verified( \wp_get_current_user() ) && \Newspack\Content_Gate::is_gated() ) { + $show_pending_verification = true; + } } - if ( isset( $_GET['newspack_reader'] ) && isset( $_GET['message'] ) ) { - $message = \sanitize_text_field( $_GET['message'] ); + + if ( isset( $_GET['newspack_reader'] ) && isset( $_GET['message'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $message = \sanitize_text_field( $_GET['message'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } - // phpcs:enable $success_registration_markup = $content; if ( empty( \wp_strip_all_tags( $content ) ) ) { @@ -192,7 +179,18 @@ function render_block( $attrs, $content ) { ob_start(); ?>
- + + +
+ + + +

+ +
+
@@ -310,16 +308,6 @@ class="newspack-ui__button newspack-ui__button--primary"
- -
- - - -

- -
Date: Thu, 5 Feb 2026 17:18:41 -0300 Subject: [PATCH 06/27] refactor: improve UI and messaging for email verification in registration block --- src/blocks/reader-registration/index.php | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index 47784d7ede..e82778fbea 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -180,15 +180,27 @@ function render_block( $attrs, $content ) { ?>
- -
+
-

- +

+

+ ' . esc_html( \wp_get_current_user()->user_email ) . '' + ); + ?> +
+ +

+

+ +

From 266590b8c99efcd354760e301d5a594c84248794 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 5 Feb 2026 19:15:55 -0300 Subject: [PATCH 07/27] feat: add method to check if gate requires account verification --- includes/content-gate/class-content-gate.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/includes/content-gate/class-content-gate.php b/includes/content-gate/class-content-gate.php index c3b88ba826..4d4fe673ff 100644 --- a/includes/content-gate/class-content-gate.php +++ b/includes/content-gate/class-content-gate.php @@ -822,6 +822,24 @@ public static function update_registration_settings( $gate_id, $settings ) { \update_post_meta( $gate_id, 'registration', $settings ); } + /** + * Whether the gate requires account verification. + * + * @param int $gate_id Optional gate ID. Default is the current gate. + * + * @return bool Whether the gate requires account verification. + */ + public static function requires_account_verification( $gate_id = null ) { + if ( ! $gate_id ) { + $gate_id = self::get_gate_post_id(); + if ( ! $gate_id ) { + return false; + } + } + $registration = self::get_registration_settings( $gate_id ); + return $registration['require_verification']; + } + /** * Get custom access settings for a gate. * From b3fef566948b066d9aa1fe72a5e882d0e1a3a380 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 5 Feb 2026 19:16:07 -0300 Subject: [PATCH 08/27] feat: implement email verification handling in registration flow --- includes/class-magic-link.php | 1 + includes/oauth/class-google-login.php | 2 + .../class-reader-activation.php | 3 + src/blocks/reader-registration/index.php | 32 +++++++++- src/blocks/reader-registration/view.js | 62 ++++++++++++++----- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/includes/class-magic-link.php b/includes/class-magic-link.php index 4987d6cdf0..c14ff4dcf3 100644 --- a/includes/class-magic-link.php +++ b/includes/class-magic-link.php @@ -923,6 +923,7 @@ public static function process_otp_request() { 'email' => $email, 'existing_user' => true, 'metadata' => $metadata, + 'verified' => Reader_Activation::is_reader_verified( $user ), ]; return self::send_otp_request_response( __( 'Login successful!', 'newspack-plugin' ), true, $data ); diff --git a/includes/oauth/class-google-login.php b/includes/oauth/class-google-login.php index 268b56b84f..e8c5f3ae0b 100644 --- a/includes/oauth/class-google-login.php +++ b/includes/oauth/class-google-login.php @@ -315,7 +315,9 @@ public static function api_google_login_register( $request ) { return $result; } + $current_user = \wp_get_current_user(); $data['metadata'] = $metadata; + $data['verified'] = $current_user ? Reader_Activation::is_reader_verified( $current_user ) : false; return \rest_ensure_response( [ 'data' => $data, diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 8be750054e..721fa3c05f 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -2092,6 +2092,9 @@ public static function process_auth_form() { $authenticated = self::set_current_reader( $user->ID ); $payload['authenticated'] = \is_wp_error( $authenticated ) ? 0 : 1; $payload['existing_user'] = \is_wp_error( $authenticated ) ? 0 : 1; + if ( ! \is_wp_error( $authenticated ) ) { + $payload['verified'] = self::is_reader_verified( $user ); + } $metadata['login_method'] = 'auth-form-password'; break; case 'link': diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index e82778fbea..3feafc12ed 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -65,6 +65,13 @@ function enqueue_scripts() { ); \wp_script_add_data( $handle, 'async', true ); \wp_script_add_data( $handle, 'amp-plus', true ); + wp_localize_script( + $handle, + 'reader_registration_block_config', + [ + 'require_account_verification' => \Newspack\Content_Gate::requires_account_verification(), + ] + ); } add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_scripts' ); @@ -320,6 +327,23 @@ class="newspack-ui__button newspack-ui__button--primary"
+
+ + + +

+

+ + . +
+ +

+

+ +

+
$metadata, ]; - if ( ! $user_logged_in ) { + // Include verified status for newly registered users. + if ( $user_logged_in ) { + $user = \get_user_by( 'id', $user_id ); + if ( $user ) { + $response['verified'] = Reader_Activation::is_reader_verified( $user ); + } + } else { $existing_user = \get_user_by( 'email', $email ); if ( $existing_user && Reader_Activation::is_user_reader( $existing_user ) ) { // Return the action type - frontend will check OTP hash validity and request fresh OTP if needed. diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index 47cb3aef3f..04dee72a7a 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -1,3 +1,4 @@ +/* globals reader_registration_block_config */ /** * Internal dependencies */ @@ -16,6 +17,7 @@ window.newspackRAS.push( function ( readerActivation ) { } let body = new FormData( form ); + let flowCompleted = false; // Guard to prevent re-running endLoginFlow const messageElement = container.querySelector( '.newspack-registration__response' ); const submitElement = form.querySelector( 'button[type="submit"]' ); const spinner = document.createElement( 'span' ); @@ -30,6 +32,11 @@ window.newspackRAS.push( function ( readerActivation ) { }; form.endLoginFlow = ( message = null, status = 500, data = null ) => { + // Prevent re-running after successful completion + if ( flowCompleted ) { + return; + } + let messageNode; // For existing users, open the auth modal with the appropriate state @@ -92,11 +99,29 @@ window.newspackRAS.push( function ( readerActivation ) { // Determine which success element to show const registrationSuccessEl = container.querySelector( '.newspack-registration__registration-success' ); const loginSuccessEl = container.querySelector( '.newspack-registration__login-success' ); - const successElement = data?.existing_user ? loginSuccessEl : registrationSuccessEl; + const verifyEmailEl = container.querySelector( '.newspack-registration__verify-email' ); + + // Check if this is a new registration that needs email verification + // Note: verified can be false, null, or undefined - we need verification if it's not true + const needsVerification = + ! data?.existing_user && reader_registration_block_config.require_account_verification && data?.verified !== true; + + let successElement; + if ( needsVerification ) { + successElement = verifyEmailEl; + // Set the email address in the verification UI + const emailAddressEl = verifyEmailEl?.querySelector( '.newspack-registration__verify-email-address' ); + if ( emailAddressEl && data?.email ) { + emailAddressEl.textContent = data.email; + } + } else { + successElement = data?.existing_user ? loginSuccessEl : registrationSuccessEl; + } - // Hide both success elements first to ensure only one shows + // Hide all success/verification elements first to ensure only one shows registrationSuccessEl?.classList.add( 'newspack-registration--hidden' ); loginSuccessEl?.classList.add( 'newspack-registration--hidden' ); + verifyEmailEl?.classList.add( 'newspack-registration--hidden' ); if ( message ) { messageNode = document.createElement( 'p' ); @@ -111,6 +136,8 @@ window.newspackRAS.push( function ( readerActivation ) { const isSuccess = status === 200; container.classList.add( `newspack-registration--${ isSuccess ? 'success' : 'error' }` ); if ( isSuccess ) { + // Set flowCompleted early to prevent 'reader' event listener from interfering + flowCompleted = true; successElement?.classList.remove( 'newspack-registration--hidden' ); if ( data?.email ) { body = new FormData( form ); @@ -187,21 +214,24 @@ window.newspackRAS.push( function ( readerActivation ) { } ); readerActivation.on( 'reader', ( { detail } ) => { - if ( detail.authenticated ) { + if ( detail.authenticated && ! flowCompleted ) { form.endLoginFlow( null, 200, { existing_user: true } ); } } ); - // Handle pending verification resend button - const resendVerificationButton = container.querySelector( '[data-resend-verification]' ); - if ( resendVerificationButton ) { - resendVerificationButton.addEventListener( 'click', () => { - resendVerificationButton.disabled = true; + // Store the form action URL before the form might be removed + const formActionUrl = form.getAttribute( 'action' ) || window.location.pathname; + + // Handle verification resend buttons (pending verification and post-registration) + container.querySelectorAll( '[data-resend-verification]' ).forEach( resendButton => { + const originalText = resendButton.textContent; + resendButton.addEventListener( 'click', () => { + resendButton.disabled = true; const reader = readerActivation.getReader(); const email = reader?.email; if ( ! email ) { - resendVerificationButton.disabled = false; + resendButton.disabled = false; return; } @@ -209,27 +239,27 @@ window.newspackRAS.push( function ( readerActivation ) { verifyBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); verifyBody.set( 'npe', email ); - fetch( form.getAttribute( 'action' ) || window.location.pathname, { + fetch( formActionUrl, { method: 'POST', headers: { Accept: 'application/json' }, body: verifyBody, } ) .then( res => { if ( res.status === 200 ) { - resendVerificationButton.textContent = 'Email sent!'; + resendButton.textContent = 'Email sent!'; setTimeout( () => { - resendVerificationButton.textContent = 'Resend verification email'; - resendVerificationButton.disabled = false; + resendButton.textContent = originalText; + resendButton.disabled = false; }, 3000 ); } else { - resendVerificationButton.disabled = false; + resendButton.disabled = false; } } ) .catch( () => { - resendVerificationButton.disabled = false; + resendButton.disabled = false; } ); } ); - } + } ); } ); } ); } ); From c9af5cb0629811d47ddc5b757074ab761c54e192 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 5 Feb 2026 20:59:16 -0300 Subject: [PATCH 09/27] feat: streamline email verification resend functionality in registration block --- src/blocks/reader-registration/view.js | 83 +++++++++++++------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index 04dee72a7a..5a6c8688a1 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -12,6 +12,47 @@ window.newspackRAS.push( function ( readerActivation ) { domReady( function () { document.querySelectorAll( '.newspack-registration' ).forEach( container => { const form = container.querySelector( 'form' ); + + // Handle verification resend buttons (works with or without form) + container.querySelectorAll( '[data-resend-verification]' ).forEach( resendButton => { + const originalText = resendButton.textContent; + resendButton.addEventListener( 'click', () => { + resendButton.disabled = true; + const reader = readerActivation.getReader(); + const email = reader?.email; + + if ( ! email ) { + resendButton.disabled = false; + return; + } + + const verifyBody = new FormData(); + verifyBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); + verifyBody.set( 'npe', email ); + + fetch( form?.getAttribute( 'action' ) || window.location.pathname, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: verifyBody, + } ) + .then( res => { + if ( res.status === 200 ) { + resendButton.textContent = 'Email sent!'; + setTimeout( () => { + resendButton.textContent = originalText; + resendButton.disabled = false; + }, 3000 ); + } else { + resendButton.disabled = false; + } + } ) + .catch( () => { + resendButton.disabled = false; + } ); + } ); + } ); + + // Form-specific logic if ( ! form ) { return; } @@ -218,48 +259,6 @@ window.newspackRAS.push( function ( readerActivation ) { form.endLoginFlow( null, 200, { existing_user: true } ); } } ); - - // Store the form action URL before the form might be removed - const formActionUrl = form.getAttribute( 'action' ) || window.location.pathname; - - // Handle verification resend buttons (pending verification and post-registration) - container.querySelectorAll( '[data-resend-verification]' ).forEach( resendButton => { - const originalText = resendButton.textContent; - resendButton.addEventListener( 'click', () => { - resendButton.disabled = true; - const reader = readerActivation.getReader(); - const email = reader?.email; - - if ( ! email ) { - resendButton.disabled = false; - return; - } - - const verifyBody = new FormData(); - verifyBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); - verifyBody.set( 'npe', email ); - - fetch( formActionUrl, { - method: 'POST', - headers: { Accept: 'application/json' }, - body: verifyBody, - } ) - .then( res => { - if ( res.status === 200 ) { - resendButton.textContent = 'Email sent!'; - setTimeout( () => { - resendButton.textContent = originalText; - resendButton.disabled = false; - }, 3000 ); - } else { - resendButton.disabled = false; - } - } ) - .catch( () => { - resendButton.disabled = false; - } ); - } ); - } ); } ); } ); } ); From 7e62e2cb349c680ee9fffb46c9fd11df82579d28 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 6 Feb 2026 16:03:27 -0300 Subject: [PATCH 10/27] feat: enhance OTP verification process in reader registration flow --- includes/class-magic-link.php | 6 +- src/blocks/reader-registration/index.php | 139 ++++++++++++++------- src/blocks/reader-registration/view.js | 146 ++++++++++++----------- src/content-gate/gate.js | 2 +- src/reader-activation-auth/auth-modal.js | 2 +- 5 files changed, 182 insertions(+), 113 deletions(-) diff --git a/includes/class-magic-link.php b/includes/class-magic-link.php index c14ff4dcf3..26b74a9790 100644 --- a/includes/class-magic-link.php +++ b/includes/class-magic-link.php @@ -861,8 +861,12 @@ public static function process_otp_request() { if ( ! Reader_Activation::is_enabled() ) { return; } + // Allow logged-in unverified readers to verify via OTP. if ( \is_user_logged_in() ) { - return; + $current_user = \wp_get_current_user(); + if ( ! Reader_Activation::is_user_reader( $current_user ) || Reader_Activation::is_reader_verified( $current_user ) ) { + return; + } } // phpcs:disable WordPress.Security.NonceVerification.Missing diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index 3feafc12ed..0921222a4b 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -70,6 +70,7 @@ function enqueue_scripts() { 'reader_registration_block_config', [ 'require_account_verification' => \Newspack\Content_Gate::requires_account_verification(), + 'verification_url' => \admin_url( 'admin-ajax.php' ), ] ); } @@ -89,6 +90,94 @@ function get_form_id() { return \wp_unique_id( 'newspack-register-' ); } +/** + * Render the verification box markup for the registration block. + * + * @return void + */ +function render_verification_box() { + $email = '%EMAIL%'; + if ( \is_user_logged_in() ) { + $current_user = \wp_get_current_user(); + $email = $current_user->user_email; + } + ?> +
+ + + +

+ ' . esc_html( $email ) . '' + ); + ?> +

+

+ +

+
+ +
+ 'newspack-reader-verification', + 'title' => __( 'Verify your email', 'newspack-plugin' ), + 'content' => $content, + ] + ); + ?> +
+ get_error_message() ); + } + + \wp_send_json_success( __( 'OTP sent', 'newspack-plugin' ) ); +} +add_action( 'wp_ajax_newspack_reader_registration_verification', __NAMESPACE__ . '\\process_verification_request' ); +add_action( 'wp_ajax_nopriv_newspack_reader_registration_verification', __NAMESPACE__ . '\\process_verification_request' ); + /** * Render Registration Block. * @@ -186,30 +275,11 @@ function render_block( $attrs, $content ) { ob_start(); ?>
- -
- - - -

-

- ' . esc_html( \wp_get_current_user()->user_email ) . '' - ); - ?> -
- -

-

- -

-
- +
@@ -327,23 +397,6 @@ class="newspack-ui__button newspack-ui__button--primary"
-
- - - -

-

- - . -
- -

-

- -

-
{ - const form = container.querySelector( 'form' ); + /** + * Send verification OTP via the dedicated AJAX endpoint. + * + * @return {Promise} Resolves on success, rejects on failure. + */ + const sendVerificationOTP = () => { + const body = new FormData(); + body.set( 'action', 'newspack_reader_registration_verification' ); + return fetch( reader_registration_block_config.verification_url, { + method: 'POST', + headers: { Accept: 'application/json' }, + body, + } ).then( res => { + if ( ! res.ok ) { + throw new Error( res.statusText ); + } + readerActivation.setOTPTimer(); + return res.json(); + } ); + }; - // Handle verification resend buttons (works with or without form) - container.querySelectorAll( '[data-resend-verification]' ).forEach( resendButton => { - const originalText = resendButton.textContent; - resendButton.addEventListener( 'click', () => { - resendButton.disabled = true; - const reader = readerActivation.getReader(); - const email = reader?.email; + const openAuth = ( initialState = 'otp' ) => { + openAuthModal( { + skipAuthenticatedCheck: true, + initialState, + closeOnSuccess: false, + skipSuccess: true, + onSuccess: () => window.location.reload(), + onDismiss: () => window.location.reload(), + } ); + }; - if ( ! email ) { - resendButton.disabled = false; - return; - } + domReady( function () { + const verificationModal = document.getElementById( 'newspack-my-account__newspack-reader-verification' ); + const verificationBox = verificationModal.querySelectorAll( '.newspack__reader-verification' ); + if ( [ ...verificationBox ].length ) { + verificationBox.forEach( box => { + const sendOtpButton = box.querySelector( '[data-send-otp]' ); - const verifyBody = new FormData(); - verifyBody.set( 'newspack_reader_registration', 'newspack_reader_registration' ); - verifyBody.set( 'npe', email ); + // Detect parent modal + const modal = box.closest( '.newspack-ui__modal-container' ); - fetch( form?.getAttribute( 'action' ) || window.location.pathname, { - method: 'POST', - headers: { Accept: 'application/json' }, - body: verifyBody, - } ) - .then( res => { - if ( res.status === 200 ) { - resendButton.textContent = 'Email sent!'; - setTimeout( () => { - resendButton.textContent = originalText; - resendButton.disabled = false; - }, 3000 ); - } else { - resendButton.disabled = false; - } - } ) - .catch( () => { - resendButton.disabled = false; - } ); - } ); + if ( sendOtpButton ) { + sendOtpButton.addEventListener( 'click', () => { + sendOtpButton.disabled = true; + sendVerificationOTP() + .then( () => { + if ( modal ) { + modal.setAttribute( 'data-state', 'closed' ); + } + openAuth( 'otp' ); + } ) + .catch( () => { + sendOtpButton.disabled = false; + } ); + } ); + } } ); + } + + document.querySelectorAll( '.newspack-registration' ).forEach( container => { + const form = container.querySelector( 'form' ); // Form-specific logic if ( ! form ) { @@ -92,21 +112,12 @@ window.newspackRAS.push( function ( readerActivation ) { // Set the reader email before opening the modal readerActivation.setReaderEmail( email ); - // Helper to open the modal - const openModal = initialState => { - openAuthModal( { - initialState, - closeOnSuccess: true, - onSuccess: () => window.location.reload(), - } ); - }; - // For OTP action, check if we have a valid OTP hash cookie if ( data.action === 'otp' ) { if ( readerActivation.getOTPHash() ) { // Valid OTP hash exists, just open the modal readerActivation.setOTPTimer(); - openModal( 'otp' ); + openAuth( 'otp' ); } else { // No valid OTP hash, request a fresh one using the email we already have const otpBody = new FormData(); @@ -122,47 +133,38 @@ window.newspackRAS.push( function ( readerActivation ) { .then( res => { if ( res.status === 200 ) { readerActivation.setOTPTimer(); - openModal( 'otp' ); + openAuth( 'otp' ); } else { - openModal( 'signin' ); + openAuth( 'signin' ); } } ) - .catch( () => openModal( 'signin' ) ); + .catch( () => openAuth( 'signin' ) ); } return; } // For password or other actions, just open the modal - openModal( data.action ); + openAuth( data.action ); return; } // Determine which success element to show const registrationSuccessEl = container.querySelector( '.newspack-registration__registration-success' ); const loginSuccessEl = container.querySelector( '.newspack-registration__login-success' ); - const verifyEmailEl = container.querySelector( '.newspack-registration__verify-email' ); // Check if this is a new registration that needs email verification // Note: verified can be false, null, or undefined - we need verification if it's not true const needsVerification = ! data?.existing_user && reader_registration_block_config.require_account_verification && data?.verified !== true; - let successElement; - if ( needsVerification ) { - successElement = verifyEmailEl; - // Set the email address in the verification UI - const emailAddressEl = verifyEmailEl?.querySelector( '.newspack-registration__verify-email-address' ); - if ( emailAddressEl && data?.email ) { - emailAddressEl.textContent = data.email; - } - } else { - successElement = data?.existing_user ? loginSuccessEl : registrationSuccessEl; - } - // Hide all success/verification elements first to ensure only one shows registrationSuccessEl?.classList.add( 'newspack-registration--hidden' ); loginSuccessEl?.classList.add( 'newspack-registration--hidden' ); - verifyEmailEl?.classList.add( 'newspack-registration--hidden' ); + + let successElement; + if ( ! needsVerification ) { + successElement = data?.existing_user ? loginSuccessEl : registrationSuccessEl; + } if ( message ) { messageNode = document.createElement( 'p' ); @@ -179,13 +181,20 @@ window.newspackRAS.push( function ( readerActivation ) { if ( isSuccess ) { // Set flowCompleted early to prevent 'reader' event listener from interfering flowCompleted = true; - successElement?.classList.remove( 'newspack-registration--hidden' ); + if ( successElement ) { + form.remove(); + successElement.classList.remove( 'newspack-registration--hidden' ); + } if ( data?.email ) { body = new FormData( form ); readerActivation.setReaderEmail( data.email ); - readerActivation.setAuthenticated( data?.authenticated ); - if ( data.authenticated ) { + if ( needsVerification ) { + verificationModal.setAttribute( 'data-state', 'open' ); + } else { + readerActivation.setAuthenticated( data?.authenticated ); + } + if ( data.authenticated && ! needsVerification ) { const baseActivity = { email: data.email }; const lists = body.getAll( 'lists[]' ); if ( body.has( 'newspack_popup_id' ) ) { @@ -217,7 +226,6 @@ window.newspackRAS.push( function ( readerActivation ) { } } } - form.remove(); } else if ( messageNode ) { messageElement.appendChild( messageNode ); messageElement.classList.remove( 'newspack-registration--hidden' ); diff --git a/src/content-gate/gate.js b/src/content-gate/gate.js index ccdb46d552..5a61e43f36 100644 --- a/src/content-gate/gate.js +++ b/src/content-gate/gate.js @@ -94,7 +94,7 @@ function initReloadHandler() { debugLog( 'log', '[Gate] Reloading page!' ); window.location.reload(); } - }, 5 ); + }, 50 ); }; ras.on( 'overlay', refreshPage ); // When an overlay is closed. diff --git a/src/reader-activation-auth/auth-modal.js b/src/reader-activation-auth/auth-modal.js index f091c7df52..0b1725ab66 100644 --- a/src/reader-activation-auth/auth-modal.js +++ b/src/reader-activation-auth/auth-modal.js @@ -38,7 +38,7 @@ export function openAuthModal( config = {} ) { const reader = window.newspackReaderActivation.getReader(); const modalTrigger = config.trigger; - if ( reader?.authenticated ) { + if ( ! config.skipAuthenticatedCheck && reader?.authenticated ) { if ( config.onSuccess && typeof config.onSuccess === 'function' ) { config.onSuccess(); } From e34730f804ba31c5a10633b7e4eac2889d56531b Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 6 Feb 2026 16:40:45 -0300 Subject: [PATCH 11/27] feat: update UI and messaging for email verification in reader registration modal --- src/blocks/reader-registration/index.php | 66 +++++++++++++++++------- src/blocks/reader-registration/view.js | 11 ++-- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index 0921222a4b..dff61cc862 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -102,24 +102,24 @@ function render_verification_box() { $email = $current_user->user_email; } ?> -
- - - -

- ' . esc_html( $email ) . '' - ); - ?> -

-

- -

+
+ + + +

+ ' . esc_html( $email ) . '' + ); + ?> +

+

+ +

user_email; + } ob_start(); - render_verification_box(); + ?> +
+ + + +

+ ' . esc_html( $email ) . '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); + ?> +

+
+ + + -
+
{ const sendOtpButton = box.querySelector( '[data-send-otp]' ); - // Detect parent modal - const modal = box.closest( '.newspack-ui__modal-container' ); + // Find parent modal + const modal = sendOtpButton.closest( '.newspack-ui__modal-container' ); if ( sendOtpButton ) { sendOtpButton.addEventListener( 'click', () => { @@ -190,6 +190,11 @@ window.newspackRAS.push( function ( readerActivation ) { readerActivation.setReaderEmail( data.email ); if ( needsVerification ) { + // Update %EMAIL% placeholder in verification modal + const emailNode = verificationModal.querySelector( '.email-address' ); + if ( emailNode ) { + emailNode.textContent = data.email; + } verificationModal.setAttribute( 'data-state', 'open' ); } else { readerActivation.setAuthenticated( data?.authenticated ); From b5b800e9982183ed0abe899fb1d284b2d099e75e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 6 Feb 2026 17:19:26 -0300 Subject: [PATCH 12/27] refactor: remove login success messaging --- src/blocks/reader-registration/block.json | 4 ---- src/blocks/reader-registration/edit.js | 22 +--------------------- src/blocks/reader-registration/index.php | 13 +------------ 3 files changed, 2 insertions(+), 37 deletions(-) diff --git a/src/blocks/reader-registration/block.json b/src/blocks/reader-registration/block.json index aa29353a5f..62a138cae0 100644 --- a/src/blocks/reader-registration/block.json +++ b/src/blocks/reader-registration/block.json @@ -52,10 +52,6 @@ "listsCheckboxes": { "type": "object", "default": {} - }, - "signedInLabel": { - "type": "string", - "default": "Success! You're signed in." } }, "supports": { diff --git a/src/blocks/reader-registration/edit.js b/src/blocks/reader-registration/edit.js index e4187ce595..4fd0c8b621 100644 --- a/src/blocks/reader-registration/edit.js +++ b/src/blocks/reader-registration/edit.js @@ -19,7 +19,6 @@ import { Icon, check } from '@wordpress/icons'; * Internal dependencies */ import './editor.scss'; -import { emailSend } from '../../../packages/icons'; const getListCheckboxId = listId => { return 'newspack-reader-registration-list-checkbox-' + listId; @@ -28,7 +27,6 @@ const getListCheckboxId = listId => { const editedStateOptions = [ { label: __( 'Initial', 'newspack-plugin' ), value: 'initial' }, { label: __( 'Registration Success', 'newspack-plugin' ), value: 'registration' }, - { label: __( 'Login Success', 'newspack-plugin' ), value: 'login' }, ]; export default function ReaderRegistrationEdit( { setAttributes, @@ -42,7 +40,6 @@ export default function ReaderRegistrationEdit( { displayListDescription, hideSubscriptionInput, newsletterLabel, - signedInLabel, lists, listsCheckboxes, }, @@ -327,7 +324,7 @@ export default function ReaderRegistrationEdit( { ) } { editedState === 'registration' && (
-
+
@@ -335,23 +332,6 @@ export default function ReaderRegistrationEdit( {
) } - { editedState === 'login' && ( -
-
- - - - setAttributes( { signedInLabel: value } ) } - placeholder={ __( 'Logged in message…', 'newspack-plugin' ) } - value={ signedInLabel } - allowedFormats={ [] } - tagName="p" - /> -
-
- ) }
); diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index dff61cc862..7eae4bb5b6 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -284,11 +284,6 @@ function render_block( $attrs, $content ) { $success_registration_markup = '

' . $success_message . '

'; } - $success_login_markup = $attrs['signedInLabel']; - if ( ! empty( \wp_strip_all_tags( $attrs['signedInLabel'] ) ) ) { - $success_login_markup = '

' . $attrs['signedInLabel'] . '

'; - } - $checked = []; if ( ! empty( $attrs['listsCheckboxes'] ) ) { foreach ( $lists as $list_id => $list_name ) { @@ -411,18 +406,12 @@ class="newspack-ui__button newspack-ui__button--primary"

-
+
-
Date: Wed, 11 Feb 2026 11:59:42 -0300 Subject: [PATCH 13/27] feat: add account verification check and improve error handling in reader registration --- src/blocks/reader-registration/index.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index 7eae4bb5b6..7984b9b423 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -130,6 +130,9 @@ function render_verification_box() { * @return void */ function render_verification_modal() { + if ( ! \Newspack\Content_Gate::requires_account_verification() ) { + return; + } $email = '%EMAIL%'; if ( \is_user_logged_in() ) { $current_user = \wp_get_current_user(); @@ -195,7 +198,7 @@ function process_verification_request() { } $otp_sent = \Newspack\Magic_Link::send_email( $current_user ); - if ( ! $otp_sent ) { + if ( \is_wp_error( $otp_sent ) ) { \wp_send_json_error( $otp_sent->get_error_message() ); } @@ -235,7 +238,6 @@ function render_block( $attrs, $content ) { $default_attrs = [ 'label' => __( 'Continue', 'newspack-plugin' ), 'newsletterLabel' => __( 'Subscribe to our newsletter', 'newspack-plugin' ), - 'signedInLabel' => __( 'An account was already registered with this email. Please check your inbox for an authentication link.', 'newspack-plugin' ), ]; $attrs = \wp_parse_args( $attrs, $default_attrs ); foreach ( $default_attrs as $key => $value ) { From 3907d326f17f26c0c85553ae46932513b765c937 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 11 Feb 2026 13:23:09 -0300 Subject: [PATCH 14/27] feat: ensure modal close callback is fired and the page reloads --- src/blocks/reader-registration/view.js | 2 +- src/reader-activation-auth/auth-modal.js | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index c46452010e..b5fd990c35 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -37,7 +37,7 @@ window.newspackRAS.push( function ( readerActivation ) { closeOnSuccess: false, skipSuccess: true, onSuccess: () => window.location.reload(), - onDismiss: () => window.location.reload(), + onClose: () => window.location.reload(), } ); }; diff --git a/src/reader-activation-auth/auth-modal.js b/src/reader-activation-auth/auth-modal.js index 0b1725ab66..c7c5bba16f 100644 --- a/src/reader-activation-auth/auth-modal.js +++ b/src/reader-activation-auth/auth-modal.js @@ -88,6 +88,12 @@ export function openAuthModal( config = {} ) { * @param {boolean} dismiss Whether it's a dismiss action. */ const close = ( dismiss = true ) => { + // Disconnect observer before setting data-state to avoid re-entry. + if ( stateObserver ) { + stateObserver.disconnect(); + stateObserver = null; + } + container.config = {}; modal.setAttribute( 'data-state', 'closed' ); document.body.classList.remove( 'newspack-signin' ); @@ -108,12 +114,24 @@ export function openAuthModal( config = {} ) { config.onDismiss(); } + if ( config.onClose && typeof config.onClose === 'function' ) { + config.onClose(); + } + document.removeEventListener( 'keydown', handleKeydown ); closeButtons.forEach( closeButton => { closeButton.removeEventListener( 'click', handleCloseButtonClick ); } ); }; + // Observe modal state to catch external closes (e.g., from newspack-ui modals.js). + let stateObserver = new MutationObserver( () => { + if ( modal.dataset.state === 'closed' ) { + close(); + } + } ); + stateObserver.observe( modal, { attributes: true, attributeFilter: [ 'data-state' ] } ); + const closeButtons = modal.querySelectorAll( 'button[data-close], .newspack-ui__modal__close' ); if ( closeButtons?.length ) { closeButtons.forEach( closeButton => { From d20bba318a272d07e6be92595fc7a3b1151c3268 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 11 Feb 2026 14:26:46 -0300 Subject: [PATCH 15/27] feat: improve modal close handling --- src/blocks/reader-registration/view.js | 12 +++++++++ src/newspack-ui/js/modals.js | 3 +++ src/reader-activation-auth/auth-modal.js | 33 +++++++++++------------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index b5fd990c35..dea7b27003 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -51,11 +51,14 @@ window.newspackRAS.push( function ( readerActivation ) { // Find parent modal const modal = sendOtpButton.closest( '.newspack-ui__modal-container' ); + let otpSent = false; + if ( sendOtpButton ) { sendOtpButton.addEventListener( 'click', () => { sendOtpButton.disabled = true; sendVerificationOTP() .then( () => { + otpSent = true; if ( modal ) { modal.setAttribute( 'data-state', 'closed' ); } @@ -66,6 +69,15 @@ window.newspackRAS.push( function ( readerActivation ) { } ); } ); } + + // Reload when the verification modal is dismissed. + if ( modal ) { + modal.addEventListener( 'closeModal', () => { + if ( ! otpSent ) { + window.location.reload(); + } + } ); + } } ); } diff --git a/src/newspack-ui/js/modals.js b/src/newspack-ui/js/modals.js index 5bb4f6f85b..f7dbc0f37a 100644 --- a/src/newspack-ui/js/modals.js +++ b/src/newspack-ui/js/modals.js @@ -47,6 +47,9 @@ function setupModalObserver( modal ) { if ( mutation.type === 'attributes' && mutation.attributeName === 'data-state' ) { const newState = modal.dataset.state; handleModalOverlay( modal, newState ); + if ( newState === 'closed' ) { + modal.dispatchEvent( new CustomEvent( 'closeModal' ) ); + } } } ); } ); diff --git a/src/reader-activation-auth/auth-modal.js b/src/reader-activation-auth/auth-modal.js index c7c5bba16f..1c647b7977 100644 --- a/src/reader-activation-auth/auth-modal.js +++ b/src/reader-activation-auth/auth-modal.js @@ -87,12 +87,16 @@ export function openAuthModal( config = {} ) { * * @param {boolean} dismiss Whether it's a dismiss action. */ - const close = ( dismiss = true ) => { - // Disconnect observer before setting data-state to avoid re-entry. - if ( stateObserver ) { - stateObserver.disconnect(); - stateObserver = null; + let closed = false; + let succeeded = false; + + const close = () => { + if ( closed ) { + return; } + closed = true; + + modal.removeEventListener( 'closeModal', handleModalClose ); container.config = {}; modal.setAttribute( 'data-state', 'closed' ); @@ -110,27 +114,19 @@ export function openAuthModal( config = {} ) { modalTrigger.focus(); } - if ( dismiss && config.onDismiss && typeof config.onDismiss === 'function' ) { + if ( ! succeeded && config.onDismiss && typeof config.onDismiss === 'function' ) { config.onDismiss(); } - if ( config.onClose && typeof config.onClose === 'function' ) { - config.onClose(); - } - document.removeEventListener( 'keydown', handleKeydown ); closeButtons.forEach( closeButton => { closeButton.removeEventListener( 'click', handleCloseButtonClick ); } ); }; - // Observe modal state to catch external closes (e.g., from newspack-ui modals.js). - let stateObserver = new MutationObserver( () => { - if ( modal.dataset.state === 'closed' ) { - close(); - } - } ); - stateObserver.observe( modal, { attributes: true, attributeFilter: [ 'data-state' ] } ); + // Listen for closeModal events dispatched by the newspack-ui modals system. + const handleModalClose = () => close(); + modal.addEventListener( 'closeModal', handleModalClose ); const closeButtons = modal.querySelectorAll( 'button[data-close], .newspack-ui__modal__close' ); if ( closeButtons?.length ) { @@ -150,8 +146,9 @@ export function openAuthModal( config = {} ) { container.config = config; container.authCallback = ( message, data ) => { + succeeded = true; if ( config?.closeOnSuccess ) { - close( false ); + close(); } if ( config.onSuccess && typeof config.onSuccess === 'function' ) { config.onSuccess( message, data ); From 6b7b4beff0f934ae2fe9ba9a47e98ab6a8fcdbb8 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 11 Feb 2026 14:52:29 -0300 Subject: [PATCH 16/27] feat: hide back buttons when user is authenticated to prevent email switching --- src/reader-activation-auth/auth-form.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/reader-activation-auth/auth-form.js b/src/reader-activation-auth/auth-form.js index fcbc2573e1..3852b046c4 100644 --- a/src/reader-activation-auth/auth-form.js +++ b/src/reader-activation-auth/auth-form.js @@ -164,6 +164,12 @@ window.newspackRAS.push( function ( readerActivation ) { if ( container.formActionCallback ) { container.formActionCallback( action ); } + // Hide back buttons when authenticated to prevent switching to a different email. + if ( readerActivation.getReader()?.authenticated ) { + backButtons.forEach( button => { + button.style.display = 'none'; + } ); + } }; container.setFormAction( 'signin' ); From 66462e93e61277fbbb36e2c17e066ea39ab713d5 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 11 Feb 2026 15:03:10 -0300 Subject: [PATCH 17/27] feat: add onClose callback to auth modal for improved close handling --- src/reader-activation-auth/auth-modal.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/reader-activation-auth/auth-modal.js b/src/reader-activation-auth/auth-modal.js index 1c647b7977..d6d35b4f73 100644 --- a/src/reader-activation-auth/auth-modal.js +++ b/src/reader-activation-auth/auth-modal.js @@ -118,6 +118,10 @@ export function openAuthModal( config = {} ) { config.onDismiss(); } + if ( config.onClose && typeof config.onClose === 'function' ) { + config.onClose(); + } + document.removeEventListener( 'keydown', handleKeydown ); closeButtons.forEach( closeButton => { closeButton.removeEventListener( 'click', handleCloseButtonClick ); From bc91175e4b0fe2468234ef8c5ec225a8cfa761bb Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 11 Feb 2026 15:11:37 -0300 Subject: [PATCH 18/27] fix: remove duplicate method --- includes/content-gate/class-content-gate.php | 21 +++----------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/includes/content-gate/class-content-gate.php b/includes/content-gate/class-content-gate.php index f91c675409..84dc504147 100644 --- a/includes/content-gate/class-content-gate.php +++ b/includes/content-gate/class-content-gate.php @@ -816,6 +816,9 @@ public static function get_registration_settings( $gate_id ) { public static function requires_account_verification( $gate_id = null ) { if ( ! $gate_id ) { $gate_id = self::get_gate_post_id(); + if ( ! $gate_id ) { + return false; + } } $registration = self::get_registration_settings( $gate_id ); return $registration['require_verification']; @@ -837,24 +840,6 @@ public static function update_registration_settings( $gate_id, $settings ) { \update_post_meta( $gate_id, 'registration', $settings ); } - /** - * Whether the gate requires account verification. - * - * @param int $gate_id Optional gate ID. Default is the current gate. - * - * @return bool Whether the gate requires account verification. - */ - public static function requires_account_verification( $gate_id = null ) { - if ( ! $gate_id ) { - $gate_id = self::get_gate_post_id(); - if ( ! $gate_id ) { - return false; - } - } - $registration = self::get_registration_settings( $gate_id ); - return $registration['require_verification']; - } - /** * Get custom access settings for a gate. * From 9fe33c738ce3a8dc84b90d3b2a4175dbb69755b1 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 11 Feb 2026 15:21:01 -0300 Subject: [PATCH 19/27] chore: update return documentation to clarify behavior when maxlength is missing --- src/reader-activation-auth/otp-input.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reader-activation-auth/otp-input.js b/src/reader-activation-auth/otp-input.js index 46496fbe79..73e59178a0 100644 --- a/src/reader-activation-auth/otp-input.js +++ b/src/reader-activation-auth/otp-input.js @@ -8,7 +8,7 @@ import { domReady } from '../utils'; * * @param {HTMLInputElement} originalInput The original input element with name="otp_code". * - * @return {HTMLInputElement|null} The hidden input holding the OTP code value, or null if initialization failed. + * @return {HTMLInputElement|null} The hidden input holding the OTP code value, the original input if maxlength is missing, or null if no input provided. */ export function initOTPInput( originalInput ) { if ( ! originalInput ) { From 61a4912afebbea6a2b7098e64c4fcb17f42e6d86 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 11 Feb 2026 16:14:22 -0300 Subject: [PATCH 20/27] feat: nonce verification and improved error handling --- src/blocks/reader-registration/index.php | 27 ++++++++++++++--------- src/blocks/reader-registration/style.scss | 6 ----- src/blocks/reader-registration/view.js | 23 +++++++++++++------ src/reader-activation-auth/auth-form.js | 4 +++- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index 7984b9b423..36214c3aa1 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -65,12 +65,13 @@ function enqueue_scripts() { ); \wp_script_add_data( $handle, 'async', true ); \wp_script_add_data( $handle, 'amp-plus', true ); - wp_localize_script( + \wp_localize_script( $handle, 'reader_registration_block_config', [ 'require_account_verification' => \Newspack\Content_Gate::requires_account_verification(), 'verification_url' => \admin_url( 'admin-ajax.php' ), + 'verification_nonce' => \wp_create_nonce( 'newspack_reader_registration_verification' ), ] ); } @@ -108,10 +109,12 @@ function render_verification_box() {

' . esc_html( $email ) . '' + echo wp_kses_post( + sprintf( + // translators: %s is the user's email address. + __( 'We\'ll send a verification code to %s.', 'newspack-plugin' ), + '' + ) ); ?>

@@ -188,8 +191,12 @@ function process_verification_request() { \wp_die( \esc_html__( 'Unsupported request method', 'newspack-plugin' ) ); } - if ( ! is_user_logged_in() ) { - \wp_send_json_error( __( 'User not logged in', 'newspack-plugin' ) ); + if ( ! \check_ajax_referer( 'newspack_reader_registration_verification', 'nonce', false ) ) { + \wp_send_json_error( \__( 'Invalid request. Please refresh the page and try again.', 'newspack-plugin' ) ); + } + + if ( ! \is_user_logged_in() ) { + \wp_send_json_error( \__( 'User not logged in', 'newspack-plugin' ) ); } $current_user = \wp_get_current_user(); @@ -205,7 +212,6 @@ function process_verification_request() { \wp_send_json_success( __( 'OTP sent', 'newspack-plugin' ) ); } add_action( 'wp_ajax_newspack_reader_registration_verification', __NAMESPACE__ . '\\process_verification_request' ); -add_action( 'wp_ajax_nopriv_newspack_reader_registration_verification', __NAMESPACE__ . '\\process_verification_request' ); /** * Render Registration Block. @@ -272,7 +278,7 @@ function render_block( $attrs, $content ) { ) ) { $registered = true; - if ( ! Reader_Activation::is_reader_verified( \wp_get_current_user() ) && \Newspack\Content_Gate::is_gated() ) { + if ( \is_user_logged_in() && ! Reader_Activation::is_reader_verified( \wp_get_current_user() ) && \Newspack\Content_Gate::is_gated() ) { $show_pending_verification = true; } } @@ -573,7 +579,8 @@ function process_form() { $response['verified'] = Reader_Activation::is_reader_verified( $user ); // Signal frontend to open OTP verification flow. if ( ! $response['verified'] && \Newspack\Content_Gate::requires_account_verification() ) { - $response['action'] = 'otp'; + $response['action'] = 'otp'; + $response['verification_nonce'] = \wp_create_nonce( 'newspack_reader_registration_verification' ); } } } else { diff --git a/src/blocks/reader-registration/style.scss b/src/blocks/reader-registration/style.scss index 56adf17549..98a0c3bdbc 100644 --- a/src/blocks/reader-registration/style.scss +++ b/src/blocks/reader-registration/style.scss @@ -271,12 +271,6 @@ @include mixins.visuallyHidden; } - // Pending verification state - &__pending-verification { - p { - margin: var(--newspack-ui-spacer-2) 0 var(--newspack-ui-spacer-4); - } - } } @container registration ( width > 568px ) { diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index dea7b27003..adc241fa68 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -17,6 +17,7 @@ window.newspackRAS.push( function ( readerActivation ) { const sendVerificationOTP = () => { const body = new FormData(); body.set( 'action', 'newspack_reader_registration_verification' ); + body.set( 'nonce', reader_registration_block_config.verification_nonce ); return fetch( reader_registration_block_config.verification_url, { method: 'POST', headers: { Accept: 'application/json' }, @@ -66,6 +67,11 @@ window.newspackRAS.push( function ( readerActivation ) { } ) .catch( () => { sendOtpButton.disabled = false; + sendOtpButton.textContent = sendOtpButton.textContent.trim(); + const errorP = box.querySelector( 'p:not(:has(button))' ); + if ( errorP ) { + errorP.textContent = 'Something went wrong. Please try again.'; + } } ); } ); } @@ -162,20 +168,18 @@ window.newspackRAS.push( function ( readerActivation ) { // Determine which success element to show const registrationSuccessEl = container.querySelector( '.newspack-registration__registration-success' ); - const loginSuccessEl = container.querySelector( '.newspack-registration__login-success' ); // Check if this is a new registration that needs email verification // Note: verified can be false, null, or undefined - we need verification if it's not true const needsVerification = ! data?.existing_user && reader_registration_block_config.require_account_verification && data?.verified !== true; - // Hide all success/verification elements first to ensure only one shows + // Hide success element first to ensure clean state registrationSuccessEl?.classList.add( 'newspack-registration--hidden' ); - loginSuccessEl?.classList.add( 'newspack-registration--hidden' ); let successElement; if ( ! needsVerification ) { - successElement = data?.existing_user ? loginSuccessEl : registrationSuccessEl; + successElement = registrationSuccessEl; } if ( message ) { @@ -200,16 +204,19 @@ window.newspackRAS.push( function ( readerActivation ) { if ( data?.email ) { body = new FormData( form ); readerActivation.setReaderEmail( data.email ); + readerActivation.setAuthenticated( data?.authenticated ); if ( needsVerification ) { + // Use the fresh nonce from the registration response (session changed after login). + if ( data.verification_nonce ) { + reader_registration_block_config.verification_nonce = data.verification_nonce; + } // Update %EMAIL% placeholder in verification modal const emailNode = verificationModal.querySelector( '.email-address' ); if ( emailNode ) { emailNode.textContent = data.email; } verificationModal.setAttribute( 'data-state', 'open' ); - } else { - readerActivation.setAuthenticated( data?.authenticated ); } if ( data.authenticated && ! needsVerification ) { const baseActivity = { email: data.email }; @@ -272,7 +279,9 @@ window.newspackRAS.push( function ( readerActivation ) { body, } ) .then( res => { - res.json().then( ( { message, data } ) => form.endLoginFlow( message, res.status, data ) ); + res.json() + .then( ( { message, data } ) => form.endLoginFlow( message, res.status, data ) ) + .catch( () => form.endLoginFlow( 'An error occurred.', res.status || 400 ) ); } ) .catch( e => { form.endLoginFlow( e?.message || 'An error occurred.', 400 ); diff --git a/src/reader-activation-auth/auth-form.js b/src/reader-activation-auth/auth-form.js index 3852b046c4..d1628a322e 100644 --- a/src/reader-activation-auth/auth-form.js +++ b/src/reader-activation-auth/auth-form.js @@ -127,7 +127,6 @@ window.newspackRAS.push( function ( readerActivation ) { } if ( 'otp' === action ) { if ( ! readerActivation.getOTPHash() ) { - console.warn( 'No OTP hash found.' ); // eslint-disable-line no-console return; } const emailAddressElements = container.querySelectorAll( '.email-address' ); @@ -218,6 +217,9 @@ window.newspackRAS.push( function ( readerActivation ) { }; const remaining = readerActivation.getOTPTimeRemaining(); if ( remaining ) { + if ( resendCodeButton.otpTimerInterval ) { + clearInterval( resendCodeButton.otpTimerInterval ); + } resendCodeButton.otpTimerInterval = setInterval( updateButton, 1000 ); updateButton(); } From 079ee8b64c93d2bae6e3137252904787fe695f7a Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 11 Feb 2026 16:44:41 -0300 Subject: [PATCH 21/27] refactor: streamline success element handling in reader registration --- src/blocks/reader-registration/view.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index adc241fa68..ac465f593c 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -166,21 +166,14 @@ window.newspackRAS.push( function ( readerActivation ) { return; } - // Determine which success element to show - const registrationSuccessEl = container.querySelector( '.newspack-registration__registration-success' ); - // Check if this is a new registration that needs email verification // Note: verified can be false, null, or undefined - we need verification if it's not true const needsVerification = ! data?.existing_user && reader_registration_block_config.require_account_verification && data?.verified !== true; // Hide success element first to ensure clean state - registrationSuccessEl?.classList.add( 'newspack-registration--hidden' ); - - let successElement; - if ( ! needsVerification ) { - successElement = registrationSuccessEl; - } + const successElement = container.querySelector( '.newspack-registration__registration-success' ); + successElement?.classList.add( 'newspack-registration--hidden' ); if ( message ) { messageNode = document.createElement( 'p' ); @@ -197,7 +190,7 @@ window.newspackRAS.push( function ( readerActivation ) { if ( isSuccess ) { // Set flowCompleted early to prevent 'reader' event listener from interfering flowCompleted = true; - if ( successElement ) { + if ( ! needsVerification && ! data?.existing_user ) { form.remove(); successElement.classList.remove( 'newspack-registration--hidden' ); } From 34b9f5d3b9b0e80260d2c628c6ab3fcf263e491c Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Fri, 13 Feb 2026 07:50:22 +0000 Subject: [PATCH 22/27] feat: update icon --- includes/class-newspack-ui-icons.php | 4 ++++ src/blocks/reader-registration/index.php | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/includes/class-newspack-ui-icons.php b/includes/class-newspack-ui-icons.php index c074ab3edd..eb7acb8401 100644 --- a/includes/class-newspack-ui-icons.php +++ b/includes/class-newspack-ui-icons.php @@ -150,6 +150,10 @@ public static function sanitize_svgs() { '', + 'login' => + '', 'logout' => '
- +

- +

Date: Fri, 13 Feb 2026 07:58:37 +0000 Subject: [PATCH 23/27] feat: update strings --- includes/reader-activation/class-reader-activation.php | 2 +- src/blocks/reader-registration/index.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 721fa3c05f..857b27b6ab 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -286,7 +286,7 @@ private static function get_reader_activation_labels( $key = null ) { 'continue' => __( 'Continue', 'newspack-plugin' ), 'resend_code' => __( 'Resend code', 'newspack-plugin' ), 'otp' => __( 'Email me a one-time code instead', 'newspack-plugin' ), - 'otp_title' => __( 'Enter the code sent to your email.', 'newspack-plugin' ), + 'otp_title' => __( 'Enter the code sent to your email', 'newspack-plugin' ), 'forgot_password' => __( 'Forgot password', 'newspack-plugin' ), 'create_account' => __( 'Create an account', 'newspack-plugin' ), 'register' => __( 'Sign in to an existing account', 'newspack-plugin' ), diff --git a/src/blocks/reader-registration/index.php b/src/blocks/reader-registration/index.php index 16b50d8374..66b4ecf8f9 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -171,7 +171,7 @@ function render_verification_modal() { \Newspack\Newspack_UI::generate_modal( [ 'id' => 'newspack-reader-verification', - 'title' => __( 'Verify your email', 'newspack-plugin' ), + 'title' => __( 'Sign in', 'newspack-plugin' ), 'content' => $content, ] ); From ff488a32536999cc4fbd59f8bf64cb27f51a818c Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 13 Feb 2026 10:08:02 -0300 Subject: [PATCH 24/27] feat: update button labels --- includes/reader-activation/class-reader-activation.php | 2 +- src/blocks/reader-registration/block.json | 2 +- src/blocks/reader-registration/edit.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 857b27b6ab..efa379eb68 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -1954,7 +1954,7 @@ public static function render_third_party_auth() {

diff --git a/src/blocks/reader-registration/block.json b/src/blocks/reader-registration/block.json index 62a138cae0..ba96bdae5d 100644 --- a/src/blocks/reader-registration/block.json +++ b/src/blocks/reader-registration/block.json @@ -19,7 +19,7 @@ }, "label": { "type": "string", - "default": "Sign up", + "default": "Continue", "required": true }, "privacyLabel": { diff --git a/src/blocks/reader-registration/edit.js b/src/blocks/reader-registration/edit.js index 4fd0c8b621..6c37868d7d 100644 --- a/src/blocks/reader-registration/edit.js +++ b/src/blocks/reader-registration/edit.js @@ -288,7 +288,7 @@ export default function ReaderRegistrationEdit( { __html: newspack_blocks.google_logo_svg, } } /> - { __( 'Sign in with Google', 'newspack-plugin' ) } + { __( 'Continue with Google', 'newspack-plugin' ) }
{ __( 'Or', 'newspack-plugin' ) }
From 5d6e4c02e2a754c8d4f8d1985ffac4421bf5b8dc Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 13 Feb 2026 10:08:21 -0300 Subject: [PATCH 25/27] feat: tweak authentication modal behavior --- src/blocks/reader-registration/view.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index ac465f593c..6647860ca5 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -34,10 +34,10 @@ window.newspackRAS.push( function ( readerActivation ) { const openAuth = ( initialState = 'otp' ) => { openAuthModal( { skipAuthenticatedCheck: true, + skipNewslettersSignup: true, initialState, - closeOnSuccess: false, - skipSuccess: true, - onSuccess: () => window.location.reload(), + closeOnSuccess: true, + skipSuccess: false, onClose: () => window.location.reload(), } ); }; From 96446182da409b590dab63afa57af147738c1b22 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 19 Feb 2026 14:17:32 -0300 Subject: [PATCH 26/27] fix: hide registration modal link for logged-in users --- src/reader-activation-auth/style.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/reader-activation-auth/style.scss b/src/reader-activation-auth/style.scss index 14b126cef4..2100618772 100644 --- a/src/reader-activation-auth/style.scss +++ b/src/reader-activation-auth/style.scss @@ -124,7 +124,8 @@ li.menu-item .newspack-reader__account-link { * Hide login links when logged in. */ body.logged-in { - a[href="#signin_modal"] { + a[href="#signin_modal"], + a[href="#register_modal"] { display: none; } } From 141a7cc52f70af604e4f5b01a40a59021825b987 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 19 Feb 2026 14:18:08 -0300 Subject: [PATCH 27/27] feat: allow the back button to close the modal --- src/blocks/reader-registration/view.js | 1 + src/reader-activation-auth/auth-form.js | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index 6647860ca5..8f1ba69545 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -35,6 +35,7 @@ window.newspackRAS.push( function ( readerActivation ) { openAuthModal( { skipAuthenticatedCheck: true, skipNewslettersSignup: true, + backButtonClosesModal: true, initialState, closeOnSuccess: true, skipSuccess: false, diff --git a/src/reader-activation-auth/auth-form.js b/src/reader-activation-auth/auth-form.js index d1628a322e..97704ce3f4 100644 --- a/src/reader-activation-auth/auth-form.js +++ b/src/reader-activation-auth/auth-form.js @@ -163,12 +163,6 @@ window.newspackRAS.push( function ( readerActivation ) { if ( container.formActionCallback ) { container.formActionCallback( action ); } - // Hide back buttons when authenticated to prevent switching to a different email. - if ( readerActivation.getReader()?.authenticated ) { - backButtons.forEach( button => { - button.style.display = 'none'; - } ); - } }; container.setFormAction( 'signin' ); @@ -192,6 +186,14 @@ window.newspackRAS.push( function ( readerActivation ) { backButtons.forEach( backButton => { backButton.addEventListener( 'click', function ( ev ) { ev.preventDefault(); + // Close the modal instead of navigating back when configured or if the reader is authenticated. + if ( container.config?.backButtonClosesModal || readerActivation.getReader()?.authenticated ) { + const modal = container.closest( '.newspack-ui__modal-container' ); + if ( modal ) { + modal.setAttribute( 'data-state', 'closed' ); + return; + } + } form.setMessageContent(); container.setFormAction( 'signin', true ); } );