From 3793569c098d129c1f42afdb791edc2110473070 Mon Sep 17 00:00:00 2001 From: Nate Weller Date: Mon, 9 Sep 2024 10:51:21 -0600 Subject: [PATCH] Protect: React Query (#39036) --- pnpm-lock.yaml | 23 + projects/plugins/protect/README.md | 17 +- .../changelog/add-protect-tanstack-query | 5 + projects/plugins/protect/package.json | 2 + .../protect/src/class-jetpack-protect.php | 11 +- .../protect/src/class-rest-controller.php | 33 +- projects/plugins/protect/src/js/api.js | 56 -- projects/plugins/protect/src/js/api.ts | 119 ++++ .../src/js/components/admin-page/index.jsx | 55 +- .../admin-page/use-registration-watcher.js | 21 - .../js/components/credentials-gate/index.jsx | 18 +- .../credentials-needed-modal/index.jsx | 19 +- .../src/js/components/error-section/index.tsx | 2 +- .../js/components/firewall-footer/index.jsx | 75 +-- .../js/components/firewall-header/index.jsx | 38 +- .../stories/broken/index.stories.jsx | 10 +- .../fix-all-threats-modal/index.jsx | 26 +- .../js/components/fix-threat-modal/index.jsx | 18 +- .../components/ignore-threat-modal/index.jsx | 25 +- .../protect/src/js/components/modal/README.md | 9 +- .../protect/src/js/components/modal/index.jsx | 13 +- .../src/js/components/notice/index.jsx | 5 +- .../js/components/paid-accordion/index.jsx | 8 +- .../js/components/paid-plan-gate/index.tsx | 6 +- .../src/js/components/pricing-table/index.jsx | 66 +-- .../src/js/components/scan-button/index.jsx | 16 +- .../src/js/components/scan-footer/index.jsx | 30 +- .../components/seventy-five-layout/index.jsx | 5 +- .../src/js/components/summary/index.jsx | 5 +- .../src/js/components/threats-list/empty.jsx | 6 +- .../js/components/threats-list/free-list.jsx | 21 +- .../src/js/components/threats-list/index.jsx | 15 +- .../js/components/threats-list/navigation.jsx | 5 +- .../js/components/threats-list/paid-list.jsx | 10 +- .../unignore-threat-modal/index.jsx | 25 +- .../user-connection-needed-modal/index.jsx | 5 +- projects/plugins/protect/src/js/constants.js | 20 +- .../use-onboarding-progress-mutator.ts | 22 + .../use-onboarding-progress-query.ts | 16 + .../src/js/data/scan/use-fixers-mutation.ts | 35 ++ .../src/js/data/scan/use-fixers-query.ts | 88 +++ .../src/js/data/scan/use-history-query.ts | 26 + .../data/scan/use-ignore-threat-mutation.ts | 36 ++ .../src/js/data/scan/use-scan-status-query.ts | 63 ++ .../js/data/scan/use-start-scan-mutation.ts | 33 ++ .../data/scan/use-unignore-threat-mutation.ts | 36 ++ .../src/js/data/use-connection-mutation.ts | 52 ++ .../src/js/data/use-credentials-query.ts | 43 ++ .../protect/src/js/data/use-has-plan-query.ts | 25 + .../src/js/data/use-product-data-query.ts | 17 + .../waf/use-toggle-waf-module-mutation.ts | 28 + .../src/js/data/waf/use-waf-mutation.ts | 73 +++ .../protect/src/js/data/waf/use-waf-query.ts | 18 + .../src/js/data/waf/use-waf-seen-mutation.ts | 21 + .../data/waf/use-waf-upgrade-seen-mutation.ts | 21 + projects/plugins/protect/src/js/global.d.ts | 1 - .../protect/src/js/hooks/use-fixers.ts | 47 ++ .../protect/src/js/hooks/use-modal.tsx | 34 ++ .../protect/src/js/hooks/use-notices.tsx | 101 ++++ .../src/js/hooks/use-onboarding/index.jsx | 42 +- .../plugins/protect/src/js/hooks/use-plan.tsx | 72 +++ .../src/js/hooks/use-protect-data/index.js | 15 +- .../src/js/hooks/use-waf-data/index.jsx | 211 ++++--- projects/plugins/protect/src/js/index.tsx | 84 +-- .../protect/src/js/routes/firewall/index.jsx | 422 +++----------- .../src/js/routes/scan/history/index.jsx | 6 +- .../protect/src/js/routes/scan/index.jsx | 56 +- .../src/js/routes/scan/onboarding-steps.jsx | 19 +- .../js/routes/scan/scan-section-header.tsx | 6 +- .../src/js/routes/scan/use-credentials.js | 16 - .../src/js/routes/scan/use-status-polling.js | 105 ---- .../setup}/index.jsx | 18 +- .../setup}/stories/broken/index.stories.jsx | 0 .../setup}/stories/broken/mock.js | 0 .../setup}/styles.module.scss | 0 .../plugins/protect/src/js/state/actions.js | 544 ------------------ .../plugins/protect/src/js/state/reducers.js | 228 -------- .../plugins/protect/src/js/state/resolvers.js | 23 - .../plugins/protect/src/js/state/selectors.js | 101 ---- .../protect/src/js/state/store-holder.js | 14 - .../plugins/protect/src/js/state/store.js | 24 - .../plugins/protect/src/js/types/fixers.ts | 9 + .../plugins/protect/src/js/types/global.d.ts | 34 ++ .../src/js/types/installed-extensions.ts | 91 +++ .../plugins/protect/src/js/types/products.ts | 89 +++ .../plugins/protect/src/js/types/scans.ts | 78 +++ .../plugins/protect/src/js/types/threats.ts | 59 ++ projects/plugins/protect/src/js/types/waf.ts | 68 +++ projects/plugins/protect/tsconfig.json | 2 +- 89 files changed, 2038 insertions(+), 2077 deletions(-) create mode 100644 projects/plugins/protect/changelog/add-protect-tanstack-query delete mode 100644 projects/plugins/protect/src/js/api.js create mode 100644 projects/plugins/protect/src/js/api.ts delete mode 100644 projects/plugins/protect/src/js/components/admin-page/use-registration-watcher.js create mode 100644 projects/plugins/protect/src/js/data/onboarding/use-onboarding-progress-mutator.ts create mode 100644 projects/plugins/protect/src/js/data/onboarding/use-onboarding-progress-query.ts create mode 100644 projects/plugins/protect/src/js/data/scan/use-fixers-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/scan/use-fixers-query.ts create mode 100644 projects/plugins/protect/src/js/data/scan/use-history-query.ts create mode 100644 projects/plugins/protect/src/js/data/scan/use-ignore-threat-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/scan/use-scan-status-query.ts create mode 100644 projects/plugins/protect/src/js/data/scan/use-start-scan-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/scan/use-unignore-threat-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/use-connection-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/use-credentials-query.ts create mode 100644 projects/plugins/protect/src/js/data/use-has-plan-query.ts create mode 100644 projects/plugins/protect/src/js/data/use-product-data-query.ts create mode 100644 projects/plugins/protect/src/js/data/waf/use-toggle-waf-module-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/waf/use-waf-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/waf/use-waf-query.ts create mode 100644 projects/plugins/protect/src/js/data/waf/use-waf-seen-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/waf/use-waf-upgrade-seen-mutation.ts delete mode 100644 projects/plugins/protect/src/js/global.d.ts create mode 100644 projects/plugins/protect/src/js/hooks/use-fixers.ts create mode 100644 projects/plugins/protect/src/js/hooks/use-modal.tsx create mode 100644 projects/plugins/protect/src/js/hooks/use-notices.tsx create mode 100644 projects/plugins/protect/src/js/hooks/use-plan.tsx delete mode 100644 projects/plugins/protect/src/js/routes/scan/use-credentials.js delete mode 100644 projects/plugins/protect/src/js/routes/scan/use-status-polling.js rename projects/plugins/protect/src/js/{components/interstitial-page => routes/setup}/index.jsx (70%) rename projects/plugins/protect/src/js/{components/interstitial-page => routes/setup}/stories/broken/index.stories.jsx (100%) rename projects/plugins/protect/src/js/{components/interstitial-page => routes/setup}/stories/broken/mock.js (100%) rename projects/plugins/protect/src/js/{components/interstitial-page => routes/setup}/styles.module.scss (100%) delete mode 100644 projects/plugins/protect/src/js/state/actions.js delete mode 100644 projects/plugins/protect/src/js/state/reducers.js delete mode 100644 projects/plugins/protect/src/js/state/resolvers.js delete mode 100644 projects/plugins/protect/src/js/state/selectors.js delete mode 100644 projects/plugins/protect/src/js/state/store-holder.js delete mode 100644 projects/plugins/protect/src/js/state/store.js create mode 100644 projects/plugins/protect/src/js/types/fixers.ts create mode 100644 projects/plugins/protect/src/js/types/global.d.ts create mode 100644 projects/plugins/protect/src/js/types/installed-extensions.ts create mode 100644 projects/plugins/protect/src/js/types/products.ts create mode 100644 projects/plugins/protect/src/js/types/scans.ts create mode 100644 projects/plugins/protect/src/js/types/threats.ts create mode 100644 projects/plugins/protect/src/js/types/waf.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f87ea163c98f..b381cfbd32e0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4219,6 +4219,12 @@ importers: '@automattic/jetpack-connection': specifier: workspace:* version: link:../../js-packages/connection + '@tanstack/react-query': + specifier: 5.20.5 + version: 5.20.5(react@18.3.1) + '@tanstack/react-query-devtools': + specifier: 5.20.5 + version: 5.20.5(@tanstack/react-query@5.20.5(react@18.3.1))(react@18.3.1) '@wordpress/api-fetch': specifier: 7.6.0 version: 7.6.0 @@ -7202,6 +7208,15 @@ packages: '@tanstack/query-core@5.20.5': resolution: {integrity: sha512-T1W28gGgWn0A++tH3lxj3ZuUVZZorsiKcv+R50RwmPYz62YoDEkG4/aXHZELGkRp4DfrW07dyq2K5dvJ4Wl1aA==} + '@tanstack/query-devtools@5.20.2': + resolution: {integrity: sha512-BZfSjhk/NGPbqte5E3Vc1Zbj28uWt///4I0DgzAdWrOtMVvdl0WlUXK23K2daLsbcyfoDR4jRI4f2Z5z/mMzuw==} + + '@tanstack/react-query-devtools@5.20.5': + resolution: {integrity: sha512-Wl7IzNuKCb4h41a5iH/YXNwalHItqJPCAr4r8+0iUYOLHNOf3E9P0G4kzZ9sqDoWKxY04qst6Vrij9bwPzLQRQ==} + peerDependencies: + '@tanstack/react-query': ^5.20.5 + react: ^18.0.0 + '@tanstack/react-query@4.35.3': resolution: {integrity: sha512-UgTPioip/rGG3EQilXfA2j4BJkhEQsR+KAbF+KIuvQ7j4MkgnTCJF01SfRpIRNtQTlEfz/+IL7+jP8WA8bFbsw==} peerDependencies: @@ -17440,6 +17455,14 @@ snapshots: '@tanstack/query-core@5.20.5': {} + '@tanstack/query-devtools@5.20.2': {} + + '@tanstack/react-query-devtools@5.20.5(@tanstack/react-query@5.20.5(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-devtools': 5.20.2 + '@tanstack/react-query': 5.20.5(react@18.3.1) + react: 18.3.1 + '@tanstack/react-query@4.35.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/query-core': 4.35.3 diff --git a/projects/plugins/protect/README.md b/projects/plugins/protect/README.md index d6129bebc7fa7..575f36b12a974 100644 --- a/projects/plugins/protect/README.md +++ b/projects/plugins/protect/README.md @@ -7,7 +7,11 @@ Jetpack Protect plugin ## Developing -Use the [Jetpack Debug Helper plugin](https://github.com/Automattic/jetpack/tree/trunk/projects/plugins/debug-helper) to force Protect into different statuses. The plugin will allow you to emulate different responses from the server so you can work on all the different statuses the plugin support. +### Debug helper plugin + +Use the [Jetpack Debug Helper plugin](https://github.com/Automattic/jetpack/tree/trunk/projects/plugins/debug-helper) to mock different scan results and application states. The plugin will allow you to emulate different responses from the server so you can work on all the different statuses the plugin support. + +### Bypassing the cache If you want to force Protect to always fetch data from the server you can use the constant below: @@ -15,6 +19,17 @@ If you want to force Protect to always fetch data from the server you can use th Be aware that a request to the server will be made in all admin pages! Use it only for debugging. +### Component Storybooks + +[Storybook](https://storybook.js.org/) is available for developing UI components in isolation. + +Run `pnpm run storybook:dev` in `projects/js-packages/storybook` to get started. + +### React Query Browser Tools + +This project also includes [React Query Devtools](https://tanstack.com/query/latest/docs/framework/react/devtools). + +Whenever the application is running in development mode (i.e. via `jetpack watch`), the tools will be available in the plugin via a floating icon in the bottom right corner. ## Contribute diff --git a/projects/plugins/protect/changelog/add-protect-tanstack-query b/projects/plugins/protect/changelog/add-protect-tanstack-query new file mode 100644 index 0000000000000..8499ce03ae272 --- /dev/null +++ b/projects/plugins/protect/changelog/add-protect-tanstack-query @@ -0,0 +1,5 @@ +Significance: patch +Type: added +Comment: Added react query, no user-facing impact. + + diff --git a/projects/plugins/protect/package.json b/projects/plugins/protect/package.json index 643ab7e8e4481..cb5050c2b0f10 100644 --- a/projects/plugins/protect/package.json +++ b/projects/plugins/protect/package.json @@ -37,6 +37,8 @@ "@wordpress/i18n": "5.6.0", "@wordpress/icons": "10.6.0", "@wordpress/url": "4.6.0", + "@tanstack/react-query": "5.20.5", + "@tanstack/react-query-devtools": "5.20.5", "camelize": "1.0.1", "clsx": "2.1.1", "diff": "^4.0.2", diff --git a/projects/plugins/protect/src/class-jetpack-protect.php b/projects/plugins/protect/src/class-jetpack-protect.php index b2db8472267ea..0f50291c34cc6 100644 --- a/projects/plugins/protect/src/class-jetpack-protect.php +++ b/projects/plugins/protect/src/class-jetpack-protect.php @@ -19,6 +19,7 @@ use Automattic\Jetpack\My_Jetpack\Initializer as My_Jetpack_Initializer; use Automattic\Jetpack\My_Jetpack\Products as My_Jetpack_Products; use Automattic\Jetpack\Plugins_Installer; +use Automattic\Jetpack\Protect\Credentials; use Automattic\Jetpack\Protect\Onboarding; use Automattic\Jetpack\Protect\REST_Controller; use Automattic\Jetpack\Protect\Scan_History; @@ -38,11 +39,6 @@ */ class Jetpack_Protect { - /** - * Licenses product ID. - * - * @var string - */ const JETPACK_SCAN_PRODUCT_IDS = array( 2010, // JETPACK_SECURITY_DAILY. 2011, // JETPACK_SECURITY_DAILY_MOTNHLY. @@ -214,6 +210,7 @@ public function initial_state() { 'apiRoot' => esc_url_raw( rest_url() ), 'apiNonce' => wp_create_nonce( 'wp_rest' ), 'registrationNonce' => wp_create_nonce( 'jetpack-registration-nonce' ), + 'credentials' => Credentials::get_credential_array(), 'status' => Status::get_status( $refresh_status_from_wpcom ), 'scanHistory' => Scan_History::get_scan_history( $refresh_status_from_wpcom ), 'installedPlugins' => Plugins_Installer::get_plugins(), @@ -223,7 +220,7 @@ public function initial_state() { 'siteSuffix' => ( new Jetpack_Status() )->get_site_suffix(), 'blogID' => Connection_Manager::get_site_id( true ), 'jetpackScan' => My_Jetpack_Products::get_product( 'scan' ), - 'hasRequiredPlan' => Plan::has_required_plan(), + 'hasPlan' => Plan::has_required_plan(), 'onboardingProgress' => Onboarding::get_current_user_progress(), 'waf' => array( 'wafSupported' => Waf_Runner::is_supported_environment(), @@ -232,8 +229,6 @@ public function initial_state() { 'upgradeIsSeen' => self::get_waf_upgrade_seen_status(), 'displayUpgradeBadge' => self::get_waf_upgrade_badge_display_status(), 'isEnabled' => Waf_Runner::is_enabled(), - 'isToggling' => false, - 'isUpdating' => false, 'config' => Waf_Runner::get_config(), 'stats' => self::get_waf_stats(), 'globalStats' => Waf_Stats::get_global_stats(), diff --git a/projects/plugins/protect/src/class-rest-controller.php b/projects/plugins/protect/src/class-rest-controller.php index 2f89144f5a86b..0aa752ddfd6d1 100644 --- a/projects/plugins/protect/src/class-rest-controller.php +++ b/projects/plugins/protect/src/class-rest-controller.php @@ -10,8 +10,10 @@ namespace Automattic\Jetpack\Protect; use Automattic\Jetpack\Connection\Rest_Authentication as Connection_Rest_Authentication; +use Automattic\Jetpack\IP\Utils as IP_Utils; use Automattic\Jetpack\Protect_Status\REST_Controller as Protect_Status_REST_Controller; use Automattic\Jetpack\Waf\Waf_Runner; +use Automattic\Jetpack\Waf\Waf_Stats; use Jetpack_Protect; use WP_Error; use WP_REST_Request; @@ -239,7 +241,7 @@ public static function api_ignore_threat( $request ) { $threat_ignored = Threats::ignore_threat( $request['threat_id'] ); if ( ! $threat_ignored ) { - return new WP_REST_Response( 'An error occured while attempting to ignore the threat.', 500 ); + return new WP_REST_Response( 'An error occurred while attempting to ignore the threat.', 500 ); } return new WP_REST_Response( 'Threat ignored.' ); @@ -260,7 +262,7 @@ public static function api_unignore_threat( $request ) { $threat_ignored = Threats::unignore_threat( $request['threat_id'] ); if ( ! $threat_ignored ) { - return new WP_REST_Response( 'An error occured while attempting to unignore the threat.', 500 ); + return new WP_REST_Response( 'An error occurred while attempting to unignore the threat.', 500 ); } return new WP_REST_Response( 'Threat unignored.' ); @@ -281,7 +283,7 @@ public static function api_fix_threats( $request ) { $threats_fixed = Threats::fix_threats( $request['threat_ids'] ); if ( ! $threats_fixed ) { - return new WP_REST_Response( 'An error occured while attempting to fix the threat.', 500 ); + return new WP_REST_Response( 'An error occurred while attempting to fix the threat.', 500 ); } return new WP_REST_Response( $threats_fixed ); @@ -302,7 +304,7 @@ public static function api_fix_threats_status( $request ) { $threats_fixed = Threats::fix_threats_status( $request['threat_ids'] ); if ( ! $threats_fixed ) { - return new WP_REST_Response( 'An error occured while attempting to get the fixer status of the threats.', 500 ); + return new WP_REST_Response( 'An error occurred while attempting to get the fixer status of the threats.', 500 ); } return new WP_REST_Response( $threats_fixed ); @@ -317,7 +319,7 @@ public static function api_check_credentials() { $credential_array = Credentials::get_credential_array(); if ( ! isset( $credential_array ) ) { - return new WP_REST_Response( 'An error occured while attempting to fetch the credentials array', 500 ); + return new WP_REST_Response( 'An error occurred while attempting to fetch the credentials array', 500 ); } return new WP_REST_Response( $credential_array ); @@ -332,7 +334,7 @@ public static function api_scan() { $scan_enqueued = Threats::scan(); if ( ! $scan_enqueued ) { - return new WP_REST_Response( 'An error occured while attempting to enqueue the scan.', 500 ); + return new WP_REST_Response( 'An error occurred while attempting to enqueue the scan.', 500 ); } return new WP_REST_Response( 'Scan enqueued.' ); @@ -349,7 +351,7 @@ public static function api_toggle_waf() { if ( ! $disabled ) { return new WP_Error( 'waf_disable_failed', - __( 'An error occured disabling the firewall.', 'jetpack-protect' ), + __( 'An error occurred disabling the firewall.', 'jetpack-protect' ), array( 'status' => 500 ) ); } @@ -361,7 +363,7 @@ public static function api_toggle_waf() { if ( ! $enabled ) { return new WP_Error( 'waf_enable_failed', - __( 'An error occured enabling the firewall.', 'jetpack-protect' ), + __( 'An error occurred enabling the firewall.', 'jetpack-protect' ), array( 'status' => 500 ) ); } @@ -380,10 +382,15 @@ public static function api_get_waf() { return new WP_REST_Response( array( - 'is_seen' => Jetpack_Protect::get_waf_seen_status(), - 'is_enabled' => Waf_Runner::is_enabled(), - 'config' => Waf_Runner::get_config(), - 'stats' => Jetpack_Protect::get_waf_stats(), + 'wafSupported' => Waf_Runner::is_supported_environment(), + 'currentIp' => IP_Utils::get_ip(), + 'isSeen' => Jetpack_Protect::get_waf_seen_status(), + 'upgradeIsSeen' => Jetpack_Protect::get_waf_upgrade_seen_status(), + 'displayUpgradeBadge' => Jetpack_Protect::get_waf_upgrade_badge_display_status(), + 'isEnabled' => Waf_Runner::is_enabled(), + 'config' => Waf_Runner::get_config(), + 'stats' => Jetpack_Protect::get_waf_stats(), + 'globalStats' => Waf_Stats::get_global_stats(), ) ); } @@ -449,7 +456,7 @@ public static function api_complete_onboarding_steps( $request ) { $completed = Onboarding::complete_steps( $request['step_ids'] ); if ( ! $completed ) { - return new WP_REST_Response( 'An error occured completing the onboarding step(s).', 500 ); + return new WP_REST_Response( 'An error occurred completing the onboarding step(s).', 500 ); } return new WP_REST_Response( 'Onboarding step(s) completed.' ); diff --git a/projects/plugins/protect/src/js/api.js b/projects/plugins/protect/src/js/api.js deleted file mode 100644 index 30ab7a200896b..0000000000000 --- a/projects/plugins/protect/src/js/api.js +++ /dev/null @@ -1,56 +0,0 @@ -import apiFetch from '@wordpress/api-fetch'; -import camelize from 'camelize'; - -const API = { - fetchWaf: () => - apiFetch( { - path: 'jetpack-protect/v1/waf', - method: 'GET', - } ).then( camelize ), - - toggleWaf: () => - apiFetch( { - method: 'POST', - path: 'jetpack-protect/v1/toggle-waf', - } ), - - updateWaf: data => - apiFetch( { - method: 'POST', - path: 'jetpack/v4/waf', - data, - } ), - - wafSeen: () => - apiFetch( { - path: 'jetpack-protect/v1/waf-seen', - method: 'POST', - } ), - - wafUpgradeSeen: () => - apiFetch( { - path: 'jetpack-protect/v1/waf-upgrade-seen', - method: 'POST', - } ), - - fetchOnboardingProgress: () => - apiFetch( { - path: 'jetpack-protect/v1/onboarding-progress', - method: 'GET', - } ), - - completeOnboardingSteps: stepIds => - apiFetch( { - path: 'jetpack-protect/v1/onboarding-progress', - method: 'POST', - data: { step_ids: stepIds }, - } ), - - fetchScanHistory: () => - apiFetch( { - path: 'jetpack-protect/v1/scan-history', - method: 'GET', - } ), -}; - -export default API; diff --git a/projects/plugins/protect/src/js/api.ts b/projects/plugins/protect/src/js/api.ts new file mode 100644 index 0000000000000..6bcef53838e09 --- /dev/null +++ b/projects/plugins/protect/src/js/api.ts @@ -0,0 +1,119 @@ +import apiFetch from '@wordpress/api-fetch'; +import camelize from 'camelize'; +import { FixersStatus } from './types/fixers'; +import { ScanStatus } from './types/scans'; +import { WafStatus } from './types/waf'; + +const API = { + getWaf: (): Promise< WafStatus > => + apiFetch( { + path: 'jetpack-protect/v1/waf', + method: 'GET', + } ).then( camelize ), + + toggleWaf: () => + apiFetch( { + method: 'POST', + path: 'jetpack-protect/v1/toggle-waf', + } ), + + updateWaf: data => + apiFetch( { + method: 'POST', + path: 'jetpack/v4/waf', + data, + } ).then( camelize ), + + wafSeen: () => + apiFetch( { + path: 'jetpack-protect/v1/waf-seen', + method: 'POST', + } ), + + wafUpgradeSeen: () => + apiFetch( { + path: 'jetpack-protect/v1/waf-upgrade-seen', + method: 'POST', + } ), + + getOnboardingProgress: () => + apiFetch( { + path: 'jetpack-protect/v1/onboarding-progress', + method: 'GET', + } ), + + completeOnboardingSteps: ( stepIds: string[] ) => + apiFetch( { + path: 'jetpack-protect/v1/onboarding-progress', + method: 'POST', + data: { step_ids: stepIds }, + } ), + + getScanHistory: (): Promise< ScanStatus > => + apiFetch( { + path: 'jetpack-protect/v1/scan-history', + method: 'GET', + } ).then( camelize ) as Promise< ScanStatus >, + + scan: () => + apiFetch( { + path: `jetpack-protect/v1/scan`, + method: 'POST', + } ), + + getScanStatus: (): Promise< ScanStatus > => + apiFetch( { + path: 'jetpack-protect/v1/status?hard_refresh=true', + method: 'GET', + } ).then( camelize ), + + fixThreats: ( threatIds: number[] ): Promise< FixersStatus > => + apiFetch( { + path: `jetpack-protect/v1/fix-threats`, + method: 'POST', + data: { threat_ids: threatIds }, + } ), + + getFixersStatus: ( threatIds: number[] ): Promise< FixersStatus > => { + const path = threatIds.reduce( ( carryPath, threatId ) => { + return `${ carryPath }threat_ids[]=${ threatId }&`; + }, 'jetpack-protect/v1/fix-threats-status?' ); + + return apiFetch( { + path, + method: 'GET', + } ); + }, + + ignoreThreat: ( threatId: number ) => + apiFetch( { + path: `jetpack-protect/v1/ignore-threat?threat_id=${ threatId }`, + method: 'POST', + } ), + + unIgnoreThreat: ( threatId: number ) => + apiFetch( { + path: `jetpack-protect/v1/unignore-threat?threat_id=${ threatId }`, + method: 'POST', + } ), + + checkCredentials: () => + apiFetch( { + path: 'jetpack-protect/v1/check-credentials', + method: 'POST', + } ) as Promise< [ Record< string, unknown > ] >, + + checkPlan: () => + apiFetch( { + path: 'jetpack-protect/v1/check-plan', + method: 'GET', + } ), + + getProductData: () => + apiFetch( { + path: '/my-jetpack/v1/site/products/scan', + method: 'GET', + } ).then( camelize ), +}; + +export default API; diff --git a/projects/plugins/protect/src/js/components/admin-page/index.jsx b/projects/plugins/protect/src/js/components/admin-page/index.jsx index 6a0e509d7c743..bdeee21f24b77 100644 --- a/projects/plugins/protect/src/js/components/admin-page/index.jsx +++ b/projects/plugins/protect/src/js/components/admin-page/index.jsx @@ -1,62 +1,35 @@ import { AdminPage as JetpackAdminPage, Container } from '@automattic/jetpack-components'; -import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; -import apiFetch from '@wordpress/api-fetch'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useConnection } from '@automattic/jetpack-connection'; import { __ } from '@wordpress/i18n'; -import { addQueryArgs, getQueryArg } from '@wordpress/url'; -import React, { useEffect } from 'react'; -import { JETPACK_SCAN_SLUG } from '../../constants'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import useNotices from '../../hooks/use-notices'; import useWafData from '../../hooks/use-waf-data'; -import { STORE_ID } from '../../state/store'; -import InterstitialPage from '../interstitial-page'; import Logo from '../logo'; import Notice from '../notice'; import Tabs, { Tab } from '../tabs'; import styles from './styles.module.scss'; -import useRegistrationWatcher from './use-registration-watcher'; const AdminPage = ( { children } ) => { - useRegistrationWatcher(); - + const { notice } = useNotices(); + const { isRegistered } = useConnection(); const { isSeen: wafSeen } = useWafData(); - const notice = useSelect( select => select( STORE_ID ).getNotice() ); - const { refreshPlan, startScanOptimistically, refreshStatus, refreshScanHistory } = - useDispatch( STORE_ID ); - const { adminUrl } = window.jetpackProtectInitialState || {}; - const { run, isRegistered, hasCheckoutStarted } = useProductCheckoutWorkflow( { - productSlug: JETPACK_SCAN_SLUG, - redirectUrl: addQueryArgs( adminUrl, { checkPlan: true } ), - siteProductAvailabilityHandler: async () => - apiFetch( { - path: 'jetpack-protect/v1/check-plan', - method: 'GET', - } ).then( hasRequiredPlan => hasRequiredPlan ), - useBlogIdSuffix: true, - } ); + const navigate = useNavigate(); + // Redirect to the setup page if the site is not registered. useEffect( () => { - if ( getQueryArg( window.location.search, 'checkPlan' ) ) { - startScanOptimistically(); - setTimeout( () => { - refreshPlan(); - refreshStatus( true ); - refreshScanHistory(); - }, 5000 ); + if ( ! isRegistered ) { + navigate( '/setup' ); } - }, [ refreshPlan, refreshStatus, refreshScanHistory, startScanOptimistically ] ); + }, [ isRegistered, navigate ] ); - /* - * Show interstital page when - * - Site is not registered - * - Checkout workflow has started - */ - if ( ! isRegistered || hasCheckoutStarted ) { - return ; + if ( ! isRegistered ) { + return null; } return ( }> - { notice.message && } + { notice && } diff --git a/projects/plugins/protect/src/js/components/admin-page/use-registration-watcher.js b/projects/plugins/protect/src/js/components/admin-page/use-registration-watcher.js deleted file mode 100644 index 01a100ba62bf7..0000000000000 --- a/projects/plugins/protect/src/js/components/admin-page/use-registration-watcher.js +++ /dev/null @@ -1,21 +0,0 @@ -import { useConnection } from '@automattic/jetpack-connection'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { useEffect } from 'react'; -import { STORE_ID } from '../../state/store'; - -const useRegistrationWatcher = () => { - const { isRegistered } = useConnection(); - const { refreshStatus, refreshScanHistory } = useDispatch( STORE_ID ); - const status = useSelect( select => select( STORE_ID ).getStatus() ); - - useEffect( () => { - if ( isRegistered && ! status.status ) { - refreshStatus(); - refreshScanHistory(); - } - // We don't want to run the effect if status changes. Only on changes on isRegistered. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ isRegistered ] ); -}; - -export default useRegistrationWatcher; diff --git a/projects/plugins/protect/src/js/components/credentials-gate/index.jsx b/projects/plugins/protect/src/js/components/credentials-gate/index.jsx index daf2f9381b700..d93e99bdc17d3 100644 --- a/projects/plugins/protect/src/js/components/credentials-gate/index.jsx +++ b/projects/plugins/protect/src/js/components/credentials-gate/index.jsx @@ -1,23 +1,13 @@ import { Spinner } from '@automattic/jetpack-components'; -import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import { STORE_ID } from '../../state/store'; +import useCredentialsQuery from '../../data/use-credentials-query'; import CredentialsNeededModal from '../credentials-needed-modal'; import styles from './styles.module.scss'; const CredentialsGate = ( { children } ) => { - const { checkCredentials } = useDispatch( STORE_ID ); + const { data: credentials, isLoading: credentialsIsFetching } = useCredentialsQuery(); - const { credentials, credentialsIsFetching } = useSelect( select => ( { - credentials: select( STORE_ID ).getCredentials(), - credentialsIsFetching: select( STORE_ID ).getCredentialsIsFetching(), - } ) ); - - if ( ! credentials && ! credentialsIsFetching ) { - checkCredentials(); - } - - if ( ! credentials ) { + if ( credentialsIsFetching ) { return (
{ ); } - if ( credentials.length === 0 ) { + if ( ! credentials || credentials.length === 0 ) { return ; } diff --git a/projects/plugins/protect/src/js/components/credentials-needed-modal/index.jsx b/projects/plugins/protect/src/js/components/credentials-needed-modal/index.jsx index b1c624869031c..fae5e28633d6e 100644 --- a/projects/plugins/protect/src/js/components/credentials-needed-modal/index.jsx +++ b/projects/plugins/protect/src/js/components/credentials-needed-modal/index.jsx @@ -1,18 +1,19 @@ import { Button, Text, getRedirectUrl } from '@automattic/jetpack-components'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useQueryClient } from '@tanstack/react-query'; import { __ } from '@wordpress/i18n'; import { useEffect } from 'react'; -import { STORE_ID } from '../../state/store'; +import { QUERY_CREDENTIALS_KEY } from '../../constants'; +import useCredentialsQuery from '../../data/use-credentials-query'; +import useModal from '../../hooks/use-modal'; import Notice from '../notice'; import styles from './styles.module.scss'; const CredentialsNeededModal = () => { - const { setModal } = useDispatch( STORE_ID ); + const queryClient = useQueryClient(); + const { setModal } = useModal(); + const { data: credentials } = useCredentialsQuery(); const { siteSuffix, blogID } = window.jetpackProtectInitialState; - const { checkCredentials } = useDispatch( STORE_ID ); - const credentials = useSelect( select => select( STORE_ID ).getCredentials() ); - const handleCancelClick = () => { return event => { event.preventDefault(); @@ -26,12 +27,12 @@ const CredentialsNeededModal = () => { useEffect( () => { const interval = setInterval( () => { if ( ! credentials || credentials.length === 0 ) { - checkCredentials(); + queryClient.invalidateQueries( { queryKey: [ QUERY_CREDENTIALS_KEY ] } ); } - }, 3000 ); + }, 5_000 ); return () => clearInterval( interval ); - }, [ checkCredentials, credentials ] ); + }, [ queryClient, credentials ] ); return ( <> diff --git a/projects/plugins/protect/src/js/components/error-section/index.tsx b/projects/plugins/protect/src/js/components/error-section/index.tsx index 55868ffe1581b..b94c0e80a17db 100644 --- a/projects/plugins/protect/src/js/components/error-section/index.tsx +++ b/projects/plugins/protect/src/js/components/error-section/index.tsx @@ -44,6 +44,6 @@ export default function ErrorScreen( {
} preserveSecondaryOnMobile={ false } - /> + > ); } diff --git a/projects/plugins/protect/src/js/components/firewall-footer/index.jsx b/projects/plugins/protect/src/js/components/firewall-footer/index.jsx index 67283d0f21e2a..15429e13f13f8 100644 --- a/projects/plugins/protect/src/js/components/firewall-footer/index.jsx +++ b/projects/plugins/protect/src/js/components/firewall-footer/index.jsx @@ -1,18 +1,15 @@ import { AdminSectionHero, Title, Text, Button } from '@automattic/jetpack-components'; -import { CheckboxControl, ExternalLink } from '@wordpress/components'; -import { useDispatch } from '@wordpress/data'; -import { createInterpolateElement } from '@wordpress/element'; +import { CheckboxControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useState, useEffect, useCallback } from 'react'; -import { FREE_PLUGIN_SUPPORT_URL, PAID_PLUGIN_SUPPORT_URL } from '../../constants'; -import useProtectData from '../../hooks/use-protect-data'; +import useModal from '../../hooks/use-modal'; +import useNotices from '../../hooks/use-notices'; import useWafData from '../../hooks/use-waf-data'; -import { STORE_ID } from '../../state/store'; import SeventyFiveLayout from '../seventy-five-layout'; import styles from './styles.module.scss'; const StandaloneMode = () => { - const { setModal } = useDispatch( STORE_ID ); + const { setModal } = useModal(); const handleClick = () => { return event => { @@ -46,9 +43,8 @@ const StandaloneMode = () => { const ShareDebugData = () => { const { config, isUpdating, toggleShareDebugData } = useWafData(); - const { hasRequiredPlan } = useProtectData(); const { jetpackWafShareDebugData } = config || {}; - const { setNotice } = useDispatch( STORE_ID ); + const { showSuccessNotice, showErrorNotice } = useNotices(); const [ settings, setSettings ] = useState( { jetpack_waf_share_debug_data: jetpackWafShareDebugData, @@ -60,34 +56,11 @@ const ShareDebugData = () => { jetpack_waf_share_debug_data: ! settings.jetpack_waf_share_debug_data, } ); toggleShareDebugData() - .then( () => - setNotice( { - type: 'success', - duration: 5000, - dismissable: true, - message: __( 'Changes saved.', 'jetpack-protect' ), - } ) - ) + .then( () => showSuccessNotice( __( 'Changes saved.', 'jetpack-protect' ) ) ) .catch( () => { - setNotice( { - type: 'error', - dismissable: true, - message: createInterpolateElement( - __( - 'An error ocurred. Please try again or contact support.', - 'jetpack-protect' - ), - { - supportLink: ( - - ), - } - ), - } ); + showErrorNotice(); } ); - }, [ settings, toggleShareDebugData, setNotice, hasRequiredPlan ] ); + }, [ settings, toggleShareDebugData, showSuccessNotice, showErrorNotice ] ); useEffect( () => { setSettings( { @@ -117,9 +90,8 @@ const ShareDebugData = () => { const ShareData = () => { const { config, isUpdating, toggleShareData } = useWafData(); - const { hasRequiredPlan } = useProtectData(); const { jetpackWafShareData } = config || {}; - const { setNotice } = useDispatch( STORE_ID ); + const { showSuccessNotice, showErrorNotice } = useNotices(); const [ settings, setSettings ] = useState( { jetpack_waf_share_data: jetpackWafShareData, @@ -128,34 +100,11 @@ const ShareData = () => { const handleShareDataChange = useCallback( () => { setSettings( { ...settings, jetpack_waf_share_data: ! settings.jetpack_waf_share_data } ); toggleShareData() - .then( () => - setNotice( { - type: 'success', - duration: 5000, - dismissable: true, - message: __( 'Changes saved.', 'jetpack-protect' ), - } ) - ) + .then( () => showSuccessNotice( __( 'Changes saved.', 'jetpack-protect' ) ) ) .catch( () => { - setNotice( { - type: 'error', - dismissable: true, - message: createInterpolateElement( - __( - 'An error ocurred. Please try again or contact support.', - 'jetpack-protect' - ), - { - supportLink: ( - - ), - } - ), - } ); + showErrorNotice(); } ); - }, [ settings, toggleShareData, setNotice, hasRequiredPlan ] ); + }, [ settings, toggleShareData, showSuccessNotice, showErrorNotice ] ); useEffect( () => { setSettings( { diff --git a/projects/plugins/protect/src/js/components/firewall-header/index.jsx b/projects/plugins/protect/src/js/components/firewall-header/index.jsx index b0552bdafd82c..c4a65bab7c63e 100644 --- a/projects/plugins/protect/src/js/components/firewall-header/index.jsx +++ b/projects/plugins/protect/src/js/components/firewall-header/index.jsx @@ -7,33 +7,29 @@ import { Button, Status, } from '@automattic/jetpack-components'; -import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; import { Spinner, Popover } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { Icon, help } from '@wordpress/icons'; import React, { useState, useCallback } from 'react'; -import { JETPACK_SCAN_SLUG } from '../../constants'; import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; -import useProtectData from '../../hooks/use-protect-data'; +import usePlan from '../../hooks/use-plan'; import useWafData from '../../hooks/use-waf-data'; import styles from './styles.module.scss'; const UpgradePrompt = () => { + const { recordEvent } = useAnalyticsTracks(); const { adminUrl } = window.jetpackProtectInitialState || {}; const firewallUrl = adminUrl + '#/firewall'; + const { upgradePlan } = usePlan( { redirectUrl: firewallUrl } ); const { config: { automaticRulesAvailable }, } = useWafData(); - const { run } = useProductCheckoutWorkflow( { - productSlug: JETPACK_SCAN_SLUG, - redirectUrl: firewallUrl, - useBlogIdSuffix: true, - } ); - - const { recordEventHandler } = useAnalyticsTracks(); - const getScan = recordEventHandler( 'jetpack_protect_waf_header_get_scan_link_click', run ); + const getScan = useCallback( () => { + recordEvent( 'jetpack_protect_waf_header_get_scan_link_click' ); + upgradePlan(); + }, [ recordEvent, upgradePlan ] ); return ( - diff --git a/projects/plugins/protect/src/js/components/ignore-threat-modal/index.jsx b/projects/plugins/protect/src/js/components/ignore-threat-modal/index.jsx index a2e222867d2e9..67d5f9894d9a2 100644 --- a/projects/plugins/protect/src/js/components/ignore-threat-modal/index.jsx +++ b/projects/plugins/protect/src/js/components/ignore-threat-modal/index.jsx @@ -1,18 +1,20 @@ import { Button, getRedirectUrl, Text } from '@automattic/jetpack-components'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { createInterpolateElement } from '@wordpress/element'; +import { createInterpolateElement, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon } from '@wordpress/icons'; -import { STORE_ID } from '../../state/store'; +import useIgnoreThreatMutation from '../../data/scan/use-ignore-threat-mutation'; +import useModal from '../../hooks/use-modal'; import ThreatSeverityBadge from '../severity'; import UserConnectionGate from '../user-connection-gate'; import styles from './styles.module.scss'; const IgnoreThreatModal = ( { id, title, label, icon, severity } ) => { - const { setModal, ignoreThreat } = useDispatch( STORE_ID ); - const threatsUpdating = useSelect( select => select( STORE_ID ).getThreatsUpdating() ); + const { setModal } = useModal(); + const ignoreThreatMutation = useIgnoreThreatMutation(); const codeableURL = getRedirectUrl( 'jetpack-protect-codeable-referral' ); + const [ isIgnoring, setIsIgnoring ] = useState( false ); + const handleCancelClick = () => { return event => { event.preventDefault(); @@ -23,9 +25,10 @@ const IgnoreThreatModal = ( { id, title, label, icon, severity } ) => { const handleIgnoreClick = () => { return async event => { event.preventDefault(); - ignoreThreat( id, () => { - setModal( { type: null } ); - } ); + setIsIgnoring( true ); + await ignoreThreatMutation.mutateAsync( id ); + setModal( { type: null } ); + setIsIgnoring( false ); }; }; @@ -64,11 +67,7 @@ const IgnoreThreatModal = ( { id, title, label, icon, severity } ) => { - diff --git a/projects/plugins/protect/src/js/components/modal/README.md b/projects/plugins/protect/src/js/components/modal/README.md index 0d808d8c7879d..554ef9def9c28 100644 --- a/projects/plugins/protect/src/js/components/modal/README.md +++ b/projects/plugins/protect/src/js/components/modal/README.md @@ -1,6 +1,6 @@ # Modal -The `` is a connected component that renders a pop-up modal based on `modal` redux state. +The `` is a connected component that renders a pop-up modal based on `modal` context state. ## Usage @@ -18,14 +18,11 @@ const MyComponent = () => ( ## Opening a modal -Trigger modals by dispatching a `setModal()` action with the modal type to open: +Trigger modals by using the `setModal()` function: ```jsx -import { useDispatch } from '@wordpress/data'; -import { STORE_ID } from './state/store'; - const MyComponent = () => { - const { setModal } = useDispatch( STORE_ID ); + const { setModal } = useModal(); const handleShowModalClick = () => { return event => { diff --git a/projects/plugins/protect/src/js/components/modal/index.jsx b/projects/plugins/protect/src/js/components/modal/index.jsx index 1f629fc955989..2d8728964fb03 100644 --- a/projects/plugins/protect/src/js/components/modal/index.jsx +++ b/projects/plugins/protect/src/js/components/modal/index.jsx @@ -1,7 +1,6 @@ -import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { close as closeIcon, Icon } from '@wordpress/icons'; -import { STORE_ID } from '../../state/store'; +import useModal from '../../hooks/use-modal'; import CredentialsNeededModal from '../credentials-needed-modal'; import FixAllThreatsModal from '../fix-all-threats-modal'; import FixThreatModal from '../fix-threat-modal'; @@ -20,11 +19,9 @@ const MODAL_COMPONENTS = { }; const Modal = () => { - const modalType = useSelect( select => select( STORE_ID ).getModalType() ); - const modalProps = useSelect( select => select( STORE_ID ).getModalProps() ); - const { setModal } = useDispatch( STORE_ID ); + const { modal, setModal } = useModal(); - if ( ! modalType ) { + if ( ! modal.type ) { return null; } @@ -35,7 +32,7 @@ const Modal = () => { }; }; - const ModalComponent = MODAL_COMPONENTS[ modalType ]; + const ModalComponent = MODAL_COMPONENTS[ modal.type ]; return (
@@ -52,7 +49,7 @@ const Modal = () => { aria-label={ __( 'Close Modal Window', 'jetpack-protect' ) } /> - +
); diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index 911b116da9d4d..cf779386bb35c 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -1,8 +1,7 @@ -import { useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { check, close, info, warning, Icon } from '@wordpress/icons'; import { useCallback, useEffect } from 'react'; -import { STORE_ID } from '../../state/store'; +import useNotices from '../../hooks/use-notices'; import styles from './styles.module.scss'; const Notice = ( { @@ -12,7 +11,7 @@ const Notice = ( { message, type = 'success', } ) => { - const { clearNotice } = useDispatch( STORE_ID ); + const { clearNotice } = useNotices(); let icon; switch ( type ) { diff --git a/projects/plugins/protect/src/js/components/paid-accordion/index.jsx b/projects/plugins/protect/src/js/components/paid-accordion/index.jsx index a8a1dae62e20c..fffabbaa138aa 100644 --- a/projects/plugins/protect/src/js/components/paid-accordion/index.jsx +++ b/projects/plugins/protect/src/js/components/paid-accordion/index.jsx @@ -1,11 +1,10 @@ import { Spinner, Text, useBreakpointMatch } from '@automattic/jetpack-components'; -import { useSelect } from '@wordpress/data'; import { dateI18n } from '@wordpress/date'; import { sprintf, __ } from '@wordpress/i18n'; import { Icon, check, chevronDown, chevronUp } from '@wordpress/icons'; import clsx from 'clsx'; import React, { useState, useCallback, useContext } from 'react'; -import { STORE_ID } from '../../state/store'; +import useFixers from '../../hooks/use-fixers'; import ThreatSeverityBadge from '../severity'; import styles from './styles.module.scss'; @@ -75,13 +74,14 @@ export const PaidAccordionItem = ( { const accordionData = useContext( PaidAccordionContext ); const open = accordionData?.open === id; const setOpen = accordionData?.setOpen; - const threatsAreFixing = useSelect( select => select( STORE_ID ).getThreatsAreFixing() ); const bodyClassNames = clsx( styles[ 'accordion-body' ], { [ styles[ 'accordion-body-open' ] ]: open, [ styles[ 'accordion-body-close' ] ]: ! open, } ); + const { fixersStatus } = useFixers(); + const handleClick = useCallback( () => { if ( ! open ) { onOpen?.(); @@ -122,7 +122,7 @@ export const PaidAccordionItem = ( {
{ fixable && ( <> - { threatsAreFixing.indexOf( id ) >= 0 ? ( + { fixersStatus?.threats?.[ id ]?.status === 'in_progress' ? ( ) : ( diff --git a/projects/plugins/protect/src/js/components/paid-plan-gate/index.tsx b/projects/plugins/protect/src/js/components/paid-plan-gate/index.tsx index 9f00fc3446df8..93fceaa94f188 100644 --- a/projects/plugins/protect/src/js/components/paid-plan-gate/index.tsx +++ b/projects/plugins/protect/src/js/components/paid-plan-gate/index.tsx @@ -1,5 +1,5 @@ import { Navigate } from 'react-router-dom'; -import useProtectData from '../../hooks/use-protect-data'; +import usePlan from '../../hooks/use-plan'; /** * Paid Plan Gate @@ -19,9 +19,9 @@ export default function PaidPlanGate( { children?: JSX.Element; redirect?: string; } ): JSX.Element { - const { hasRequiredPlan } = useProtectData(); + const { hasPlan } = usePlan(); - if ( ! hasRequiredPlan ) { + if ( ! hasPlan ) { return ; } diff --git a/projects/plugins/protect/src/js/components/pricing-table/index.jsx b/projects/plugins/protect/src/js/components/pricing-table/index.jsx index 3b2b4bc4c8ac1..3edd7911a0b6a 100644 --- a/projects/plugins/protect/src/js/components/pricing-table/index.jsx +++ b/projects/plugins/protect/src/js/components/pricing-table/index.jsx @@ -1,6 +1,3 @@ -/** - * External dependencies - */ import { Button, ProductPrice, @@ -10,34 +7,30 @@ import { PricingTableItem, } from '@automattic/jetpack-components'; import { useConnection } from '@automattic/jetpack-connection'; -import { useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import useConnectSiteMutation from '../../data/use-connection-mutation'; import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; +import usePlan from '../../hooks/use-plan'; import useProtectData from '../../hooks/use-protect-data'; -import useWafData from '../../hooks/use-waf-data'; -import { STORE_ID } from '../../state/store'; /** * Product Detail component. * - * @param {object} props - Component props - * @param {Function} props.onScanAdd - Callback when adding paid protect product successfully * @return {object} ConnectedPricingTable react component. */ -const ConnectedPricingTable = ( { onScanAdd } ) => { - const { handleRegisterSite, registrationError } = useConnection( { +const ConnectedPricingTable = () => { + const navigate = useNavigate(); + const { recordEvent } = useAnalyticsTracks(); + const connectSiteMutation = useConnectSiteMutation(); + const { upgradePlan, isLoading: isPlanLoading } = usePlan(); + const { registrationError } = useConnection( { skipUserConnection: true, } ); - const { refreshPlan, refreshStatus, startScanOptimistically } = useDispatch( STORE_ID ); - - const [ getProtectFreeButtonIsLoading, setGetProtectFreeButtonIsLoading ] = useState( false ); - const [ getScanButtonIsLoading, setGetScanButtonIsLoading ] = useState( false ); - // Access paid protect product data const { jetpackScan } = useProtectData(); - const { refreshWaf } = useWafData(); const { pricingForUi } = jetpackScan; const { introductoryOffer, currencyCode: currency = 'USD' } = pricingForUi; @@ -47,33 +40,16 @@ const ConnectedPricingTable = ( { onScanAdd } ) => { ? Math.ceil( ( introductoryOffer.costPerInterval / 12 ) * 100 ) / 100 : null; - // Track free and paid click events - const { recordEvent, recordEventHandler } = useAnalyticsTracks(); - const getScan = recordEventHandler( 'jetpack_protect_pricing_table_get_scan_link_click', () => { - setGetScanButtonIsLoading( true ); - onScanAdd(); - } ); + const getScan = useCallback( () => { + recordEvent( 'jetpack_protect_pricing_table_get_scan_link_click' ); + upgradePlan(); + }, [ recordEvent, upgradePlan ] ); const getProtectFree = useCallback( async () => { recordEvent( 'jetpack_protect_connected_product_activated' ); - setGetProtectFreeButtonIsLoading( true ); - try { - await handleRegisterSite(); - startScanOptimistically(); - await refreshPlan(); - await refreshWaf(); - await refreshStatus( true ); - } finally { - setGetProtectFreeButtonIsLoading( false ); - } - }, [ - handleRegisterSite, - recordEvent, - refreshWaf, - refreshPlan, - refreshStatus, - startScanOptimistically, - ] ); + await connectSiteMutation.mutateAsync(); + navigate( '/scan' ); + }, [ connectSiteMutation, recordEvent, navigate ] ); const args = { title: __( 'Stay one step ahead of threats', 'jetpack-protect' ), @@ -120,8 +96,8 @@ const ConnectedPricingTable = ( { onScanAdd } ) => { @@ -158,8 +134,8 @@ const ConnectedPricingTable = ( { onScanAdd } ) => { fullWidth variant="secondary" onClick={ getProtectFree } - isLoading={ getProtectFreeButtonIsLoading } - disabled={ getProtectFreeButtonIsLoading || getScanButtonIsLoading } + isLoading={ connectSiteMutation.isPending } + disabled={ connectSiteMutation.isPending || isPlanLoading } error={ registrationError ? __( 'An error occurred. Please try again.', 'jetpack-protect' ) diff --git a/projects/plugins/protect/src/js/components/scan-button/index.jsx b/projects/plugins/protect/src/js/components/scan-button/index.jsx index 91554cdaf4b93..4844d9e4c060c 100644 --- a/projects/plugins/protect/src/js/components/scan-button/index.jsx +++ b/projects/plugins/protect/src/js/components/scan-button/index.jsx @@ -1,28 +1,20 @@ import { Button } from '@automattic/jetpack-components'; -import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import React, { forwardRef } from 'react'; -import { STORE_ID } from '../../state/store'; +import useStartScanMutator from '../../data/scan/use-start-scan-mutation'; const ScanButton = forwardRef( ( { variant = 'secondary', children, ...props }, ref ) => { - const { scan } = useDispatch( STORE_ID ); - const scanIsEnqueuing = useSelect( select => select( STORE_ID ).getScanIsEnqueuing(), [] ); + const startScanMutation = useStartScanMutator(); const handleScanClick = () => { return event => { event.preventDefault(); - scan(); + startScanMutation.mutate(); }; }; return ( - ); diff --git a/projects/plugins/protect/src/js/components/scan-footer/index.jsx b/projects/plugins/protect/src/js/components/scan-footer/index.jsx index 341212a746366..200752c48e676 100644 --- a/projects/plugins/protect/src/js/components/scan-footer/index.jsx +++ b/projects/plugins/protect/src/js/components/scan-footer/index.jsx @@ -7,31 +7,25 @@ import { Col, Container, } from '@automattic/jetpack-components'; -import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; import { __, sprintf } from '@wordpress/i18n'; -import React from 'react'; -import { JETPACK_SCAN_SLUG } from '../../constants'; +import React, { useCallback } from 'react'; import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; -import useProtectData from '../../hooks/use-protect-data'; +import usePlan from '../../hooks/use-plan'; import useWafData from '../../hooks/use-waf-data'; import SeventyFiveLayout from '../seventy-five-layout'; import styles from './styles.module.scss'; const ProductPromotion = () => { - const { adminUrl, siteSuffix, blogID } = window.jetpackProtectInitialState || {}; + const { recordEvent } = useAnalyticsTracks(); + const { hasPlan, upgradePlan } = usePlan(); + const { siteSuffix, blogID } = window.jetpackProtectInitialState || {}; - const { run } = useProductCheckoutWorkflow( { - productSlug: JETPACK_SCAN_SLUG, - redirectUrl: adminUrl, - useBlogIdSuffix: true, - } ); + const getScan = useCallback( () => { + recordEvent( 'jetpack_protect_footer_get_scan_link_click' ); + upgradePlan(); + }, [ recordEvent, upgradePlan ] ); - const { recordEventHandler } = useAnalyticsTracks(); - const getScan = recordEventHandler( 'jetpack_protect_footer_get_scan_link_click', run ); - - const { hasRequiredPlan } = useProtectData(); - - if ( hasRequiredPlan ) { + if ( hasPlan ) { const goToCloudUrl = getRedirectUrl( 'jetpack-scan-dash', { site: blogID ?? siteSuffix } ); return ( @@ -74,14 +68,14 @@ const ProductPromotion = () => { }; const FooterInfo = () => { - const { hasRequiredPlan } = useProtectData(); + const { hasPlan } = usePlan(); const { globalStats } = useWafData(); const totalVulnerabilities = parseInt( globalStats?.totalVulnerabilities ); const totalVulnerabilitiesFormatted = isNaN( totalVulnerabilities ) ? '50,000' : totalVulnerabilities.toLocaleString(); - if ( hasRequiredPlan ) { + if ( hasPlan ) { const learnMoreScanUrl = getRedirectUrl( 'protect-footer-learn-more-scan' ); return ( diff --git a/projects/plugins/protect/src/js/components/seventy-five-layout/index.jsx b/projects/plugins/protect/src/js/components/seventy-five-layout/index.jsx index c5a6402f04b6a..3848d09864ec3 100644 --- a/projects/plugins/protect/src/js/components/seventy-five-layout/index.jsx +++ b/projects/plugins/protect/src/js/components/seventy-five-layout/index.jsx @@ -13,9 +13,10 @@ import React from 'react'; * @param {React.ReactNode} props.main - Main section component * @param {React.ReactNode} props.secondary - Secondary section component * @param {boolean} props.preserveSecondaryOnMobile - Whether to show secondary section on mobile + * @param {boolean} props.fluid - Whether to use fluid layout * @return {React.ReactNode} - React meta-component */ -const SeventyFiveLayout = ( { main, secondary, preserveSecondaryOnMobile = false } ) => { +const SeventyFiveLayout = ( { main, secondary, preserveSecondaryOnMobile = false, fluid } ) => { const [ isSmall, isLarge ] = useBreakpointMatch( [ 'sm', 'lg' ] ); /* @@ -26,7 +27,7 @@ const SeventyFiveLayout = ( { main, secondary, preserveSecondaryOnMobile = false const hideSecondarySection = ! preserveSecondaryOnMobile && isSmall; return ( - + { ! hideSecondarySection && ( <> diff --git a/projects/plugins/protect/src/js/components/summary/index.jsx b/projects/plugins/protect/src/js/components/summary/index.jsx index 60abb79dba7a3..618f4b78213ad 100644 --- a/projects/plugins/protect/src/js/components/summary/index.jsx +++ b/projects/plugins/protect/src/js/components/summary/index.jsx @@ -2,6 +2,7 @@ import { useBreakpointMatch } from '@automattic/jetpack-components'; import { dateI18n } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; import React, { useState } from 'react'; +import usePlan from '../../hooks/use-plan'; import useProtectData from '../../hooks/use-protect-data'; import ScanSectionHeader from '../../routes/scan/scan-section-header'; import OnboardingPopover from '../onboarding-popover'; @@ -13,8 +14,8 @@ const Summary = () => { current: { threats: numThreats }, }, lastChecked, - hasRequiredPlan, } = useProtectData(); + const { hasPlan } = usePlan(); // Popover anchors const [ dailyScansPopoverAnchor, setDailyScansPopoverAnchor ] = useState( null ); @@ -40,7 +41,7 @@ const Summary = () => { dateI18n( 'F jS', lastChecked ) ) }
- { ! hasRequiredPlan && ( + { ! hasPlan && ( { }; const EmptyList = () => { - const { lastChecked, hasRequiredPlan } = useProtectData(); + const { lastChecked } = useProtectData(); + const { hasPlan } = usePlan(); const [ dailyAndManualScansPopoverAnchor, setDailyAndManualScansPopoverAnchor ] = useState( null ); @@ -115,7 +117,7 @@ const EmptyList = () => { } ) } - { hasRequiredPlan && ( + { hasPlan && ( <> { - const { adminUrl } = window.jetpackProtectInitialState || {}; - const { run } = useProductCheckoutWorkflow( { - productSlug: JETPACK_SCAN_SLUG, - redirectUrl: adminUrl, - useBlogIdSuffix: true, - } ); + const { recordEvent } = useAnalyticsTracks(); + const { upgradePlan } = usePlan(); - const { recordEventHandler } = useAnalyticsTracks(); - const getScan = recordEventHandler( 'jetpack_protect_threat_list_get_scan_link_click', run ); + const getScan = useCallback( () => { + recordEvent( 'jetpack_protect_threat_list_get_scan_link_click' ); + upgradePlan(); + }, [ recordEvent, upgradePlan ] ); const learnMoreButton = source ? ( - diff --git a/projects/plugins/protect/src/js/components/user-connection-needed-modal/index.jsx b/projects/plugins/protect/src/js/components/user-connection-needed-modal/index.jsx index 4fd0d97554db3..47d551248eea5 100644 --- a/projects/plugins/protect/src/js/components/user-connection-needed-modal/index.jsx +++ b/projects/plugins/protect/src/js/components/user-connection-needed-modal/index.jsx @@ -1,13 +1,12 @@ import { Button, Text } from '@automattic/jetpack-components'; import { useConnection } from '@automattic/jetpack-connection'; -import { useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import { STORE_ID } from '../../state/store'; +import useModal from '../../hooks/use-modal'; import Notice from '../notice'; import styles from './styles.module.scss'; const UserConnectionNeededModal = () => { - const { setModal } = useDispatch( STORE_ID ); + const { setModal } = useModal(); const { userIsConnecting, handleConnectUser } = useConnection( { redirectUri: 'admin.php?page=jetpack-protect', } ); diff --git a/projects/plugins/protect/src/js/constants.js b/projects/plugins/protect/src/js/constants.js index a82cc01aa3a4e..5ec94bdccafc9 100644 --- a/projects/plugins/protect/src/js/constants.js +++ b/projects/plugins/protect/src/js/constants.js @@ -1,9 +1,11 @@ -export const FREE_PLUGIN_SUPPORT_URL = 'https://wordpress.org/support/plugin/jetpack-protect/'; +export const JETPACK_SCAN_SLUG = 'jetpack_scan'; +/** + * URLs + */ +export const FREE_PLUGIN_SUPPORT_URL = 'https://wordpress.org/support/plugin/jetpack-protect/'; export const PAID_PLUGIN_SUPPORT_URL = 'https://jetpack.com/contact-support/?rel=support'; -export const JETPACK_SCAN_SLUG = 'jetpack_scan'; - /** * Scan Status Constants */ @@ -17,3 +19,15 @@ export const SCAN_IN_PROGRESS_STATUSES = [ SCAN_STATUS_SCANNING, SCAN_STATUS_OPTIMISTICALLY_SCANNING, ]; + +/** + * Query names + */ +export const QUERY_CREDENTIALS_KEY = 'credentials'; +export const QUERY_FIXERS_KEY = 'fixers'; +export const QUERY_HAS_PLAN_KEY = 'has plan'; +export const QUERY_HISTORY_KEY = 'history'; +export const QUERY_ONBOARDING_PROGRESS_KEY = 'onboarding progress'; +export const QUERY_PRODUCT_DATA_KEY = 'product data'; +export const QUERY_SCAN_STATUS_KEY = 'scan status'; +export const QUERY_WAF_KEY = 'waf'; diff --git a/projects/plugins/protect/src/js/data/onboarding/use-onboarding-progress-mutator.ts b/projects/plugins/protect/src/js/data/onboarding/use-onboarding-progress-mutator.ts new file mode 100644 index 0000000000000..6c43fd53cc19b --- /dev/null +++ b/projects/plugins/protect/src/js/data/onboarding/use-onboarding-progress-mutator.ts @@ -0,0 +1,22 @@ +import { useMutation, type UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import API from '../../api'; +import { QUERY_ONBOARDING_PROGRESS_KEY } from '../../constants'; + +/** + * Onboarding Progress Mutation Hook + * + * @return {UseMutationResult} - useMutation result. + */ +export default function useOnboardingProgressMutation(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: API.completeOnboardingSteps, + onMutate: ( stepIds: string[] ) => { + // Optimistically update the query data. + queryClient.setQueryData( + [ QUERY_ONBOARDING_PROGRESS_KEY ], + ( currentProgress: string[] ) => [ ...currentProgress, ...stepIds ] + ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/onboarding/use-onboarding-progress-query.ts b/projects/plugins/protect/src/js/data/onboarding/use-onboarding-progress-query.ts new file mode 100644 index 0000000000000..e8c08bda8e451 --- /dev/null +++ b/projects/plugins/protect/src/js/data/onboarding/use-onboarding-progress-query.ts @@ -0,0 +1,16 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import API from '../../api'; +import { QUERY_ONBOARDING_PROGRESS_KEY } from '../../constants'; + +/** + * Use Onboarding Progress Query + * + * @return {UseQueryResult} - useQuery result. + */ +export default function useOnboardingProgressQuery(): UseQueryResult { + return useQuery( { + queryKey: [ QUERY_ONBOARDING_PROGRESS_KEY ], + queryFn: API.getOnboardingProgress, + initialData: window?.jetpackProtectInitialState?.onboardingProgress || [], + } ); +} diff --git a/projects/plugins/protect/src/js/data/scan/use-fixers-mutation.ts b/projects/plugins/protect/src/js/data/scan/use-fixers-mutation.ts new file mode 100644 index 0000000000000..c50c3611c8592 --- /dev/null +++ b/projects/plugins/protect/src/js/data/scan/use-fixers-mutation.ts @@ -0,0 +1,35 @@ +import { useMutation, type UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import API from '../../api'; +import { QUERY_FIXERS_KEY } from '../../constants'; +import useNotices from '../../hooks/use-notices'; + +/** + * Fixers Mutatation Hook + * + * @return {UseMutationResult} Mutation result. + */ +export default function useFixersMutation(): UseMutationResult { + const queryClient = useQueryClient(); + const { showSuccessNotice, showErrorNotice } = useNotices(); + + return useMutation( { + mutationFn: API.fixThreats, + onSuccess: data => { + // The data returned from the API is the same as the data we need to update the cache. + queryClient.setQueryData( [ QUERY_FIXERS_KEY ], data ); + + // Show a success notice. + showSuccessNotice( + __( + "We're hard at work fixing this threat in the background. Please check back shortly.", + 'jetpack-protect' + ) + ); + }, + onError: () => { + // Show an error notice. + showErrorNotice( __( 'An error occurred fixing threats.', 'jetpack-protect' ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts b/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts new file mode 100644 index 0000000000000..791555f1b16ca --- /dev/null +++ b/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts @@ -0,0 +1,88 @@ +import { useConnection } from '@automattic/jetpack-connection'; +import { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import API from '../../api'; +import { QUERY_FIXERS_KEY, QUERY_HISTORY_KEY, QUERY_SCAN_STATUS_KEY } from '../../constants'; +import useNotices from '../../hooks/use-notices'; +import { FixersStatus } from '../../types/fixers'; + +/** + * Fixers Query Hook + * + * @param {object} args - Hook arguments. + * @param {number[]} args.threatIds - The threat IDs to monitor for fixer status. + * @param {boolean} args.usePolling - Whether to continuously poll for fixer status while fixers are in progress. + * + * @return {UseQueryResult} The query hook result. + */ +export default function useFixersQuery( { + threatIds, + usePolling, +}: { + threatIds: number[]; + usePolling?: boolean; +} ): UseQueryResult< FixersStatus > { + const queryClient = useQueryClient(); + const { showSuccessNotice, showErrorNotice } = useNotices(); + const { isRegistered } = useConnection( { + autoTrigger: false, + from: 'protect', + redirectUri: null, + skipUserConnection: true, + } ); + + return useQuery( { + queryKey: [ QUERY_FIXERS_KEY ], + queryFn: async () => { + const data = await API.getFixersStatus( threatIds ); + const cachedData = queryClient.getQueryData( [ QUERY_FIXERS_KEY ] ) as + | { threats: object } + | undefined; + + // Check if any fixers have completed, by comparing the latest data against the cache. + Object.keys( data?.threats ).forEach( ( threatId: string ) => { + // Find the specific threat in the cached data. + const threat = data?.threats[ threatId ]; + const cachedThreat = cachedData?.threats?.[ threatId ]; + + if ( + cachedThreat && + cachedThreat.status === 'in_progress' && + threat.status !== 'in_progress' + ) { + // Invalidate related queries when a fixer has completed. + queryClient.invalidateQueries( { queryKey: [ QUERY_SCAN_STATUS_KEY ] } ); + queryClient.invalidateQueries( { queryKey: [ QUERY_HISTORY_KEY ] } ); + + // Show a relevant notice. + if ( threat.status === 'fixed' ) { + showSuccessNotice( __( 'Threat fixed successfully.', 'jetpack-protect' ) ); + } else { + showErrorNotice( __( 'Threat could not be fixed.', 'jetpack-protect' ) ); + } + } + } ); + + return data; + }, + refetchInterval( query ) { + if ( ! usePolling || ! query.state.data ) { + return false; + } + + // Refetch while any threats are still in progress. + if ( + Object.values( query.state.data?.threats ).some( + ( threat: { status: string } ) => threat.status === 'in_progress' + ) + ) { + // Refetch on a shorter interval first, then slow down if it is taking a while. + return query.state.dataUpdateCount < 5 ? 5_000 : 15_000; + } + + return false; + }, + initialData: { threats: {} }, // to do: provide initial data in window.jetpackProtectInitialState + enabled: isRegistered, + } ); +} diff --git a/projects/plugins/protect/src/js/data/scan/use-history-query.ts b/projects/plugins/protect/src/js/data/scan/use-history-query.ts new file mode 100644 index 0000000000000..c5145e5c30768 --- /dev/null +++ b/projects/plugins/protect/src/js/data/scan/use-history-query.ts @@ -0,0 +1,26 @@ +import { useConnection } from '@automattic/jetpack-connection'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import camelize from 'camelize'; +import API from '../../api'; +import { QUERY_HISTORY_KEY } from '../../constants'; + +/** + * Use History Query + * + * @return {UseQueryResult} useQuery result. + */ +export default function useHistoryQuery(): UseQueryResult { + const { isRegistered } = useConnection( { + autoTrigger: false, + from: 'protect', + redirectUri: null, + skipUserConnection: true, + } ); + + return useQuery( { + queryKey: [ QUERY_HISTORY_KEY ], + queryFn: API.getScanHistory, + initialData: camelize( window.jetpackProtectInitialState?.scanHistory ), + enabled: isRegistered, + } ); +} diff --git a/projects/plugins/protect/src/js/data/scan/use-ignore-threat-mutation.ts b/projects/plugins/protect/src/js/data/scan/use-ignore-threat-mutation.ts new file mode 100644 index 0000000000000..f15ac1086f442 --- /dev/null +++ b/projects/plugins/protect/src/js/data/scan/use-ignore-threat-mutation.ts @@ -0,0 +1,36 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import API from '../../api'; +import { QUERY_HISTORY_KEY, QUERY_SCAN_STATUS_KEY } from '../../constants'; +import useNotices from '../../hooks/use-notices'; + +/** + * Ignore Threat Mutatation + * + * @return {UseMutationResult} useMutation result. + */ +export default function useIgnoreThreatMutation(): UseMutationResult { + const queryClient = useQueryClient(); + const { showSuccessNotice, showErrorNotice } = useNotices(); + + return useMutation( { + mutationFn: async ( threatId: number ) => { + const response = await API.ignoreThreat( threatId ); + + // Refetch the scan status and history queries as a part of the mutation function. + // This keeps the mutator in a loading state until the side effects of the mutation are handled. + await Promise.all( [ + queryClient.refetchQueries( { queryKey: [ QUERY_SCAN_STATUS_KEY ] } ), + queryClient.refetchQueries( { queryKey: [ QUERY_HISTORY_KEY ] } ), + ] ); + + return response; + }, + onSuccess: () => { + showSuccessNotice( __( 'Threat ignored.', 'jetpack-protect' ) ); + }, + onError: () => { + showErrorNotice( __( 'An error occurred ignoring the threat.', 'jetpack-protect' ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/scan/use-scan-status-query.ts b/projects/plugins/protect/src/js/data/scan/use-scan-status-query.ts new file mode 100644 index 0000000000000..e01d5fcfb1213 --- /dev/null +++ b/projects/plugins/protect/src/js/data/scan/use-scan-status-query.ts @@ -0,0 +1,63 @@ +import { useConnection } from '@automattic/jetpack-connection'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import camelize from 'camelize'; +import API from '../../api'; +import { + SCAN_IN_PROGRESS_STATUSES, + SCAN_STATUS_IDLE, + SCAN_STATUS_UNAVAILABLE, +} from '../../constants'; +import { ScanStatus } from '../../types/scans'; +import { QUERY_SCAN_STATUS_KEY } from './../../constants'; + +export const isScanInProgress = ( status: ScanStatus ) => { + // If there has never been a scan, and the scan status is idle or unavailable, then we must still be getting set up. + const scanIsInitializing = + ! status?.lastChecked && + [ SCAN_STATUS_IDLE, SCAN_STATUS_UNAVAILABLE ].includes( status?.status ); + + const scanIsInProgress = SCAN_IN_PROGRESS_STATUSES.indexOf( status?.status ) >= 0; + + return scanIsInitializing || scanIsInProgress; +}; + +/** + * Use Scan Status Query + * + * @param {object} args - Hook arguments. + * @param {boolean} args.usePolling - When enabled, the query will poll for updates when the scan is in progress. + * + * @return {UseQueryResult} useQuery result. + */ +export default function useScanStatusQuery( { + usePolling, +}: { usePolling?: boolean } = {} ): UseQueryResult< ScanStatus > { + const { isRegistered } = useConnection( { + autoTrigger: false, + from: 'protect', + redirectUri: null, + skipUserConnection: true, + } ); + + return useQuery( { + queryKey: [ QUERY_SCAN_STATUS_KEY ], + queryFn: API.getScanStatus, + initialData: camelize( window?.jetpackProtectInitialState?.status ), + enabled: isRegistered, + refetchInterval( query ) { + if ( ! usePolling ) { + return false; + } + + // Refetch on a shorter interval for the first few updates. + const interval = query.state.dataUpdateCount < 5 ? 5_000 : 15_000; + + // Refetch when scanning. + if ( isScanInProgress( query.state.data ) ) { + return interval; + } + + return false; + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/scan/use-start-scan-mutation.ts b/projects/plugins/protect/src/js/data/scan/use-start-scan-mutation.ts new file mode 100644 index 0000000000000..8f65b0410f83d --- /dev/null +++ b/projects/plugins/protect/src/js/data/scan/use-start-scan-mutation.ts @@ -0,0 +1,33 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import API from '../../api'; +import { QUERY_SCAN_STATUS_KEY, SCAN_STATUS_OPTIMISTICALLY_SCANNING } from './../../constants'; + +/** + * Use Start Scan Mutation + * + * @return {UseMutationResult} Mutation result. + */ +export default function useStartScanMutation(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: API.scan, + onMutate() { + // Optimistically update the scan status to 'scanning'. + queryClient.setQueryData( [ QUERY_SCAN_STATUS_KEY ], ( currentStatus: object ) => ( { + ...currentStatus, + status: SCAN_STATUS_OPTIMISTICALLY_SCANNING, + } ) ); + }, + onSuccess() { + // The scan has been enqueued successfully, ensure the scan status is still 'scanning'. + queryClient.setQueryData( [ QUERY_SCAN_STATUS_KEY ], ( currentStatus: object ) => ( { + ...currentStatus, + status: SCAN_STATUS_OPTIMISTICALLY_SCANNING, + } ) ); + }, + onError() { + // The scan failed to enqueue, invalidate the scan status query to reset the current status. + queryClient.invalidateQueries( { queryKey: [ QUERY_SCAN_STATUS_KEY ] } ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/scan/use-unignore-threat-mutation.ts b/projects/plugins/protect/src/js/data/scan/use-unignore-threat-mutation.ts new file mode 100644 index 0000000000000..fedae11299c8b --- /dev/null +++ b/projects/plugins/protect/src/js/data/scan/use-unignore-threat-mutation.ts @@ -0,0 +1,36 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import API from '../../api'; +import { QUERY_HISTORY_KEY, QUERY_SCAN_STATUS_KEY } from '../../constants'; +import useNotices from '../../hooks/use-notices'; + +/** + * Use Un-Ignore Threat Mutatation + * + * @return {UseMutationResult} Mutation result. + */ +export default function useUnIgnoreThreatMutation(): UseMutationResult { + const queryClient = useQueryClient(); + const { showSuccessNotice, showErrorNotice } = useNotices(); + + return useMutation( { + mutationFn: async ( threatId: number ) => { + const response = await API.unIgnoreThreat( threatId ); + + // Refetch the scan status and history queries as a part of the mutation function. + // This keeps the mutator in a loading state until the side effects of the mutation are handled. + await Promise.all( [ + queryClient.refetchQueries( { queryKey: [ QUERY_SCAN_STATUS_KEY ] } ), + queryClient.refetchQueries( { queryKey: [ QUERY_HISTORY_KEY ] } ), + ] ); + + return response; + }, + onSuccess: () => { + showSuccessNotice( __( 'Threat is no longer ignored.', 'jetpack-protect' ) ); + }, + onError: () => { + showErrorNotice( __( 'An error occurred un-ignoring the threat.', 'jetpack-protect' ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/use-connection-mutation.ts b/projects/plugins/protect/src/js/data/use-connection-mutation.ts new file mode 100644 index 0000000000000..90d8481f65edd --- /dev/null +++ b/projects/plugins/protect/src/js/data/use-connection-mutation.ts @@ -0,0 +1,52 @@ +import { useConnection } from '@automattic/jetpack-connection'; +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import { + QUERY_CREDENTIALS_KEY, + QUERY_HAS_PLAN_KEY, + QUERY_HISTORY_KEY, + QUERY_SCAN_STATUS_KEY, + QUERY_WAF_KEY, + SCAN_STATUS_OPTIMISTICALLY_SCANNING, +} from '../constants'; +import useNotices from '../hooks/use-notices'; +import { ScanStatus } from '../types/scans'; + +/** + * Connect Site Mutation + * + * Mutation hook that triggers the Jetpack connection process for a site. + * + * @return {UseMutationResult} useMutation result. + */ +export default function useConnectSiteMutation(): UseMutationResult { + const queryClient = useQueryClient(); + const { showErrorNotice } = useNotices(); + + const { handleRegisterSite } = useConnection( { + autoTrigger: false, + from: 'protect', + redirectUri: null, + skipUserConnection: true, + } ); + + return useMutation( { + mutationFn: handleRegisterSite, + onSuccess: async () => { + // Optimistically update the scan status to 'scanning'. + queryClient.setQueryData( [ QUERY_SCAN_STATUS_KEY ], ( scanStatus: ScanStatus ) => ( { + ...scanStatus, + status: SCAN_STATUS_OPTIMISTICALLY_SCANNING, + } ) ); + + // Invalidate all queries that depend on the connection status. + queryClient.invalidateQueries( { queryKey: [ QUERY_HISTORY_KEY ] } ); + queryClient.invalidateQueries( { queryKey: [ QUERY_WAF_KEY ] } ); + queryClient.invalidateQueries( { queryKey: [ QUERY_HAS_PLAN_KEY ] } ); + queryClient.invalidateQueries( { queryKey: [ QUERY_CREDENTIALS_KEY ] } ); + }, + onError: () => { + showErrorNotice( __( 'Could not connect site.', 'jetpack-protect' ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/use-credentials-query.ts b/projects/plugins/protect/src/js/data/use-credentials-query.ts new file mode 100644 index 0000000000000..431f5c9045751 --- /dev/null +++ b/projects/plugins/protect/src/js/data/use-credentials-query.ts @@ -0,0 +1,43 @@ +import { useConnection } from '@automattic/jetpack-connection'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import API from '../api'; +import { QUERY_CREDENTIALS_KEY } from '../constants'; + +/** + * Credentials Query Hook + * + * @param {object} args - Args. + * @param {boolean} args.usePolling - Use polling. + * + * @return {UseQueryResult} useQuery result. + */ +export default function useCredentialsQuery( { + usePolling, +}: { usePolling?: boolean } = {} ): UseQueryResult< [ Record< string, unknown > ] > { + const { isRegistered } = useConnection( { + autoTrigger: false, + from: 'protect', + redirectUri: null, + skipUserConnection: true, + } ); + + return useQuery( { + queryKey: [ QUERY_CREDENTIALS_KEY ], + queryFn: API.checkCredentials, + initialData: window?.jetpackProtectInitialState?.credentials, + refetchInterval: query => { + if ( ! usePolling ) { + return false; + } + if ( ! query.state.data ) { + return false; + } + if ( query.state.data?.length ) { + return false; + } + + return 5_000; + }, + enabled: isRegistered, + } ); +} diff --git a/projects/plugins/protect/src/js/data/use-has-plan-query.ts b/projects/plugins/protect/src/js/data/use-has-plan-query.ts new file mode 100644 index 0000000000000..7adaea60a8d26 --- /dev/null +++ b/projects/plugins/protect/src/js/data/use-has-plan-query.ts @@ -0,0 +1,25 @@ +import { useConnection } from '@automattic/jetpack-connection'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import API from '../api'; +import { QUERY_HAS_PLAN_KEY } from '../constants'; + +/** + * Plan Query Hook + * + * @return {UseQueryResult} useQuery result. + */ +export default function usePlanQuery(): UseQueryResult { + const { isRegistered } = useConnection( { + autoTrigger: false, + from: 'protect', + redirectUri: null, + skipUserConnection: true, + } ); + + return useQuery( { + queryKey: [ QUERY_HAS_PLAN_KEY ], + queryFn: API.checkPlan, + initialData: !! window?.jetpackProtectInitialState?.hasPlan, + enabled: isRegistered, + } ); +} diff --git a/projects/plugins/protect/src/js/data/use-product-data-query.ts b/projects/plugins/protect/src/js/data/use-product-data-query.ts new file mode 100644 index 0000000000000..902733ae9d00a --- /dev/null +++ b/projects/plugins/protect/src/js/data/use-product-data-query.ts @@ -0,0 +1,17 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import camelize from 'camelize'; +import API from '../api'; +import { QUERY_PRODUCT_DATA_KEY } from '../constants'; + +/** + * Product Data Query Hook + * + * @return {UseQueryResult} useQuery result. + */ +export default function useProductDataQuery(): UseQueryResult { + return useQuery( { + queryKey: [ QUERY_PRODUCT_DATA_KEY ], + queryFn: API.getProductData, + initialData: camelize( window?.jetpackProtectInitialState?.jetpackScan ), + } ); +} diff --git a/projects/plugins/protect/src/js/data/waf/use-toggle-waf-module-mutation.ts b/projects/plugins/protect/src/js/data/waf/use-toggle-waf-module-mutation.ts new file mode 100644 index 0000000000000..2313f67b5cc6f --- /dev/null +++ b/projects/plugins/protect/src/js/data/waf/use-toggle-waf-module-mutation.ts @@ -0,0 +1,28 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import API from '../../api'; +import { QUERY_WAF_KEY } from '../../constants'; +import useNotices from '../../hooks/use-notices'; + +/** + * Toggle WAF Mutatation + * + * @return {UseMutationResult} useMutation result. + */ +export default function useToggleWafMutation(): UseMutationResult { + const queryClient = useQueryClient(); + const { showSuccessNotice, showErrorNotice } = useNotices(); + + return useMutation( { + mutationFn: API.toggleWaf, + onSuccess: () => { + showSuccessNotice( __( 'WAF module enabled.', 'jetpack-protect' ) ); + }, + onError: () => { + showErrorNotice( __( 'An error occurred enabling the WAF module.', 'jetpack-protect' ) ); + }, + onSettled: () => { + queryClient.invalidateQueries( { queryKey: [ QUERY_WAF_KEY ] } ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/waf/use-waf-mutation.ts b/projects/plugins/protect/src/js/data/waf/use-waf-mutation.ts new file mode 100644 index 0000000000000..a89d0abb9dd5f --- /dev/null +++ b/projects/plugins/protect/src/js/data/waf/use-waf-mutation.ts @@ -0,0 +1,73 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import camelize from 'camelize'; +import API from '../../api'; +import { QUERY_WAF_KEY } from '../../constants'; +import useNotices from '../../hooks/use-notices'; +import { WafStatus } from '../../types/waf'; + +/** + * WAF Mutatation Hook + * + * @return {UseMutationResult} useMutation result. + */ +export default function useWafMutation(): UseMutationResult< + unknown, + { [ key: string ]: unknown }, + unknown, + { initialValue: WafStatus } +> { + const queryClient = useQueryClient(); + const { showSuccessNotice, showSavingNotice, showErrorNotice } = useNotices(); + + /** + * Get a custom error message based on the error code. + * + * @param {object} error - Error object. + * @return string|bool Custom error message or false if no custom message exists. + */ + const getCustomErrorMessage = useCallback( ( error: { [ key: string ]: unknown } ) => { + switch ( error.code ) { + case 'file_system_error': + return __( 'A filesystem error occurred.', 'jetpack-protect' ); + case 'rules_api_error': + return __( + 'An error occurred retrieving the latest firewall rules from Jetpack.', + 'jetpack-protect' + ); + default: + return __( 'An error occurred.', 'jetpack-protect' ); + } + }, [] ); + + return useMutation( { + mutationFn: API.updateWaf, + onMutate: config => { + showSavingNotice(); + + // Get the current WAF config. + const initialValue = queryClient.getQueryData( [ QUERY_WAF_KEY ] ) as WafStatus; + + // Optimistically update the WAF config. + queryClient.setQueryData( [ QUERY_WAF_KEY ], ( wafStatus: WafStatus ) => ( { + ...wafStatus, + config: { + ...wafStatus.config, + ...camelize( config ), + }, + } ) ); + + return { initialValue }; + }, + onSuccess: () => { + showSuccessNotice( __( 'Changes saved.', 'jetpack-protect' ) ); + }, + onError: ( error, variables, context ) => { + // Reset the WAF config to its previous state. + queryClient.setQueryData( [ QUERY_WAF_KEY ], context.initialValue ); + + showErrorNotice( getCustomErrorMessage( error ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/waf/use-waf-query.ts b/projects/plugins/protect/src/js/data/waf/use-waf-query.ts new file mode 100644 index 0000000000000..f8d9cce28bca1 --- /dev/null +++ b/projects/plugins/protect/src/js/data/waf/use-waf-query.ts @@ -0,0 +1,18 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import camelize from 'camelize'; +import API from '../../api'; +import { QUERY_WAF_KEY } from '../../constants'; +import { WafStatus } from '../../types/waf'; + +/** + * WAF Query Hook + * + * @return {UseQueryResult} useQuery result. + */ +export default function useWafQuery(): UseQueryResult< WafStatus > { + return useQuery( { + queryKey: [ QUERY_WAF_KEY ], + queryFn: API.getWaf, + initialData: camelize( window?.jetpackProtectInitialState?.waf ), + } ); +} diff --git a/projects/plugins/protect/src/js/data/waf/use-waf-seen-mutation.ts b/projects/plugins/protect/src/js/data/waf/use-waf-seen-mutation.ts new file mode 100644 index 0000000000000..0485a01f525aa --- /dev/null +++ b/projects/plugins/protect/src/js/data/waf/use-waf-seen-mutation.ts @@ -0,0 +1,21 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import API from '../../api'; +import { QUERY_WAF_KEY } from '../../constants'; + +/** + * WAF Seen Mutation Hook + * + * @return {UseMutationResult} - Mutation result. + */ +export default function useWafSeenMutation(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: API.wafSeen, + onMutate: () => { + queryClient.setQueryData( [ QUERY_WAF_KEY ], ( currentWaf: object ) => ( { + ...currentWaf, + isSeen: true, + } ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/waf/use-waf-upgrade-seen-mutation.ts b/projects/plugins/protect/src/js/data/waf/use-waf-upgrade-seen-mutation.ts new file mode 100644 index 0000000000000..794581756ec99 --- /dev/null +++ b/projects/plugins/protect/src/js/data/waf/use-waf-upgrade-seen-mutation.ts @@ -0,0 +1,21 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import API from '../../api'; +import { QUERY_WAF_KEY } from '../../constants'; + +/** + * WAF Upgrade Seen Mutation + * + * @return {UseMutationResult} - Mutation result. + */ +export default function useWafUpgradeSeenMutation(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: API.wafUpgradeSeen, + onMutate: () => { + queryClient.setQueryData( [ QUERY_WAF_KEY ], ( currentWaf: object ) => ( { + ...currentWaf, + upgradeIsSeen: true, + } ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/global.d.ts b/projects/plugins/protect/src/js/global.d.ts deleted file mode 100644 index d5cf927a7cd3e..0000000000000 --- a/projects/plugins/protect/src/js/global.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '*.scss'; diff --git a/projects/plugins/protect/src/js/hooks/use-fixers.ts b/projects/plugins/protect/src/js/hooks/use-fixers.ts new file mode 100644 index 0000000000000..6c7c8062e98f1 --- /dev/null +++ b/projects/plugins/protect/src/js/hooks/use-fixers.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import useFixersMutation from '../data/scan/use-fixers-mutation'; +import useFixersQuery from '../data/scan/use-fixers-query'; +import useScanStatusQuery from '../data/scan/use-scan-status-query'; +import { FixersStatus } from '../types/fixers'; +import { Threat } from '../types/threats'; + +type UseFixersResult = { + fixableThreats: Threat[]; + fixersStatus: FixersStatus; + fixThreats: ( threatIds: number[] ) => Promise< unknown >; + isLoading: boolean; +}; + +/** + * Use Fixers Hook + * + * @return {UseFixersResult} Fixers object + */ +export default function useFixers(): UseFixersResult { + const { data: status } = useScanStatusQuery(); + const fixersMutation = useFixersMutation(); + + const fixableThreats = useMemo( () => { + const threats = [ + ...( status?.core?.threats || [] ), + ...( status?.plugins?.map( plugin => plugin.threats ).flat() || [] ), + ...( status?.themes?.map( theme => theme.threats ).flat() || [] ), + ...( status?.files || [] ), + ...( status?.database || [] ), + ]; + + return threats.filter( threat => threat.fixable ); + }, [ status ] ); + + const { data: fixersStatus } = useFixersQuery( { + threatIds: fixableThreats.map( threat => threat.id ), + usePolling: true, + } ); + + return { + fixableThreats, + fixersStatus, + fixThreats: fixersMutation.mutateAsync, + isLoading: fixersMutation.isPending, + }; +} diff --git a/projects/plugins/protect/src/js/hooks/use-modal.tsx b/projects/plugins/protect/src/js/hooks/use-modal.tsx new file mode 100644 index 0000000000000..5718348cc3d01 --- /dev/null +++ b/projects/plugins/protect/src/js/hooks/use-modal.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { createContext, useContext, useState } from 'react'; + +interface ModalState { + type?: string; + props?: Record< string, unknown >; +} + +interface ModalContextValue { + modal: ModalState | null; + setModal: React.Dispatch< React.SetStateAction< ModalState | null > > | null; +} + +const ModalContext = createContext< ModalContextValue >( { modal: null, setModal: null } ); + +export const ModalProvider: React.FC< { children: React.ReactNode } > = ( { children } ) => { + const [ modal, setModal ] = useState< ModalState | null >( {} ); + + return { children }; +}; + +/** + * Modal Hook + * + * @return {object} Modals object + */ +export default function useModal() { + const { modal, setModal } = useContext( ModalContext ); + + return { + modal, + setModal, + }; +} diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx new file mode 100644 index 0000000000000..bbc1f888aa3df --- /dev/null +++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx @@ -0,0 +1,101 @@ +import { ExternalLink } from '@wordpress/components'; +import { createInterpolateElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { createContext, useCallback, useContext, useState } from 'react'; +import { FREE_PLUGIN_SUPPORT_URL, PAID_PLUGIN_SUPPORT_URL } from '../constants'; +import usePlan from './use-plan'; + +interface NoticeState { + message?: string | JSX.Element; + dismissable?: boolean; + duration?: number; + type?: 'success' | 'info' | 'error'; +} + +interface NoticeContextValue { + notice: NoticeState; + setNotice: React.Dispatch< React.SetStateAction< NoticeState > >; +} + +const NoticeContext = createContext< NoticeContextValue | undefined >( undefined ); + +export const NoticeProvider: React.FC< { children: React.ReactNode } > = ( { children } ) => { + const [ notice, setNotice ] = useState< NoticeState >( null ); + + return ( + { children } + ); +}; + +/** + * Notices Hook + * + * @return {object} Notices object + */ +export default function useNotices() { + const { hasPlan } = usePlan(); + const { notice, setNotice } = useContext( NoticeContext ); + + const clearNotice = useCallback( () => { + setNotice( null ); + }, [ setNotice ] ); + + const showSuccessNotice = useCallback( + ( message: string ) => { + setNotice( { + type: 'success', + dismissable: true, + duration: 7_500, + message, + } ); + }, + [ setNotice ] + ); + + const showSavingNotice = useCallback( + ( message?: string ) => { + setNotice( { + type: 'info', + dismissable: false, + message: message || __( 'Saving Changes…', 'jetpack-protect' ), + } ); + }, + [ setNotice ] + ); + + const showErrorNotice = useCallback( + ( message: string ) => { + setNotice( { + type: 'error', + dismissable: true, + message: ( + <> + { message || __( 'An error occurred.', 'jetpack-protect' ) }{ ' ' } + { createInterpolateElement( + __( + 'Please try again or contact support.', + 'jetpack-protect' + ), + { + supportLink: ( + + ), + } + ) } + + ), + } ); + }, + [ hasPlan, setNotice ] + ); + + return { + notice, + clearNotice, + showSavingNotice, + showSuccessNotice, + showErrorNotice, + }; +} diff --git a/projects/plugins/protect/src/js/hooks/use-onboarding/index.jsx b/projects/plugins/protect/src/js/hooks/use-onboarding/index.jsx index 73ca3a7144bef..ea7d2330126b1 100644 --- a/projects/plugins/protect/src/js/hooks/use-onboarding/index.jsx +++ b/projects/plugins/protect/src/js/hooks/use-onboarding/index.jsx @@ -1,7 +1,6 @@ -import { useDispatch, useSelect } from '@wordpress/data'; -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import API from '../../api'; -import { STORE_ID } from '../../state/store'; +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import useOnboardingProgressMutation from '../../data/onboarding/use-onboarding-progress-mutator'; +import useOnboardingProgressQuery from '../../data/onboarding/use-onboarding-progress-query'; export const OnboardingContext = createContext( [] ); export const OnboardingRenderedContext = createContext( [] ); @@ -17,13 +16,11 @@ export const OnboardingRenderedContextProvider = ( { children } ) => { }; const useOnboarding = () => { - const { completeOnboardingSteps, fetchOnboardingProgress } = API; - const steps = useContext( OnboardingContext ); const { renderedSteps } = useContext( OnboardingRenderedContext ); - const progress = useSelect( select => select( STORE_ID ).getOnboardingProgress() ); - const { setOnboardingProgress } = useDispatch( STORE_ID ); + const { data: progress } = useOnboardingProgressQuery(); + const onboardingProgressMutation = useOnboardingProgressMutation(); /** * Current Step @@ -52,12 +49,9 @@ const useOnboarding = () => { const completeCurrentStep = useCallback( () => { if ( currentStep ) { - // Complete the step immediately in the UI - setOnboardingProgress( [ ...progress, currentStep.id ] ); - // Save the completion in the background - completeOnboardingSteps( [ currentStep.id ] ); + onboardingProgressMutation.mutate( [ currentStep.id ] ); } - }, [ currentStep, setOnboardingProgress, progress, completeOnboardingSteps ] ); + }, [ currentStep, onboardingProgressMutation ] ); /** * Complete All Free Steps @@ -70,12 +64,8 @@ const useOnboarding = () => { return carry; }, [] ); - // Complete the free steps immediately in the UI - const combinedProgress = [ ...progress, ...freeStepIds ]; - setOnboardingProgress( [ ...new Set( combinedProgress ) ] ); - // Save the completions in the background - completeOnboardingSteps( freeStepIds ); - }, [ steps, progress, setOnboardingProgress, completeOnboardingSteps ] ); + onboardingProgressMutation.mutate( freeStepIds ); + }, [ steps, onboardingProgressMutation ] ); /** * Complete All Paid Steps @@ -88,12 +78,8 @@ const useOnboarding = () => { return carry; }, [] ); - // Complete the paid steps immediately in the UI - const combinedProgress = [ ...progress, ...paidStepIds ]; - setOnboardingProgress( [ ...new Set( combinedProgress ) ] ); - // Save the completions in the background - completeOnboardingSteps( paidStepIds ); - }, [ steps, progress, setOnboardingProgress, completeOnboardingSteps ] ); + onboardingProgressMutation.mutate( paidStepIds ); + }, [ steps, onboardingProgressMutation ] ); /** * Complete All Current Steps @@ -107,12 +93,6 @@ const useOnboarding = () => { } }, [ completeAllFreeSteps, completeAllPaidSteps, currentStep ] ); - useEffect( () => { - if ( null === progress ) { - fetchOnboardingProgress().then( latestProgress => setOnboardingProgress( latestProgress ) ); - } - }, [ fetchOnboardingProgress, progress, setOnboardingProgress ] ); - return { progress, stepsCount, diff --git a/projects/plugins/protect/src/js/hooks/use-plan.tsx b/projects/plugins/protect/src/js/hooks/use-plan.tsx new file mode 100644 index 0000000000000..b5ab18da01875 --- /dev/null +++ b/projects/plugins/protect/src/js/hooks/use-plan.tsx @@ -0,0 +1,72 @@ +import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; +import { createContext, useContext, useState, useCallback } from 'react'; +import API from '../api'; +import { JETPACK_SCAN_SLUG } from '../constants'; +import usePlanQuery from '../data/use-has-plan-query'; + +type CheckoutContextType = { + hasCheckoutStarted: boolean; + setHasCheckoutStarted: ( hasCheckoutStarted: boolean ) => void; +}; + +const CheckoutContext = createContext< CheckoutContextType >( { + hasCheckoutStarted: false, + setHasCheckoutStarted: () => {}, +} ); + +export const CheckoutProvider = ( { children } ) => { + const [ hasCheckoutStarted, setHasCheckoutStarted ] = useState( false ); + + return ( + + { children } + + ); +}; + +export const useCheckoutContext = () => useContext( CheckoutContext ); + +/** + * Plan hook. + * + * Provides data and functions related to the site's current plan. + * + * @param {object} props - Hook props. + * @param {string} props.redirectUrl - Post-checkout redirect URL. + * + * @return {object} Hook data + */ +export default function usePlan( { redirectUrl }: { redirectUrl?: string } = {} ) { + const { adminUrl } = window.jetpackProtectInitialState || {}; + const { data: hasPlan, isLoading: isPlanLoading } = usePlanQuery(); + const { hasCheckoutStarted, setHasCheckoutStarted } = useCheckoutContext(); + + const { run: checkout } = useProductCheckoutWorkflow( { + productSlug: JETPACK_SCAN_SLUG, + redirectUrl: redirectUrl || adminUrl, + siteProductAvailabilityHandler: API.checkPlan, + useBlogIdSuffix: true, + connectAfterCheckout: false, + from: () => 'protect', + } ) as unknown as { + run: ( event?: Event, redirect?: string ) => void; + isRegistered: boolean; + hasCheckoutStarted: boolean; + }; + + const upgradePlan = useCallback( () => { + setHasCheckoutStarted( true ); + checkout(); + }, [ checkout, setHasCheckoutStarted ] ); + + return { + hasPlan, + upgradePlan, + isLoading: isPlanLoading || hasCheckoutStarted, + }; +} diff --git a/projects/plugins/protect/src/js/hooks/use-protect-data/index.js b/projects/plugins/protect/src/js/hooks/use-protect-data/index.js index 1ce57b341678c..91daaf6a468e6 100644 --- a/projects/plugins/protect/src/js/hooks/use-protect-data/index.js +++ b/projects/plugins/protect/src/js/hooks/use-protect-data/index.js @@ -1,7 +1,8 @@ -import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { useMemo } from 'react'; -import { STORE_ID } from '../../state/store'; +import useHistoryQuery from '../../data/scan/use-history-query'; +import useScanStatusQuery from '../../data/scan/use-scan-status-query'; +import useProductDataQuery from '../../data/use-product-data-query'; // Valid "key" values for filtering. const KEY_FILTERS = [ 'all', 'core', 'plugins', 'themes', 'files', 'database' ]; @@ -50,12 +51,9 @@ export default function useProtectData( filter: { status: null, key: null }, } ) { - const { status, scanHistory, jetpackScan, hasRequiredPlan } = useSelect( select => ( { - status: select( STORE_ID ).getStatus(), - scanHistory: select( STORE_ID ).getScanHistory(), - jetpackScan: select( STORE_ID ).getJetpackScan(), - hasRequiredPlan: select( STORE_ID ).hasRequiredPlan(), - } ) ); + const { data: status } = useScanStatusQuery(); + const { data: scanHistory } = useHistoryQuery(); + const { data: jetpackScan } = useProductDataQuery(); const { counts, results, error, lastChecked, hasUncheckedItems } = useMemo( () => { // This hook can provide data from two sources: the current scan or the scan history. @@ -166,6 +164,5 @@ export default function useProtectData( lastChecked, hasUncheckedItems, jetpackScan, - hasRequiredPlan, }; } diff --git a/projects/plugins/protect/src/js/hooks/use-waf-data/index.jsx b/projects/plugins/protect/src/js/hooks/use-waf-data/index.jsx index f0eccf83fb8e3..5912439b893d3 100644 --- a/projects/plugins/protect/src/js/hooks/use-waf-data/index.jsx +++ b/projects/plugins/protect/src/js/hooks/use-waf-data/index.jsx @@ -1,7 +1,8 @@ -import { useDispatch, useSelect } from '@wordpress/data'; -import { useCallback, useEffect } from 'react'; -import API from '../../api'; -import { STORE_ID } from '../../state/store'; +import { useCallback } from 'react'; +import useToggleWafMutation from '../../data/waf/use-toggle-waf-module-mutation'; +import useWafMutation from '../../data/waf/use-waf-mutation'; +import useWafQuery from '../../data/waf/use-waf-query'; +import useAnalyticsTracks from '../use-analytics-tracks'; /** * Use WAF Data Hook @@ -9,53 +10,29 @@ import { STORE_ID } from '../../state/store'; * @return {object} WAF data and methods for interacting with it. */ const useWafData = () => { - const { setWafConfig, setWafStats, setWafIsEnabled, setWafIsUpdating, setWafIsToggling } = - useDispatch( STORE_ID ); - const waf = useSelect( select => select( STORE_ID ).getWaf() ); - - /** - * Refresh WAF Configuration - * - * Fetches the firewall data and updates it in application state. - */ - const refreshWaf = useCallback( () => { - setWafIsUpdating( true ); - return API.fetchWaf() - .then( response => { - setWafIsEnabled( response?.isEnabled ); - setWafConfig( response?.config ); - setWafStats( response?.stats ); - } ) - .finally( () => setWafIsUpdating( false ) ); - }, [ setWafConfig, setWafStats, setWafIsEnabled, setWafIsUpdating ] ); + const { recordEvent } = useAnalyticsTracks(); + const { data: waf } = useWafQuery(); + const wafMutation = useWafMutation(); + const toggleWafMutation = useToggleWafMutation(); /** * Toggle WAF Module * * Flips the switch on the WAF module, and then refreshes the data. */ - const toggleWaf = useCallback( () => { - if ( ! waf.isEnabled ) { - setWafIsToggling( true ); - } - setWafIsUpdating( true ); - return API.toggleWaf() - .then( refreshWaf ) - .finally( () => { - setWafIsToggling( false ); - setWafIsUpdating( false ); - } ); - }, [ refreshWaf, waf.isEnabled, setWafIsToggling, setWafIsUpdating ] ); + const toggleWaf = useCallback( async () => { + toggleWafMutation.mutate(); + }, [ toggleWafMutation ] ); /** * Ensure WAF Module Is Enabled */ - const ensureModuleIsEnabled = useCallback( () => { + const ensureModuleIsEnabled = useCallback( async () => { if ( ! waf.isEnabled ) { - return toggleWaf(); + return await toggleWaf(); } - return Promise.resolve(); + return true; }, [ toggleWaf, waf.isEnabled ] ); /** @@ -63,117 +40,133 @@ const useWafData = () => { * * Flips the switch on the WAF automatic rules feature, and then refreshes the data. */ - const toggleAutomaticRules = useCallback( () => { - setWafIsUpdating( true ); - return ensureModuleIsEnabled() - .then( () => - API.updateWaf( { jetpack_waf_automatic_rules: ! waf.config.jetpackWafAutomaticRules } ) - ) - .then( refreshWaf ) - .finally( () => setWafIsUpdating( false ) ); - }, [ ensureModuleIsEnabled, refreshWaf, setWafIsUpdating, waf.config.jetpackWafAutomaticRules ] ); + const toggleAutomaticRules = useCallback( async () => { + const value = ! waf.config.jetpackWafAutomaticRules; + await ensureModuleIsEnabled(); + await wafMutation.mutateAsync( { + jetpack_waf_automatic_rules: value, + } ); + recordEvent( + value ? 'jetpack_protect_automatic_rules_enabled' : 'jetpack_protect_automatic_rules_disabled' + ); + }, [ ensureModuleIsEnabled, recordEvent, waf.config.jetpackWafAutomaticRules, wafMutation ] ); /** * Toggle IP Allow List * * Flips the switch on the WAF IP allow list feature, and then refreshes the data. */ - const toggleIpAllowList = useCallback( () => { - setWafIsUpdating( true ); - return API.updateWaf( { - jetpack_waf_ip_allow_list_enabled: ! waf.config.jetpackWafIpAllowListEnabled, - } ) - .then( refreshWaf ) - .finally( () => setWafIsUpdating( false ) ); - }, [ refreshWaf, setWafIsUpdating, waf.config.jetpackWafIpAllowListEnabled ] ); + const toggleIpAllowList = useCallback( async () => { + const value = ! waf.config.jetpackWafIpAllowListEnabled; + await wafMutation.mutateAsync( { + jetpack_waf_ip_allow_list_enabled: value, + } ); + recordEvent( + value ? 'jetpack_protect_ip_allow_list_enabled' : 'jetpack_protect_ip_allow_list_disabled' + ); + }, [ recordEvent, waf.config.jetpackWafIpAllowListEnabled, wafMutation ] ); + + /** + * Save IP Allow List + */ + const saveIpAllowList = useCallback( + async value => { + await wafMutation.mutateAsync( { + jetpack_waf_ip_allow_list: value, + } ); + recordEvent( 'jetpack_protect_ip_allow_list_updated' ); + }, + [ recordEvent, wafMutation ] + ); /** * Toggle IP Block List * * Flips the switch on the WAF IP block list feature, and then refreshes the data. */ - const toggleIpBlockList = useCallback( () => { - setWafIsUpdating( true ); - return API.updateWaf( { - jetpack_waf_ip_block_list_enabled: ! waf.config.jetpackWafIpBlockListEnabled, - } ) - .then( refreshWaf ) - .finally( () => setWafIsUpdating( false ) ); - }, [ refreshWaf, setWafIsUpdating, waf.config.jetpackWafIpBlockListEnabled ] ); + const toggleIpBlockList = useCallback( async () => { + const value = ! waf.config.jetpackWafIpBlockListEnabled; + await ensureModuleIsEnabled(); + await wafMutation.mutateAsync( { + jetpack_waf_ip_block_list_enabled: value, + } ); + recordEvent( + value ? 'jetpack_protect_ip_block_list_enabled' : 'jetpack_protect_ip_block_list_disabled' + ); + }, [ ensureModuleIsEnabled, recordEvent, waf.config.jetpackWafIpBlockListEnabled, wafMutation ] ); + + /** + * Save IP Block List + */ + const saveIpBlockList = useCallback( + async value => { + await ensureModuleIsEnabled(); + await wafMutation.mutateAsync( { + jetpack_waf_ip_block_list: value, + } ); + recordEvent( 'jetpack_protect_ip_block_list_updated' ); + }, + [ ensureModuleIsEnabled, wafMutation, recordEvent ] + ); /** * Toggle Brute Force Protection * * Flips the switch on the brute force protection feature, and then refreshes the data. */ - const toggleBruteForceProtection = useCallback( () => { - setWafIsUpdating( true ); - return API.updateWaf( { brute_force_protection: ! waf.config.bruteForceProtection } ) - .then( refreshWaf ) - .finally( () => setWafIsUpdating( false ) ); - }, [ refreshWaf, setWafIsUpdating, waf.config.bruteForceProtection ] ); + const toggleBruteForceProtection = useCallback( async () => { + const value = ! waf.config.bruteForceProtection; + await wafMutation.mutateAsync( { brute_force_protection: value } ); + recordEvent( + value + ? 'jetpack_protect_brute_force_protection_enabled' + : 'jetpack_protect_brute_force_protection_disabled' + ); + }, [ recordEvent, waf.config.bruteForceProtection, wafMutation ] ); /** * Toggle Share Data * * Flips the switch on the share data option, and then refreshes the data. */ - const toggleShareData = useCallback( () => { - setWafIsUpdating( true ); - return ensureModuleIsEnabled() - .then( () => API.updateWaf( { jetpack_waf_share_data: ! waf.config.jetpackWafShareData } ) ) - .then( refreshWaf ) - .finally( () => setWafIsUpdating( false ) ); - }, [ ensureModuleIsEnabled, refreshWaf, setWafIsUpdating, waf.config.jetpackWafShareData ] ); + const toggleShareData = useCallback( async () => { + const value = ! waf.config.jetpackWafShareData; + await wafMutation.mutateAsync( { jetpack_waf_share_data: value } ); + recordEvent( + value ? 'jetpack_protect_share_data_enabled' : 'jetpack_protect_share_data_disabled' + ); + }, [ recordEvent, waf.config.jetpackWafShareData, wafMutation ] ); /** * Toggle Share Debug Data * * Flips the switch on the share debug data option, and then refreshes the data. */ - const toggleShareDebugData = useCallback( () => { - setWafIsUpdating( true ); - return ensureModuleIsEnabled() - .then( () => - API.updateWaf( { jetpack_waf_share_debug_data: ! waf.config.jetpackWafShareDebugData } ) - ) - .then( refreshWaf ) - .finally( () => setWafIsUpdating( false ) ); - }, [ ensureModuleIsEnabled, refreshWaf, setWafIsUpdating, waf.config.jetpackWafShareDebugData ] ); - - /** - * Update WAF Config - */ - const updateConfig = useCallback( - update => { - setWafIsUpdating( true ); - return API.updateWaf( update ) - .then( refreshWaf ) - .finally( () => setWafIsUpdating( false ) ); - }, - [ refreshWaf, setWafIsUpdating ] - ); - - /** - * Ensures the WAF data is loaded each time the hook is used. - */ - useEffect( () => { - if ( waf.config === undefined && ! waf.isFetching ) { - refreshWaf(); - } - }, [ waf.config, waf.isFetching, setWafIsUpdating, refreshWaf ] ); + const toggleShareDebugData = useCallback( async () => { + const value = ! waf.config.jetpackWafShareDebugData; + await wafMutation.mutateAsync( { + jetpack_waf_share_debug_data: value, + } ); + recordEvent( + value + ? 'jetpack_protect_share_debug_data_enabled' + : 'jetpack_protect_share_debug_data_disabled' + ); + }, [ recordEvent, waf.config.jetpackWafShareDebugData, wafMutation ] ); return { ...waf, - refreshWaf, + isUpdating: wafMutation.isPending, + isToggling: toggleWafMutation.isPending, toggleWaf, toggleAutomaticRules, toggleIpAllowList, + saveIpAllowList, toggleIpBlockList, + saveIpBlockList, toggleBruteForceProtection, toggleShareData, toggleShareDebugData, - updateConfig, }; }; diff --git a/projects/plugins/protect/src/js/index.tsx b/projects/plugins/protect/src/js/index.tsx index d486dfb530479..b8983d65bb836 100644 --- a/projects/plugins/protect/src/js/index.tsx +++ b/projects/plugins/protect/src/js/index.tsx @@ -1,18 +1,28 @@ import { ThemeProvider } from '@automattic/jetpack-components'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import * as WPElement from '@wordpress/element'; import React, { useEffect } from 'react'; import { HashRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom'; import Modal from './components/modal'; import PaidPlanGate from './components/paid-plan-gate'; +import { ModalProvider } from './hooks/use-modal'; +import { NoticeProvider } from './hooks/use-notices'; import { OnboardingRenderedContextProvider } from './hooks/use-onboarding'; +import { CheckoutProvider } from './hooks/use-plan'; import FirewallRoute from './routes/firewall'; import ScanRoute from './routes/scan'; import ScanHistoryRoute from './routes/scan/history'; -import { initStore } from './state/store'; +import SetupRoute from './routes/setup'; import './styles.module.scss'; -// Initialize Jetpack Protect store -initStore(); +const queryClient = new QueryClient( { + defaultOptions: { + queries: { + staleTime: Infinity, + }, + }, +} ); /** * Component to scroll window to top on route change. @@ -37,35 +47,45 @@ function render() { } const component = ( - - - - - - } /> - - - - } - /> - - - - } - /> - } /> - } /> - - - - - + + + + + + + + + + } /> + } /> + + + + } + /> + + + + } + /> + } /> + } /> + + + + + + + + + + ); WPElement.createRoot( container ).render( component ); } diff --git a/projects/plugins/protect/src/js/routes/firewall/index.jsx b/projects/plugins/protect/src/js/routes/firewall/index.jsx index 4af9d28a64ddb..60bf9b02a4f12 100644 --- a/projects/plugins/protect/src/js/routes/firewall/index.jsx +++ b/projects/plugins/protect/src/js/routes/firewall/index.jsx @@ -7,38 +7,30 @@ import { useBreakpointMatch, Notice as JetpackNotice, } from '@automattic/jetpack-components'; -import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; -import { ExternalLink, Popover } from '@wordpress/components'; -import { useDispatch } from '@wordpress/data'; +import { Popover } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Icon, closeSmall } from '@wordpress/icons'; import moment from 'moment'; import { useCallback, useEffect, useState, useMemo } from 'react'; -import API from '../../api'; import AdminPage from '../../components/admin-page'; import FirewallFooter from '../../components/firewall-footer'; import ConnectedFirewallHeader from '../../components/firewall-header'; import FormToggle from '../../components/form-toggle'; import ScanFooter from '../../components/scan-footer'; import Textarea from '../../components/textarea'; -import { - JETPACK_SCAN_SLUG, - FREE_PLUGIN_SUPPORT_URL, - PAID_PLUGIN_SUPPORT_URL, -} from '../../constants'; +import { FREE_PLUGIN_SUPPORT_URL, PAID_PLUGIN_SUPPORT_URL } from '../../constants'; +import useWafSeenMutation from '../../data/waf/use-waf-seen-mutation'; +import useWafUpgradeSeenMutation from '../../data/waf/use-waf-upgrade-seen-mutation'; import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; -import useProtectData from '../../hooks/use-protect-data'; +import usePlan from '../../hooks/use-plan'; import useWafData from '../../hooks/use-waf-data'; -import { STORE_ID } from '../../state/store'; import styles from './styles.module.scss'; const ADMIN_URL = window?.jetpackProtectInitialState?.adminUrl; -const SUCCESS_NOTICE_DURATION = 5000; const FirewallPage = () => { const [ isSmall ] = useBreakpointMatch( [ 'sm', 'lg' ], [ null, '<' ] ); - const { setWafIsSeen, setWafUpgradeIsSeen, setNotice } = useDispatch( STORE_ID ); const { config: { jetpackWafAutomaticRules, @@ -59,19 +51,17 @@ const FirewallPage = () => { stats: { automaticRulesLastUpdated }, toggleAutomaticRules, toggleIpAllowList, + saveIpAllowList, toggleIpBlockList, + saveIpBlockList, toggleBruteForceProtection, toggleWaf, - updateConfig, } = useWafData(); - const { hasRequiredPlan } = useProtectData(); - const { run: runCheckoutWorkflow } = useProductCheckoutWorkflow( { - productSlug: JETPACK_SCAN_SLUG, - redirectUrl: `${ ADMIN_URL }#/firewall`, - useBlogIdSuffix: true, - } ); - const { recordEventHandler, recordEvent } = useAnalyticsTracks(); - + const { hasPlan } = usePlan(); + const { upgradePlan } = usePlan( { redirectUrl: `${ ADMIN_URL }#/firewall` } ); + const { recordEvent } = useAnalyticsTracks(); + const wafSeenMutation = useWafSeenMutation(); + const wafUpgradeSeenMutation = useWafUpgradeSeenMutation(); /** * Automatic Rules Installation Error State * @@ -85,137 +75,28 @@ const FirewallPage = () => { * @member {object} formState - Current form values. */ const [ formState, setFormState ] = useState( { - jetpack_waf_automatic_rules: jetpackWafAutomaticRules, - jetpack_waf_ip_block_list_enabled: jetpackWafIpBlockListEnabled, - jetpack_waf_ip_allow_list_enabled: jetpackWafIpAllowListEnabled, jetpack_waf_ip_block_list: jetpackWafIpBlockList, jetpack_waf_ip_allow_list: jetpackWafIpAllowList, - brute_force_protection: isBruteForceModuleEnabled, } ); - const [ formIsSubmitting, setFormIsSubmitting ] = useState( false ); - const [ ipAllowListIsUpdating, setIpAllowListIsUpdating ] = useState( false ); - const [ ipBlockListIsUpdating, setIpBlockListIsUpdating ] = useState( false ); - - const canEditFirewallSettings = isWafModuleEnabled && ! formIsSubmitting; - const canToggleAutomaticRules = - isWafModuleEnabled && ( hasRequiredPlan || automaticRulesAvailable ); - const canEditIpAllowList = ! formIsSubmitting && !! formState.jetpack_waf_ip_allow_list_enabled; + const canEditFirewallSettings = isWafModuleEnabled && ! isUpdating; + const canToggleAutomaticRules = isWafModuleEnabled && ( hasPlan || automaticRulesAvailable ); + const canEditIpAllowList = ! isUpdating && jetpackWafIpAllowListEnabled; const ipBlockListHasChanges = formState.jetpack_waf_ip_block_list !== jetpackWafIpBlockList; const ipAllowListHasChanges = formState.jetpack_waf_ip_allow_list !== jetpackWafIpAllowList; const ipBlockListHasContent = !! formState.jetpack_waf_ip_block_list; const ipAllowListHasContent = !! formState.jetpack_waf_ip_allow_list; - const ipBlockListEnabled = isWafModuleEnabled && formState.jetpack_waf_ip_block_list_enabled; - - /** - * Get a custom error message based on the error code. - * - * @param {object} error - Error object. - * @return string|bool Custom error message or false if no custom message exists. - */ - const getCustomErrorMessage = useCallback( error => { - switch ( error.code ) { - case 'file_system_error': - return __( 'A filesystem error occurred.', 'jetpack-protect' ); - case 'rules_api_error': - return __( - 'An error occurred retrieving the latest firewall rules from Jetpack.', - 'jetpack-protect' - ); - default: - return false; - } - }, [] ); - - /** - * Handle errors returned by the API. - */ - const handleApiError = useCallback( - error => { - const errorMessage = - getCustomErrorMessage( error ) || __( 'An error occurred.', 'jetpack-protect' ); - const supportMessage = createInterpolateElement( - __( 'Please try again or contact support.', 'jetpack-protect' ), - { - supportLink: ( - - ), - } - ); - - setNotice( { - type: 'error', - message: ( - <> - { errorMessage } { supportMessage } - - ), - } ); - }, - [ getCustomErrorMessage, setNotice, hasRequiredPlan ] - ); + const ipBlockListEnabled = isWafModuleEnabled && jetpackWafIpBlockListEnabled; /** * Get Scan * * Records an event and then starts the checkout flow for Jetpack Scan */ - const getScan = recordEventHandler( - 'jetpack_protect_waf_page_get_scan_link_click', - runCheckoutWorkflow - ); - - /** - * Save IP Allow List Changes - * - * Updates the WAF settings with the current form state values. - * - * @return void - */ - const saveIpAllowListChanges = useCallback( () => { - setFormIsSubmitting( true ); - setIpAllowListIsUpdating( true ); - updateConfig( formState ) - .then( () => - setNotice( { - type: 'success', - duration: SUCCESS_NOTICE_DURATION, - message: __( 'Allow list changes saved.', 'jetpack-protect' ), - } ) - ) - .catch( handleApiError ) - .finally( () => { - setFormIsSubmitting( false ); - setIpAllowListIsUpdating( false ); - } ); - }, [ updateConfig, formState, handleApiError, setNotice ] ); - - /** - * Save IP Block List Changes - * - * Updates the WAF settings with the current form state values. - * - * @return void - */ - const saveIpBlockListChanges = useCallback( () => { - setFormIsSubmitting( true ); - setIpBlockListIsUpdating( true ); - updateConfig( formState ) - .then( () => - setNotice( { - type: 'success', - duration: SUCCESS_NOTICE_DURATION, - message: __( 'Block list changes saved.', 'jetpack-protect' ), - } ) - ) - .catch( handleApiError ) - .finally( () => { - setFormIsSubmitting( false ); - setIpBlockListIsUpdating( false ); - } ); - }, [ updateConfig, formState, handleApiError, setNotice ] ); + const getScan = useCallback( () => { + recordEvent( 'jetpack_protect_waf_page_get_scan_link_click' ); + upgradePlan(); + }, [ recordEvent, upgradePlan ] ); /** * Handle Change @@ -234,172 +115,69 @@ const FirewallPage = () => { ); /** - * Handle Automatic Rules Change + * Returns an event listener that syncs the target input's value with form state, before calling a callback. * - * Toggles the WAF's automatic rules option. - * - * @return void + * @param {*} callback - The function to call with the input's value. + * @return {Function} - Event listener */ - const handleAutomaticRulesChange = useCallback( () => { - setFormIsSubmitting( true ); - const newValue = ! formState.jetpack_waf_automatic_rules; - setFormState( { - ...formState, - jetpack_waf_automatic_rules: newValue, - } ); - toggleAutomaticRules() - .then( () => { - setAutomaticRulesInstallationError( false ); - setNotice( { - type: 'success', - duration: SUCCESS_NOTICE_DURATION, - message: newValue - ? __( `Automatic firewall protection is enabled.`, 'jetpack-protect' ) - : __( - `Automatic firewall protection is disabled.`, - 'jetpack-protect', - /* dummy arg to avoid bad minification */ 0 - ), - } ); - recordEvent( - newValue - ? 'jetpack_protect_automatic_rules_enabled' - : 'jetpack_protect_automatic_rules_disabled' - ); - } ) - .then( () => { - if ( ! upgradeIsSeen ) { - setWafUpgradeIsSeen( true ); - API.wafUpgradeSeen(); - } - } ) - .catch( error => { - setAutomaticRulesInstallationError( true ); - handleApiError( error ); - } ) - .finally( () => setFormIsSubmitting( false ) ); - }, [ - formState, - toggleAutomaticRules, - setNotice, - recordEvent, - upgradeIsSeen, - setWafUpgradeIsSeen, - handleApiError, - ] ); + const withFormState = callback => { + return event => { + const { id, value, ariaChecked } = event.target; + const inputValue = ariaChecked ? ariaChecked !== 'true' : value; + setFormState( prevState => ( { + ...prevState, + [ id ]: inputValue, + } ) ); + return callback( inputValue ); + }; + }; /** - * Handle Brute Force Protection Change + * Handle Automatic Rules Change * - * Toggles the brute force protection module. + * Toggles the WAF's automatic rules option. * * @return void */ - const handleBruteForceProtectionChange = useCallback( () => { - setFormIsSubmitting( true ); - const newValue = ! formState.brute_force_protection; - setFormState( { - ...formState, - brute_force_protection: newValue, - } ); - toggleBruteForceProtection() - .then( () => { - setNotice( { - type: 'success', - duration: SUCCESS_NOTICE_DURATION, - message: newValue - ? __( `Brute force protection is enabled.`, 'jetpack-protect' ) - : __( - `Brute force protection is disabled.`, - 'jetpack-protect', - /* dummy arg to avoid bad minification */ 0 - ), - } ); - recordEvent( - newValue - ? 'jetpack_protect_brute_force_protection_enabled' - : 'jetpack_protect_brute_force_protection_disabled' - ); - } ) - .catch( handleApiError ) - .finally( () => setFormIsSubmitting( false ) ); - }, [ formState, toggleBruteForceProtection, handleApiError, setNotice, recordEvent ] ); + const handleAutomaticRulesChange = useCallback( () => { + setFormState( prevState => ( { + ...prevState, + jetpack_waf_automatic_rules: ! prevState.jetpack_waf_automatic_rules, + } ) ); + + try { + toggleAutomaticRules(); + setAutomaticRulesInstallationError( false ); + } catch ( error ) { + setAutomaticRulesInstallationError( true ); + setFormState( prevState => ( { + ...prevState, + jetpack_waf_automatic_rules: ! prevState.jetpack_waf_automatic_rules, + } ) ); + } + }, [ toggleAutomaticRules ] ); /** - * Handle IP Allow List Change + * Save IP Block List Changes * - * Toggles the WAF's IP allow list option. + * Updates the WAF settings with the current form state values. * * @return void */ - const handleIpAllowListChange = useCallback( () => { - const newIpAllowListStatus = ! formState.jetpack_waf_ip_allow_list_enabled; - setFormIsSubmitting( true ); - setIpAllowListIsUpdating( true ); - setFormState( { ...formState, jetpack_waf_ip_allow_list_enabled: newIpAllowListStatus } ); - toggleIpAllowList() - .then( () => { - setNotice( { - type: 'success', - duration: SUCCESS_NOTICE_DURATION, - message: newIpAllowListStatus - ? __( 'Allow list active.', 'jetpack-protect' ) - : __( - 'Allow list is disabled.', - 'jetpack-protect', - /* dummy arg to avoid bad minification */ 0 - ), - } ); - recordEvent( - newIpAllowListStatus - ? 'jetpack_protect_ip_allow_list_enabled' - : 'jetpack_protect_ip_allow_list_disabled' - ); - } ) - .catch( handleApiError ) - .finally( () => { - setFormIsSubmitting( false ); - setIpAllowListIsUpdating( false ); - } ); - }, [ formState, toggleIpAllowList, handleApiError, setNotice, recordEvent ] ); + const saveIpBlockListChanges = useCallback( async () => { + await saveIpBlockList( formState.jetpack_waf_ip_block_list ); + }, [ saveIpBlockList, formState.jetpack_waf_ip_block_list ] ); /** - * Handle IP Block List Change + * Save IP Allow List Changes * - * Toggles the WAF's IP block list option. + * Updates the WAF settings with the current form state values. * * @return void */ - const handleIpBlockListChange = useCallback( () => { - const newIpBlockListStatus = ! formState.jetpack_waf_ip_block_list_enabled; - setFormIsSubmitting( true ); - setIpBlockListIsUpdating( true ); - setFormState( { ...formState, jetpack_waf_ip_block_list_enabled: newIpBlockListStatus } ); - toggleIpBlockList() - .then( () => { - setNotice( { - type: 'success', - duration: SUCCESS_NOTICE_DURATION, - message: newIpBlockListStatus - ? __( 'Block list is active.', 'jetpack-protect' ) - : __( - 'Block list is disabled.', - 'jetpack-protect', - /* dummy arg to avoid bad minification */ 0 - ), - } ); - recordEvent( - newIpBlockListStatus - ? 'jetpack_protect_ip_block_list_enabled' - : 'jetpack_protect_ip_block_list_disabled' - ); - } ) - .catch( handleApiError ) - .finally( () => { - setFormIsSubmitting( false ); - setIpBlockListIsUpdating( false ); - } ); - }, [ formState, toggleIpBlockList, handleApiError, setNotice, recordEvent ] ); + const saveIpAllowListChanges = useCallback( async () => { + await saveIpAllowList( formState.jetpack_waf_ip_allow_list ); + }, [ saveIpAllowList, formState.jetpack_waf_ip_allow_list ] ); /** * Handle Close Popover Click @@ -409,9 +187,8 @@ const FirewallPage = () => { * @return void */ const handleClosePopoverClick = useCallback( () => { - setWafUpgradeIsSeen( true ); - API.wafUpgradeSeen(); - }, [ setWafUpgradeIsSeen ] ); + wafUpgradeSeenMutation.mutate(); + }, [ wafUpgradeSeenMutation ] ); /** * Checks if the current IP address is allow listed. @@ -419,7 +196,7 @@ const FirewallPage = () => { * @return {boolean} - Indicates whether the current IP address is allow listed. */ const isCurrentIpAllowed = useMemo( () => { - return formState.jetpack_waf_ip_allow_list.includes( currentIp ); + return formState.jetpack_waf_ip_allow_list?.includes( currentIp ); }, [ formState.jetpack_waf_ip_allow_list, currentIp ] ); /** @@ -445,23 +222,11 @@ const FirewallPage = () => { useEffect( () => { if ( ! isUpdating ) { setFormState( { - jetpack_waf_automatic_rules: jetpackWafAutomaticRules, - jetpack_waf_ip_block_list_enabled: jetpackWafIpBlockListEnabled, - jetpack_waf_ip_allow_list_enabled: jetpackWafIpAllowListEnabled, jetpack_waf_ip_block_list: jetpackWafIpBlockList, jetpack_waf_ip_allow_list: jetpackWafIpAllowList, - brute_force_protection: isBruteForceModuleEnabled, } ); } - }, [ - jetpackWafIpBlockListEnabled, - jetpackWafIpAllowListEnabled, - jetpackWafIpBlockList, - jetpackWafIpAllowList, - jetpackWafAutomaticRules, - isBruteForceModuleEnabled, - isUpdating, - ] ); + }, [ jetpackWafIpBlockList, jetpackWafIpAllowList, isUpdating ] ); /** * "WAF Seen" useEffect() @@ -471,18 +236,14 @@ const FirewallPage = () => { return; } - // remove the "new" badge immediately - setWafIsSeen( true ); - - // update the meta value in the background - API.wafSeen(); - }, [ isSeen, setWafIsSeen ] ); + wafSeenMutation.mutate(); + }, [ isSeen, wafSeenMutation ] ); // Track view for Protect WAF page. useAnalyticsTracks( { pageViewEventName: 'protect_waf', pageViewEventProperties: { - has_plan: hasRequiredPlan, + has_plan: hasPlan, }, } ); @@ -520,11 +281,11 @@ const FirewallPage = () => { >
- { hasRequiredPlan && upgradeIsSeen === false && ( + { hasPlan && upgradeIsSeen === false && (
@@ -565,7 +326,7 @@ const FirewallPage = () => { { __( 'Automatic firewall protection', 'jetpack-protect' ) } - { ! isSmall && hasRequiredPlan && displayUpgradeBadge && ( + { ! isSmall && hasPlan && displayUpgradeBadge && ( { __( 'NOW AVAILABLE', 'jetpack-protect' ) } ) }
@@ -605,12 +366,11 @@ const FirewallPage = () => { variant={ 'body-small' } mt={ 2 } > - { __( 'Failed to update automatic firewall rules.', 'jetpack-protect' ) }{ ' ' } - { getCustomErrorMessage( automaticRulesInstallationError ) } + { __( 'Failed to update automatic firewall rules.', 'jetpack-protect' ) }
- { ! hasRequiredPlan && ( + { ! hasPlan && (
{
@@ -684,7 +444,7 @@ const FirewallPage = () => {
@@ -714,7 +474,7 @@ const FirewallPage = () => {