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.