diff --git a/includes/class-magic-link.php b/includes/class-magic-link.php index 4987d6cdf0..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 @@ -923,6 +927,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/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' => '
@@ -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/block.json b/src/blocks/reader-registration/block.json index e7302bcc6c..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": { @@ -52,14 +52,6 @@ "listsCheckboxes": { "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." } }, "supports": { diff --git a/src/blocks/reader-registration/edit.js b/src/blocks/reader-registration/edit.js index 9c4d95598a..6c37868d7d 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,8 +40,6 @@ export default function ReaderRegistrationEdit( { displayListDescription, hideSubscriptionInput, newsletterLabel, - signInLabel, - signedInLabel, lists, listsCheckboxes, }, @@ -292,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' ) }
@@ -303,7 +299,7 @@ export default function ReaderRegistrationEdit( {
-
- ev.preventDefault() } - className="newspack-ui__button newspack-ui__button--ghost" - > - setAttributes( { signInLabel: value } ) } - placeholder={ __( 'Sign in to an existing account', 'newspack-plugin' ) } - value={ signInLabel } - allowedFormats={ [] } - tagName="span" - /> - -
setAttributes( { privacyLabel: value } ) } @@ -343,7 +324,7 @@ export default function ReaderRegistrationEdit( { ) } { editedState === 'registration' && (
-
+
@@ -351,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 ca78bea45e..66b4ecf8f9 100644 --- a/src/blocks/reader-registration/index.php +++ b/src/blocks/reader-registration/index.php @@ -65,6 +65,15 @@ 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(), + 'verification_url' => \admin_url( 'admin-ajax.php' ), + 'verification_nonce' => \wp_create_nonce( 'newspack_reader_registration_verification' ), + ] + ); } add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_scripts' ); @@ -82,6 +91,128 @@ 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 ) . '' + ) + ); + ?> +

+

+ +

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

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

+
+ + + +
+ 'newspack-reader-verification', + 'title' => __( 'Sign in', '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' ); + /** * Render Registration Block. * @@ -94,9 +225,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 ) { @@ -109,10 +242,8 @@ function render_block( $attrs, $content ) { /** Handle default attributes. */ $default_attrs = [ - 'label' => __( 'Sign up', 'newspack-plugin' ), + 'label' => __( 'Continue', 'newspack-plugin' ), 'newsletterLabel' => __( 'Subscribe to our newsletter', 'newspack-plugin' ), - 'signInLabel' => __( 'Sign in to an existing account', '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 ) { @@ -121,11 +252,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' ) ) { @@ -142,34 +268,30 @@ function render_block( $attrs, $content ) { $is_admin_preview = method_exists( 'Newspack_Popups', 'is_user_admin' ) && \Newspack_Popups::is_user_admin(); - // 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 ( \is_user_logged_in() && ! 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 ) ) ) { $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 ) { @@ -182,7 +304,11 @@ function render_block( $attrs, $content ) { ob_start(); ?>
- +
@@ -270,11 +396,6 @@ class="newspack-ui__button newspack-ui__button--primary"
-
- - - -

-
+
-
- - - - -
$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, + ]; + + // 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 ); + // Signal frontend to open OTP verification flow. + if ( ! $response['verified'] && \Newspack\Content_Gate::requires_account_verification() ) { + $response['action'] = 'otp'; + $response['verification_nonce'] = \wp_create_nonce( 'newspack_reader_registration_verification' ); + } + } + } 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. + $response['action'] = Reader_Activation::is_reader_without_password( $existing_user ) ? 'otp' : '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..98a0c3bdbc 100644 --- a/src/blocks/reader-registration/style.scss +++ b/src/blocks/reader-registration/style.scss @@ -270,6 +270,7 @@ input[type="email"].nphp { @include mixins.visuallyHidden; } + } @container registration ( width > 568px ) { diff --git a/src/blocks/reader-registration/view.js b/src/blocks/reader-registration/view.js index fdf4642b11..8f1ba69545 100644 --- a/src/blocks/reader-registration/view.js +++ b/src/blocks/reader-registration/view.js @@ -1,46 +1,107 @@ +/* globals reader_registration_block_config */ /** * 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 { openAuthModal } from '../../reader-activation-auth/auth-modal'; window.newspackRAS = window.newspackRAS || []; window.newspackRAS.push( function ( readerActivation ) { + /** + * 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' ); + body.set( 'nonce', reader_registration_block_config.verification_nonce ); + 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(); + } ); + }; + + const openAuth = ( initialState = 'otp' ) => { + openAuthModal( { + skipAuthenticatedCheck: true, + skipNewslettersSignup: true, + backButtonClosesModal: true, + initialState, + closeOnSuccess: true, + skipSuccess: false, + onClose: () => window.location.reload(), + } ); + }; + domReady( function () { + const verificationModal = document.getElementById( 'newspack-my-account__newspack-reader-verification' ); + const verificationBox = document.querySelectorAll( '.newspack__reader-verification' ); + if ( [ ...verificationBox ].length ) { + verificationBox.forEach( box => { + const sendOtpButton = box.querySelector( '[data-send-otp]' ); + + // 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' ); + } + openAuth( 'otp' ); + } ) + .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.'; + } + } ); + } ); + } + + // Reload when the verification modal is dismissed. + if ( modal ) { + modal.addEventListener( 'closeModal', () => { + if ( ! otpSent ) { + window.location.reload(); + } + } ); + } + } ); + } + document.querySelectorAll( '.newspack-registration' ).forEach( container => { const form = container.querySelector( 'form' ); + + // Form-specific logic if ( ! form ) { return; } 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' ); spinner.classList.add( 'spinner' ); - let successElement = container.querySelector( '.newspack-registration__registration-success' ); form.startLoginFlow = () => { messageElement.classList.add( 'newspack-registration--hidden' ); @@ -51,17 +112,75 @@ window.newspackRAS.push( function ( readerActivation ) { }; form.endLoginFlow = ( message = null, status = 500, data = null ) => { + // Prevent re-running after successful completion + if ( flowCompleted ) { + return; + } + let messageNode; - if ( data?.existing_user ) { - successElement = container.querySelector( '.newspack-registration__login-success' ); + // 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; + 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 ); + + // 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(); + openAuth( '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(); + openAuth( 'otp' ); + } else { + openAuth( 'signin' ); + } + } ) + .catch( () => openAuth( 'signin' ) ); + } + return; + } + + // For password or other actions, just open the modal + openAuth( data.action ); + return; } + // 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 + const successElement = container.querySelector( '.newspack-registration__registration-success' ); + successElement?.classList.add( 'newspack-registration--hidden' ); + if ( message ) { messageNode = document.createElement( 'p' ); messageNode.textContent = message; - const defaultMessage = successElement.querySelector( 'p' ); + const defaultMessage = successElement?.querySelector( 'p' ); if ( defaultMessage && data?.sso ) { defaultMessage.replaceWith( messageNode ); } @@ -70,14 +189,30 @@ 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' ); + // Set flowCompleted early to prevent 'reader' event listener from interfering + flowCompleted = true; + if ( ! needsVerification && ! data?.existing_user ) { + form.remove(); + successElement.classList.remove( 'newspack-registration--hidden' ); + } 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 ) { + 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' ); + } + if ( data.authenticated && ! needsVerification ) { const baseActivity = { email: data.email }; const lists = body.getAll( 'lists[]' ); if ( body.has( 'newspack_popup_id' ) ) { @@ -109,7 +244,6 @@ window.newspackRAS.push( function ( readerActivation ) { } } } - form.remove(); } else if ( messageNode ) { messageElement.appendChild( messageNode ); messageElement.classList.remove( 'newspack-registration--hidden' ); @@ -126,31 +260,31 @@ 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, { method: 'POST', - headers: { - Accept: 'application/json', - }, + headers: { Accept: 'application/json' }, 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, 400 ); + form.endLoginFlow( e?.message || 'An error occurred.', 400 ); } ); } ); - readerActivation.on( 'reader', ( { detail: { authenticated } } ) => { - if ( authenticated ) { - form.endLoginFlow( null, 200 ); + readerActivation.on( 'reader', ( { detail } ) => { + if ( detail.authenticated && ! flowCompleted ) { + form.endLoginFlow( null, 200, { existing_user: true } ); } } ); } ); diff --git a/src/content-gate/gate.js b/src/content-gate/gate.js index dcdc679140..6a17bae8b4 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/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-form.js b/src/reader-activation-auth/auth-form.js index 16c2a53c58..97704ce3f4 100644 --- a/src/reader-activation-auth/auth-form.js +++ b/src/reader-activation-auth/auth-form.js @@ -186,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 ); } ); @@ -211,6 +219,9 @@ window.newspackRAS.push( function ( readerActivation ) { }; const remaining = readerActivation.getOTPTimeRemaining(); if ( remaining ) { + if ( resendCodeButton.otpTimerInterval ) { + clearInterval( resendCodeButton.otpTimerInterval ); + } resendCodeButton.otpTimerInterval = setInterval( updateButton, 1000 ); updateButton(); } diff --git a/src/reader-activation-auth/auth-modal.js b/src/reader-activation-auth/auth-modal.js index f091c7df52..d6d35b4f73 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(); } @@ -87,7 +87,17 @@ export function openAuthModal( config = {} ) { * * @param {boolean} dismiss Whether it's a dismiss action. */ - const close = ( dismiss = true ) => { + let closed = false; + let succeeded = false; + + const close = () => { + if ( closed ) { + return; + } + closed = true; + + modal.removeEventListener( 'closeModal', handleModalClose ); + container.config = {}; modal.setAttribute( 'data-state', 'closed' ); document.body.classList.remove( 'newspack-signin' ); @@ -104,16 +114,24 @@ 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 ); } ); }; + // 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 ) { closeButtons.forEach( closeButton => { @@ -132,8 +150,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 ); diff --git a/src/reader-activation-auth/otp-input.js b/src/reader-activation-auth/otp-input.js index ae282f98b1..73e59178a0 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, the original input if maxlength is missing, or null if no input provided. + */ +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 ); 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; } }