diff --git a/projects/js-packages/connection/changelog/add-protected_owner_error_manual b/projects/js-packages/connection/changelog/add-protected_owner_error_manual new file mode 100644 index 0000000000000..21544c0ebfb1b --- /dev/null +++ b/projects/js-packages/connection/changelog/add-protected_owner_error_manual @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Connection: error handling for protected owner on WPcom. diff --git a/projects/js-packages/connection/components/connection-error-notice/index.jsx b/projects/js-packages/connection/components/connection-error-notice/index.jsx index bf9dfb07f319b..c37f0e472e714 100644 --- a/projects/js-packages/connection/components/connection-error-notice/index.jsx +++ b/projects/js-packages/connection/components/connection-error-notice/index.jsx @@ -12,8 +12,13 @@ import styles from './styles.module.scss'; * @return {React.Component} The `ConnectionErrorNotice` component. */ const ConnectionErrorNotice = props => { - const { message, isRestoringConnection, restoreConnectionCallback, restoreConnectionError } = - props; + const { + message, + isRestoringConnection, + restoreConnectionCallback, + restoreConnectionError, + actions = [], // New prop for custom actions + } = props; const [ isBiggerThanMedium ] = useBreakpointMatch( [ 'md' ], [ '>' ] ); const wrapperClassName = @@ -73,6 +78,39 @@ const ConnectionErrorNotice = props => { ) : null; + // Determine which actions to show + let actionButtons = []; + + if ( actions.length > 0 ) { + // Use custom actions + actionButtons = actions.map( ( action, index ) => ( + + { action.isLoading + ? action.loadingText || __( 'Loading…', 'jetpack-connection-js' ) + : action.label } + + ) ); + } else if ( restoreConnectionCallback ) { + // Use default restore connection action for backward compatibility + actionButtons = [ + + { __( 'Restore Connection', 'jetpack-connection-js' ) } + , + ]; + } + return ( <> { errorRender } @@ -81,16 +119,7 @@ const ConnectionErrorNotice = props => { { icon } { message } - { restoreConnectionCallback && ( - - { __( 'Restore Connection', 'jetpack-connection-js' ) } - - ) } + { actionButtons.length > 0 &&
{ actionButtons }
} ); @@ -98,13 +127,23 @@ const ConnectionErrorNotice = props => { ConnectionErrorNotice.propTypes = { /** The notice message. */ - message: PropTypes.string.isRequired, + message: PropTypes.oneOfType( [ PropTypes.string, PropTypes.element ] ).isRequired, /** "Restore Connection" button callback. */ restoreConnectionCallback: PropTypes.func, /** Whether connection restore is in progress. */ isRestoringConnection: PropTypes.bool, /** The connection error text if there is one. */ restoreConnectionError: PropTypes.string, + /** Array of custom action objects. */ + actions: PropTypes.arrayOf( + PropTypes.shape( { + label: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + isLoading: PropTypes.bool, + loadingText: PropTypes.string, + variant: PropTypes.oneOf( [ 'primary', 'secondary' ] ), + } ) + ), }; export default ConnectionErrorNotice; diff --git a/projects/js-packages/connection/components/connection-error-notice/styles.module.scss b/projects/js-packages/connection/components/connection-error-notice/styles.module.scss index 833768cc92afb..b0be9c4d6bf7b 100644 --- a/projects/js-packages/connection/components/connection-error-notice/styles.module.scss +++ b/projects/js-packages/connection/components/connection-error-notice/styles.module.scss @@ -55,6 +55,16 @@ margin-top: 24px; background: #000; border-radius: var( --jp-border-radius ); + + &.primary { + background: var( --jp-green-50 ); + } + } + + .actions { + display: flex; + gap: calc( var( --spacing-base ) * 2 ); // 16px + flex-wrap: wrap; } &.bigger-than-medium { diff --git a/projects/js-packages/connection/hooks/use-connection-error-notice/index.jsx b/projects/js-packages/connection/hooks/use-connection-error-notice/index.jsx index 82b78fe298926..be64821d32f9a 100644 --- a/projects/js-packages/connection/hooks/use-connection-error-notice/index.jsx +++ b/projects/js-packages/connection/hooks/use-connection-error-notice/index.jsx @@ -1,10 +1,39 @@ +import { __ } from '@wordpress/i18n'; import ConnectionErrorNotice from '../../components/connection-error-notice'; import useConnection from '../../components/use-connection'; import useRestoreConnection from '../../hooks/use-restore-connection/index.jsx'; +/** + * Helper function to generate user creation URL with email prepopulation + * + * @param {object} connectionError - The connection error object + * @param {string} baseUrl - Base admin URL (defaults to '/wp-admin/') + * @return {string} The complete URL for user creation with email parameters + */ +export function getProtectedOwnerCreateAccountUrl( connectionError, baseUrl = '/wp-admin/' ) { + let redirectUrl = baseUrl + 'user-new.php'; + + // Add protected owner email if available for prepopulation + if ( connectionError?.error_data?.wpcom_user_email ) { + const params = new URLSearchParams( { + jetpack_protected_owner_email: connectionError.error_data.wpcom_user_email, + jetpack_create_missing_account: '1', + } ); + redirectUrl += '?' + params.toString(); + } else if ( connectionError?.error_data?.email ) { + const params = new URLSearchParams( { + jetpack_protected_owner_email: connectionError.error_data.email, + jetpack_create_missing_account: '1', + } ); + redirectUrl += '?' + params.toString(); + } + + return redirectUrl; +} + /** * Connection error notice hook. - * Returns a ConnectionErrorNotice component and the conditional flag on whether + * Returns connection error data and conditional flag on whether * to render the component or not. * * @return {object} - The hook data. @@ -12,27 +41,90 @@ import useRestoreConnection from '../../hooks/use-restore-connection/index.jsx'; export default function useConnectionErrorNotice() { const { connectionErrors } = useConnection( {} ); const connectionErrorList = Object.values( connectionErrors ).shift(); - const connectionErrorMessage = + const firstError = connectionErrorList && Object.values( connectionErrorList ).length && - Object.values( connectionErrorList ).shift().error_message; + Object.values( connectionErrorList ).shift(); + const connectionErrorMessage = firstError && firstError.error_message; + + // Return all connection errors, including protected owner errors const hasConnectionError = Boolean( connectionErrorMessage ); - return { hasConnectionError, connectionErrorMessage }; + return { + hasConnectionError, + connectionErrorMessage, + connectionError: firstError, // Full error object with error_type, etc. + connectionErrors, // All errors for advanced use cases + }; } -export const ConnectionError = () => { - const { hasConnectionError, connectionErrorMessage } = useConnectionErrorNotice(); +export const ConnectionError = ( { + onCreateMissingAccount = null, // Custom handler for protected owner errors + trackingCallback = null, // Custom tracking function + customActions = null, // Function that returns custom actions based on error +} = {} ) => { + const { hasConnectionError, connectionErrorMessage, connectionError } = + useConnectionErrorNotice(); const { restoreConnection, isRestoringConnection, restoreConnectionError } = useRestoreConnection(); - return hasConnectionError ? ( + if ( ! hasConnectionError ) { + return null; + } + + // Determine error type + const isProtectedOwnerError = connectionError && connectionError.error_type === 'protected_owner'; + + // Build actions array based on error type + let actions = []; + + if ( customActions ) { + // Use provided custom actions function + actions = customActions( connectionError, { restoreConnection, isRestoringConnection } ); + } else if ( isProtectedOwnerError && onCreateMissingAccount ) { + // Handle protected owner error with custom handler + actions = [ + { + label: __( 'Create missing account', 'jetpack-connection-js' ), + onClick: () => { + if ( trackingCallback ) { + trackingCallback( 'jetpack_connection_protected_owner_create_account_attempt', {} ); + } + onCreateMissingAccount(); + }, + variant: 'primary', + }, + ]; + } else if ( ! isProtectedOwnerError ) { + // Standard connection error - use restore connection + actions = [ + { + label: __( 'Restore Connection', 'jetpack-connection-js' ), + onClick: () => { + if ( trackingCallback ) { + trackingCallback( 'jetpack_connection_error_notice_reconnect_cta_click', {} ); + } + restoreConnection(); + }, + isLoading: isRestoringConnection, + loadingText: __( 'Reconnecting Jetpack…', 'jetpack-connection-js' ), + }, + ]; + } + + // For protected owner errors without custom handler, don't show the component + if ( isProtectedOwnerError && ! onCreateMissingAccount && ! customActions ) { + return null; + } + + return ( - ) : null; + ); }; diff --git a/projects/js-packages/connection/hooks/use-connection-error-notice/test/index.test.jsx b/projects/js-packages/connection/hooks/use-connection-error-notice/test/index.test.jsx new file mode 100644 index 0000000000000..9302e7f41c043 --- /dev/null +++ b/projects/js-packages/connection/hooks/use-connection-error-notice/test/index.test.jsx @@ -0,0 +1,311 @@ +import { jest } from '@jest/globals'; +import { render, renderHook } from '@testing-library/react'; +import React from 'react'; +import { getProtectedOwnerCreateAccountUrl } from '../index.jsx'; + +// Create manual mocks +const mockConnectionData = { + connectionErrors: {}, +}; + +const mockRestoreConnectionData = { + restoreConnection: jest.fn(), + isRestoringConnection: false, + restoreConnectionError: null, +}; + +// Mock useConnection manually +const mockUseConnection = jest.fn().mockReturnValue( mockConnectionData ); + +// Mock useRestoreConnection manually +const mockUseRestoreConnection = jest.fn().mockReturnValue( mockRestoreConnectionData ); + +// Mock the ConnectionErrorNotice component manually +const MockConnectionErrorNotice = jest.fn().mockImplementation( () =>
Mocked Notice
); + +// Create a custom hook that uses our mocked dependencies +/** + * Hook for testing connection error notice functionality. + * + * @return {object} Hook return object. + * @property {boolean} hasConnectionError - Whether a connection error exists. + * @property {string} connectionErrorMessage - The connection error message. + * @property {object} connectionError - The connection error object. + * @property {object} connectionErrors - All connection errors. + */ +function mockUseConnectionErrorNotice() { + const { connectionErrors } = mockUseConnection( {} ); + const connectionErrorList = Object.values( connectionErrors ).shift(); + const firstError = + connectionErrorList && + Object.values( connectionErrorList ).length && + Object.values( connectionErrorList ).shift(); + + const connectionErrorMessage = firstError && firstError.error_message; + const hasConnectionError = Boolean( connectionErrorMessage ); + + return { + hasConnectionError, + connectionErrorMessage, + connectionError: firstError, + connectionErrors, + }; +} + +// Create a custom ConnectionError component that uses our mocked dependencies +const MockConnectionError = ( { + onCreateMissingAccount = null, + trackingCallback = null, + customActions = null, +} = {} ) => { + const { hasConnectionError, connectionErrorMessage, connectionError } = + mockUseConnectionErrorNotice(); + const { restoreConnection, isRestoringConnection, restoreConnectionError } = + mockUseRestoreConnection(); + + if ( ! hasConnectionError ) { + return null; + } + + const isProtectedOwnerError = connectionError && connectionError.error_type === 'protected_owner'; + + let actions = []; + + if ( customActions ) { + actions = customActions( connectionError, { restoreConnection, isRestoringConnection } ); + } else if ( isProtectedOwnerError && onCreateMissingAccount ) { + actions = [ + { + label: 'Create missing account', + onClick: () => { + if ( trackingCallback ) { + trackingCallback( 'jetpack_connection_protected_owner_create_account_attempt', {} ); + } + onCreateMissingAccount(); + }, + variant: 'primary', + }, + ]; + } else if ( ! isProtectedOwnerError ) { + actions = [ + { + label: 'Restore Connection', + onClick: () => { + if ( trackingCallback ) { + trackingCallback( 'jetpack_connection_error_notice_reconnect_cta_click', {} ); + } + restoreConnection(); + }, + isLoading: isRestoringConnection, + loadingText: 'Reconnecting Jetpack…', + }, + ]; + } + + if ( isProtectedOwnerError && ! onCreateMissingAccount && ! customActions ) { + return null; + } + + return ( + + ); +}; + +describe( 'useConnectionErrorNotice', () => { + beforeEach( () => { + jest.clearAllMocks(); + mockUseConnection.mockReturnValue( mockConnectionData ); + mockUseRestoreConnection.mockReturnValue( mockRestoreConnectionData ); + } ); + + it( 'should return hasConnectionError as false when no errors', () => { + const { result } = renderHook( () => mockUseConnectionErrorNotice() ); + + expect( result.current.hasConnectionError ).toBe( false ); + expect( result.current.connectionErrorMessage ).toBeUndefined(); + expect( result.current.connectionError ).toBeUndefined(); + } ); + + it( 'should extract and return the first error when errors exist', () => { + const mockError = { + error_code: 'invalid_token', + error_message: 'The connection token is invalid', + error_type: 'connection', + }; + + mockUseConnection.mockReturnValue( { + connectionErrors: { + invalid_token: { + 123: mockError, + }, + }, + } ); + + const { result } = renderHook( () => mockUseConnectionErrorNotice() ); + + expect( result.current.hasConnectionError ).toBe( true ); + expect( result.current.connectionErrorMessage ).toBe( 'The connection token is invalid' ); + expect( result.current.connectionError ).toEqual( mockError ); + } ); + + it( 'should handle protected owner errors', () => { + const protectedOwnerError = { + error_code: 'protected_owner', + error_message: 'The WordPress.com plan owner is missing', + error_type: 'protected_owner', + }; + + mockUseConnection.mockReturnValue( { + connectionErrors: { + protected_owner: { + 123: protectedOwnerError, + }, + }, + } ); + + const { result } = renderHook( () => mockUseConnectionErrorNotice() ); + + expect( result.current.hasConnectionError ).toBe( true ); + expect( result.current.connectionErrorMessage ).toBe( + 'The WordPress.com plan owner is missing' + ); + expect( result.current.connectionError ).toEqual( protectedOwnerError ); + } ); +} ); + +describe( 'ConnectionError component', () => { + beforeEach( () => { + jest.clearAllMocks(); + mockUseConnection.mockReturnValue( mockConnectionData ); + mockUseRestoreConnection.mockReturnValue( mockRestoreConnectionData ); + } ); + + it( 'should not render when there are no connection errors', () => { + const { container } = render( ); + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'should not render for protected owner errors without custom handler', () => { + mockUseConnection.mockReturnValue( { + connectionErrors: { + protected_owner: { + 123: { + error_code: 'protected_owner', + error_message: 'The WordPress.com plan owner is missing', + error_type: 'protected_owner', + }, + }, + }, + } ); + + const { container } = render( ); + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'should render for protected owner errors when onCreateMissingAccount is provided', () => { + const mockOnCreateMissingAccount = jest.fn(); + + mockUseConnection.mockReturnValue( { + connectionErrors: { + protected_owner: { + 123: { + error_code: 'protected_owner', + error_message: 'The WordPress.com plan owner is missing', + error_type: 'protected_owner', + }, + }, + }, + } ); + + const { container } = render( + + ); + expect( container ).not.toBeEmptyDOMElement(); + } ); + + it( 'should render for standard connection errors', () => { + mockUseConnection.mockReturnValue( { + connectionErrors: { + invalid_token: { + 123: { + error_code: 'invalid_token', + error_message: 'Connection failed', + error_type: 'connection', + }, + }, + }, + } ); + + const { container } = render( ); + expect( container ).not.toBeEmptyDOMElement(); + } ); +} ); + +describe( 'getProtectedOwnerCreateAccountUrl', () => { + it( 'should generate URL with wpcom_user_email parameter', () => { + const connectionError = { + error_data: { + wpcom_user_email: 'test@example.com', + }, + }; + + const url = getProtectedOwnerCreateAccountUrl( connectionError, '/wp-admin/' ); + + expect( url ).toBe( + '/wp-admin/user-new.php?jetpack_protected_owner_email=test%40example.com&jetpack_create_missing_account=1' + ); + } ); + + it( 'should generate URL with email parameter when wpcom_user_email is not available', () => { + const connectionError = { + error_data: { + email: 'fallback@example.com', + }, + }; + + const url = getProtectedOwnerCreateAccountUrl( connectionError, '/custom-admin/' ); + + expect( url ).toBe( + '/custom-admin/user-new.php?jetpack_protected_owner_email=fallback%40example.com&jetpack_create_missing_account=1' + ); + } ); + + it( 'should prioritize wpcom_user_email over email when both are available', () => { + const connectionError = { + error_data: { + email: 'fallback@example.com', + wpcom_user_email: 'primary@example.com', + }, + }; + + const url = getProtectedOwnerCreateAccountUrl( connectionError ); + + expect( url ).toBe( + '/wp-admin/user-new.php?jetpack_protected_owner_email=primary%40example.com&jetpack_create_missing_account=1' + ); + } ); + + it( 'should return basic URL when no email data is available', () => { + const connectionError = { + error_data: {}, + }; + + const url = getProtectedOwnerCreateAccountUrl( connectionError, '/wp-admin/' ); + + expect( url ).toBe( '/wp-admin/user-new.php' ); + } ); + + it( 'should handle missing error_data', () => { + const connectionError = {}; + + const url = getProtectedOwnerCreateAccountUrl( connectionError ); + + expect( url ).toBe( '/wp-admin/user-new.php' ); + } ); +} ); diff --git a/projects/js-packages/connection/index.jsx b/projects/js-packages/connection/index.jsx index e3513280bf39a..296cdaea83fec 100644 --- a/projects/js-packages/connection/index.jsx +++ b/projects/js-packages/connection/index.jsx @@ -49,4 +49,7 @@ export { STORE_ID as CONNECTION_STORE_ID } from './state/store'; */ export { default as useProductCheckoutWorkflow } from './hooks/use-product-checkout-workflow'; export { default as useRestoreConnection } from './hooks/use-restore-connection'; -export { default as useConnectionErrorNotice } from './hooks/use-connection-error-notice'; +export { + default as useConnectionErrorNotice, + getProtectedOwnerCreateAccountUrl, +} from './hooks/use-connection-error-notice'; diff --git a/projects/packages/connection/changelog/add-protected_owner_error_manual b/projects/packages/connection/changelog/add-protected_owner_error_manual new file mode 100644 index 0000000000000..21544c0ebfb1b --- /dev/null +++ b/projects/packages/connection/changelog/add-protected_owner_error_manual @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Connection: error handling for protected owner on WPcom. diff --git a/projects/packages/connection/src/class-error-handler.php b/projects/packages/connection/src/class-error-handler.php index 55b65b622d774..c6d796bc737d6 100644 --- a/projects/packages/connection/src/class-error-handler.php +++ b/projects/packages/connection/src/class-error-handler.php @@ -115,6 +115,7 @@ class Error_Handler { 'invalid_nonce', 'signature_mismatch', 'invalid_connection_owner', + 'protected_owner_missing', ); /** @@ -173,6 +174,7 @@ public function handle_verified_errors() { case 'no_user_tokens': case 'no_token_for_user': case 'invalid_connection_owner': + case 'protected_owner_missing': add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) ); add_action( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ) ); $this->error_code = $error_code; @@ -427,14 +429,44 @@ public function get_stored_errors() { } /** - * Gets the verified errors stored in the database + * Gets the verified errors stored in the database and applies filters. * * @since 1.14.2 * * @return array $errors */ public function get_verified_errors() { + $verified_errors = $this->get_stored_verified_errors(); + /** + * Filter verified connection errors to allow external plugins to inject their own error types + * + * This filter allows external plugins (like wpcomsh with Protected Owner errors) + * to inject their own error types into the standard connection error flow. + * External errors should follow the same structure as regular connection errors. + * + * @since $$next-version$$ + * + * @param array $verified_errors Array of verified connection errors + */ + $verified_errors = apply_filters( 'jetpack_connection_get_verified_errors', $verified_errors ); + + return $verified_errors; + } + + /** + * Gets the verified errors stored in the database without applying filters + * + * This method retrieves only the errors that are actually stored in the database, + * without applying any filters that might inject additional errors. This is used + * internally by methods that need to modify and store the verified errors back + * to the database to prevent accidentally persisting filtered/injected errors. + * + * @since $$next-version$$ + * + * @return array $errors + */ + protected function get_stored_verified_errors() { $verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION ); if ( ! is_array( $verified_errors ) ) { @@ -518,7 +550,7 @@ public function delete_all_api_errors() { } } - $verified_errors = $this->get_verified_errors(); + $verified_errors = $this->get_stored_verified_errors(); if ( is_array( $verified_errors ) && count( $verified_errors ) ) { $verified_errors = array_filter( array_map( $type_filter, $verified_errors ) ); if ( count( $verified_errors ) ) { @@ -598,7 +630,7 @@ public function get_error_by_nonce( $nonce ) { */ public function verify_error( $error ) { - $verified_errors = $this->get_verified_errors(); + $verified_errors = $this->get_stored_verified_errors(); $error_code = $error['error_code']; $user_id = $error['user_id']; @@ -723,12 +755,39 @@ public function generic_admin_notice_error() { * @return array */ public function jetpack_react_dashboard_error( $errors ) { + // Default values for all errors + $error_message = __( 'Your connection with WordPress.com seems to be broken. If you\'re experiencing issues, please try reconnecting.', 'jetpack-connection' ); + $action = 'reconnect'; + $error_data = array( 'api_error_code' => $this->error_code ); + + // Special handling for protected_owner type errors + $verified_errors = $this->get_verified_errors(); + if ( isset( $verified_errors[ $this->error_code ] ) ) { + $first_error = reset( $verified_errors[ $this->error_code ] ); + + // Check if this is a protected_owner type error + if ( ! empty( $first_error['error_type'] ) && 'protected_owner' === $first_error['error_type'] ) { + if ( ! empty( $first_error['error_message'] ) ) { + $error_message = $first_error['error_message']; + } + + if ( ! empty( $first_error['error_data'] ) ) { + $error_data = array_merge( $error_data, $first_error['error_data'] ); + + if ( ! empty( $first_error['error_data']['action'] ) ) { + $action = $first_error['error_data']['action']; + } + } + } + } + $errors[] = array( 'code' => 'connection_error', - 'message' => __( 'Your connection with WordPress.com seems to be broken. If you\'re experiencing issues, please try reconnecting.', 'jetpack-connection' ), - 'action' => 'reconnect', - 'data' => array( 'api_error_code' => $this->error_code ), + 'message' => $error_message, + 'action' => $action, + 'data' => $error_data, ); + return $errors; } diff --git a/projects/packages/connection/tests/php/Error_Handler_Test.php b/projects/packages/connection/tests/php/Error_Handler_Test.php index e814d4f22a7a3..00d78684b26f2 100644 --- a/projects/packages/connection/tests/php/Error_Handler_Test.php +++ b/projects/packages/connection/tests/php/Error_Handler_Test.php @@ -407,4 +407,516 @@ public function test_delete_all_api_errors() { $this->assertArrayNotHasKey( 'unknown_user', $stored_errors ); $this->assertArrayHasKey( 'invalid_connection_owner', $stored_errors ); } + + /** + * Test get_instance singleton pattern + */ + public function test_get_instance() { + $instance1 = Error_Handler::get_instance(); + $instance2 = Error_Handler::get_instance(); + + $this->assertInstanceOf( Error_Handler::class, $instance1 ); + $this->assertSame( $instance1, $instance2, 'get_instance should return the same instance (singleton pattern)' ); + } + + /** + * Test wp_error_to_array method + */ + public function test_wp_error_to_array() { + $error = $this->get_sample_error( 'invalid_token', 5, 'rest' ); + $error_array = $this->error_handler->wp_error_to_array( $error ); + + $this->assertIsArray( $error_array ); + $this->assertArrayHasKey( 'error_code', $error_array ); + $this->assertArrayHasKey( 'user_id', $error_array ); + $this->assertArrayHasKey( 'error_message', $error_array ); + $this->assertArrayHasKey( 'error_data', $error_array ); + $this->assertArrayHasKey( 'timestamp', $error_array ); + $this->assertArrayHasKey( 'nonce', $error_array ); + $this->assertArrayHasKey( 'error_type', $error_array ); + + $this->assertEquals( 'invalid_token', $error_array['error_code'] ); + $this->assertSame( '5', $error_array['user_id'] ); + $this->assertEquals( 'An error was triggered', $error_array['error_message'] ); + $this->assertEquals( 'rest', $error_array['error_type'] ); + $this->assertIsArray( $error_array['error_data'] ); + $this->assertIsInt( $error_array['timestamp'] ); + $this->assertIsString( $error_array['nonce'] ); + } + + /** + * Test wp_error_to_array with invalid error (missing signature_details) + */ + public function test_wp_error_to_array_invalid_error() { + $error = new \WP_Error( 'test_error', 'Test message', array() ); + $result = $this->error_handler->wp_error_to_array( $error ); + + $this->assertFalse( $result ); + } + + /** + * Test wp_error_to_array with invalid error (missing token) + */ + public function test_wp_error_to_array_missing_token() { + $error = new \WP_Error( + 'test_error', + 'Test message', + array( + 'signature_details' => array( + 'timestamp' => time(), + 'nonce' => 'test_nonce', + ), + ) + ); + $result = $this->error_handler->wp_error_to_array( $error ); + + $this->assertFalse( $result ); + } + + /** + * Test delete_all_errors method + */ + public function test_delete_all_errors() { + add_filter( 'jetpack_connection_bypass_error_reporting_gate', '__return_true' ); + + // Add some errors + $error1 = $this->get_sample_error( 'invalid_token', 1 ); + $error2 = $this->get_sample_error( 'unknown_user', 2 ); + + $this->error_handler->report_error( $error1 ); + $this->error_handler->report_error( $error2 ); + + // Verify errors and verify them + $stored_errors = $this->error_handler->get_stored_errors(); + foreach ( $stored_errors as $users ) { + foreach ( $users as $error ) { + $this->error_handler->verify_error( $error ); + } + } + + // Ensure we have both stored and verified errors + $this->assertNotEmpty( $this->error_handler->get_stored_errors() ); + $this->assertNotEmpty( $this->error_handler->get_verified_errors() ); + + // Delete all errors + $this->error_handler->delete_all_errors(); + + // Verify both stored and verified errors are deleted + $this->assertEmpty( $this->error_handler->get_stored_errors() ); + $this->assertEmpty( $this->error_handler->get_verified_errors() ); + } + + /** + * Test delete_stored_errors method + */ + public function test_delete_stored_errors() { + add_filter( 'jetpack_connection_bypass_error_reporting_gate', '__return_true' ); + + $error = $this->get_sample_error( 'invalid_token', 1 ); + $this->error_handler->report_error( $error ); + + $this->assertNotEmpty( $this->error_handler->get_stored_errors() ); + + $result = $this->error_handler->delete_stored_errors(); + + $this->assertTrue( $result ); + $this->assertEmpty( $this->error_handler->get_stored_errors() ); + } + + /** + * Test delete_verified_errors method + */ + public function test_delete_verified_errors() { + add_filter( 'jetpack_connection_bypass_error_reporting_gate', '__return_true' ); + + $error = $this->get_sample_error( 'invalid_token', 1 ); + $this->error_handler->report_error( $error ); + + $stored_errors = $this->error_handler->get_stored_errors(); + $this->error_handler->verify_error( $stored_errors['invalid_token']['1'] ); + + $this->assertNotEmpty( $this->error_handler->get_verified_errors() ); + + $result = $this->error_handler->delete_verified_errors(); + + $this->assertTrue( $result ); + $this->assertEmpty( $this->error_handler->get_verified_errors() ); + } + + /** + * Test delete_all_errors_and_return_unfiltered_value method + */ + public function test_delete_all_errors_and_return_unfiltered_value() { + add_filter( 'jetpack_connection_bypass_error_reporting_gate', '__return_true' ); + + // Add some errors + $error = $this->get_sample_error( 'invalid_token', 1 ); + $this->error_handler->report_error( $error ); + + $stored_errors = $this->error_handler->get_stored_errors(); + $this->error_handler->verify_error( $stored_errors['invalid_token']['1'] ); + + $this->assertNotEmpty( $this->error_handler->get_stored_errors() ); + $this->assertNotEmpty( $this->error_handler->get_verified_errors() ); + + $test_value = 'test_return_value'; + $result = $this->error_handler->delete_all_errors_and_return_unfiltered_value( $test_value ); + + // Should return the original value + $this->assertEquals( $test_value, $result ); + + // Should delete all errors + $this->assertEmpty( $this->error_handler->get_stored_errors() ); + $this->assertEmpty( $this->error_handler->get_verified_errors() ); + } + + /** + * Test send_error_to_wpcom method + */ + public function test_send_error_to_wpcom() { + // Mock Jetpack_Options::get_option + add_filter( + 'pre_option_jetpack_options', + function ( $pre_option, $option ) { + if ( 'jetpack_options' === $option ) { + return array( 'id' => 12345 ); + } + return $pre_option; + }, + 10, + 3 + ); + + $error_array = array( + 'error_code' => 'test_error', + 'user_id' => '1', + 'error_message' => 'Test error message', + 'error_data' => array( 'test' => 'data' ), + 'timestamp' => time(), + 'nonce' => 'test_nonce', + 'error_type' => 'rest', + ); + + // Mock wp_remote_post to avoid actual HTTP requests + add_filter( + 'pre_http_request', + function ( $preempt, $_parsed_args, $url ) { + if ( strpos( $url, 'public-api.wordpress.com' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => '{"success": true}', + ); + } + return $preempt; + }, + 10, + 3 + ); + + $result = $this->error_handler->send_error_to_wpcom( $error_array ); + + $this->assertTrue( $result ); + } + + /** + * Test send_error_to_wpcom with encryption failure + */ + public function test_send_error_to_wpcom_encryption_failure() { + // Mock encryption to fail + $error_handler_mock = $this->getMockBuilder( Error_Handler::class ) + ->disableOriginalConstructor() + ->onlyMethods( array( 'encrypt_data_to_wpcom' ) ) + ->getMock(); + + $error_handler_mock->method( 'encrypt_data_to_wpcom' ) + ->willReturn( false ); + + $error_array = array( + 'error_code' => 'test_error', + 'user_id' => '1', + ); + + $result = $error_handler_mock->send_error_to_wpcom( $error_array ); + + $this->assertFalse( $result ); + } + + /** + * Test handle_verified_errors method + */ + public function test_handle_verified_errors() { + add_filter( 'jetpack_connection_bypass_error_reporting_gate', '__return_true' ); + + // Add an error that should trigger admin notices + $error = $this->get_sample_error( 'invalid_token', 1 ); + $this->error_handler->report_error( $error ); + + $stored_errors = $this->error_handler->get_stored_errors(); + $this->error_handler->verify_error( $stored_errors['invalid_token']['1'] ); + + $this->error_handler->handle_verified_errors(); + + // Verify that admin_notices and react_connection_errors_initial_state actions were added + // has_action returns the priority (not boolean true) when the action exists, false when it doesn't + $this->assertNotFalse( has_action( 'admin_notices', array( $this->error_handler, 'generic_admin_notice_error' ) ) ); + $this->assertNotFalse( has_action( 'react_connection_errors_initial_state', array( $this->error_handler, 'jetpack_react_dashboard_error' ) ) ); + } + + /** + * Test jetpack_react_dashboard_error method with default error + */ + public function test_jetpack_react_dashboard_error_default() { + add_filter( 'jetpack_connection_bypass_error_reporting_gate', '__return_true' ); + + $error = $this->get_sample_error( 'invalid_token', 1 ); + $this->error_handler->report_error( $error ); + + $stored_errors = $this->error_handler->get_stored_errors(); + $this->error_handler->verify_error( $stored_errors['invalid_token']['1'] ); + + // Set the error_code property + $reflection = new \ReflectionClass( $this->error_handler ); + $property = $reflection->getProperty( 'error_code' ); + $property->setAccessible( true ); + $property->setValue( $this->error_handler, 'invalid_token' ); + + $errors = array(); + $result = $this->error_handler->jetpack_react_dashboard_error( $errors ); + + $this->assertCount( 1, $result ); + $this->assertEquals( 'connection_error', $result[0]['code'] ); + $this->assertEquals( 'reconnect', $result[0]['action'] ); + $this->assertStringContainsString( 'broken', $result[0]['message'] ); + $this->assertArrayHasKey( 'api_error_code', $result[0]['data'] ); + $this->assertEquals( 'invalid_token', $result[0]['data']['api_error_code'] ); + } + + /** + * Test jetpack_react_dashboard_error method with protected_owner error + */ + public function test_jetpack_react_dashboard_error_protected_owner() { + // Create a protected_owner error manually + $protected_owner_error = array( + 'error_code' => 'protected_owner_missing', + 'user_id' => '1', + 'error_message' => 'Custom protected owner message', + 'error_data' => array( + 'action' => 'custom_action', + 'custom' => 'data', + ), + 'timestamp' => time(), + 'nonce' => 'test_nonce', + 'error_type' => 'protected_owner', + ); + + // Manually add to verified errors + $verified_errors = array( + 'protected_owner_missing' => array( + '1' => $protected_owner_error, + ), + ); + update_option( Error_Handler::STORED_VERIFIED_ERRORS_OPTION, $verified_errors ); + + // Set the error_code property + $reflection = new \ReflectionClass( $this->error_handler ); + $property = $reflection->getProperty( 'error_code' ); + $property->setAccessible( true ); + $property->setValue( $this->error_handler, 'protected_owner_missing' ); + + $errors = array(); + $result = $this->error_handler->jetpack_react_dashboard_error( $errors ); + + $this->assertCount( 1, $result ); + $this->assertEquals( 'connection_error', $result[0]['code'] ); + $this->assertEquals( 'custom_action', $result[0]['action'] ); + $this->assertEquals( 'Custom protected owner message', $result[0]['message'] ); + $this->assertArrayHasKey( 'api_error_code', $result[0]['data'] ); + $this->assertArrayHasKey( 'custom', $result[0]['data'] ); + $this->assertEquals( 'data', $result[0]['data']['custom'] ); + } + + /** + * Test jetpack_react_dashboard_error method with non-protected_owner error_type + */ + public function test_jetpack_react_dashboard_error_non_protected_owner() { + // Create an error with different error_type + $regular_error = array( + 'error_code' => 'invalid_token', + 'user_id' => '1', + 'error_message' => 'Custom message that should not be used', + 'error_data' => array( + 'action' => 'custom_action_ignored', + ), + 'timestamp' => time(), + 'nonce' => 'test_nonce', + 'error_type' => 'xmlrpc', + ); + + // Manually add to verified errors + $verified_errors = array( + 'invalid_token' => array( + '1' => $regular_error, + ), + ); + update_option( Error_Handler::STORED_VERIFIED_ERRORS_OPTION, $verified_errors ); + + // Set the error_code property + $reflection = new \ReflectionClass( $this->error_handler ); + $property = $reflection->getProperty( 'error_code' ); + $property->setAccessible( true ); + $property->setValue( $this->error_handler, 'invalid_token' ); + + $errors = array(); + $result = $this->error_handler->jetpack_react_dashboard_error( $errors ); + + $this->assertCount( 1, $result ); + $this->assertEquals( 'connection_error', $result[0]['code'] ); + $this->assertEquals( 'reconnect', $result[0]['action'] ); // Should use default + $this->assertStringContainsString( 'broken', $result[0]['message'] ); // Should use default message + } + + /** + * Test verify_xml_rpc_error method with valid nonce + */ + public function test_verify_xml_rpc_error_valid_nonce() { + add_filter( 'jetpack_connection_bypass_error_reporting_gate', '__return_true' ); + + $error = $this->get_sample_error( 'invalid_token', 1 ); + $this->error_handler->report_error( $error ); + + $stored_errors = $this->error_handler->get_stored_errors(); + $nonce = $stored_errors['invalid_token']['1']['nonce']; + + $request = new \WP_REST_Request( 'POST', '/jetpack/v4/verify_xmlrpc_error' ); + $request->set_param( 'nonce', $nonce ); + + $response = $this->error_handler->verify_xml_rpc_error( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $response->get_data() ); + + // Verify the error was added to verified errors + $verified_errors = $this->error_handler->get_verified_errors(); + $this->assertArrayHasKey( 'invalid_token', $verified_errors ); + } + + /** + * Test verify_xml_rpc_error method with invalid nonce + */ + public function test_verify_xml_rpc_error_invalid_nonce() { + $request = new \WP_REST_Request( 'POST', '/jetpack/v4/verify_xmlrpc_error' ); + $request->set_param( 'nonce', 'invalid_nonce' ); + + $response = $this->error_handler->verify_xml_rpc_error( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertFalse( $response->get_data() ); + } + + /** + * Test generic_admin_notice_error method on jetpack dashboard page + */ + public function test_generic_admin_notice_error_jetpack_page() { + global $pagenow; + $original_pagenow = $pagenow; + $pagenow = 'admin.php'; + $_GET['page'] = 'jetpack'; + + // Mock current_user_can to return true + $user = wp_get_current_user(); + $user->add_cap( 'jetpack_connect' ); + + add_filter( + 'jetpack_connection_error_notice_message', + function () { + return 'Should not be displayed'; + }, + 10, + 2 + ); + + ob_start(); + $this->error_handler->generic_admin_notice_error(); + $output = ob_get_clean(); + + $this->assertEmpty( $output, 'Should not display notice on jetpack dashboard page' ); + + // Restore globals + $pagenow = $original_pagenow; + unset( $_GET['page'] ); + } + + /** + * Test generic_admin_notice_error method without jetpack_connect capability + */ + public function test_generic_admin_notice_error_no_capability() { + // Ensure user doesn't have jetpack_connect capability + $user = wp_get_current_user(); + $user->remove_cap( 'jetpack_connect' ); + + add_filter( + 'jetpack_connection_error_notice_message', + function () { + return 'Should not be displayed'; + }, + 10, + 2 + ); + + ob_start(); + $this->error_handler->generic_admin_notice_error(); + $output = ob_get_clean(); + + $this->assertEmpty( $output, 'Should not display notice without jetpack_connect capability' ); + } + + /** + * Test get_verified_errors filter + */ + public function test_get_verified_errors_filter() { + // Add a verified error + $error = array( + 'error_code' => 'test_error', + 'user_id' => '1', + 'error_message' => 'Test message', + 'error_data' => array(), + 'timestamp' => time(), + 'nonce' => 'test_nonce', + 'error_type' => 'test', + ); + + $verified_errors = array( + 'test_error' => array( + '1' => $error, + ), + ); + update_option( Error_Handler::STORED_VERIFIED_ERRORS_OPTION, $verified_errors ); + + // Add filter to inject additional errors + add_filter( + 'jetpack_connection_get_verified_errors', + function ( $errors ) { + $errors['injected_error'] = array( + '1' => array( + 'error_code' => 'injected_error', + 'user_id' => '1', + 'error_message' => 'Injected error', + 'error_data' => array(), + 'timestamp' => time(), + 'nonce' => 'injected_nonce', + 'error_type' => 'injected', + ), + ); + return $errors; + } + ); + + $result = $this->error_handler->get_verified_errors(); + + $this->assertCount( 2, $result ); + $this->assertArrayHasKey( 'test_error', $result ); + $this->assertArrayHasKey( 'injected_error', $result ); + } } diff --git a/projects/packages/my-jetpack/_inc/hooks/use-notification-watcher/test/use-connection-errors-notice.test.tsx b/projects/packages/my-jetpack/_inc/hooks/use-notification-watcher/test/use-connection-errors-notice.test.tsx new file mode 100644 index 0000000000000..37f97cd9b91a4 --- /dev/null +++ b/projects/packages/my-jetpack/_inc/hooks/use-notification-watcher/test/use-connection-errors-notice.test.tsx @@ -0,0 +1,351 @@ +import '@testing-library/jest-dom'; +import { useConnectionErrorNotice, useRestoreConnection } from '@automattic/jetpack-connection'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { NoticeContext } from '../../../context/notices/noticeContext'; +import useAnalytics from '../../use-analytics'; +import useConnectionErrorsNotice from '../use-connection-errors-notice'; +import type { NoticeContextType } from '../../../context/notices/types'; + +// Mock the dependencies +jest.mock( '@automattic/jetpack-connection' ); +jest.mock( '../../use-analytics' ); + +// Mock window.location to prevent navigation errors in tests +Object.defineProperty( window, 'location', { + value: { + href: '', + }, + writable: true, +} ); + +jest.mock( '@automattic/jetpack-components', () => ( { + Col: ( { children }: { children: React.ReactNode } ) =>
{ children }
, + Text: ( { children }: { children: React.ReactNode } ) => { children }, +} ) ); +jest.mock( '@wordpress/i18n', () => ( { + __: ( text: string ) => text, + sprintf: ( text: string, ...args: string[] ) => { + return text.replace( /%s/g, () => args.shift() ); + }, + isRTL: () => false, + _x: ( text: string ) => text, + _n: ( single: string, plural: string, number: number ) => ( number === 1 ? single : plural ), +} ) ); + +const mockUseConnectionErrorNotice = useConnectionErrorNotice as jest.MockedFunction< + typeof useConnectionErrorNotice +>; +const mockUseRestoreConnection = useRestoreConnection as jest.MockedFunction< + typeof useRestoreConnection +>; +const mockUseAnalytics = useAnalytics as jest.MockedFunction< typeof useAnalytics >; + +describe( 'useConnectionErrorsNotice', () => { + const mockSetNotice = jest.fn(); + const mockRecordEvent = jest.fn(); + const mockRestoreConnection = jest.fn(); + + const mockNoticeContext: NoticeContextType = { + setNotice: mockSetNotice, + resetNotice: jest.fn(), + currentNotice: { + message: '', + title: '', + options: { + id: '', + level: 'info', + actions: [], + priority: 0, + }, + }, + }; + + const defaultConnectionData = { + hasConnectionError: false, + connectionErrorMessage: '', + connectionError: null, + connectionErrors: {}, + }; + + const defaultRestoreConnection = { + restoreConnection: mockRestoreConnection, + isRestoringConnection: false, + restoreConnectionError: null, + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockUseConnectionErrorNotice.mockReturnValue( defaultConnectionData ); + mockUseRestoreConnection.mockReturnValue( defaultRestoreConnection ); + mockUseAnalytics.mockReturnValue( { recordEvent: mockRecordEvent } ); + } ); + + const renderWithNoticeContext = ( contextValue = mockNoticeContext ) => { + const wrapper = ( { children }: { children: React.ReactNode } ) => ( + { children } + ); + + return renderHook( () => useConnectionErrorsNotice(), { wrapper } ); + }; + + describe( 'when there are no connection errors', () => { + it( 'should not set any notice', () => { + renderWithNoticeContext(); + expect( mockSetNotice ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'when there is a standard connection error', () => { + beforeEach( () => { + mockUseConnectionErrorNotice.mockReturnValue( { + hasConnectionError: true, + connectionErrorMessage: 'Connection failed due to network issue', + connectionError: { + error_code: 'invalid_token', + error_message: 'Connection failed due to network issue', + error_type: 'connection', + user_id: '1', + timestamp: Date.now(), + nonce: 'test-nonce', + }, + connectionErrors: { + invalid_token: { + '1': { + error_code: 'invalid_token', + error_message: 'Connection failed due to network issue', + error_type: 'connection', + user_id: '1', + timestamp: Date.now(), + nonce: 'test-nonce', + }, + }, + }, + } ); + } ); + + it( 'should set a notice with restore connection action', async () => { + renderWithNoticeContext(); + + await waitFor( () => { + expect( mockSetNotice ).toHaveBeenCalledWith( { + message: 'Connection failed due to network issue', + options: { + id: 'connection-error-notice', + level: 'error', + actions: [ + { + label: 'Restore Connection', + onClick: expect.any( Function ), + isLoading: false, + loadingText: 'Reconnecting Jetpack…', + noDefaultClasses: true, + }, + ], + priority: 300, // NOTICE_PRIORITY_HIGH + 0 + }, + } ); + } ); + } ); + + it( 'should call restoreConnection and record analytics when restore button is clicked', async () => { + renderWithNoticeContext(); + + await waitFor( () => { + expect( mockSetNotice ).toHaveBeenCalled(); + } ); + + const setNoticeCall = mockSetNotice.mock.calls[ 0 ][ 0 ]; + const restoreAction = setNoticeCall.options.actions[ 0 ]; + + // Simulate clicking the restore button + restoreAction.onClick(); + + expect( mockRestoreConnection ).toHaveBeenCalled(); + expect( mockRecordEvent ).toHaveBeenCalledWith( + 'jetpack_my_jetpack_connection_error_notice_reconnect_cta_click' + ); + } ); + } ); + + describe( 'when there is a protected owner error', () => { + beforeEach( () => { + mockUseConnectionErrorNotice.mockReturnValue( { + hasConnectionError: true, + connectionErrorMessage: 'The WordPress.com plan owner is missing', + connectionError: { + error_code: 'protected_owner', + error_message: 'The WordPress.com plan owner is missing', + error_type: 'protected_owner', + user_id: '1', + timestamp: Date.now(), + nonce: 'test-nonce', + error_data: { + email: 'owner@example.com', + wpcom_user_email: 'owner@example.com', + }, + }, + connectionErrors: {}, + } ); + } ); + + it( 'should set a notice with create missing account action', async () => { + renderWithNoticeContext(); + + await waitFor( () => { + expect( mockSetNotice ).toHaveBeenCalledWith( { + message: 'The WordPress.com plan owner is missing', + options: { + id: 'connection-error-notice', + level: 'error', + actions: [ + { + label: 'Create missing account', + onClick: expect.any( Function ), + noDefaultClasses: true, + variant: 'primary', + }, + ], + priority: 300, // NOTICE_PRIORITY_HIGH + 0 + }, + } ); + } ); + } ); + + it( 'should record analytics when create missing account is clicked', async () => { + renderWithNoticeContext(); + + await waitFor( () => { + expect( mockSetNotice ).toHaveBeenCalled(); + } ); + + const setNoticeCall = mockSetNotice.mock.calls[ 0 ][ 0 ]; + const createAccountAction = setNoticeCall.options.actions[ 0 ]; + + // Simulate clicking the create missing account button + createAccountAction.onClick(); + + expect( mockRecordEvent ).toHaveBeenCalledWith( + 'jetpack_my_jetpack_protected_owner_create_account_attempt', + {} + ); + } ); + + it( 'should detect protected owner error by error_type field', async () => { + // Test with different error message but correct error_type + mockUseConnectionErrorNotice.mockReturnValue( { + hasConnectionError: true, + connectionErrorMessage: 'Some other error message without keywords', + connectionError: { + error_code: 'protected_owner', + error_message: 'Some other error message without keywords', + error_type: 'protected_owner', + user_id: '1', + timestamp: Date.now(), + nonce: 'test-nonce', + error_data: { + email: 'owner@example.com', + wpcom_user_email: 'owner@example.com', + }, + }, + connectionErrors: {}, + } ); + + renderWithNoticeContext(); + + await waitFor( () => { + expect( mockSetNotice ).toHaveBeenCalledWith( { + message: 'Some other error message without keywords', + options: { + id: 'connection-error-notice', + level: 'error', + actions: [ + { + label: 'Create missing account', + onClick: expect.any( Function ), + noDefaultClasses: true, + variant: 'primary', + }, + ], + priority: 300, + }, + } ); + } ); + } ); + + it( 'should not detect protected owner error with wrong error_type', async () => { + // Test with message containing keywords but wrong error_type + mockUseConnectionErrorNotice.mockReturnValue( { + hasConnectionError: true, + connectionErrorMessage: 'The WordPress.com plan owner has an invalid token', + connectionError: { + error_code: 'invalid_token', + error_message: 'The WordPress.com plan owner has an invalid token', + error_type: 'connection', + user_id: '1', + timestamp: Date.now(), + nonce: 'test-nonce', + }, + connectionErrors: {}, + } ); + + renderWithNoticeContext(); + + await waitFor( () => { + expect( mockSetNotice ).toHaveBeenCalledWith( { + message: 'The WordPress.com plan owner has an invalid token', + options: { + id: 'connection-error-notice', + level: 'error', + actions: [ + { + label: 'Restore Connection', + onClick: expect.any( Function ), + isLoading: false, + loadingText: 'Reconnecting Jetpack…', + noDefaultClasses: true, + }, + ], + priority: 300, + }, + } ); + } ); + } ); + } ); + + describe( 'notice priority calculation', () => { + it( 'should use higher priority when restoring connection', async () => { + mockUseConnectionErrorNotice.mockReturnValue( { + hasConnectionError: true, + connectionErrorMessage: 'Connection error', + connectionError: { + error_code: 'invalid_token', + error_message: 'Connection error', + error_type: 'connection', + user_id: '1', + timestamp: Date.now(), + nonce: 'test-nonce', + }, + connectionErrors: {}, + } ); + + mockUseRestoreConnection.mockReturnValue( { + ...defaultRestoreConnection, + isRestoringConnection: true, + } ); + + renderWithNoticeContext(); + + await waitFor( () => { + expect( mockSetNotice ).toHaveBeenCalledWith( + expect.objectContaining( { + options: expect.objectContaining( { + priority: 301, // NOTICE_PRIORITY_HIGH + 1 + } ), + } ) + ); + } ); + } ); + } ); +} ); diff --git a/projects/packages/my-jetpack/_inc/hooks/use-notification-watcher/use-connection-errors-notice.tsx b/projects/packages/my-jetpack/_inc/hooks/use-notification-watcher/use-connection-errors-notice.tsx index 1aaa13850cad2..f4aedb5046828 100644 --- a/projects/packages/my-jetpack/_inc/hooks/use-notification-watcher/use-connection-errors-notice.tsx +++ b/projects/packages/my-jetpack/_inc/hooks/use-notification-watcher/use-connection-errors-notice.tsx @@ -1,25 +1,57 @@ import { Col, Text } from '@automattic/jetpack-components'; -import { useConnectionErrorNotice, useRestoreConnection } from '@automattic/jetpack-connection'; +import { + useConnectionErrorNotice, + useRestoreConnection, + getProtectedOwnerCreateAccountUrl, +} from '@automattic/jetpack-connection'; import { __, sprintf } from '@wordpress/i18n'; -import { useContext, useEffect } from 'react'; +import { useContext, useEffect, useCallback } from 'react'; import { NOTICE_PRIORITY_HIGH } from '../../context/constants'; import { NoticeContext } from '../../context/notices/noticeContext'; import useAnalytics from '../use-analytics'; import type { NoticeOptions } from '../../context/notices/types'; +// Define NoticeAction type since it's not exported +interface NoticeAction { + label: string; + onClick: () => void; + isLoading?: boolean; + loadingText?: string; + noDefaultClasses?: boolean; + variant?: 'primary' | 'secondary'; +} + const useConnectionErrorsNotice = () => { - const { setNotice, currentNotice } = useContext( NoticeContext ); - const { hasConnectionError, connectionErrorMessage } = useConnectionErrorNotice(); + const { setNotice } = useContext( NoticeContext ); + const { hasConnectionError, connectionError } = useConnectionErrorNotice(); // Using enhanced hook const { restoreConnection, isRestoringConnection, restoreConnectionError } = useRestoreConnection(); const { recordEvent } = useAnalytics(); + // Handler for creating missing account (protected owner errors) + const handleCreateMissingAccount = useCallback( () => { + // Track the attempt to use create missing account + recordEvent( 'jetpack_my_jetpack_protected_owner_create_account_attempt', {} ); + + // Get admin URL and generate the complete URL with email prepopulation + const initialState = window?.Initial_State as { adminUrl?: string } | undefined; + const adminUrl = initialState?.adminUrl || '/wp-admin/'; + const redirectUrl = getProtectedOwnerCreateAccountUrl( connectionError, adminUrl ); + + window.location.href = redirectUrl; + }, [ recordEvent, connectionError ] ); + useEffect( () => { - if ( ! hasConnectionError ) { + // Use the enhanced hook data - it now includes protected owner errors + if ( ! hasConnectionError || ! connectionError ) { return; } - let errorMessage = connectionErrorMessage; + // Check if this is a protected owner error based on the error_type field + const isProtectedOwnerError = connectionError.error_type === 'protected_owner'; + + // Use the error message provided by the backend + let errorMessage: string | React.ReactElement = connectionError.error_message; if ( restoreConnectionError ) { errorMessage = ( @@ -31,31 +63,45 @@ const useConnectionErrorsNotice = () => { restoreConnectionError ) } - { connectionErrorMessage } + { connectionError.error_message } ); } - const onCtaClick = () => { - restoreConnection(); - recordEvent( 'jetpack_my_jetpack_connection_error_notice_reconnect_cta_click' ); - }; - - const loadingButtonLabel = __( 'Reconnecting Jetpack…', 'jetpack-my-jetpack' ); - const restoreButtonLabel = __( 'Restore Connection', 'jetpack-my-jetpack' ); + // Add action buttons based on error type + let noticeActions: NoticeAction[] = []; + if ( isProtectedOwnerError ) { + // Protected owner mismatch error - add only "Create missing account" button + noticeActions = [ + { + label: __( 'Create missing account', 'jetpack-my-jetpack' ), + onClick: handleCreateMissingAccount, + noDefaultClasses: true, + variant: 'primary', + }, + ]; + } else { + // Standard connection error - add "Restore Connection" button + const onCtaClick = () => { + restoreConnection(); + recordEvent( 'jetpack_my_jetpack_connection_error_notice_reconnect_cta_click' ); + }; - const noticeOptions: NoticeOptions = { - id: 'connection-error-notice', - level: 'error', - actions: [ + noticeActions = [ { - label: restoreButtonLabel, + label: __( 'Restore Connection', 'jetpack-my-jetpack' ), onClick: onCtaClick, isLoading: isRestoringConnection, - loadingText: loadingButtonLabel, + loadingText: __( 'Reconnecting Jetpack…', 'jetpack-my-jetpack' ), noDefaultClasses: true, }, - ], + ]; + } + + const noticeOptions: NoticeOptions = { + id: 'connection-error-notice', + level: 'error', + actions: noticeActions, priority: NOTICE_PRIORITY_HIGH + ( isRestoringConnection ? 1 : 0 ), }; @@ -67,11 +113,11 @@ const useConnectionErrorsNotice = () => { setNotice, recordEvent, hasConnectionError, - connectionErrorMessage, + connectionError, restoreConnection, isRestoringConnection, restoreConnectionError, - currentNotice.options.priority, + handleCreateMissingAccount, ] ); }; diff --git a/projects/packages/my-jetpack/changelog/add-protected_owner_error_manual b/projects/packages/my-jetpack/changelog/add-protected_owner_error_manual new file mode 100644 index 0000000000000..21544c0ebfb1b --- /dev/null +++ b/projects/packages/my-jetpack/changelog/add-protected_owner_error_manual @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Connection: error handling for protected owner on WPcom. diff --git a/projects/plugins/jetpack/_inc/client/components/jetpack-notices/jetpack-connection-errors.jsx b/projects/plugins/jetpack/_inc/client/components/jetpack-notices/jetpack-connection-errors.jsx index 7991408d3fe4f..cffe0f0ab9ff6 100644 --- a/projects/plugins/jetpack/_inc/client/components/jetpack-notices/jetpack-connection-errors.jsx +++ b/projects/plugins/jetpack/_inc/client/components/jetpack-notices/jetpack-connection-errors.jsx @@ -1,3 +1,4 @@ +import { getProtectedOwnerCreateAccountUrl } from '@automattic/jetpack-connection'; import { __ } from '@wordpress/i18n'; import PropTypes from 'prop-types'; import React from 'react'; @@ -42,6 +43,40 @@ export default class JetpackConnectionErrors extends React.Component { ); + case 'create_missing_account': { + // Check if this is a protected owner error with email data + let createAccountUrl = errorData.support_url || '/wp-admin/user-new.php'; + + // If we have error data that looks like a protected owner error, use prepopulation + if ( errorData && ( errorData.email || errorData.wpcom_user_email ) ) { + // Create a mock connection error object for the helper function + const connectionError = { + error_data: { + email: errorData.email, + wpcom_user_email: errorData.wpcom_user_email, + }, + }; + + // Get admin URL from window.Initial_State or use default + const adminUrl = + ( typeof window !== 'undefined' && window.Initial_State?.adminUrl ) || '/wp-admin/'; + createAccountUrl = getProtectedOwnerCreateAccountUrl( connectionError, adminUrl ); + } + + return ( + + + { __( 'Create Account', 'jetpack' ) } + + + ); + } } return null; diff --git a/projects/plugins/jetpack/changelog/add-protected_owner_error_manual b/projects/plugins/jetpack/changelog/add-protected_owner_error_manual new file mode 100644 index 0000000000000..d895012252fed --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-protected_owner_error_manual @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Connection: error handling for protected owner on WPcom.