From cc7e4f42b6a10b0cd5756f7264799bf61a90df69 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 11:30:42 -0700 Subject: [PATCH 01/24] Protect Dashboard: Add a feature-flagged module with a blank wp-build Protect page --- pnpm-lock.yaml | 3 + ...etpack-protect-dashboard-feature-flags.php | 66 +++++++++++++ .../changelog/add-protect-dashboard-module | 4 + projects/plugins/jetpack/load-jetpack.php | 1 + .../jetpack/modules/protect-dashboard.php | 21 ++++ .../class-jetpack-protect-dashboard.php | 97 +++++++++++++++++++ projects/plugins/jetpack/package.json | 7 ++ .../routes/protect-dashboard/package.json | 11 +++ .../routes/protect-dashboard/stage.tsx | 11 +++ 9 files changed, 221 insertions(+) create mode 100644 projects/plugins/jetpack/_inc/lib/class-jetpack-protect-dashboard-feature-flags.php create mode 100644 projects/plugins/jetpack/changelog/add-protect-dashboard-module create mode 100644 projects/plugins/jetpack/modules/protect-dashboard.php create mode 100644 projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/package.json create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/stage.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 597b42c8063c..e68e862a7797 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6296,6 +6296,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 + '@wordpress/admin-ui': + specifier: 2.10.0 + version: 2.10.0(@date-fns/tz@1.5.0)(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/api-fetch': specifier: 7.55.0 version: 7.55.0 diff --git a/projects/plugins/jetpack/_inc/lib/class-jetpack-protect-dashboard-feature-flags.php b/projects/plugins/jetpack/_inc/lib/class-jetpack-protect-dashboard-feature-flags.php new file mode 100644 index 000000000000..f4c83e26e548 --- /dev/null +++ b/projects/plugins/jetpack/_inc/lib/class-jetpack-protect-dashboard-feature-flags.php @@ -0,0 +1,66 @@ + false, + 'description' => 'Make the Protect dashboard module available, which adds a Protect page to the Jetpack sidebar.', + 'owner' => 'jetpack', + ) + ); + + add_filter( 'jetpack_get_available_modules', array( __CLASS__, 'filter_available_modules' ) ); + } + + /** + * Whether the Protect dashboard flag is on. + * + * @return bool + */ + public static function is_enabled() { + return Feature_Flags::is_enabled( self::DASHBOARD ); + } + + /** + * Drop the module from the available list while the flag is off, which also keeps it from loading. + * + * @param array $modules Available modules, keyed by slug. + * @return array + */ + public static function filter_available_modules( $modules ) { + if ( ! self::is_enabled() ) { + unset( $modules[ self::MODULE ] ); + } + return $modules; + } +} + +Jetpack_Protect_Dashboard_Feature_Flags::register(); diff --git a/projects/plugins/jetpack/changelog/add-protect-dashboard-module b/projects/plugins/jetpack/changelog/add-protect-dashboard-module new file mode 100644 index 000000000000..2b5d9e6d9a3c --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-protect-dashboard-module @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Protect: Add a feature-flagged Protect Dashboard module that adds a Protect page to the Jetpack sidebar. diff --git a/projects/plugins/jetpack/load-jetpack.php b/projects/plugins/jetpack/load-jetpack.php index f86c1fbaacfa..779cd9fa03dd 100644 --- a/projects/plugins/jetpack/load-jetpack.php +++ b/projects/plugins/jetpack/load-jetpack.php @@ -64,6 +64,7 @@ function jetpack_should_use_minified_assets() { require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-settings.php'; require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-feature-flags.php'; require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-settings-feature-flags.php'; +require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-protect-dashboard-feature-flags.php'; \Automattic\Jetpack\Newsletter\Settings::register_feature_flags(); diff --git a/projects/plugins/jetpack/modules/protect-dashboard.php b/projects/plugins/jetpack/modules/protect-dashboard.php new file mode 100644 index 000000000000..4afaa67844af --- /dev/null +++ b/projects/plugins/jetpack/modules/protect-dashboard.php @@ -0,0 +1,21 @@ + Jetpack_Protect_Dashboard_Feature_Flags::MODULE, + 'key' => self::MENU_SLUG, + ) + ); + } + + /** + * Render the wp-build page, or say why it could not be rendered. + * + * @return void + */ + public static function render() { + $render_fn = 'jetpack_plugin_' . str_replace( '-', '_', self::WP_BUILD_PAGE_ID ) . '_wp_admin_render_page'; + if ( function_exists( $render_fn ) ) { + $render_fn(); + return; + } + + printf( + '

Protect

%s

', + esc_html__( 'The Protect dashboard could not be loaded because its assets are missing.', 'jetpack' ) + ); + } +} diff --git a/projects/plugins/jetpack/package.json b/projects/plugins/jetpack/package.json index b178f925ca09..d08529c1ce23 100644 --- a/projects/plugins/jetpack/package.json +++ b/projects/plugins/jetpack/package.json @@ -147,6 +147,7 @@ "@types/react": "18.3.31", "@types/wordpress__block-editor": "15.0.6", "@typescript/native-preview": "7.0.0-dev.20260707.2", + "@wordpress/admin-ui": "2.10.0", "@wordpress/api-fetch": "7.55.0", "@wordpress/blob": "4.55.0", "@wordpress/block-serialization-default-parser": "5.55.0", @@ -194,6 +195,12 @@ "init": [ "@jetpack-plugin/init" ] + }, + { + "id": "jetpack-protect-hub", + "init": [ + "@jetpack-plugin/init" + ] } ] } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/package.json b/projects/plugins/jetpack/routes/protect-dashboard/package.json new file mode 100644 index 000000000000..e0a4bfcf4824 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/package.json @@ -0,0 +1,11 @@ +{ + "version": "1.0.0", + "private": true, + "dependencies": { + "@wordpress/admin-ui": "2.10.0" + }, + "route": { + "path": "/", + "page": "jetpack-protect-hub" + } +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx new file mode 100644 index 000000000000..5e504be6f5e2 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx @@ -0,0 +1,11 @@ +import { Page } from '@wordpress/admin-ui'; + +/** + * Boot stage for the Protect dashboard, left blank for now. + * + * @return The Protect page. + */ +// "Protect" is a product name and is not translated. +const Stage = () => ; + +export { Stage as stage }; From b57245ac8806e73d4f25c3cb8f7b681dc897ffc6 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 11:58:32 -0700 Subject: [PATCH 02/24] Protect Dashboard: Take over the Protect page and switch from the Jetpack Protect plugin --- .../changelog/add-protect-dashboard-module | 4 ++++ .../my-jetpack/src/class-main-features.php | 4 +++- .../class-jetpack-protect-dashboard.php | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 projects/packages/my-jetpack/changelog/add-protect-dashboard-module diff --git a/projects/packages/my-jetpack/changelog/add-protect-dashboard-module b/projects/packages/my-jetpack/changelog/add-protect-dashboard-module new file mode 100644 index 000000000000..97f9571e347e --- /dev/null +++ b/projects/packages/my-jetpack/changelog/add-protect-dashboard-module @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Features: Offer the Protect switch through Jetpack when the Protect Dashboard feature flag is on. diff --git a/projects/packages/my-jetpack/src/class-main-features.php b/projects/packages/my-jetpack/src/class-main-features.php index 39da33d7644e..8dc83c502b9e 100644 --- a/projects/packages/my-jetpack/src/class-main-features.php +++ b/projects/packages/my-jetpack/src/class-main-features.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\My_Jetpack; +use Automattic\Jetpack\Feature_Flags\Feature_Flags; use Automattic\Jetpack\Plugins_Installer; /** @@ -364,7 +365,8 @@ public static function get_feature_definitions() { __( 'Instant email alerts when a threat is found', 'jetpack-my-jetpack' ), ), 'delivery' => array( - 'jetpack' => false, + // The flag is registered by the Jetpack plugin, which owns the `protect-dashboard` module. + 'jetpack' => Feature_Flags::is_enabled( 'jetpack-protect-dashboard' ), 'plugin' => 'jetpack-protect', 'plugin_name' => __( 'Jetpack Protect', 'jetpack-my-jetpack' ), 'plugin_url' => 'https://wordpress.org/plugins/jetpack-protect/', diff --git a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php index eca6ea185f3a..6c3189fec53e 100644 --- a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php +++ b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php @@ -21,11 +21,11 @@ class Jetpack_Protect_Dashboard { /** - * Sidebar menu slug. + * Sidebar menu slug, shared with the Jetpack Protect plugin so the page address never changes. * * @var string */ - const MENU_SLUG = 'jetpack-protect-dashboard'; + const MENU_SLUG = 'jetpack-protect'; /** * The wp-build route's page id, which must not be the menu slug. @@ -41,7 +41,8 @@ class Jetpack_Protect_Dashboard { */ public static function init() { add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); - add_action( 'admin_menu', array( __CLASS__, 'add_menu' ) ); + // Before Admin_Menu registers its items at 1000, and after the Protect plugin adds its own on `_admin_menu`. + add_action( 'admin_menu', array( __CLASS__, 'add_menu' ), 999 ); } /** @@ -57,11 +58,18 @@ public static function maybe_load_wp_build() { } /** - * Add the "Protect" item to the Jetpack sidebar. + * Add the "Protect" item to the Jetpack sidebar, in place of the Jetpack Protect plugin's. * * @return void */ public static function add_menu() { + // Take the slug over from the Jetpack Protect plugin: drop its item and the scripts it loads for that page. + while ( Admin_Menu::remove_menu( self::MENU_SLUG ) ) { + continue; + } + remove_all_actions( 'load-jetpack_page_' . self::MENU_SLUG ); + remove_all_actions( 'load-admin_page_' . self::MENU_SLUG ); + Admin_Menu::add_menu( // "Protect" is a product name and is not translated. 'Protect', From f734ccf8ba8c653f5a497d7a3a633beea05d16db Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 12:09:19 -0700 Subject: [PATCH 03/24] Protect Dashboard: Name the module Protect and reuse the Protect plugin's description --- projects/plugins/jetpack/modules/protect-dashboard.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/plugins/jetpack/modules/protect-dashboard.php b/projects/plugins/jetpack/modules/protect-dashboard.php index 4afaa67844af..e2d3d28668ee 100644 --- a/projects/plugins/jetpack/modules/protect-dashboard.php +++ b/projects/plugins/jetpack/modules/protect-dashboard.php @@ -1,7 +1,7 @@ Date: Mon, 5 Oct 2026 12:10:13 -0700 Subject: [PATCH 04/24] Protect Dashboard: Pass Page its required children --- projects/plugins/jetpack/routes/protect-dashboard/stage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx index 5e504be6f5e2..36f8cfe575e2 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx @@ -6,6 +6,6 @@ import { Page } from '@wordpress/admin-ui'; * @return The Protect page. */ // "Protect" is a product name and is not translated. -const Stage = () => ; +const Stage = () => { null }; export { Stage as stage }; From 0b321962687ded2ccb55777387ff70bfc6762e10 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 16:58:06 -0700 Subject: [PATCH 05/24] Protect Dashboard: Add tabbed layout and section framework --- pnpm-lock.yaml | 6 ++ .../add-protect-dashboard-foundation | 4 + projects/plugins/jetpack/composer.json | 2 + projects/plugins/jetpack/composer.lock | 4 +- ...lass-jetpack-protect-dashboard-threats.php | 63 ++++++++++++ .../class-jetpack-protect-dashboard.php | 78 +++++++++++++++ ...face-jetpack-protect-dashboard-section.php | 39 ++++++++ projects/plugins/jetpack/package.json | 2 + .../protect-dashboard/components/card.tsx | 98 +++++++++++++++++++ .../protect-dashboard/components/overview.tsx | 27 +++++ .../components/settings-link.tsx | 38 +++++++ .../components/settings-tab.tsx | 46 +++++++++ .../components/settings/ip-list-field.tsx | 85 ++++++++++++++++ .../components/settings/setting-toggle.tsx | 39 ++++++++ .../data/is-module-active.ts | 17 ++++ .../data/use-protect-settings.ts | 75 ++++++++++++++ .../routes/protect-dashboard/package.json | 13 ++- .../routes/protect-dashboard/route.scss | 51 ++++++++++ .../sections/firewall/index.tsx | 5 + .../sections/history/index.tsx | 5 + .../protect-dashboard/sections/index.ts | 11 +++ .../sections/login-protection/index.tsx | 5 + .../sections/monitor/index.tsx | 5 + .../protect-dashboard/sections/scan/index.tsx | 5 + .../protect-dashboard/sections/types.ts | 29 ++++++ .../routes/protect-dashboard/stage.tsx | 81 ++++++++++++++- .../jetpack/routes/protect-dashboard/types.ts | 8 ++ 27 files changed, 835 insertions(+), 6 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/add-protect-dashboard-foundation create mode 100644 projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php create mode 100644 projects/plugins/jetpack/modules/protect-dashboard/interface-jetpack-protect-dashboard-section.php create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/overview.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings/setting-toggle.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/data/is-module-active.ts create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/route.scss create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/firewall/index.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/history/index.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/index.ts create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/monitor/index.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/scan/index.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e68e862a7797..b0b7a8c6cde4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6025,6 +6025,9 @@ importers: '@automattic/jetpack-publicize': specifier: workspace:* version: link:../../packages/publicize + '@automattic/jetpack-scan': + specifier: workspace:* + version: link:../../js-packages/scan '@automattic/jetpack-script-data': specifier: workspace:* version: link:../../js-packages/script-data @@ -6124,6 +6127,9 @@ importers: '@wordpress/rich-text': specifier: 7.55.0 version: 7.55.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/route': + specifier: 0.21.0 + version: 0.21.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/theme': specifier: 2.1.0 version: 2.1.0(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/projects/plugins/jetpack/changelog/add-protect-dashboard-foundation b/projects/plugins/jetpack/changelog/add-protect-dashboard-foundation new file mode 100644 index 000000000000..48927a47a4cf --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-protect-dashboard-foundation @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Protect: Add the tabbed layout and section framework for the feature-flagged Protect dashboard. diff --git a/projects/plugins/jetpack/composer.json b/projects/plugins/jetpack/composer.json index 1f056077c524..da775cc48dfa 100644 --- a/projects/plugins/jetpack/composer.json +++ b/projects/plugins/jetpack/composer.json @@ -52,6 +52,8 @@ "automattic/jetpack-post-list": "@dev", "automattic/jetpack-post-media": "@dev", "automattic/jetpack-premium-analytics": "@dev", + "automattic/jetpack-protect-models": "@dev", + "automattic/jetpack-protect-status": "@dev", "automattic/jetpack-publicize": "@dev", "automattic/jetpack-redirect": "@dev", "automattic/jetpack-roles": "@dev", diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 2b0ed1029e53..50d0aa66d884 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8f59b8dcd3034cfaa02277b260237ac0", + "content-hash": "d91c4ee230c801317c2b83153137e339", "packages": [ { "name": "automattic/block-delimiter", @@ -6541,6 +6541,8 @@ "automattic/jetpack-post-list": 20, "automattic/jetpack-post-media": 20, "automattic/jetpack-premium-analytics": 20, + "automattic/jetpack-protect-models": 20, + "automattic/jetpack-protect-status": 20, "automattic/jetpack-publicize": 20, "automattic/jetpack-redirect": 20, "automattic/jetpack-roles": 20, diff --git a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php new file mode 100644 index 000000000000..7363b87d7de4 --- /dev/null +++ b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php @@ -0,0 +1,63 @@ +extension ?? null; + + return array( + 'id' => $threat->id ?? null, + 'signature' => $threat->signature ?? null, + 'title' => $threat->title ?? null, + 'description' => $threat->description ?? null, + 'status' => $threat->status ?? null, + 'severity' => $threat->severity ?? null, + 'firstDetected' => $threat->first_detected ?? null, + 'fixedIn' => $threat->fixed_in ?? null, + 'fixedOn' => $threat->fixed_on ?? null, + 'fixable' => empty( $threat->fixable ) ? false : $threat->fixable, + 'filename' => $threat->filename ?? null, + 'extension' => $extension ? array( + 'slug' => $extension->slug ?? null, + 'name' => $extension->name ?? null, + 'version' => $extension->version ?? null, + // History names extension types in the singular; the threat list reads the plural. + 'type' => in_array( $extension->type ?? '', array( 'plugin', 'theme' ), true ) ? $extension->type . 's' : ( $extension->type ?? null ), + ) : null, + ); + } + + /** + * Shape a list of threats. + * + * @param iterable $threats Threats. + * @return array + */ + public static function format_all( $threats ) { + $formatted = array(); + foreach ( (array) $threats as $threat ) { + $formatted[] = self::format( $threat ); + } + return $formatted; + } +} diff --git a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php index 6c3189fec53e..405f888273c3 100644 --- a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php +++ b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php @@ -6,12 +6,15 @@ */ use Automattic\Jetpack\Admin_UI\Admin_Menu; +use Automattic\Jetpack\Modules; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); } require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-wp-build-page.php'; +require_once __DIR__ . '/interface-jetpack-protect-dashboard-section.php'; +require_once __DIR__ . '/class-jetpack-protect-dashboard-threats.php'; /** * Registers the Protect sidebar item and renders its wp-build route. @@ -34,6 +37,13 @@ class Jetpack_Protect_Dashboard { */ const WP_BUILD_PAGE_ID = 'jetpack-protect-hub'; + /** + * Registered sections, keyed by section key. + * + * @var Jetpack_Protect_Dashboard_Section[] + */ + private static $sections = array(); + /** * Wire the hooks. Runs only while the `protect-dashboard` module is active. * @@ -43,6 +53,71 @@ public static function init() { add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); // Before Admin_Menu registers its items at 1000, and after the Protect plugin adds its own on `_admin_menu`. add_action( 'admin_menu', array( __CLASS__, 'add_menu' ), 999 ); + add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) ); + + // Each feature lives in its own file and registers itself, so features can land independently. + $section_files = glob( __DIR__ . '/sections/class-*.php' ); + foreach ( is_array( $section_files ) ? $section_files : array() as $section_file ) { + require_once $section_file; + } + } + + /** + * Add a section to the dashboard. + * + * @param Jetpack_Protect_Dashboard_Section $section The section. + * @return void + */ + public static function register_section( Jetpack_Protect_Dashboard_Section $section ) { + self::$sections[ $section->get_key() ] = $section; + } + + /** + * Register every section's REST routes. + * + * @return void + */ + public static function register_rest_routes() { + foreach ( self::$sections as $section ) { + $section->register_routes(); + } + } + + /** + * Each section's state, keyed by section key. + * + * @return array + */ + public static function get_initial_state() { + $state = array(); + foreach ( self::$sections as $key => $section ) { + $state[ $key ] = $section->get_state(); + } + return $state; + } + + /** + * Whether the current user may see and use the dashboard's REST routes. + * + * @return bool + */ + public static function can_manage() { + return current_user_can( 'manage_options' ); + } + + /** + * Whether a module can run on this site, and whether it is on. + * + * @param string $module Module slug. + * @return array + */ + public static function get_module_state( $module ) { + $modules = new Modules(); + + return array( + 'available' => $modules->is_module( $module ), + 'active' => $modules->is_active( $module ), + ); } /** @@ -93,6 +168,9 @@ public static function add_menu() { public static function render() { $render_fn = 'jetpack_plugin_' . str_replace( '-', '_', self::WP_BUILD_PAGE_ID ) . '_wp_admin_render_page'; if ( function_exists( $render_fn ) ) { + wp_print_inline_script_tag( + 'window.jetpackProtectDashboard = ' . wp_json_encode( (object) self::get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';' + ); $render_fn(); return; } diff --git a/projects/plugins/jetpack/modules/protect-dashboard/interface-jetpack-protect-dashboard-section.php b/projects/plugins/jetpack/modules/protect-dashboard/interface-jetpack-protect-dashboard-section.php new file mode 100644 index 000000000000..86265365bafa --- /dev/null +++ b/projects/plugins/jetpack/modules/protect-dashboard/interface-jetpack-protect-dashboard-section.php @@ -0,0 +1,39 @@ +[ 'intent' ] }; + +type CardProps = { + icon: ReactElement; + title: string; + status?: CardStatus; + actions?: ReactNode; + children: ReactNode; +}; + +/** + * A Protect dashboard card: a titled header, then rows divided by rules. + * + * @param props - Component props. + * @param props.icon - The feature's icon. + * @param props.title - The feature's name. + * @param props.status - Optional status badge. + * @param props.actions - Optional controls beside the badge. + * @param props.children - The card's rows. + * @return The card. + */ +export function ProtectCard( { icon, title, status, actions, children }: CardProps ) { + return ( +
+ + + + { title } + + + { status && { status.label } } + { actions } + + + { children } +
+ ); +} + +/** + * One row of a card. + * + * @param props - Component props. + * @param props.children - Row contents. + * @param props.className - Extra class names. + * @return The row. + */ +export function CardRow( { + children, + className = '', +}: { + children: ReactNode; + className?: string; +} ) { + return
{ children }
; +} + +/** + * A labelled figure, such as a count of blocked requests. + * + * @param props - Component props. + * @param props.label - What the figure counts. + * @param props.value - The figure. + * @param props.warning - Whether to highlight the figure as needing attention. + * @return The stat. + */ +export function Stat( { + label, + value, + warning = false, +}: { + label: string; + value: ReactNode; + warning?: boolean; +} ) { + return ( + + { label } + + { value } + + + ); +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/overview.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/overview.tsx new file mode 100644 index 000000000000..f6efe175b27e --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/overview.tsx @@ -0,0 +1,27 @@ +import { __ } from '@wordpress/i18n'; +import { Text } from '@wordpress/ui'; +import sections from '../sections'; +import type { DashboardContext } from '../sections/types'; + +/** + * The Overview tab: each section's card. + * + * @param props - The dashboard context, without a section's state. + * @return The tab. + */ +export default function Overview( props: Omit< DashboardContext, 'state' > ) { + const state = window.jetpackProtectDashboard ?? {}; + const cards = sections.filter( section => section.OverviewCard ); + + if ( ! cards.length ) { + return { __( 'There’s nothing to show yet.', 'jetpack' ) }; + } + + return ( +
+ { cards.map( ( { key, OverviewCard: Card } ) => ( + + ) ) } +
+ ); +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx new file mode 100644 index 000000000000..19b6b431bf02 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx @@ -0,0 +1,38 @@ +import { useCallback } from '@wordpress/element'; +import { Link } from '@wordpress/ui'; +import type { MouseEvent, ReactNode } from 'react'; + +const SETTINGS_URL = 'admin.php?page=jetpack-protect&p=%2F%3Ftab%3Dsettings'; + +/** + * A link to the Settings tab that switches tabs in place, and still opens in a new tab when asked. + * + * @param props - Component props. + * @param props.onOpen - Switches to the Settings tab. + * @param props.children - The link text. + * @return The link. + */ +export default function SettingsLink( { + onOpen, + children, +}: { + onOpen: () => void; + children: ReactNode; +} ) { + const onClick = useCallback( + ( event: MouseEvent< HTMLAnchorElement > ) => { + if ( event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0 ) { + return; + } + event.preventDefault(); + onOpen(); + }, + [ onOpen ] + ); + + return ( + + { children } + + ); +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx new file mode 100644 index 000000000000..0e81b76e48db --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx @@ -0,0 +1,46 @@ +import { __ } from '@wordpress/i18n'; +import { Notice, Spinner, Stack, Text } from '@wordpress/ui'; +import sections from '../sections'; +import type { DashboardContext } from '../sections/types'; + +/** + * The Settings tab: each section's settings card, once Jetpack's settings have loaded. + * + * @param props - The dashboard context, without a section's state. + * @return The tab. + */ +export default function SettingsTab( props: Omit< DashboardContext, 'state' > ) { + const { settings, error, dismissError } = props.settings; + const state = window.jetpackProtectDashboard ?? {}; + const cards = sections.filter( section => section.SettingsCard ); + + if ( ! settings ) { + return error ? ( + + { error } + + ) : ( + + + + ); + } + + return ( +
+ { error && ( + + { error } + + + ) } + { cards.length ? ( + cards.map( ( { key, SettingsCard: Card } ) => ( + + ) ) + ) : ( + { __( 'There’s nothing to set up yet.', 'jetpack' ) } + ) } +
+ ); +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx new file mode 100644 index 000000000000..fe8d85308de8 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { Button, Stack, TextareaControl } from '@wordpress/ui'; +import type { ProtectSettingsData } from '../../data/use-protect-settings'; +import type { ChangeEvent } from 'react'; + +type Props = { + data: ProtectSettingsData; + name: string; + label: string; + description: string; + currentIp?: string; +}; + +/** + * An editable list of IP addresses, saved with its own button. + * + * @param props - Component props. + * @param props.data - The settings and their save function. + * @param props.name - The setting holding the list. + * @param props.label - The field's label. + * @param props.description - What the list does. + * @param props.currentIp - When set, offers to add the visitor's own address. + * @return The field. + */ +export default function IpListField( { data, name, label, description, currentIp }: Props ) { + const saved = + typeof data.settings?.[ name ] === 'string' ? ( data.settings[ name ] as string ) : ''; + const [ draft, setDraft ] = useState( saved ); + + useEffect( () => setDraft( saved ), [ saved ] ); + + const onChange = useCallback( + ( event: ChangeEvent< HTMLTextAreaElement > ) => setDraft( event.target.value ), + [] + ); + const { save } = data; + const onSave = useCallback( () => save( { [ name ]: draft } ), [ save, name, draft ] ); + const onAddIp = useCallback( + () => + setDraft( current => + current.trim() ? `${ current.trim() }\n${ currentIp }` : `${ currentIp }` + ), + [ currentIp ] + ); + + const hasIp = !! currentIp && draft.split( /\s+/ ).includes( currentIp ); + + return ( + + + + + { currentIp && ! hasIp && ( + + ) } + + + ); +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/setting-toggle.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/setting-toggle.tsx new file mode 100644 index 000000000000..5c406e8bf201 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/setting-toggle.tsx @@ -0,0 +1,39 @@ +import { ToggleControl } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; +import type { ProtectSettingsData } from '../../data/use-protect-settings'; +import type { ReactNode } from 'react'; + +type Props = { + data: ProtectSettingsData; + name: string; + label: string; + help?: ReactNode; + disabled?: boolean; +}; + +/** + * A toggle bound to one Jetpack setting or module, saved as soon as it changes. + * + * @param props - Component props. + * @param props.data - The settings and their save function. + * @param props.name - The setting key or module slug. + * @param props.label - The toggle's label. + * @param props.help - Optional help text. + * @param props.disabled - Whether the toggle is unavailable. + * @return The toggle. + */ +export default function SettingToggle( { data, name, label, help, disabled = false }: Props ) { + const { save } = data; + const onChange = useCallback( ( value: boolean ) => save( { [ name ]: value } ), [ save, name ] ); + + return ( + + ); +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/data/is-module-active.ts b/projects/plugins/jetpack/routes/protect-dashboard/data/is-module-active.ts new file mode 100644 index 000000000000..35b83c5db14b --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/data/is-module-active.ts @@ -0,0 +1,17 @@ +import type { ProtectSettings } from './use-protect-settings'; + +/** + * A module's live on/off state: the Settings tab's value once loaded, else the page-load state. + * + * @param settings - Jetpack settings, or null while loading. + * @param module - Module slug. + * @param fallback - The state from page load. + * @return Whether the module is on. + */ +export default function isModuleActive( + settings: ProtectSettings | null, + module: string, + fallback: boolean +): boolean { + return typeof settings?.[ module ] === 'boolean' ? ( settings[ module ] as boolean ) : fallback; +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts b/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts new file mode 100644 index 000000000000..a9d99eb790aa --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts @@ -0,0 +1,75 @@ +import apiFetch from '@wordpress/api-fetch'; +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** Flat Jetpack settings, keyed by option or module slug, as `jetpack/v4/settings` returns them. */ +export type ProtectSettings = Record< string, unknown >; + +export type WafConfig = { + waf_supported?: boolean; + automatic_rules_available?: boolean; +}; + +export type ProtectSettingsData = { + settings: ProtectSettings | null; + waf: WafConfig | null; + error: string | null; + dismissError: () => void; + isSaving: ( key: string ) => boolean; + save: ( patch: ProtectSettings ) => Promise< void >; +}; + +/** + * Read and save the Jetpack settings behind Protect's features, the same way Jetpack Settings does. + * + * Saves apply optimistically and roll back if the request fails. + * + * @return The settings, the firewall's capabilities, and a save function. + */ +export default function useProtectSettings(): ProtectSettingsData { + const [ settings, setSettings ] = useState< ProtectSettings | null >( null ); + const [ waf, setWaf ] = useState< WafConfig | null >( null ); + const [ error, setError ] = useState< string | null >( null ); + const [ saving, setSaving ] = useState< string[] >( [] ); + + useEffect( () => { + Promise.all( [ + apiFetch< ProtectSettings >( { path: '/jetpack/v4/settings' } ), + apiFetch< WafConfig >( { path: '/jetpack/v4/waf' } ).catch( () => null ), + ] ) + .then( ( [ nextSettings, nextWaf ] ) => { + setSettings( nextSettings ); + setWaf( nextWaf ); + } ) + .catch( () => setError( __( 'Your settings couldn’t be loaded. Try again.', 'jetpack' ) ) ); + }, [] ); + + const save = useCallback( async ( patch: ProtectSettings ) => { + const keys = Object.keys( patch ); + let previous: ProtectSettings = {}; + + setSettings( current => { + previous = Object.fromEntries( keys.map( key => [ key, current?.[ key ] ] ) ); + return { ...current, ...patch }; + } ); + setSaving( current => [ ...current, ...keys ] ); + setError( null ); + + try { + await apiFetch( { path: '/jetpack/v4/settings', method: 'POST', data: patch } ); + } catch ( e ) { + setSettings( current => ( { ...current, ...previous } ) ); + setError( + ( e as { message?: string } )?.message || + __( 'Your change couldn’t be saved. Try again.', 'jetpack' ) + ); + } finally { + setSaving( current => current.filter( key => ! keys.includes( key ) ) ); + } + }, [] ); + + const isSaving = useCallback( ( key: string ) => saving.includes( key ), [ saving ] ); + const dismissError = useCallback( () => setError( null ), [] ); + + return { settings, waf, error, dismissError, isSaving, save }; +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/package.json b/projects/plugins/jetpack/routes/protect-dashboard/package.json index e0a4bfcf4824..7a3e5e189a2c 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/package.json +++ b/projects/plugins/jetpack/routes/protect-dashboard/package.json @@ -2,7 +2,18 @@ "version": "1.0.0", "private": true, "dependencies": { - "@wordpress/admin-ui": "2.10.0" + "@automattic/jetpack-base-styles": "workspace:*", + "@automattic/jetpack-components": "workspace:*", + "@automattic/jetpack-scan": "workspace:*", + "@wordpress/admin-ui": "2.10.0", + "@wordpress/api-fetch": "7.55.0", + "@wordpress/components": "40.1.0", + "@wordpress/date": "5.55.0", + "@wordpress/element": "8.7.0", + "@wordpress/i18n": "6.28.0", + "@wordpress/icons": "16.0.0", + "@wordpress/route": "0.21.0", + "@wordpress/ui": "0.22.0" }, "route": { "path": "/", diff --git a/projects/plugins/jetpack/routes/protect-dashboard/route.scss b/projects/plugins/jetpack/routes/protect-dashboard/route.scss new file mode 100644 index 000000000000..6c67cd1d2da7 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/route.scss @@ -0,0 +1,51 @@ +@use "@automattic/jetpack-base-styles/admin-page-layout" as *; + +// Shared Jetpack layout: fixed header and tab strip, scrolling middle. +body.jetpack_page_jetpack-protect, +body.toplevel_page_jetpack-protect { + + @include jetpack-admin-page-layout-wp-build; +} + +.jp-protect-dashboard__body { + box-sizing: border-box; + width: 100%; + max-width: 1184px; + margin-inline: auto; + padding: 24px; +} + +.jp-protect-dashboard__cards { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-lg); +} + +.jp-protect-card { + overflow: hidden; + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); + border-radius: var(--wpds-border-radius-md); + background: var(--wpds-color-background-surface-neutral); + + &__header, + &__row { + padding: 16px 24px; + } + + &__row { + border-block-start: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); + } + + &__muted { + color: var(--wpds-color-foreground-content-neutral-weak); + } + + &__subheading { + margin: 0; + font-weight: 500; + } + + &__stat-value.is-warning { + color: #8a6116; + } +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/firewall/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/firewall/index.tsx new file mode 100644 index 000000000000..797b5da81c13 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/firewall/index.tsx @@ -0,0 +1,5 @@ +import type { ProtectSection } from '../types'; + +const section: ProtectSection = { key: 'firewall' }; + +export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/history/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/history/index.tsx new file mode 100644 index 000000000000..e691af472b7b --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/history/index.tsx @@ -0,0 +1,5 @@ +import type { ProtectSection } from '../types'; + +const section: ProtectSection = { key: 'history' }; + +export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/index.ts b/projects/plugins/jetpack/routes/protect-dashboard/sections/index.ts new file mode 100644 index 000000000000..5a9caa79b8ab --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/index.ts @@ -0,0 +1,11 @@ +import firewall from './firewall'; +import history from './history'; +import loginProtection from './login-protection'; +import monitor from './monitor'; +import scan from './scan'; +import type { ProtectSection } from './types'; + +/** Sections in the order their cards appear. */ +const sections: ProtectSection[] = [ scan, monitor, firewall, loginProtection, history ]; + +export default sections; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx new file mode 100644 index 000000000000..28996967d55a --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx @@ -0,0 +1,5 @@ +import type { ProtectSection } from '../types'; + +const section: ProtectSection = { key: 'loginProtection' }; + +export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/monitor/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/monitor/index.tsx new file mode 100644 index 000000000000..96a7c5a4384f --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/monitor/index.tsx @@ -0,0 +1,5 @@ +import type { ProtectSection } from '../types'; + +const section: ProtectSection = { key: 'monitor' }; + +export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/scan/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/scan/index.tsx new file mode 100644 index 000000000000..738f17c094d1 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/scan/index.tsx @@ -0,0 +1,5 @@ +import type { ProtectSection } from '../types'; + +const section: ProtectSection = { key: 'scan' }; + +export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts b/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts new file mode 100644 index 000000000000..2fe168d3c48b --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts @@ -0,0 +1,29 @@ +import type { ProtectSettingsData } from '../data/use-protect-settings'; +import type { ComponentType } from 'react'; + +export type DashboardContext = { + /** This section's state from PHP, or undefined when PHP registered no section by this key. */ + state: unknown; + /** Jetpack settings and modules, shared by every Settings card. */ + settings: ProtectSettingsData; + /** Switches to the Settings tab. */ + openSettings: () => void; +}; + +export type SectionTab = { + value: string; + label: () => string; + /** Whether the tab shows, for example only with a paid plan. */ + isAvailable: ( context: DashboardContext ) => boolean; + Panel: ComponentType< DashboardContext >; +}; + +/** + * One feature of the dashboard. Every part is optional, so a section can land one piece at a time. + */ +export type ProtectSection = { + key: string; + OverviewCard?: ComponentType< DashboardContext >; + SettingsCard?: ComponentType< DashboardContext >; + tab?: SectionTab; +}; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx index 36f8cfe575e2..cbedb5962fca 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx @@ -1,11 +1,84 @@ -import { Page } from '@wordpress/admin-ui'; +import AdminPage from '@automattic/jetpack-components/admin-page'; +import { useCallback, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { useNavigate, useSearch } from '@wordpress/route'; +import { Tabs } from '@wordpress/ui'; +import Overview from './components/overview'; +import SettingsTab from './components/settings-tab'; +import useProtectSettings from './data/use-protect-settings'; +import sections from './sections'; +import './route.scss'; /** - * Boot stage for the Protect dashboard, left blank for now. + * Boot stage for the Protect dashboard: Overview, any section tabs, then Settings; the active one in `?tab=`. * * @return The Protect page. */ -// "Protect" is a product name and is not translated. -const Stage = () => { null }; +const Stage = () => { + const search = useSearch( { from: '/' as unknown as never, strict: false } ) as { tab?: string }; + const navigate = useNavigate(); + const goTo = useCallback( + ( next: unknown ) => { + navigate( { + search: ( prev: Record< string, unknown > ) => ( { + ...prev, + tab: next === 'overview' ? undefined : next, + } ), + } as unknown as Parameters< typeof navigate >[ 0 ] ); + }, + [ navigate ] + ); + const openSettings = useCallback( () => goTo( 'settings' ), [ goTo ] ); + const settings = useProtectSettings(); + const context = useMemo( () => ( { settings, openSettings } ), [ settings, openSettings ] ); + + const state = window.jetpackProtectDashboard ?? {}; + const sectionTabs = sections.flatMap( section => + section.tab && section.tab.isAvailable( { ...context, state: state[ section.key ] } ) + ? [ { key: section.key, ...section.tab } ] + : [] + ); + const tabValues = [ 'overview', ...sectionTabs.map( tab => tab.value ), 'settings' ]; + const activeTab = tabValues.includes( search.tab ?? '' ) ? ( search.tab as string ) : 'overview'; + + return ( + + +
+ + { __( 'Overview', 'jetpack' ) } + { sectionTabs.map( tab => ( + + { tab.label() } + + ) ) } + { __( 'Settings', 'jetpack' ) } + +
+
+ + { activeTab === 'overview' && } + + { sectionTabs.map( ( { key, value, Panel } ) => ( + + { activeTab === value && } + + ) ) } + + { activeTab === 'settings' && } + +
+
+
+ ); +}; export { Stage as stage }; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/types.ts b/projects/plugins/jetpack/routes/protect-dashboard/types.ts new file mode 100644 index 000000000000..f1fcda4e4b8a --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/types.ts @@ -0,0 +1,8 @@ +/** Each section's state from PHP, keyed by section key. */ +export type ProtectDashboardState = Record< string, unknown >; + +declare global { + interface Window { + jetpackProtectDashboard?: ProtectDashboardState; + } +} From 6cd5960727490e563618a2b5cf34860fe502e9eb Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 17:42:41 -0700 Subject: [PATCH 06/24] Protect Dashboard: Update lockfile for @wordpress/build peer resolution --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0b7a8c6cde4..8c24b029a3dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6316,7 +6316,7 @@ importers: version: 5.55.0 '@wordpress/build': specifier: 0.23.0 - version: 0.23.0(patch_hash=151ae1c0c3786b862724512e48d8cfcb4b34faf4cac98e352a450abd4741e022)(@babel/core@7.29.7)(@wordpress/theme@2.1.0(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 0.23.0(patch_hash=151ae1c0c3786b862724512e48d8cfcb4b34faf4cac98e352a450abd4741e022)(@babel/core@7.29.7)(@wordpress/route@0.21.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@2.1.0(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@wordpress/components': specifier: 40.1.0 version: 40.1.0(@date-fns/tz@1.5.0)(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From 5786579ba8e4f404ed2d65697601bb69c586c96d Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 17:47:40 -0700 Subject: [PATCH 07/24] Protect Dashboard: Address review feedback for the foundation --- ...lass-jetpack-protect-dashboard-threats.php | 7 +- .../class-jetpack-protect-dashboard.php | 27 ++++- .../components/settings-link.tsx | 5 +- .../components/settings-tab.tsx | 4 +- .../components/settings/ip-list-field.tsx | 2 +- .../data/use-protect-settings.ts | 104 ++++++++++++------ .../routes/protect-dashboard/package.json | 1 + .../routes/protect-dashboard/route.scss | 8 +- .../routes/protect-dashboard/stage.tsx | 9 +- 9 files changed, 122 insertions(+), 45 deletions(-) diff --git a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php index 7363b87d7de4..2d239b988048 100644 --- a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php +++ b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php @@ -50,12 +50,15 @@ public static function format( $threat ) { /** * Shape a list of threats. * - * @param iterable $threats Threats. + * @param iterable $threats Threats, as an array or a Traversable. * @return array */ public static function format_all( $threats ) { $formatted = array(); - foreach ( (array) $threats as $threat ) { + if ( ! is_iterable( $threats ) ) { + return $formatted; + } + foreach ( $threats as $threat ) { $formatted[] = self::format( $threat ); } return $formatted; diff --git a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php index 405f888273c3..acbf71b20f8d 100644 --- a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php +++ b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php @@ -7,6 +7,7 @@ use Automattic\Jetpack\Admin_UI\Admin_Menu; use Automattic\Jetpack\Modules; +use Automattic\Jetpack\Protect_Status\Plan; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -69,7 +70,17 @@ public static function init() { * @return void */ public static function register_section( Jetpack_Protect_Dashboard_Section $section ) { - self::$sections[ $section->get_key() ] = $section; + $key = $section->get_key(); + if ( isset( self::$sections[ $key ] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s is a dashboard section key. */ + esc_html( sprintf( __( 'A Protect dashboard section with the key "%s" is already registered.', 'jetpack' ), $key ) ), + '$$next-version$$' + ); + return; + } + self::$sections[ $key ] = $section; } /** @@ -105,6 +116,15 @@ public static function can_manage() { return current_user_can( 'manage_options' ); } + /** + * Whether the site has a plan that includes Scan. + * + * @return bool + */ + public static function has_scan_plan() { + return class_exists( Plan::class ) && Plan::has_required_plan(); + } + /** * Whether a module can run on this site, and whether it is on. * @@ -168,9 +188,8 @@ public static function add_menu() { public static function render() { $render_fn = 'jetpack_plugin_' . str_replace( '-', '_', self::WP_BUILD_PAGE_ID ) . '_wp_admin_render_page'; if ( function_exists( $render_fn ) ) { - wp_print_inline_script_tag( - 'window.jetpackProtectDashboard = ' . wp_json_encode( (object) self::get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';' - ); + $state = wp_json_encode( (object) self::get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); + wp_print_inline_script_tag( 'window.jetpackProtectDashboard = ' . ( false === $state ? '{}' : $state ) . ';' ); $render_fn(); return; } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx index 19b6b431bf02..3d3db0afcf2b 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx @@ -2,7 +2,10 @@ import { useCallback } from '@wordpress/element'; import { Link } from '@wordpress/ui'; import type { MouseEvent, ReactNode } from 'react'; -const SETTINGS_URL = 'admin.php?page=jetpack-protect&p=%2F%3Ftab%3Dsettings'; +/** Matches `Jetpack_Protect_Dashboard::MENU_SLUG`. */ +export const PROTECT_PAGE_SLUG = 'jetpack-protect'; + +const SETTINGS_URL = `admin.php?page=${ PROTECT_PAGE_SLUG }&p=${ encodeURIComponent( '/?tab=settings' ) }`; /** * A link to the Settings tab that switches tabs in place, and still opens in a new tab when asked. diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx index 0e81b76e48db..2dd045c8c703 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx @@ -10,11 +10,11 @@ import type { DashboardContext } from '../sections/types'; * @return The tab. */ export default function SettingsTab( props: Omit< DashboardContext, 'state' > ) { - const { settings, error, dismissError } = props.settings; + const { isLoaded, error, dismissError } = props.settings; const state = window.jetpackProtectDashboard ?? {}; const cards = sections.filter( section => section.SettingsCard ); - if ( ! settings ) { + if ( ! isLoaded ) { return error ? ( { error } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx index fe8d85308de8..97f6fc0e47b5 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx @@ -44,7 +44,7 @@ export default function IpListField( { data, name, label, description, currentIp [ currentIp ] ); - const hasIp = !! currentIp && draft.split( /\s+/ ).includes( currentIp ); + const hasIp = !! currentIp && draft.split( /[\s,]+/ ).includes( currentIp ); return ( diff --git a/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts b/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts index a9d99eb790aa..bb3adac32082 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts +++ b/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts @@ -1,5 +1,5 @@ import apiFetch from '@wordpress/api-fetch'; -import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useCallback, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** Flat Jetpack settings, keyed by option or module slug, as `jetpack/v4/settings` returns them. */ @@ -11,65 +11,107 @@ export type WafConfig = { }; export type ProtectSettingsData = { + /** Null until `load()` has finished, or partial when a save ran first. */ settings: ProtectSettings | null; + /** Whether `load()` has fetched the full settings. */ + isLoaded: boolean; waf: WafConfig | null; error: string | null; dismissError: () => void; isSaving: ( key: string ) => boolean; - save: ( patch: ProtectSettings ) => Promise< void >; + /** Fetches the settings once; later calls do nothing. */ + load: () => void; + /** Saves to `path` (default `/jetpack/v4/settings`) and merges the patch into `settings`. */ + save: ( patch: ProtectSettings, path?: string ) => Promise< void >; }; /** * Read and save the Jetpack settings behind Protect's features, the same way Jetpack Settings does. * - * Saves apply optimistically and roll back if the request fails. + * Nothing is fetched until `load()` runs. Saves apply optimistically and roll back if the request fails. * - * @return The settings, the firewall's capabilities, and a save function. + * @return The settings, the firewall's capabilities, and load and save functions. */ export default function useProtectSettings(): ProtectSettingsData { - const [ settings, setSettings ] = useState< ProtectSettings | null >( null ); + const [ settings, setSettingsState ] = useState< ProtectSettings | null >( null ); const [ waf, setWaf ] = useState< WafConfig | null >( null ); + const [ isLoaded, setIsLoaded ] = useState( false ); const [ error, setError ] = useState< string | null >( null ); const [ saving, setSaving ] = useState< string[] >( [] ); + const settingsRef = useRef< ProtectSettings | null >( null ); + const loadStarted = useRef( false ); + // Keys saved so far; the initial GET's response must not overwrite them. + const savedKeys = useRef( new Set< string >() ); + + const setSettings = useCallback( ( next: ProtectSettings ) => { + settingsRef.current = next; + setSettingsState( next ); + }, [] ); + + const load = useCallback( () => { + if ( loadStarted.current ) { + return; + } + loadStarted.current = true; - useEffect( () => { Promise.all( [ apiFetch< ProtectSettings >( { path: '/jetpack/v4/settings' } ), apiFetch< WafConfig >( { path: '/jetpack/v4/waf' } ).catch( () => null ), ] ) .then( ( [ nextSettings, nextWaf ] ) => { - setSettings( nextSettings ); + const current = settingsRef.current ?? {}; + const kept = [ ...savedKeys.current ] + .filter( key => key in current ) + .map( key => [ key, current[ key ] ] ); + setSettings( { ...nextSettings, ...Object.fromEntries( kept ) } ); setWaf( nextWaf ); + setIsLoaded( true ); } ) - .catch( () => setError( __( 'Your settings couldn’t be loaded. Try again.', 'jetpack' ) ) ); - }, [] ); + .catch( () => { + loadStarted.current = false; + setError( __( 'Your settings couldn’t be loaded. Try again.', 'jetpack' ) ); + } ); + }, [ setSettings ] ); - const save = useCallback( async ( patch: ProtectSettings ) => { - const keys = Object.keys( patch ); - let previous: ProtectSettings = {}; + const save = useCallback( + async ( patch: ProtectSettings, path = '/jetpack/v4/settings' ) => { + const keys = Object.keys( patch ); + const before = settingsRef.current; + const previous = Object.fromEntries( + keys.filter( key => before && key in before ).map( key => [ key, before?.[ key ] ] ) + ); - setSettings( current => { - previous = Object.fromEntries( keys.map( key => [ key, current?.[ key ] ] ) ); - return { ...current, ...patch }; - } ); - setSaving( current => [ ...current, ...keys ] ); - setError( null ); + keys.forEach( key => savedKeys.current.add( key ) ); + setSettings( { ...before, ...patch } ); + setSaving( current => [ ...current, ...keys ] ); + setError( null ); - try { - await apiFetch( { path: '/jetpack/v4/settings', method: 'POST', data: patch } ); - } catch ( e ) { - setSettings( current => ( { ...current, ...previous } ) ); - setError( - ( e as { message?: string } )?.message || - __( 'Your change couldn’t be saved. Try again.', 'jetpack' ) - ); - } finally { - setSaving( current => current.filter( key => ! keys.includes( key ) ) ); - } - }, [] ); + try { + await apiFetch( { path, method: 'POST', data: patch } ); + } catch ( e ) { + const rolledBack = { ...settingsRef.current }; + keys.forEach( key => { + savedKeys.current.delete( key ); + if ( key in previous ) { + rolledBack[ key ] = previous[ key ]; + } else { + delete rolledBack[ key ]; + } + } ); + setSettings( rolledBack ); + setError( + ( e as { message?: string } )?.message || + __( 'Your change couldn’t be saved. Try again.', 'jetpack' ) + ); + } finally { + setSaving( current => current.filter( key => ! keys.includes( key ) ) ); + } + }, + [ setSettings ] + ); const isSaving = useCallback( ( key: string ) => saving.includes( key ), [ saving ] ); const dismissError = useCallback( () => setError( null ), [] ); - return { settings, waf, error, dismissError, isSaving, save }; + return { settings, isLoaded, waf, error, dismissError, isSaving, load, save }; } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/package.json b/projects/plugins/jetpack/routes/protect-dashboard/package.json index 7a3e5e189a2c..4cae61dcb5f6 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/package.json +++ b/projects/plugins/jetpack/routes/protect-dashboard/package.json @@ -8,6 +8,7 @@ "@wordpress/admin-ui": "2.10.0", "@wordpress/api-fetch": "7.55.0", "@wordpress/components": "40.1.0", + "@wordpress/dataviews": "19.0.0", "@wordpress/date": "5.55.0", "@wordpress/element": "8.7.0", "@wordpress/i18n": "6.28.0", diff --git a/projects/plugins/jetpack/routes/protect-dashboard/route.scss b/projects/plugins/jetpack/routes/protect-dashboard/route.scss index 6c67cd1d2da7..cba82638e489 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/route.scss +++ b/projects/plugins/jetpack/routes/protect-dashboard/route.scss @@ -1,4 +1,6 @@ +@use "sass:meta"; @use "@automattic/jetpack-base-styles/admin-page-layout" as *; +@include meta.load-css("@wordpress/dataviews/build-style/style.css"); // Shared Jetpack layout: fixed header and tab strip, scrolling middle. body.jetpack_page_jetpack-protect, @@ -9,8 +11,8 @@ body.toplevel_page_jetpack-protect { .jp-protect-dashboard__body { box-sizing: border-box; - width: 100%; - max-width: 1184px; + inline-size: 100%; + max-inline-size: 1184px; margin-inline: auto; padding: 24px; } @@ -46,6 +48,6 @@ body.toplevel_page_jetpack-protect { } &__stat-value.is-warning { - color: #8a6116; + color: var(--wpds-color-foreground-content-warning); } } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx index cbedb5962fca..f42f4841ca07 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx @@ -1,5 +1,5 @@ import AdminPage from '@automattic/jetpack-components/admin-page'; -import { useCallback, useMemo } from '@wordpress/element'; +import { useCallback, useEffect, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useNavigate, useSearch } from '@wordpress/route'; import { Tabs } from '@wordpress/ui'; @@ -40,6 +40,13 @@ const Stage = () => { ); const tabValues = [ 'overview', ...sectionTabs.map( tab => tab.value ), 'settings' ]; const activeTab = tabValues.includes( search.tab ?? '' ) ? ( search.tab as string ) : 'overview'; + const { load } = settings; + + useEffect( () => { + if ( activeTab === 'settings' ) { + load(); + } + }, [ activeTab, load ] ); return ( Date: Mon, 5 Oct 2026 17:59:46 -0700 Subject: [PATCH 08/24] Protect Dashboard: Render card titles as headings --- .../jetpack/routes/protect-dashboard/components/card.tsx | 4 +++- projects/plugins/jetpack/routes/protect-dashboard/route.scss | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx index 394ceeb203d3..3f08d1d753b6 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx @@ -34,7 +34,9 @@ export function ProtectCard( { icon, title, status, actions, children }: CardPro > - { title } + }> + { title } + { status && { status.label } } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/route.scss b/projects/plugins/jetpack/routes/protect-dashboard/route.scss index cba82638e489..b0e2f7dda69d 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/route.scss +++ b/projects/plugins/jetpack/routes/protect-dashboard/route.scss @@ -42,6 +42,10 @@ body.toplevel_page_jetpack-protect { color: var(--wpds-color-foreground-content-neutral-weak); } + &__title { + margin: 0; + } + &__subheading { margin: 0; font-weight: 500; From 771a39091bfd1514ff2a5638c7489245745fa449 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 18:08:37 -0700 Subject: [PATCH 09/24] Protect Dashboard: Keep the _doing_it_wrong version tag replaceable --- .../class-jetpack-protect-dashboard.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php index acbf71b20f8d..9a85a55b072d 100644 --- a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php +++ b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php @@ -72,12 +72,9 @@ public static function init() { public static function register_section( Jetpack_Protect_Dashboard_Section $section ) { $key = $section->get_key(); if ( isset( self::$sections[ $key ] ) ) { - _doing_it_wrong( - __METHOD__, - /* translators: %s is a dashboard section key. */ - esc_html( sprintf( __( 'A Protect dashboard section with the key "%s" is already registered.', 'jetpack' ), $key ) ), - '$$next-version$$' - ); + /* translators: %s is a dashboard section key. */ + $message = sprintf( __( 'A Protect dashboard section with the key "%s" is already registered.', 'jetpack' ), $key ); + _doing_it_wrong( __METHOD__, esc_html( $message ), '$$next-version$$' ); return; } self::$sections[ $key ] = $section; From 9f9babd4656e60944e2ff788d3bf4540d7d0b176 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 17:09:22 -0700 Subject: [PATCH 10/24] Protect Dashboard: Add the Login protection section --- .../add-protect-dashboard-login-protection | 4 + ...ack-protect-dashboard-login-protection.php | 61 +++++++++ .../sections/login-protection/index.tsx | 8 +- .../login-protection-card.tsx | 127 ++++++++++++++++++ .../login-protection-settings-card.tsx | 123 +++++++++++++++++ .../sections/login-protection/types.ts | 15 +++ 6 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 projects/plugins/jetpack/changelog/add-protect-dashboard-login-protection create mode 100644 projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts diff --git a/projects/plugins/jetpack/changelog/add-protect-dashboard-login-protection b/projects/plugins/jetpack/changelog/add-protect-dashboard-login-protection new file mode 100644 index 000000000000..230c46a64ef5 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-protect-dashboard-login-protection @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Protect: Add the Login protection section to the Protect dashboard, covering brute force protection, account protection and WordPress.com login. diff --git a/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php b/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php new file mode 100644 index 000000000000..90dec689d0b1 --- /dev/null +++ b/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php @@ -0,0 +1,61 @@ + $brute_force, + 'accountProtection' => Jetpack_Protect_Dashboard::get_module_state( 'account-protection' ), + 'sso' => Jetpack_Protect_Dashboard::get_module_state( 'sso' ), + 'blockedCount' => $brute_force['active'] ? (int) get_site_option( 'jetpack_protect_blocked_attempts', 0 ) : null, + // A filter or constant can force these, and Jetpack Settings then locks the toggle. + 'ssoLocks' => array( + 'matchByEmail' => method_exists( SSO_Helpers::class, 'is_match_by_email_checkbox_disabled' ) && SSO_Helpers::is_match_by_email_checkbox_disabled(), + 'twoStep' => method_exists( SSO_Helpers::class, 'is_require_two_step_checkbox_disabled' ) && SSO_Helpers::is_require_two_step_checkbox_disabled(), + ), + 'currentIp' => (string) IP_Utils::get_ip(), + ); + } + + /** + * The section uses the core Jetpack settings endpoints, so it has no routes of its own. + * + * @return void + */ + public function register_routes() {} +} + +Jetpack_Protect_Dashboard::register_section( new Jetpack_Protect_Dashboard_Login_Protection() ); diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx index 28996967d55a..f1ca9d54d20a 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx @@ -1,5 +1,11 @@ +import LoginProtectionCard from './login-protection-card'; +import LoginProtectionSettingsCard from './login-protection-settings-card'; import type { ProtectSection } from '../types'; -const section: ProtectSection = { key: 'loginProtection' }; +const section: ProtectSection = { + key: 'loginProtection', + OverviewCard: LoginProtectionCard, + SettingsCard: LoginProtectionSettingsCard, +}; export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx new file mode 100644 index 000000000000..3800623c410b --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx @@ -0,0 +1,127 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { lock } from '@wordpress/icons'; +import { Badge, Stack, Text } from '@wordpress/ui'; +import { CardRow, ProtectCard, Stat } from '../../components/card'; +import SettingsLink from '../../components/settings-link'; +import isModuleActive from '../../data/is-module-active'; +import type { DashboardContext } from '../types'; +import type { LoginProtectionState, ModuleState } from './types'; + +/** + * One way of protecting logins, with whether it's on. + * + * @param props - Component props. + * @param props.name - The method's name. + * @param props.description - What it does. + * @param props.state - Its module's live state. + * @return The row. + */ +function Method( { + name, + description, + state, +}: { + name: string; + description: string; + state: ModuleState; +} ) { + let badge = { __( 'Off', 'jetpack' ) }; + if ( ! state.available ) { + badge = { __( 'Unavailable', 'jetpack' ) }; + } else if ( state.active ) { + badge = { __( 'On', 'jetpack' ) }; + } + + return ( + + + + { name } + + { description } + + + { badge } + + + ); +} + +/** + * The Login protection card: brute force protection, account protection and WordPress.com login. + * + * @param props - The dashboard context. + * @param props.state - This section's state from PHP. + * @param props.settings - Jetpack settings, for live module state. + * @param props.openSettings - Switches to the Settings tab. + * @return The card, or nothing without the section's state. + */ +export default function LoginProtectionCard( { state, settings, openSettings }: DashboardContext ) { + const login = state as LoginProtectionState | undefined; + if ( ! login ) { + return null; + } + + const live = ( module: string, initial: ModuleState ): ModuleState => ( { + available: initial.available, + active: isModuleActive( settings.settings, module, initial.active ), + } ); + const bruteForce = live( 'protect', login.bruteForce ); + const accountProtection = live( 'account-protection', login.accountProtection ); + const sso = live( 'sso', login.sso ); + + const methods = [ bruteForce, accountProtection, sso ]; + const on = methods.filter( method => method.available && method.active ).length; + const available = methods.filter( method => method.available ).length; + + return ( + + { bruteForce.active && ( + + + + ) } + + + + + + { __( 'Configure Login Protection', 'jetpack' ) } + + + + ); +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx new file mode 100644 index 000000000000..39e468ea94ec --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx @@ -0,0 +1,123 @@ +import { __ } from '@wordpress/i18n'; +import { lock } from '@wordpress/icons'; +import { Stack, Text } from '@wordpress/ui'; +import { CardRow, ProtectCard } from '../../components/card'; +import IpListField from '../../components/settings/ip-list-field'; +import SettingToggle from '../../components/settings/setting-toggle'; +import type { DashboardContext } from '../types'; +import type { LoginProtectionState } from './types'; + +const NO_LOCKS: LoginProtectionState[ 'ssoLocks' ] = { matchByEmail: false, twoStep: false }; + +/** + * The Login protection settings: brute force protection, account protection and WordPress.com login. + * + * @param props - The dashboard context. + * @param props.state - This section's state from PHP. + * @param props.settings - The settings and their save function. + * @return The card. + */ +export default function LoginProtectionSettingsCard( { state, settings: data }: DashboardContext ) { + const login = state as LoginProtectionState | undefined; + const settings = data.settings ?? {}; + const ssoLocks = login?.ssoLocks ?? NO_LOCKS; + + return ( + + + + } className="jp-protect-card__subheading"> + { __( 'Brute force protection', 'jetpack' ) } + + + + { Boolean( settings.jetpack_waf_ip_allow_list_enabled ) && ( + + ) } + + + + + } className="jp-protect-card__subheading"> + { __( 'Account protection', 'jetpack' ) } + + + + + + + } className="jp-protect-card__subheading"> + { __( 'WordPress.com login', 'jetpack' ) } + + + + + + + + ); +} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts new file mode 100644 index 000000000000..f778408399e8 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts @@ -0,0 +1,15 @@ +export type ModuleState = { + available: boolean; + active: boolean; +}; + +export type LoginProtectionState = { + bruteForce: ModuleState; + accountProtection: ModuleState; + sso: ModuleState; + /** All-time blocked login attempts, or null while brute force protection is off. */ + blockedCount: number | null; + /** Settings a filter or constant forces, so they can't be changed here. */ + ssoLocks: { matchByEmail: boolean; twoStep: boolean }; + currentIp: string; +}; From 5c74b1ed1e6f20ba1c372b37708b65b5b0248e68 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 5 Oct 2026 17:53:31 -0700 Subject: [PATCH 11/24] Protect Dashboard: Address review feedback for the Login protection section --- ...ack-protect-dashboard-login-protection.php | 14 +- .../login-protection-card.tsx | 21 ++- .../login-protection-settings-card.tsx | 130 ++++++++++++++---- .../sections/login-protection/types.ts | 12 +- 4 files changed, 142 insertions(+), 35 deletions(-) diff --git a/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php b/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php index 90dec689d0b1..2c1f9225d68c 100644 --- a/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php +++ b/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php @@ -5,8 +5,10 @@ * @package automattic/jetpack */ +use Automattic\Jetpack\Connection\Manager as Connection_Manager; use Automattic\Jetpack\Connection\SSO\Helpers as SSO_Helpers; use Automattic\Jetpack\IP\Utils as IP_Utils; +use Automattic\Jetpack\Status; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -34,18 +36,22 @@ public function get_key() { * @return array */ public function get_state() { - $brute_force = Jetpack_Protect_Dashboard::get_module_state( 'protect' ); - return array( - 'bruteForce' => $brute_force, + 'bruteForce' => Jetpack_Protect_Dashboard::get_module_state( 'protect' ), 'accountProtection' => Jetpack_Protect_Dashboard::get_module_state( 'account-protection' ), 'sso' => Jetpack_Protect_Dashboard::get_module_state( 'sso' ), - 'blockedCount' => $brute_force['active'] ? (int) get_site_option( 'jetpack_protect_blocked_attempts', 0 ) : null, + 'blockedCount' => number_format_i18n( (int) get_site_option( 'jetpack_protect_blocked_attempts', 0 ) ), + // WordPress.com login needs a connected owner and doesn't work in offline mode. + 'ssoUsable' => ( new Connection_Manager( 'jetpack' ) )->has_connected_owner() && ! ( new Status() )->is_offline_mode(), // A filter or constant can force these, and Jetpack Settings then locks the toggle. 'ssoLocks' => array( 'matchByEmail' => method_exists( SSO_Helpers::class, 'is_match_by_email_checkbox_disabled' ) && SSO_Helpers::is_match_by_email_checkbox_disabled(), 'twoStep' => method_exists( SSO_Helpers::class, 'is_require_two_step_checkbox_disabled' ) && SSO_Helpers::is_require_two_step_checkbox_disabled(), ), + 'ssoEffective' => array( + 'matchByEmail' => SSO_Helpers::match_by_email(), + 'twoStep' => SSO_Helpers::is_two_step_required(), + ), 'currentIp' => (string) IP_Utils::get_ip(), ); } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx index 3800623c410b..f9bf77d65eac 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx @@ -4,6 +4,7 @@ import { Badge, Stack, Text } from '@wordpress/ui'; import { CardRow, ProtectCard, Stat } from '../../components/card'; import SettingsLink from '../../components/settings-link'; import isModuleActive from '../../data/is-module-active'; +import type { CardStatus } from '../../components/card'; import type { DashboardContext } from '../types'; import type { LoginProtectionState, ModuleState } from './types'; @@ -47,6 +48,20 @@ function Method( { ); } +/** + * The card badge's intent: neutral when no method is available. + * + * @param on - How many methods are on. + * @param available - How many methods are available. + * @return The intent. + */ +function getStatusIntent( on: number, available: number ): CardStatus[ 'intent' ] { + if ( available === 0 ) { + return 'none'; + } + return on === available ? 'stable' : 'medium'; +} + /** * The Login protection card: brute force protection, account protection and WordPress.com login. * @@ -85,14 +100,14 @@ export default function LoginProtectionCard( { state, settings, openSettings }: on, available ), - intent: on === available ? 'stable' : 'medium', + intent: getStatusIntent( on, available ), } } > { bruteForce.active && ( ) } @@ -119,7 +134,7 @@ export default function LoginProtectionCard( { state, settings, openSettings }: /> - { __( 'Configure Login Protection', 'jetpack' ) } + { __( 'Configure login protection', 'jetpack' ) } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx index 39e468ea94ec..d1bb500c5baf 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx @@ -1,14 +1,64 @@ +import { ToggleControl } from '@wordpress/components'; +import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { lock } from '@wordpress/icons'; -import { Stack, Text } from '@wordpress/ui'; +import { AlertDialog, Stack, Text } from '@wordpress/ui'; import { CardRow, ProtectCard } from '../../components/card'; import IpListField from '../../components/settings/ip-list-field'; import SettingToggle from '../../components/settings/setting-toggle'; +import type { ProtectSettingsData } from '../../data/use-protect-settings'; import type { DashboardContext } from '../types'; import type { LoginProtectionState } from './types'; +const noop = () => {}; const NO_LOCKS: LoginProtectionState[ 'ssoLocks' ] = { matchByEmail: false, twoStep: false }; +/** + * A WordPress.com login option; a locked one shows the value SSO actually applies. + * + * @param props - Component props. + * @param props.data - The settings and their save function. + * @param props.name - The setting key. + * @param props.label - The toggle's label. + * @param props.lockedHelp - Help text shown while a filter or constant forces the value. + * @param props.locked - Whether a filter or constant forces the value. + * @param props.effective - The forced value. + * @param props.ssoOn - Whether WordPress.com login is on. + * @return The toggle. + */ +function SsoOptionToggle( { + data, + name, + label, + lockedHelp, + locked, + effective, + ssoOn, +}: { + data: ProtectSettingsData; + name: string; + label: string; + lockedHelp: string; + locked: boolean; + effective: boolean; + ssoOn: boolean; +} ) { + if ( ! locked ) { + return ; + } + + return ( + + ); +} + /** * The Login protection settings: brute force protection, account protection and WordPress.com login. * @@ -21,6 +71,17 @@ export default function LoginProtectionSettingsCard( { state, settings: data }: const login = state as LoginProtectionState | undefined; const settings = data.settings ?? {}; const ssoLocks = login?.ssoLocks ?? NO_LOCKS; + const ssoEffective = login?.ssoEffective ?? NO_LOCKS; + const ssoUsable = login?.ssoUsable ?? false; + const ssoOn = Boolean( settings.sso ); + const { save } = data; + + const [ confirmingSsoOff, setConfirmingSsoOff ] = useState( false ); + const onSsoChange = useCallback( + ( value: boolean ) => ( value ? save( { sso: true } ) : setConfirmingSsoOff( true ) ), + [ save ] + ); + const disableSso = useCallback( () => save( { sso: false } ), [ save ] ); return ( @@ -32,6 +93,7 @@ export default function LoginProtectionSettingsCard( { state, settings: data }: } className="jp-protect-card__subheading"> { __( 'WordPress.com login', 'jetpack' ) } - - - + + + ); } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts index f778408399e8..95c07c057a07 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts @@ -3,13 +3,19 @@ export type ModuleState = { active: boolean; }; +type SsoOptions = { matchByEmail: boolean; twoStep: boolean }; + export type LoginProtectionState = { bruteForce: ModuleState; accountProtection: ModuleState; sso: ModuleState; - /** All-time blocked login attempts, or null while brute force protection is off. */ - blockedCount: number | null; + /** All-time blocked login attempts, formatted for the site's locale. */ + blockedCount: string; + /** Whether WordPress.com login can be turned on: it needs a connected owner and no offline mode. */ + ssoUsable: boolean; /** Settings a filter or constant forces, so they can't be changed here. */ - ssoLocks: { matchByEmail: boolean; twoStep: boolean }; + ssoLocks: SsoOptions; + /** The values SSO actually applies, after filters and constants. */ + ssoEffective: SsoOptions; currentIp: string; }; From 32d7400b4254fc5661da6ffba5c0b5b57a73b7df Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 11:04:04 -0500 Subject: [PATCH 12/24] My Jetpack: Point Protect's manage link at the dashboard module With the protect-dashboard module active and no standalone plugin, the link went to protect-details or Jetpack Cloud instead of the Protect page. --- .../my-jetpack/src/products/class-protect.php | 4 +- .../tests/php/Protect_Product_Test.php | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 projects/packages/my-jetpack/tests/php/Protect_Product_Test.php diff --git a/projects/packages/my-jetpack/src/products/class-protect.php b/projects/packages/my-jetpack/src/products/class-protect.php index ec0a95efbfe4..494c8be12bf1 100644 --- a/projects/packages/my-jetpack/src/products/class-protect.php +++ b/projects/packages/my-jetpack/src/products/class-protect.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\My_Jetpack\Products; +use Automattic\Jetpack\Modules; use Automattic\Jetpack\My_Jetpack\Hybrid_Product; use Automattic\Jetpack\My_Jetpack\Wpcom_Products; use Automattic\Jetpack\Protect_Status\Status as Protect_Status; @@ -439,7 +440,8 @@ public static function get_post_checkout_urls_by_feature() { * @return ?string */ public static function get_manage_url() { - if ( static::is_standalone_plugin_active() ) { + // The Jetpack plugin's `protect-dashboard` module serves the same page as the standalone plugin. + if ( static::is_standalone_plugin_active() || ( new Modules() )->is_active( 'protect-dashboard' ) ) { // Protect admin dashboard. return admin_url( 'admin.php?page=jetpack-protect' ); } diff --git a/projects/packages/my-jetpack/tests/php/Protect_Product_Test.php b/projects/packages/my-jetpack/tests/php/Protect_Product_Test.php new file mode 100644 index 000000000000..45f59e6df8a7 --- /dev/null +++ b/projects/packages/my-jetpack/tests/php/Protect_Product_Test.php @@ -0,0 +1,43 @@ +assertFalse( Protect::is_standalone_plugin_active() ); + $this->assertSame( admin_url( 'admin.php?page=jetpack-protect' ), Protect::get_manage_url() ); + } +} From 8477ec89d67d53de03fb16630f2c3527930d1cac Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 11:04:08 -0500 Subject: [PATCH 13/24] Protect Dashboard: Test the flag gate and the menu takeover --- projects/plugins/jetpack/phpunit.11.xml.dist | 3 + projects/plugins/jetpack/phpunit.9.xml.dist | 3 + ...k_Protect_Dashboard_Feature_Flags_Test.php | 65 ++++++++++ .../Jetpack_Protect_Dashboard_Test.php | 112 ++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Protect_Dashboard_Feature_Flags_Test.php create mode 100644 projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php diff --git a/projects/plugins/jetpack/phpunit.11.xml.dist b/projects/plugins/jetpack/phpunit.11.xml.dist index e580234a75bd..68ed8d9a40ec 100644 --- a/projects/plugins/jetpack/phpunit.11.xml.dist +++ b/projects/plugins/jetpack/phpunit.11.xml.dist @@ -138,6 +138,9 @@ tests/php/modules/canonical-urls + + tests/php/modules/protect-dashboard + tests/php/Jetpack_Compat_Functions_Test.php diff --git a/projects/plugins/jetpack/phpunit.9.xml.dist b/projects/plugins/jetpack/phpunit.9.xml.dist index b6a6c95d8a6d..b84e34be935b 100644 --- a/projects/plugins/jetpack/phpunit.9.xml.dist +++ b/projects/plugins/jetpack/phpunit.9.xml.dist @@ -129,6 +129,9 @@ tests/php/modules/canonical-urls + + tests/php/modules/protect-dashboard + tests/php/Jetpack_Compat_Functions_Test.php diff --git a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Protect_Dashboard_Feature_Flags_Test.php b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Protect_Dashboard_Feature_Flags_Test.php new file mode 100644 index 000000000000..43cca6ba12da --- /dev/null +++ b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Protect_Dashboard_Feature_Flags_Test.php @@ -0,0 +1,65 @@ +assertNotNull( Feature_Flags::get( Jetpack_Protect_Dashboard_Feature_Flags::DASHBOARD ) ); + } + + public function test_is_off_by_default() { + $this->assertFalse( Feature_Flags::is_enabled( Jetpack_Protect_Dashboard_Feature_Flags::DASHBOARD ) ); + } + + public function test_module_is_unavailable_while_the_flag_is_off() { + $this->assertNotContains( Jetpack_Protect_Dashboard_Feature_Flags::MODULE, Jetpack::get_available_modules() ); + } + + public function test_module_is_available_while_the_flag_is_on() { + $this->enable_flag(); + + $this->assertContains( Jetpack_Protect_Dashboard_Feature_Flags::MODULE, Jetpack::get_available_modules() ); + } + + public function test_my_jetpack_reads_the_same_flag() { + $this->enable_flag(); + + // Main_Features memoizes per locale, so a locale nothing else uses gets a fresh read of the flag. + add_filter( + 'locale', + static function () { + return 'protect_dashboard_flag_on'; + } + ); + + $this->assertTrue( Main_Features::get_feature_definitions()['protect-dashboard']['delivery']['jetpack'] ); + } +} diff --git a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php new file mode 100644 index 000000000000..355886ae0fcb --- /dev/null +++ b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php @@ -0,0 +1,112 @@ + array( 0 ), + 'the slug registered twice' => array( 2 ), + ); + } + + /** + * @dataProvider provide_registered_item_counts + * + * @param int $registered Items already queued on the Protect slug. + */ + #[DataProvider( 'provide_registered_item_counts' )] + public function test_add_menu_leaves_one_item_that_renders_the_dashboard( $registered ) { + for ( $i = 0; $i < $registered; $i++ ) { + $this->register_plugin_item(); + } + + Jetpack_Protect_Dashboard::add_menu(); + + $item = Admin_Menu::remove_menu( Jetpack_Protect_Dashboard::MENU_SLUG ); + $this->assertIsArray( $item ); + $this->assertSame( array( Jetpack_Protect_Dashboard::class, 'render' ), $item['function'] ); + $this->assertFalse( Admin_Menu::remove_menu( Jetpack_Protect_Dashboard::MENU_SLUG ) ); + } + + /** + * Both names core can give the page. + * + * @return array[] + */ + public static function provide_load_hooks() { + return array( + array( 'load-jetpack_page_' . Jetpack_Protect_Dashboard::MENU_SLUG ), + array( 'load-admin_page_' . Jetpack_Protect_Dashboard::MENU_SLUG ), + ); + } + + /** + * @dataProvider provide_load_hooks + * + * @param string $hook The page's load hook. + */ + #[DataProvider( 'provide_load_hooks' )] + public function test_add_menu_drops_the_callbacks_already_on_the_load_hook( $hook ) { + $this->register_plugin_item(); + add_action( $hook, '__return_null' ); + + Jetpack_Protect_Dashboard::add_menu(); + + $this->assertFalse( has_action( $hook, '__return_null' ) ); + // Admin_Menu::add_menu() puts its own callback back for the new item. + $this->assertNotFalse( has_action( $hook, array( Admin_Menu::class, 'hide_core_admin_notices' ) ) ); + } + + public function test_render_says_so_when_the_build_is_missing() { + if ( function_exists( 'jetpack_plugin_jetpack_protect_hub_wp_admin_render_page' ) ) { + $this->markTestSkipped( 'Needs the wp-build output not to be loaded.' ); + } + + $this->assertStringContainsString( 'notice-error', get_echo( array( Jetpack_Protect_Dashboard::class, 'render' ) ) ); + } +} From fb487b835059b92b517c119055b66f6134b5c407 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 11:24:41 -0500 Subject: [PATCH 14/24] Protect Dashboard: Capture render output with output buffering --- .../protect-dashboard/Jetpack_Protect_Dashboard_Test.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php index 355886ae0fcb..a186fb4ad4f2 100644 --- a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php @@ -107,6 +107,10 @@ public function test_render_says_so_when_the_build_is_missing() { $this->markTestSkipped( 'Needs the wp-build output not to be loaded.' ); } - $this->assertStringContainsString( 'notice-error', get_echo( array( Jetpack_Protect_Dashboard::class, 'render' ) ) ); + ob_start(); + Jetpack_Protect_Dashboard::render(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'notice-error', $output ); } } From f334b5e3316622b109f1b8dd7dc6564eaa57951b Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 11:25:05 -0500 Subject: [PATCH 15/24] Protect Dashboard: Keep IP list drafts and let a failed settings load retry A failed save no longer resets the IP list field to the saved value, and a failed settings load now clears its error on retry and offers a Try again button. Section types take an optional state type, the tab search param is narrowed at runtime, and the settings hook returns a stable object. --- .../components/settings-tab.tsx | 7 ++++- .../components/settings/ip-list-field.tsx | 26 +++++++++++++++---- .../data/use-protect-settings.ts | 12 ++++++--- .../protect-dashboard/sections/types.ts | 18 ++++++------- .../routes/protect-dashboard/stage.tsx | 10 ++++--- 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx index 2dd045c8c703..1d286bcc29b5 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx @@ -10,7 +10,7 @@ import type { DashboardContext } from '../sections/types'; * @return The tab. */ export default function SettingsTab( props: Omit< DashboardContext, 'state' > ) { - const { isLoaded, error, dismissError } = props.settings; + const { isLoaded, error, dismissError, load } = props.settings; const state = window.jetpackProtectDashboard ?? {}; const cards = sections.filter( section => section.SettingsCard ); @@ -18,6 +18,11 @@ export default function SettingsTab( props: Omit< DashboardContext, 'state' > ) return error ? ( { error } + + + { __( 'Try again', 'jetpack' ) } + + ) : ( diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx index 97f6fc0e47b5..2128c6b1c83d 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Button, Stack, TextareaControl } from '@wordpress/ui'; import type { ProtectSettingsData } from '../../data/use-protect-settings'; @@ -28,14 +28,30 @@ export default function IpListField( { data, name, label, description, currentIp typeof data.settings?.[ name ] === 'string' ? ( data.settings[ name ] as string ) : ''; const [ draft, setDraft ] = useState( saved ); - useEffect( () => setDraft( saved ), [ saved ] ); + const lastSaved = useRef( saved ); + const submitted = useRef< string | null >( null ); + const saving = data.isSaving( name ); + + // Follow `saved` unless the draft has edits, or `saved` moved because this field's own save failed. + useEffect( () => { + const previous = lastSaved.current; + const rolledBack = submitted.current !== null && saved !== submitted.current; + setDraft( current => ( current === previous && ! rolledBack ? saved : current ) ); + lastSaved.current = saved; + if ( ! saving ) { + submitted.current = null; + } + }, [ saved, saving ] ); const onChange = useCallback( ( event: ChangeEvent< HTMLTextAreaElement > ) => setDraft( event.target.value ), [] ); const { save } = data; - const onSave = useCallback( () => save( { [ name ]: draft } ), [ save, name, draft ] ); + const onSave = useCallback( () => { + submitted.current = draft; + return save( { [ name ]: draft } ); + }, [ save, name, draft ] ); const onAddIp = useCallback( () => setDraft( current => @@ -65,8 +81,8 @@ export default function IpListField( { data, name, label, description, currentIp variant="outline" size="compact" onClick={ onSave } - disabled={ draft === saved || data.isSaving( name ) } - loading={ data.isSaving( name ) } + disabled={ draft === saved || saving } + loading={ saving } > { __( 'Save', 'jetpack' ) } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts b/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts index bb3adac32082..1b2a2d5d7bbe 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts +++ b/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts @@ -1,5 +1,5 @@ import apiFetch from '@wordpress/api-fetch'; -import { useCallback, useRef, useState } from '@wordpress/element'; +import { useCallback, useMemo, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** Flat Jetpack settings, keyed by option or module slug, as `jetpack/v4/settings` returns them. */ @@ -19,7 +19,7 @@ export type ProtectSettingsData = { error: string | null; dismissError: () => void; isSaving: ( key: string ) => boolean; - /** Fetches the settings once; later calls do nothing. */ + /** Fetches the settings once; later calls do nothing unless the fetch failed. */ load: () => void; /** Saves to `path` (default `/jetpack/v4/settings`) and merges the patch into `settings`. */ save: ( patch: ProtectSettings, path?: string ) => Promise< void >; @@ -53,6 +53,7 @@ export default function useProtectSettings(): ProtectSettingsData { return; } loadStarted.current = true; + setError( null ); Promise.all( [ apiFetch< ProtectSettings >( { path: '/jetpack/v4/settings' } ), @@ -69,7 +70,7 @@ export default function useProtectSettings(): ProtectSettingsData { } ) .catch( () => { loadStarted.current = false; - setError( __( 'Your settings couldn’t be loaded. Try again.', 'jetpack' ) ); + setError( __( 'Your settings couldn’t be loaded.', 'jetpack' ) ); } ); }, [ setSettings ] ); @@ -113,5 +114,8 @@ export default function useProtectSettings(): ProtectSettingsData { const isSaving = useCallback( ( key: string ) => saving.includes( key ), [ saving ] ); const dismissError = useCallback( () => setError( null ), [] ); - return { settings, isLoaded, waf, error, dismissError, isSaving, load, save }; + return useMemo( + () => ( { settings, isLoaded, waf, error, dismissError, isSaving, load, save } ), + [ settings, isLoaded, waf, error, dismissError, isSaving, load, save ] + ); } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts b/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts index 2fe168d3c48b..63b702680310 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts @@ -1,29 +1,29 @@ import type { ProtectSettingsData } from '../data/use-protect-settings'; import type { ComponentType } from 'react'; -export type DashboardContext = { +export type DashboardContext< S = unknown > = { /** This section's state from PHP, or undefined when PHP registered no section by this key. */ - state: unknown; + state: S; /** Jetpack settings and modules, shared by every Settings card. */ settings: ProtectSettingsData; /** Switches to the Settings tab. */ openSettings: () => void; }; -export type SectionTab = { +export type SectionTab< S = unknown > = { value: string; label: () => string; /** Whether the tab shows, for example only with a paid plan. */ - isAvailable: ( context: DashboardContext ) => boolean; - Panel: ComponentType< DashboardContext >; + isAvailable: ( context: DashboardContext< S > ) => boolean; + Panel: ComponentType< DashboardContext< S > >; }; /** * One feature of the dashboard. Every part is optional, so a section can land one piece at a time. */ -export type ProtectSection = { +export type ProtectSection< S = unknown > = { key: string; - OverviewCard?: ComponentType< DashboardContext >; - SettingsCard?: ComponentType< DashboardContext >; - tab?: SectionTab; + OverviewCard?: ComponentType< DashboardContext< S > >; + SettingsCard?: ComponentType< DashboardContext< S > >; + tab?: SectionTab< S >; }; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx index f42f4841ca07..79e9c192c3da 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx @@ -15,16 +15,18 @@ import './route.scss'; * @return The Protect page. */ const Stage = () => { - const search = useSearch( { from: '/' as unknown as never, strict: false } ) as { tab?: string }; + // `@wordpress/route` types no route tree, so `from` and the navigate argument below need a cast. + const search: Record< string, unknown > = useSearch( { from: '/' as never, strict: false } ); + const requestedTab = typeof search.tab === 'string' ? search.tab : ''; const navigate = useNavigate(); const goTo = useCallback( - ( next: unknown ) => { + ( next: string ) => { navigate( { search: ( prev: Record< string, unknown > ) => ( { ...prev, tab: next === 'overview' ? undefined : next, } ), - } as unknown as Parameters< typeof navigate >[ 0 ] ); + } as Parameters< typeof navigate >[ 0 ] ); }, [ navigate ] ); @@ -39,7 +41,7 @@ const Stage = () => { : [] ); const tabValues = [ 'overview', ...sectionTabs.map( tab => tab.value ), 'settings' ]; - const activeTab = tabValues.includes( search.tab ?? '' ) ? ( search.tab as string ) : 'overview'; + const activeTab = tabValues.includes( requestedTab ) ? requestedTab : 'overview'; const { load } = settings; useEffect( () => { From 7a799590558d86c7745f4a911e237e7282ba14e5 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 11:25:12 -0500 Subject: [PATCH 16/24] Protect Dashboard: Build ProtectCard on the shared Card component ProtectCard and CardRow keep their props and now render through Card from the WordPress UI package, so border, radius, background and header spacing come from the design system. --- .../protect-dashboard/components/card.tsx | 28 +++++++++---------- .../routes/protect-dashboard/route.scss | 12 ++------ 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx index 3f08d1d753b6..7818afd5542f 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx @@ -1,4 +1,4 @@ -import { Badge, Icon, Stack, Text } from '@wordpress/ui'; +import { Badge, Card, Icon, Stack, Text } from '@wordpress/ui'; import type { ComponentProps, ReactElement, ReactNode } from 'react'; export type CardStatus = { label: string; intent: ComponentProps< typeof Badge >[ 'intent' ] }; @@ -24,27 +24,21 @@ type CardProps = { */ export function ProtectCard( { icon, title, status, actions, children }: CardProps ) { return ( -
- } className="jp-protect-card" aria-label={ title }> + } > - }> - { title } - + }>{ title } { status && { status.label } } { actions } - - { children } -
+ + { children } + ); } @@ -63,7 +57,11 @@ export function CardRow( { children: ReactNode; className?: string; } ) { - return
{ children }
; + return ( + + { children } + + ); } /** diff --git a/projects/plugins/jetpack/routes/protect-dashboard/route.scss b/projects/plugins/jetpack/routes/protect-dashboard/route.scss index b0e2f7dda69d..023ea545067a 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/route.scss +++ b/projects/plugins/jetpack/routes/protect-dashboard/route.scss @@ -24,17 +24,11 @@ body.toplevel_page_jetpack-protect { } .jp-protect-card { - overflow: hidden; - border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); - border-radius: var(--wpds-border-radius-md); - background: var(--wpds-color-background-surface-neutral); - - &__header, - &__row { - padding: 16px 24px; - } + // The row is full-bleed for its divider; the padding re-aligns its contents. &__row { + box-sizing: border-box; + padding: 16px var(--wp-ui-card-padding); border-block-start: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); } From 823915c01818bb330f5d2ddb9728e449580d8f2a Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 11:25:17 -0500 Subject: [PATCH 17/24] Protect Dashboard: Add tests for the settings hook, IP list field and section registry Adds routes/ to the client Jest roots so route tests run. --- .../settings/test/ip-list-field.test.tsx | 34 ++++++ .../data/test/use-protect-settings.test.ts | 96 ++++++++++++++++ .../routes/protect-dashboard/package.json | 2 + .../jetpack/tests/jest.config.client.js | 2 + .../Jetpack_Protect_Dashboard_Test.php | 98 ++++++++++++++++ ...Jetpack_Protect_Dashboard_Threats_Test.php | 107 ++++++++++++++++++ 6 files changed, 339 insertions(+) create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings/test/ip-list-field.test.tsx create mode 100644 projects/plugins/jetpack/routes/protect-dashboard/data/test/use-protect-settings.test.ts create mode 100644 projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Test.php create mode 100644 projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Threats_Test.php diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/test/ip-list-field.test.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/test/ip-list-field.test.tsx new file mode 100644 index 000000000000..284d8526a578 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/test/ip-list-field.test.tsx @@ -0,0 +1,34 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import apiFetch from '@wordpress/api-fetch'; +import useProtectSettings from '../../../data/use-protect-settings'; +import IpListField from '../ip-list-field'; + +jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) ); + +const mockApiFetch = apiFetch as unknown as jest.Mock; + +const Field = () => ( + +); + +describe( 'IpListField', () => { + it( 'keeps the typed draft when its save fails', async () => { + let failSave: ( reason: unknown ) => void; + mockApiFetch.mockReturnValue( new Promise( ( _resolve, reject ) => ( failSave = reject ) ) ); + const user = userEvent.setup(); + render( ); + await user.type( screen.getByRole( 'textbox' ), '12.12.12.1' ); + await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); + + await act( async () => failSave( { message: 'Nope' } ) ); + + expect( screen.getByRole( 'textbox' ) ).toHaveValue( '12.12.12.1' ); + expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeEnabled(); + } ); +} ); diff --git a/projects/plugins/jetpack/routes/protect-dashboard/data/test/use-protect-settings.test.ts b/projects/plugins/jetpack/routes/protect-dashboard/data/test/use-protect-settings.test.ts new file mode 100644 index 000000000000..b8b0d5edc6a1 --- /dev/null +++ b/projects/plugins/jetpack/routes/protect-dashboard/data/test/use-protect-settings.test.ts @@ -0,0 +1,96 @@ +import { act, renderHook } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +import useProtectSettings from '../use-protect-settings'; + +jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) ); + +const mockApiFetch = apiFetch as unknown as jest.Mock; + +type Deferred = { + promise: Promise< unknown >; + resolve: ( value: unknown ) => void; + reject: ( reason: unknown ) => void; +}; + +const defer = (): Deferred => { + const deferred = {} as Deferred; + deferred.promise = new Promise( ( resolve, reject ) => { + deferred.resolve = resolve; + deferred.reject = reject; + } ); + return deferred; +}; + +/** + * Answer the next settings GET and the next POST with the given promises. + * + * @param responses - The promises to answer with. + * @param responses.get - Answers `GET /jetpack/v4/settings`. + * @param responses.post - Answers any POST. + */ +const respondWith = ( { get, post }: { get?: Promise< unknown >; post?: Promise< unknown > } ) => { + mockApiFetch.mockImplementation( ( { path, method } ) => { + if ( method === 'POST' ) { + return post; + } + return path === '/jetpack/v4/settings' ? get : Promise.resolve( null ); + } ); +}; + +const settingsRequests = () => + mockApiFetch.mock.calls.filter( + ( [ { path, method } ] ) => path === '/jetpack/v4/settings' && method !== 'POST' + ); + +describe( 'useProtectSettings', () => { + beforeEach( () => mockApiFetch.mockReset() ); + + it.each( [ + [ 'restores a key that had a value', { list: 'old' }, { list: 'old' } ], + [ 'removes a key that had none', { other: 1 }, { other: 1 } ], + ] )( 'a failed save %s', async ( _name, loaded, expected ) => { + respondWith( { + get: Promise.resolve( loaded ), + post: Promise.reject( { message: 'Nope' } ), + } ); + const { result } = renderHook( () => useProtectSettings() ); + await act( async () => result.current.load() ); + + await act( () => result.current.save( { list: 'new' } ) ); + + expect( result.current.settings ).toEqual( expected ); + expect( result.current.error ).toBe( 'Nope' ); + expect( result.current.isSaving( 'list' ) ).toBe( false ); + } ); + + it( 'keeps a value saved while the load was in flight', async () => { + const get = defer(); + respondWith( { get: get.promise, post: Promise.resolve( {} ) } ); + const { result } = renderHook( () => useProtectSettings() ); + act( () => result.current.load() ); + await act( () => result.current.save( { list: 'new' } ) ); + + await act( async () => get.resolve( { list: 'old', other: 1 } ) ); + + expect( result.current.isLoaded ).toBe( true ); + expect( result.current.settings ).toEqual( { list: 'new', other: 1 } ); + } ); + + it( 'refetches after a failed load, clearing the error', async () => { + respondWith( { get: Promise.reject( new Error( 'offline' ) ) } ); + const { result } = renderHook( () => useProtectSettings() ); + await act( async () => result.current.load() ); + expect( result.current.error ).toEqual( expect.any( String ) ); + expect( result.current.isLoaded ).toBe( false ); + + const retry = defer(); + respondWith( { get: retry.promise } ); + act( () => result.current.load() ); + expect( result.current.error ).toBeNull(); + await act( async () => retry.resolve( { other: 1 } ) ); + + expect( settingsRequests() ).toHaveLength( 2 ); + expect( result.current.isLoaded ).toBe( true ); + expect( result.current.settings ).toEqual( { other: 1 } ); + } ); +} ); diff --git a/projects/plugins/jetpack/routes/protect-dashboard/package.json b/projects/plugins/jetpack/routes/protect-dashboard/package.json index 4cae61dcb5f6..640ef0362958 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/package.json +++ b/projects/plugins/jetpack/routes/protect-dashboard/package.json @@ -5,6 +5,8 @@ "@automattic/jetpack-base-styles": "workspace:*", "@automattic/jetpack-components": "workspace:*", "@automattic/jetpack-scan": "workspace:*", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.7", "@wordpress/admin-ui": "2.10.0", "@wordpress/api-fetch": "7.55.0", "@wordpress/components": "40.1.0", diff --git a/projects/plugins/jetpack/tests/jest.config.client.js b/projects/plugins/jetpack/tests/jest.config.client.js index 271a05e4f292..be6be6b60d5a 100644 --- a/projects/plugins/jetpack/tests/jest.config.client.js +++ b/projects/plugins/jetpack/tests/jest.config.client.js @@ -8,6 +8,7 @@ module.exports = { '/_inc/shared/', '/_inc/content-guidelines-ai/', '/modules/', + '/routes/', ], setupFilesAfterEnv: [ ...baseConfig.setupFilesAfterEnv, '/tests/jest-globals.gui.js' ], coverageDirectory: baseConfig.coverageDirectory + '/client', @@ -17,6 +18,7 @@ module.exports = { '/_inc/shared/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', '/_inc/content-guidelines-ai/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', '/modules/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', + '/routes/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', ...baseConfig.collectCoverageFrom.slice( 3 ), ], }; diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Test.php new file mode 100644 index 000000000000..db99e850a87b --- /dev/null +++ b/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Test.php @@ -0,0 +1,98 @@ +setAccessible( true ); + } + $sections->setValue( null, array() ); + } + + /** + * Build a section double. + * + * @param string $key Section key. + * @param array $state Section state. + * @param bool $expects_routes Whether the section must be asked for its routes once. + * @return Jetpack_Protect_Dashboard_Section + */ + private function make_section( $key, $state, $expects_routes = false ) { + $section = $expects_routes + ? $this->createMock( Jetpack_Protect_Dashboard_Section::class ) + : $this->createStub( Jetpack_Protect_Dashboard_Section::class ); + $section->method( 'get_key' )->willReturn( $key ); + $section->method( 'get_state' )->willReturn( $state ); + if ( $expects_routes ) { + $section->expects( $this->once() )->method( 'register_routes' ); + } + return $section; + } + + /** + * Test that a second section with a registered key is refused. + */ + public function test_register_section_keeps_the_first_section_for_a_key() { + $this->setExpectedIncorrectUsage( 'Jetpack_Protect_Dashboard::register_section' ); + + Jetpack_Protect_Dashboard::register_section( $this->make_section( 'scan', array( 'order' => 'first' ) ) ); + Jetpack_Protect_Dashboard::register_section( $this->make_section( 'scan', array( 'order' => 'second' ) ) ); + + $this->assertSame( array( 'scan' => array( 'order' => 'first' ) ), Jetpack_Protect_Dashboard::get_initial_state() ); + } + + /** + * Test that every section contributes its state and its REST routes. + */ + public function test_sections_provide_state_by_key_and_register_routes() { + $scan = $this->make_section( 'scan', array( 'threats' => 2 ), true ); + $monitor = $this->make_section( 'monitor', array( 'active' => true ), true ); + + Jetpack_Protect_Dashboard::register_section( $scan ); + Jetpack_Protect_Dashboard::register_section( $monitor ); + Jetpack_Protect_Dashboard::register_rest_routes(); + + $this->assertSame( + array( + 'scan' => array( 'threats' => 2 ), + 'monitor' => array( 'active' => true ), + ), + Jetpack_Protect_Dashboard::get_initial_state() + ); + } +} diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Threats_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Threats_Test.php new file mode 100644 index 000000000000..8ac50eef93cc --- /dev/null +++ b/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Threats_Test.php @@ -0,0 +1,107 @@ + 'update' ); + + return array( + 'plugin type is pluralized' => array( + array( 'extension' => (object) array( 'type' => 'plugin' ) ), + array( 'extension', 'type' ), + 'plugins', + ), + 'theme type is pluralized' => array( + array( 'extension' => (object) array( 'type' => 'theme' ) ), + array( 'extension', 'type' ), + 'themes', + ), + 'core type is kept' => array( + array( 'extension' => (object) array( 'type' => 'core' ) ), + array( 'extension', 'type' ), + 'core', + ), + 'no extension is null' => array( + array(), + array( 'extension' ), + null, + ), + 'empty fixable is false' => array( + array( 'fixable' => array() ), + array( 'fixable' ), + false, + ), + 'populated fixable is unchanged' => array( + array( 'fixable' => $fixable ), + array( 'fixable' ), + $fixable, + ), + ); + } + + /** + * Test format(). + * + * @dataProvider provider_format + * @param array $threat The threat's properties. + * @param string[] $path Keys leading to the value under test. + * @param mixed $expected The expected value. + */ + #[DataProvider( 'provider_format' )] + public function test_format( $threat, $path, $expected ) { + $actual = Jetpack_Protect_Dashboard_Threats::format( (object) $threat ); + foreach ( $path as $key ) { + $this->assertArrayHasKey( $key, $actual ); + $actual = $actual[ $key ]; + } + + $this->assertSame( $expected, $actual ); + } + + /** + * Inputs for format_all(), and the ids of the threats it should return. + * + * @return array[] + */ + public static function provider_format_all() { + return array( + 'a Traversable is walked' => array( + new ArrayIterator( array( (object) array( 'id' => 1 ), (object) array( 'id' => 2 ) ) ), + array( 1, 2 ), + ), + 'a non-iterable gives no threats' => array( null, array() ), + ); + } + + /** + * Test format_all(). + * + * @dataProvider provider_format_all + * @param mixed $threats The threats to format. + * @param int[] $expected The ids of the formatted threats. + */ + #[DataProvider( 'provider_format_all' )] + public function test_format_all( $threats, $expected ) { + $this->assertSame( $expected, array_column( Jetpack_Protect_Dashboard_Threats::format_all( $threats ), 'id' ) ); + } +} From 3cc966f5210efc36841c75ad7d62b5764689c055 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 11:27:42 -0500 Subject: [PATCH 18/24] Protect Dashboard: Move section and threat tests into the module suite The base branch added a Jetpack_Protect_Dashboard_Test class of its own, so the section registry tests now live in that class and the threats test moves into the same suite directory. --- .../Jetpack_Protect_Dashboard_Test.php | 98 ------------------- .../Jetpack_Protect_Dashboard_Test.php | 70 ++++++++++++- ...Jetpack_Protect_Dashboard_Threats_Test.php | 0 3 files changed, 68 insertions(+), 100 deletions(-) delete mode 100644 projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Test.php rename projects/plugins/jetpack/tests/php/{general => modules/protect-dashboard}/Jetpack_Protect_Dashboard_Threats_Test.php (100%) diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Test.php deleted file mode 100644 index db99e850a87b..000000000000 --- a/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Test.php +++ /dev/null @@ -1,98 +0,0 @@ -setAccessible( true ); - } - $sections->setValue( null, array() ); - } - - /** - * Build a section double. - * - * @param string $key Section key. - * @param array $state Section state. - * @param bool $expects_routes Whether the section must be asked for its routes once. - * @return Jetpack_Protect_Dashboard_Section - */ - private function make_section( $key, $state, $expects_routes = false ) { - $section = $expects_routes - ? $this->createMock( Jetpack_Protect_Dashboard_Section::class ) - : $this->createStub( Jetpack_Protect_Dashboard_Section::class ); - $section->method( 'get_key' )->willReturn( $key ); - $section->method( 'get_state' )->willReturn( $state ); - if ( $expects_routes ) { - $section->expects( $this->once() )->method( 'register_routes' ); - } - return $section; - } - - /** - * Test that a second section with a registered key is refused. - */ - public function test_register_section_keeps_the_first_section_for_a_key() { - $this->setExpectedIncorrectUsage( 'Jetpack_Protect_Dashboard::register_section' ); - - Jetpack_Protect_Dashboard::register_section( $this->make_section( 'scan', array( 'order' => 'first' ) ) ); - Jetpack_Protect_Dashboard::register_section( $this->make_section( 'scan', array( 'order' => 'second' ) ) ); - - $this->assertSame( array( 'scan' => array( 'order' => 'first' ) ), Jetpack_Protect_Dashboard::get_initial_state() ); - } - - /** - * Test that every section contributes its state and its REST routes. - */ - public function test_sections_provide_state_by_key_and_register_routes() { - $scan = $this->make_section( 'scan', array( 'threats' => 2 ), true ); - $monitor = $this->make_section( 'monitor', array( 'active' => true ), true ); - - Jetpack_Protect_Dashboard::register_section( $scan ); - Jetpack_Protect_Dashboard::register_section( $monitor ); - Jetpack_Protect_Dashboard::register_rest_routes(); - - $this->assertSame( - array( - 'scan' => array( 'threats' => 2 ), - 'monitor' => array( 'active' => true ), - ), - Jetpack_Protect_Dashboard::get_initial_state() - ); - } -} diff --git a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php index a186fb4ad4f2..fb3ab5a5917f 100644 --- a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php @@ -20,21 +20,55 @@ class Jetpack_Protect_Dashboard_Test extends WP_UnitTestCase { use \Automattic\Jetpack\PHPUnit\WP_UnitTestCase_Fix; /** - * Start from an empty menu queue. + * Start from an empty menu queue and no sections. */ public function set_up() { parent::set_up(); Admin_Menu::reset(); + self::reset_sections(); } /** - * Leave no queued items behind. + * Leave no queued items or sections behind. */ public function tear_down() { Admin_Menu::reset(); + self::reset_sections(); parent::tear_down(); } + /** + * Empty the private section registry. + */ + private static function reset_sections() { + $sections = new ReflectionProperty( Jetpack_Protect_Dashboard::class, 'sections' ); + // @todo Remove this call once we no longer need to support PHP <8.1. + if ( PHP_VERSION_ID < 80100 ) { + $sections->setAccessible( true ); + } + $sections->setValue( null, array() ); + } + + /** + * Build a section double. + * + * @param string $key Section key. + * @param array $state Section state. + * @param bool $expects_routes Whether the section must be asked for its routes once. + * @return Jetpack_Protect_Dashboard_Section + */ + private function make_section( $key, $state, $expects_routes = false ) { + $section = $expects_routes + ? $this->createMock( Jetpack_Protect_Dashboard_Section::class ) + : $this->createStub( Jetpack_Protect_Dashboard_Section::class ); + $section->method( 'get_key' )->willReturn( $key ); + $section->method( 'get_state' )->willReturn( $state ); + if ( $expects_routes ) { + $section->expects( $this->once() )->method( 'register_routes' ); + } + return $section; + } + /** * Queue an item on the Protect slug, as the Jetpack Protect plugin does. */ @@ -113,4 +147,36 @@ public function test_render_says_so_when_the_build_is_missing() { $this->assertStringContainsString( 'notice-error', $output ); } + + /** + * Test that a second section with a registered key is refused. + */ + public function test_register_section_keeps_the_first_section_for_a_key() { + $this->setExpectedIncorrectUsage( 'Jetpack_Protect_Dashboard::register_section' ); + + Jetpack_Protect_Dashboard::register_section( $this->make_section( 'scan', array( 'order' => 'first' ) ) ); + Jetpack_Protect_Dashboard::register_section( $this->make_section( 'scan', array( 'order' => 'second' ) ) ); + + $this->assertSame( array( 'scan' => array( 'order' => 'first' ) ), Jetpack_Protect_Dashboard::get_initial_state() ); + } + + /** + * Test that every section contributes its state and its REST routes. + */ + public function test_sections_provide_state_by_key_and_register_routes() { + $scan = $this->make_section( 'scan', array( 'threats' => 2 ), true ); + $monitor = $this->make_section( 'monitor', array( 'active' => true ), true ); + + Jetpack_Protect_Dashboard::register_section( $scan ); + Jetpack_Protect_Dashboard::register_section( $monitor ); + Jetpack_Protect_Dashboard::register_rest_routes(); + + $this->assertSame( + array( + 'scan' => array( 'threats' => 2 ), + 'monitor' => array( 'active' => true ), + ), + Jetpack_Protect_Dashboard::get_initial_state() + ); + } } diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Threats_Test.php b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Threats_Test.php similarity index 100% rename from projects/plugins/jetpack/tests/php/general/Jetpack_Protect_Dashboard_Threats_Test.php rename to projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Threats_Test.php From 98ff7ae03b8df3b833abb1240b8b52898ccb3274 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 12:00:41 -0500 Subject: [PATCH 19/24] My Jetpack: Read Protect's Jetpack delivery from a filter My Jetpack no longer depends on the feature flags package, so the Jetpack plugin now reports the flag through jetpack_my_jetpack_protect_in_jetpack. --- .../packages/my-jetpack/src/class-main-features.php | 11 ++++++++--- .../class-jetpack-protect-dashboard-feature-flags.php | 2 ++ .../Jetpack_Protect_Dashboard_Feature_Flags_Test.php | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/projects/packages/my-jetpack/src/class-main-features.php b/projects/packages/my-jetpack/src/class-main-features.php index 755c1603079d..6b34c067a2a2 100644 --- a/projects/packages/my-jetpack/src/class-main-features.php +++ b/projects/packages/my-jetpack/src/class-main-features.php @@ -7,7 +7,6 @@ namespace Automattic\Jetpack\My_Jetpack; -use Automattic\Jetpack\Feature_Flags\Feature_Flags; use Automattic\Jetpack\Plugins_Installer; /** @@ -365,8 +364,14 @@ public static function get_feature_definitions() { __( 'Instant email alerts when a threat is found', 'jetpack-my-jetpack' ), ), 'delivery' => array( - // The flag is registered by the Jetpack plugin, which owns the `protect-dashboard` module. - 'jetpack' => Feature_Flags::is_enabled( 'jetpack-protect-dashboard' ), + /** + * Filters whether the Jetpack plugin delivers Protect, through its `protect-dashboard` module. + * + * @since $$next-version$$ + * + * @param bool $in_jetpack Whether the Jetpack plugin delivers Protect. Default false. + */ + 'jetpack' => (bool) apply_filters( 'jetpack_my_jetpack_protect_in_jetpack', false ), 'plugin' => 'jetpack-protect', 'plugin_name' => __( 'Jetpack Protect', 'jetpack-my-jetpack' ), 'plugin_url' => 'https://wordpress.org/plugins/jetpack-protect/', diff --git a/projects/plugins/jetpack/_inc/lib/class-jetpack-protect-dashboard-feature-flags.php b/projects/plugins/jetpack/_inc/lib/class-jetpack-protect-dashboard-feature-flags.php index f4c83e26e548..61dc336647df 100644 --- a/projects/plugins/jetpack/_inc/lib/class-jetpack-protect-dashboard-feature-flags.php +++ b/projects/plugins/jetpack/_inc/lib/class-jetpack-protect-dashboard-feature-flags.php @@ -38,6 +38,8 @@ public static function register() { ); add_filter( 'jetpack_get_available_modules', array( __CLASS__, 'filter_available_modules' ) ); + // My Jetpack offers Protect through Jetpack only while the module can be switched on. + add_filter( 'jetpack_my_jetpack_protect_in_jetpack', array( __CLASS__, 'is_enabled' ) ); } /** diff --git a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Protect_Dashboard_Feature_Flags_Test.php b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Protect_Dashboard_Feature_Flags_Test.php index 43cca6ba12da..8bd504d8c6d9 100644 --- a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Protect_Dashboard_Feature_Flags_Test.php +++ b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Protect_Dashboard_Feature_Flags_Test.php @@ -49,7 +49,7 @@ public function test_module_is_available_while_the_flag_is_on() { $this->assertContains( Jetpack_Protect_Dashboard_Feature_Flags::MODULE, Jetpack::get_available_modules() ); } - public function test_my_jetpack_reads_the_same_flag() { + public function test_my_jetpack_follows_the_flag() { $this->enable_flag(); // Main_Features memoizes per locale, so a locale nothing else uses gets a fresh read of the flag. From e0ee4f86ebcd7f1c2210c2b76e016595d30cea5a Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 6 Oct 2026 12:27:18 -0500 Subject: [PATCH 20/24] Protect Dashboard: Type the Login protection section's state Pass LoginProtectionState to ProtectSection and DashboardContext now that they take a state type, and drop the casts. --- .../sections/login-protection/index.tsx | 3 ++- .../login-protection/login-protection-card.tsx | 10 ++++++---- .../login-protection-settings-card.tsx | 9 +++++---- .../sections/login-protection/types.ts | 5 +++++ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx index f1ca9d54d20a..153dc0392bf2 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx @@ -1,8 +1,9 @@ import LoginProtectionCard from './login-protection-card'; import LoginProtectionSettingsCard from './login-protection-settings-card'; import type { ProtectSection } from '../types'; +import type { LoginProtectionState } from './types'; -const section: ProtectSection = { +const section: ProtectSection< LoginProtectionState | undefined > = { key: 'loginProtection', OverviewCard: LoginProtectionCard, SettingsCard: LoginProtectionSettingsCard, diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx index f9bf77d65eac..b4174cf72418 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx @@ -4,9 +4,8 @@ import { Badge, Stack, Text } from '@wordpress/ui'; import { CardRow, ProtectCard, Stat } from '../../components/card'; import SettingsLink from '../../components/settings-link'; import isModuleActive from '../../data/is-module-active'; +import type { LoginProtectionContext, ModuleState } from './types'; import type { CardStatus } from '../../components/card'; -import type { DashboardContext } from '../types'; -import type { LoginProtectionState, ModuleState } from './types'; /** * One way of protecting logins, with whether it's on. @@ -71,8 +70,11 @@ function getStatusIntent( on: number, available: number ): CardStatus[ 'intent' * @param props.openSettings - Switches to the Settings tab. * @return The card, or nothing without the section's state. */ -export default function LoginProtectionCard( { state, settings, openSettings }: DashboardContext ) { - const login = state as LoginProtectionState | undefined; +export default function LoginProtectionCard( { + state: login, + settings, + openSettings, +}: LoginProtectionContext ) { if ( ! login ) { return null; } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx index d1bb500c5baf..b698b46142c3 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx @@ -6,9 +6,8 @@ import { AlertDialog, Stack, Text } from '@wordpress/ui'; import { CardRow, ProtectCard } from '../../components/card'; import IpListField from '../../components/settings/ip-list-field'; import SettingToggle from '../../components/settings/setting-toggle'; +import type { LoginProtectionContext, LoginProtectionState } from './types'; import type { ProtectSettingsData } from '../../data/use-protect-settings'; -import type { DashboardContext } from '../types'; -import type { LoginProtectionState } from './types'; const noop = () => {}; const NO_LOCKS: LoginProtectionState[ 'ssoLocks' ] = { matchByEmail: false, twoStep: false }; @@ -67,8 +66,10 @@ function SsoOptionToggle( { * @param props.settings - The settings and their save function. * @return The card. */ -export default function LoginProtectionSettingsCard( { state, settings: data }: DashboardContext ) { - const login = state as LoginProtectionState | undefined; +export default function LoginProtectionSettingsCard( { + state: login, + settings: data, +}: LoginProtectionContext ) { const settings = data.settings ?? {}; const ssoLocks = login?.ssoLocks ?? NO_LOCKS; const ssoEffective = login?.ssoEffective ?? NO_LOCKS; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts index 95c07c057a07..52f749ff0d2d 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts +++ b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts @@ -1,3 +1,5 @@ +import type { DashboardContext } from '../types'; + export type ModuleState = { available: boolean; active: boolean; @@ -19,3 +21,6 @@ export type LoginProtectionState = { ssoEffective: SsoOptions; currentIp: string; }; + +/** Undefined when PHP registered no Login protection section. */ +export type LoginProtectionContext = DashboardContext< LoginProtectionState | undefined >; From 4b15be7f00d296a23292bd0099f925f078b9bb08 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Thu, 8 Oct 2026 10:48:12 -0500 Subject: [PATCH 21/24] Protect Dashboard: Move the Login protection section into the protect package --- pnpm-lock.yaml | 11 +- .../add-protect-dashboard-login-protection | 4 + .../sections/login-protection/index.tsx | 9 +- .../login-protection-card.tsx | 29 +-- .../login-protection-settings-card.tsx | 50 +++-- .../sections/login-protection/types.ts | 0 .../src/sections/class-login-protection.php} | 18 +- ...lass-jetpack-protect-dashboard-threats.php | 66 ------ .../class-jetpack-protect-dashboard.php | 199 ------------------ ...face-jetpack-protect-dashboard-section.php | 39 ---- projects/plugins/jetpack/package.json | 9 - projects/plugins/jetpack/phpunit.11.xml.dist | 3 - projects/plugins/jetpack/phpunit.9.xml.dist | 3 - .../protect-dashboard/components/card.tsx | 98 --------- .../protect-dashboard/components/overview.tsx | 27 --- .../components/settings-link.tsx | 41 ---- .../components/settings-tab.tsx | 51 ----- .../components/settings/ip-list-field.tsx | 101 --------- .../components/settings/setting-toggle.tsx | 39 ---- .../settings/test/ip-list-field.test.tsx | 34 --- .../data/is-module-active.ts | 17 -- .../data/test/use-protect-settings.test.ts | 96 --------- .../data/use-protect-settings.ts | 121 ----------- .../routes/protect-dashboard/package.json | 25 --- .../routes/protect-dashboard/route.scss | 51 ----- .../sections/firewall/index.tsx | 5 - .../sections/history/index.tsx | 5 - .../protect-dashboard/sections/index.ts | 11 - .../sections/login-protection/index.tsx | 12 -- .../sections/monitor/index.tsx | 5 - .../protect-dashboard/sections/scan/index.tsx | 5 - .../protect-dashboard/sections/types.ts | 29 --- .../routes/protect-dashboard/stage.tsx | 93 -------- .../jetpack/routes/protect-dashboard/types.ts | 8 - .../jetpack/tests/jest.config.client.js | 2 - .../Jetpack_Protect_Dashboard_Test.php | 182 ---------------- ...Jetpack_Protect_Dashboard_Threats_Test.php | 107 ---------- 37 files changed, 67 insertions(+), 1538 deletions(-) create mode 100644 projects/packages/protect/changelog/add-protect-dashboard-login-protection rename projects/{plugins/jetpack/routes/protect-dashboard => packages/protect/routes/dashboard}/sections/login-protection/login-protection-card.tsx (80%) rename projects/{plugins/jetpack/routes/protect-dashboard => packages/protect/routes/dashboard}/sections/login-protection/login-protection-settings-card.tsx (85%) rename projects/{plugins/jetpack/routes/protect-dashboard => packages/protect/routes/dashboard}/sections/login-protection/types.ts (100%) rename projects/{plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php => packages/protect/src/sections/class-login-protection.php} (74%) delete mode 100644 projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php delete mode 100644 projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php delete mode 100644 projects/plugins/jetpack/modules/protect-dashboard/interface-jetpack-protect-dashboard-section.php delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/overview.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings/setting-toggle.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/components/settings/test/ip-list-field.test.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/data/is-module-active.ts delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/data/test/use-protect-settings.test.ts delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/package.json delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/route.scss delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/firewall/index.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/history/index.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/index.ts delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/monitor/index.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/scan/index.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/stage.tsx delete mode 100644 projects/plugins/jetpack/routes/protect-dashboard/types.ts delete mode 100644 projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php delete mode 100644 projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Threats_Test.php diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a775d6e445c..bab8d20d14b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6136,9 +6136,6 @@ importers: '@automattic/jetpack-publicize': specifier: workspace:* version: link:../../packages/publicize - '@automattic/jetpack-scan': - specifier: workspace:* - version: link:../../js-packages/scan '@automattic/jetpack-script-data': specifier: workspace:* version: link:../../js-packages/script-data @@ -6238,9 +6235,6 @@ importers: '@wordpress/rich-text': specifier: 7.55.0 version: 7.55.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@wordpress/route': - specifier: 0.21.0 - version: 0.21.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/theme': specifier: 2.1.0 version: 2.1.0(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -6413,9 +6407,6 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 - '@wordpress/admin-ui': - specifier: 2.10.0 - version: 2.10.0(@date-fns/tz@1.5.0)(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/api-fetch': specifier: 7.55.0 version: 7.55.0 @@ -6427,7 +6418,7 @@ importers: version: 5.55.0 '@wordpress/build': specifier: 0.23.0 - version: 0.23.0(patch_hash=151ae1c0c3786b862724512e48d8cfcb4b34faf4cac98e352a450abd4741e022)(@babel/core@7.29.7)(@wordpress/route@0.21.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@2.1.0(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 0.23.0(patch_hash=151ae1c0c3786b862724512e48d8cfcb4b34faf4cac98e352a450abd4741e022)(@babel/core@7.29.7)(@wordpress/theme@2.1.0(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@wordpress/components': specifier: 40.1.0 version: 40.1.0(@date-fns/tz@1.5.0)(@types/react@18.3.31)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/projects/packages/protect/changelog/add-protect-dashboard-login-protection b/projects/packages/protect/changelog/add-protect-dashboard-login-protection new file mode 100644 index 000000000000..6eb28c842152 --- /dev/null +++ b/projects/packages/protect/changelog/add-protect-dashboard-login-protection @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add the Login protection section to the Protect dashboard, covering brute force protection, account protection and WordPress.com login. diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/index.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/index.tsx index 28996967d55a..153dc0392bf2 100644 --- a/projects/packages/protect/routes/dashboard/sections/login-protection/index.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/index.tsx @@ -1,5 +1,12 @@ +import LoginProtectionCard from './login-protection-card'; +import LoginProtectionSettingsCard from './login-protection-settings-card'; import type { ProtectSection } from '../types'; +import type { LoginProtectionState } from './types'; -const section: ProtectSection = { key: 'loginProtection' }; +const section: ProtectSection< LoginProtectionState | undefined > = { + key: 'loginProtection', + OverviewCard: LoginProtectionCard, + SettingsCard: LoginProtectionSettingsCard, +}; export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx similarity index 80% rename from projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx rename to projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx index b4174cf72418..ae7b6cb2dbda 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-card.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx @@ -25,11 +25,11 @@ function Method( { description: string; state: ModuleState; } ) { - let badge = { __( 'Off', 'jetpack' ) }; + let badge = { __( 'Off', 'jetpack-protect-pkg' ) }; if ( ! state.available ) { - badge = { __( 'Unavailable', 'jetpack' ) }; + badge = { __( 'Unavailable', 'jetpack-protect-pkg' ) }; } else if ( state.active ) { - badge = { __( 'On', 'jetpack' ) }; + badge = { __( 'On', 'jetpack-protect-pkg' ) }; } return ( @@ -94,11 +94,11 @@ export default function LoginProtectionCard( { return ( ) } - { __( 'Configure login protection', 'jetpack' ) } + { __( 'Configure login protection', 'jetpack-protect-pkg' ) } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx similarity index 85% rename from projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx rename to projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx index b698b46142c3..86d63f3cef9f 100644 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/login-protection-settings-card.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx @@ -85,37 +85,37 @@ export default function LoginProtectionSettingsCard( { const disableSso = useCallback( () => save( { sso: false } ), [ save ] ); return ( - + } className="jp-protect-card__subheading"> - { __( 'Brute force protection', 'jetpack' ) } + { __( 'Brute force protection', 'jetpack-protect-pkg' ) } { Boolean( settings.jetpack_waf_ip_allow_list_enabled ) && ( ) } @@ -124,16 +124,16 @@ export default function LoginProtectionSettingsCard( { } className="jp-protect-card__subheading"> - { __( 'Account protection', 'jetpack' ) } + { __( 'Account protection', 'jetpack-protect-pkg' ) } @@ -141,18 +141,24 @@ export default function LoginProtectionSettingsCard( { } className="jp-protect-card__subheading"> - { __( 'WordPress.com login', 'jetpack' ) } + { __( 'WordPress.com login', 'jetpack-protect-pkg' ) } diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts b/projects/packages/protect/routes/dashboard/sections/login-protection/types.ts similarity index 100% rename from projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/types.ts rename to projects/packages/protect/routes/dashboard/sections/login-protection/types.ts diff --git a/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php b/projects/packages/protect/src/sections/class-login-protection.php similarity index 74% rename from projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php rename to projects/packages/protect/src/sections/class-login-protection.php index 2c1f9225d68c..26e519d74e37 100644 --- a/projects/plugins/jetpack/modules/protect-dashboard/sections/class-jetpack-protect-dashboard-login-protection.php +++ b/projects/packages/protect/src/sections/class-login-protection.php @@ -2,12 +2,16 @@ /** * Protect dashboard: the Login protection section. * - * @package automattic/jetpack + * @package automattic/jetpack-protect */ +namespace Automattic\Jetpack\Protect\Sections; + use Automattic\Jetpack\Connection\Manager as Connection_Manager; use Automattic\Jetpack\Connection\SSO\Helpers as SSO_Helpers; use Automattic\Jetpack\IP\Utils as IP_Utils; +use Automattic\Jetpack\Protect\Dashboard; +use Automattic\Jetpack\Protect\Dashboard_Section; use Automattic\Jetpack\Status; if ( ! defined( 'ABSPATH' ) ) { @@ -19,7 +23,7 @@ * * @since $$next-version$$ */ -class Jetpack_Protect_Dashboard_Login_Protection implements Jetpack_Protect_Dashboard_Section { +class Login_Protection implements Dashboard_Section { /** * The key the section's state is printed under. @@ -37,12 +41,12 @@ public function get_key() { */ public function get_state() { return array( - 'bruteForce' => Jetpack_Protect_Dashboard::get_module_state( 'protect' ), - 'accountProtection' => Jetpack_Protect_Dashboard::get_module_state( 'account-protection' ), - 'sso' => Jetpack_Protect_Dashboard::get_module_state( 'sso' ), + 'bruteForce' => Dashboard::get_module_state( 'protect' ), + 'accountProtection' => Dashboard::get_module_state( 'account-protection' ), + 'sso' => Dashboard::get_module_state( 'sso' ), 'blockedCount' => number_format_i18n( (int) get_site_option( 'jetpack_protect_blocked_attempts', 0 ) ), // WordPress.com login needs a connected owner and doesn't work in offline mode. - 'ssoUsable' => ( new Connection_Manager( 'jetpack' ) )->has_connected_owner() && ! ( new Status() )->is_offline_mode(), + 'ssoUsable' => ( new Connection_Manager( 'jetpack-protect-pkg' ) )->has_connected_owner() && ! ( new Status() )->is_offline_mode(), // A filter or constant can force these, and Jetpack Settings then locks the toggle. 'ssoLocks' => array( 'matchByEmail' => method_exists( SSO_Helpers::class, 'is_match_by_email_checkbox_disabled' ) && SSO_Helpers::is_match_by_email_checkbox_disabled(), @@ -63,5 +67,3 @@ public function get_state() { */ public function register_routes() {} } - -Jetpack_Protect_Dashboard::register_section( new Jetpack_Protect_Dashboard_Login_Protection() ); diff --git a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php deleted file mode 100644 index 2d239b988048..000000000000 --- a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard-threats.php +++ /dev/null @@ -1,66 +0,0 @@ -extension ?? null; - - return array( - 'id' => $threat->id ?? null, - 'signature' => $threat->signature ?? null, - 'title' => $threat->title ?? null, - 'description' => $threat->description ?? null, - 'status' => $threat->status ?? null, - 'severity' => $threat->severity ?? null, - 'firstDetected' => $threat->first_detected ?? null, - 'fixedIn' => $threat->fixed_in ?? null, - 'fixedOn' => $threat->fixed_on ?? null, - 'fixable' => empty( $threat->fixable ) ? false : $threat->fixable, - 'filename' => $threat->filename ?? null, - 'extension' => $extension ? array( - 'slug' => $extension->slug ?? null, - 'name' => $extension->name ?? null, - 'version' => $extension->version ?? null, - // History names extension types in the singular; the threat list reads the plural. - 'type' => in_array( $extension->type ?? '', array( 'plugin', 'theme' ), true ) ? $extension->type . 's' : ( $extension->type ?? null ), - ) : null, - ); - } - - /** - * Shape a list of threats. - * - * @param iterable $threats Threats, as an array or a Traversable. - * @return array - */ - public static function format_all( $threats ) { - $formatted = array(); - if ( ! is_iterable( $threats ) ) { - return $formatted; - } - foreach ( $threats as $threat ) { - $formatted[] = self::format( $threat ); - } - return $formatted; - } -} diff --git a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php b/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php deleted file mode 100644 index 9a85a55b072d..000000000000 --- a/projects/plugins/jetpack/modules/protect-dashboard/class-jetpack-protect-dashboard.php +++ /dev/null @@ -1,199 +0,0 @@ -get_key(); - if ( isset( self::$sections[ $key ] ) ) { - /* translators: %s is a dashboard section key. */ - $message = sprintf( __( 'A Protect dashboard section with the key "%s" is already registered.', 'jetpack' ), $key ); - _doing_it_wrong( __METHOD__, esc_html( $message ), '$$next-version$$' ); - return; - } - self::$sections[ $key ] = $section; - } - - /** - * Register every section's REST routes. - * - * @return void - */ - public static function register_rest_routes() { - foreach ( self::$sections as $section ) { - $section->register_routes(); - } - } - - /** - * Each section's state, keyed by section key. - * - * @return array - */ - public static function get_initial_state() { - $state = array(); - foreach ( self::$sections as $key => $section ) { - $state[ $key ] = $section->get_state(); - } - return $state; - } - - /** - * Whether the current user may see and use the dashboard's REST routes. - * - * @return bool - */ - public static function can_manage() { - return current_user_can( 'manage_options' ); - } - - /** - * Whether the site has a plan that includes Scan. - * - * @return bool - */ - public static function has_scan_plan() { - return class_exists( Plan::class ) && Plan::has_required_plan(); - } - - /** - * Whether a module can run on this site, and whether it is on. - * - * @param string $module Module slug. - * @return array - */ - public static function get_module_state( $module ) { - $modules = new Modules(); - - return array( - 'available' => $modules->is_module( $module ), - 'active' => $modules->is_active( $module ), - ); - } - - /** - * Load wp-build output, only on this page's request. - * - * @return void - */ - public static function maybe_load_wp_build() { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only. - if ( isset( $_GET['page'] ) && self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) { - Jetpack_WP_Build_Page::load( self::WP_BUILD_PAGE_ID ); - } - } - - /** - * Add the "Protect" item to the Jetpack sidebar, in place of the Jetpack Protect plugin's. - * - * @return void - */ - public static function add_menu() { - // Take the slug over from the Jetpack Protect plugin: drop its item and the scripts it loads for that page. - while ( Admin_Menu::remove_menu( self::MENU_SLUG ) ) { - continue; - } - remove_all_actions( 'load-jetpack_page_' . self::MENU_SLUG ); - remove_all_actions( 'load-admin_page_' . self::MENU_SLUG ); - - Admin_Menu::add_menu( - // "Protect" is a product name and is not translated. - 'Protect', - 'Protect', - 'manage_options', - self::MENU_SLUG, - array( __CLASS__, 'render' ), - null, - array( - 'module' => Jetpack_Protect_Dashboard_Feature_Flags::MODULE, - 'key' => self::MENU_SLUG, - ) - ); - } - - /** - * Render the wp-build page, or say why it could not be rendered. - * - * @return void - */ - public static function render() { - $render_fn = 'jetpack_plugin_' . str_replace( '-', '_', self::WP_BUILD_PAGE_ID ) . '_wp_admin_render_page'; - if ( function_exists( $render_fn ) ) { - $state = wp_json_encode( (object) self::get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); - wp_print_inline_script_tag( 'window.jetpackProtectDashboard = ' . ( false === $state ? '{}' : $state ) . ';' ); - $render_fn(); - return; - } - - printf( - '

Protect

%s

', - esc_html__( 'The Protect dashboard could not be loaded because its assets are missing.', 'jetpack' ) - ); - } -} diff --git a/projects/plugins/jetpack/modules/protect-dashboard/interface-jetpack-protect-dashboard-section.php b/projects/plugins/jetpack/modules/protect-dashboard/interface-jetpack-protect-dashboard-section.php deleted file mode 100644 index 86265365bafa..000000000000 --- a/projects/plugins/jetpack/modules/protect-dashboard/interface-jetpack-protect-dashboard-section.php +++ /dev/null @@ -1,39 +0,0 @@ - tests/php/modules/canonical-urls - - tests/php/modules/protect-dashboard - tests/php/Jetpack_Compat_Functions_Test.php diff --git a/projects/plugins/jetpack/phpunit.9.xml.dist b/projects/plugins/jetpack/phpunit.9.xml.dist index b84e34be935b..b6a6c95d8a6d 100644 --- a/projects/plugins/jetpack/phpunit.9.xml.dist +++ b/projects/plugins/jetpack/phpunit.9.xml.dist @@ -129,9 +129,6 @@ tests/php/modules/canonical-urls - - tests/php/modules/protect-dashboard - tests/php/Jetpack_Compat_Functions_Test.php diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx deleted file mode 100644 index 7818afd5542f..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/card.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Badge, Card, Icon, Stack, Text } from '@wordpress/ui'; -import type { ComponentProps, ReactElement, ReactNode } from 'react'; - -export type CardStatus = { label: string; intent: ComponentProps< typeof Badge >[ 'intent' ] }; - -type CardProps = { - icon: ReactElement; - title: string; - status?: CardStatus; - actions?: ReactNode; - children: ReactNode; -}; - -/** - * A Protect dashboard card: a titled header, then rows divided by rules. - * - * @param props - Component props. - * @param props.icon - The feature's icon. - * @param props.title - The feature's name. - * @param props.status - Optional status badge. - * @param props.actions - Optional controls beside the badge. - * @param props.children - The card's rows. - * @return The card. - */ -export function ProtectCard( { icon, title, status, actions, children }: CardProps ) { - return ( - } className="jp-protect-card" aria-label={ title }> - } - > - - - }>{ title } - - - { status && { status.label } } - { actions } - - - { children } - - ); -} - -/** - * One row of a card. - * - * @param props - Component props. - * @param props.children - Row contents. - * @param props.className - Extra class names. - * @return The row. - */ -export function CardRow( { - children, - className = '', -}: { - children: ReactNode; - className?: string; -} ) { - return ( - - { children } - - ); -} - -/** - * A labelled figure, such as a count of blocked requests. - * - * @param props - Component props. - * @param props.label - What the figure counts. - * @param props.value - The figure. - * @param props.warning - Whether to highlight the figure as needing attention. - * @return The stat. - */ -export function Stat( { - label, - value, - warning = false, -}: { - label: string; - value: ReactNode; - warning?: boolean; -} ) { - return ( - - { label } - - { value } - - - ); -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/overview.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/overview.tsx deleted file mode 100644 index f6efe175b27e..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/overview.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { Text } from '@wordpress/ui'; -import sections from '../sections'; -import type { DashboardContext } from '../sections/types'; - -/** - * The Overview tab: each section's card. - * - * @param props - The dashboard context, without a section's state. - * @return The tab. - */ -export default function Overview( props: Omit< DashboardContext, 'state' > ) { - const state = window.jetpackProtectDashboard ?? {}; - const cards = sections.filter( section => section.OverviewCard ); - - if ( ! cards.length ) { - return { __( 'There’s nothing to show yet.', 'jetpack' ) }; - } - - return ( -
- { cards.map( ( { key, OverviewCard: Card } ) => ( - - ) ) } -
- ); -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx deleted file mode 100644 index 3d3db0afcf2b..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-link.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useCallback } from '@wordpress/element'; -import { Link } from '@wordpress/ui'; -import type { MouseEvent, ReactNode } from 'react'; - -/** Matches `Jetpack_Protect_Dashboard::MENU_SLUG`. */ -export const PROTECT_PAGE_SLUG = 'jetpack-protect'; - -const SETTINGS_URL = `admin.php?page=${ PROTECT_PAGE_SLUG }&p=${ encodeURIComponent( '/?tab=settings' ) }`; - -/** - * A link to the Settings tab that switches tabs in place, and still opens in a new tab when asked. - * - * @param props - Component props. - * @param props.onOpen - Switches to the Settings tab. - * @param props.children - The link text. - * @return The link. - */ -export default function SettingsLink( { - onOpen, - children, -}: { - onOpen: () => void; - children: ReactNode; -} ) { - const onClick = useCallback( - ( event: MouseEvent< HTMLAnchorElement > ) => { - if ( event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0 ) { - return; - } - event.preventDefault(); - onOpen(); - }, - [ onOpen ] - ); - - return ( - - { children } - - ); -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx deleted file mode 100644 index 1d286bcc29b5..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings-tab.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { Notice, Spinner, Stack, Text } from '@wordpress/ui'; -import sections from '../sections'; -import type { DashboardContext } from '../sections/types'; - -/** - * The Settings tab: each section's settings card, once Jetpack's settings have loaded. - * - * @param props - The dashboard context, without a section's state. - * @return The tab. - */ -export default function SettingsTab( props: Omit< DashboardContext, 'state' > ) { - const { isLoaded, error, dismissError, load } = props.settings; - const state = window.jetpackProtectDashboard ?? {}; - const cards = sections.filter( section => section.SettingsCard ); - - if ( ! isLoaded ) { - return error ? ( - - { error } - - - { __( 'Try again', 'jetpack' ) } - - - - ) : ( - - - - ); - } - - return ( -
- { error && ( - - { error } - - - ) } - { cards.length ? ( - cards.map( ( { key, SettingsCard: Card } ) => ( - - ) ) - ) : ( - { __( 'There’s nothing to set up yet.', 'jetpack' ) } - ) } -
- ); -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx deleted file mode 100644 index 2128c6b1c83d..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/ip-list-field.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; -import { Button, Stack, TextareaControl } from '@wordpress/ui'; -import type { ProtectSettingsData } from '../../data/use-protect-settings'; -import type { ChangeEvent } from 'react'; - -type Props = { - data: ProtectSettingsData; - name: string; - label: string; - description: string; - currentIp?: string; -}; - -/** - * An editable list of IP addresses, saved with its own button. - * - * @param props - Component props. - * @param props.data - The settings and their save function. - * @param props.name - The setting holding the list. - * @param props.label - The field's label. - * @param props.description - What the list does. - * @param props.currentIp - When set, offers to add the visitor's own address. - * @return The field. - */ -export default function IpListField( { data, name, label, description, currentIp }: Props ) { - const saved = - typeof data.settings?.[ name ] === 'string' ? ( data.settings[ name ] as string ) : ''; - const [ draft, setDraft ] = useState( saved ); - - const lastSaved = useRef( saved ); - const submitted = useRef< string | null >( null ); - const saving = data.isSaving( name ); - - // Follow `saved` unless the draft has edits, or `saved` moved because this field's own save failed. - useEffect( () => { - const previous = lastSaved.current; - const rolledBack = submitted.current !== null && saved !== submitted.current; - setDraft( current => ( current === previous && ! rolledBack ? saved : current ) ); - lastSaved.current = saved; - if ( ! saving ) { - submitted.current = null; - } - }, [ saved, saving ] ); - - const onChange = useCallback( - ( event: ChangeEvent< HTMLTextAreaElement > ) => setDraft( event.target.value ), - [] - ); - const { save } = data; - const onSave = useCallback( () => { - submitted.current = draft; - return save( { [ name ]: draft } ); - }, [ save, name, draft ] ); - const onAddIp = useCallback( - () => - setDraft( current => - current.trim() ? `${ current.trim() }\n${ currentIp }` : `${ currentIp }` - ), - [ currentIp ] - ); - - const hasIp = !! currentIp && draft.split( /[\s,]+/ ).includes( currentIp ); - - return ( - - - - - { currentIp && ! hasIp && ( - - ) } - - - ); -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/setting-toggle.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/setting-toggle.tsx deleted file mode 100644 index 5c406e8bf201..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/setting-toggle.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ToggleControl } from '@wordpress/components'; -import { useCallback } from '@wordpress/element'; -import type { ProtectSettingsData } from '../../data/use-protect-settings'; -import type { ReactNode } from 'react'; - -type Props = { - data: ProtectSettingsData; - name: string; - label: string; - help?: ReactNode; - disabled?: boolean; -}; - -/** - * A toggle bound to one Jetpack setting or module, saved as soon as it changes. - * - * @param props - Component props. - * @param props.data - The settings and their save function. - * @param props.name - The setting key or module slug. - * @param props.label - The toggle's label. - * @param props.help - Optional help text. - * @param props.disabled - Whether the toggle is unavailable. - * @return The toggle. - */ -export default function SettingToggle( { data, name, label, help, disabled = false }: Props ) { - const { save } = data; - const onChange = useCallback( ( value: boolean ) => save( { [ name ]: value } ), [ save, name ] ); - - return ( - - ); -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/test/ip-list-field.test.tsx b/projects/plugins/jetpack/routes/protect-dashboard/components/settings/test/ip-list-field.test.tsx deleted file mode 100644 index 284d8526a578..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/components/settings/test/ip-list-field.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import apiFetch from '@wordpress/api-fetch'; -import useProtectSettings from '../../../data/use-protect-settings'; -import IpListField from '../ip-list-field'; - -jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) ); - -const mockApiFetch = apiFetch as unknown as jest.Mock; - -const Field = () => ( - -); - -describe( 'IpListField', () => { - it( 'keeps the typed draft when its save fails', async () => { - let failSave: ( reason: unknown ) => void; - mockApiFetch.mockReturnValue( new Promise( ( _resolve, reject ) => ( failSave = reject ) ) ); - const user = userEvent.setup(); - render( ); - await user.type( screen.getByRole( 'textbox' ), '12.12.12.1' ); - await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); - - await act( async () => failSave( { message: 'Nope' } ) ); - - expect( screen.getByRole( 'textbox' ) ).toHaveValue( '12.12.12.1' ); - expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeEnabled(); - } ); -} ); diff --git a/projects/plugins/jetpack/routes/protect-dashboard/data/is-module-active.ts b/projects/plugins/jetpack/routes/protect-dashboard/data/is-module-active.ts deleted file mode 100644 index 35b83c5db14b..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/data/is-module-active.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ProtectSettings } from './use-protect-settings'; - -/** - * A module's live on/off state: the Settings tab's value once loaded, else the page-load state. - * - * @param settings - Jetpack settings, or null while loading. - * @param module - Module slug. - * @param fallback - The state from page load. - * @return Whether the module is on. - */ -export default function isModuleActive( - settings: ProtectSettings | null, - module: string, - fallback: boolean -): boolean { - return typeof settings?.[ module ] === 'boolean' ? ( settings[ module ] as boolean ) : fallback; -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/data/test/use-protect-settings.test.ts b/projects/plugins/jetpack/routes/protect-dashboard/data/test/use-protect-settings.test.ts deleted file mode 100644 index b8b0d5edc6a1..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/data/test/use-protect-settings.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import apiFetch from '@wordpress/api-fetch'; -import useProtectSettings from '../use-protect-settings'; - -jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) ); - -const mockApiFetch = apiFetch as unknown as jest.Mock; - -type Deferred = { - promise: Promise< unknown >; - resolve: ( value: unknown ) => void; - reject: ( reason: unknown ) => void; -}; - -const defer = (): Deferred => { - const deferred = {} as Deferred; - deferred.promise = new Promise( ( resolve, reject ) => { - deferred.resolve = resolve; - deferred.reject = reject; - } ); - return deferred; -}; - -/** - * Answer the next settings GET and the next POST with the given promises. - * - * @param responses - The promises to answer with. - * @param responses.get - Answers `GET /jetpack/v4/settings`. - * @param responses.post - Answers any POST. - */ -const respondWith = ( { get, post }: { get?: Promise< unknown >; post?: Promise< unknown > } ) => { - mockApiFetch.mockImplementation( ( { path, method } ) => { - if ( method === 'POST' ) { - return post; - } - return path === '/jetpack/v4/settings' ? get : Promise.resolve( null ); - } ); -}; - -const settingsRequests = () => - mockApiFetch.mock.calls.filter( - ( [ { path, method } ] ) => path === '/jetpack/v4/settings' && method !== 'POST' - ); - -describe( 'useProtectSettings', () => { - beforeEach( () => mockApiFetch.mockReset() ); - - it.each( [ - [ 'restores a key that had a value', { list: 'old' }, { list: 'old' } ], - [ 'removes a key that had none', { other: 1 }, { other: 1 } ], - ] )( 'a failed save %s', async ( _name, loaded, expected ) => { - respondWith( { - get: Promise.resolve( loaded ), - post: Promise.reject( { message: 'Nope' } ), - } ); - const { result } = renderHook( () => useProtectSettings() ); - await act( async () => result.current.load() ); - - await act( () => result.current.save( { list: 'new' } ) ); - - expect( result.current.settings ).toEqual( expected ); - expect( result.current.error ).toBe( 'Nope' ); - expect( result.current.isSaving( 'list' ) ).toBe( false ); - } ); - - it( 'keeps a value saved while the load was in flight', async () => { - const get = defer(); - respondWith( { get: get.promise, post: Promise.resolve( {} ) } ); - const { result } = renderHook( () => useProtectSettings() ); - act( () => result.current.load() ); - await act( () => result.current.save( { list: 'new' } ) ); - - await act( async () => get.resolve( { list: 'old', other: 1 } ) ); - - expect( result.current.isLoaded ).toBe( true ); - expect( result.current.settings ).toEqual( { list: 'new', other: 1 } ); - } ); - - it( 'refetches after a failed load, clearing the error', async () => { - respondWith( { get: Promise.reject( new Error( 'offline' ) ) } ); - const { result } = renderHook( () => useProtectSettings() ); - await act( async () => result.current.load() ); - expect( result.current.error ).toEqual( expect.any( String ) ); - expect( result.current.isLoaded ).toBe( false ); - - const retry = defer(); - respondWith( { get: retry.promise } ); - act( () => result.current.load() ); - expect( result.current.error ).toBeNull(); - await act( async () => retry.resolve( { other: 1 } ) ); - - expect( settingsRequests() ).toHaveLength( 2 ); - expect( result.current.isLoaded ).toBe( true ); - expect( result.current.settings ).toEqual( { other: 1 } ); - } ); -} ); diff --git a/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts b/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts deleted file mode 100644 index 1b2a2d5d7bbe..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/data/use-protect-settings.ts +++ /dev/null @@ -1,121 +0,0 @@ -import apiFetch from '@wordpress/api-fetch'; -import { useCallback, useMemo, useRef, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; - -/** Flat Jetpack settings, keyed by option or module slug, as `jetpack/v4/settings` returns them. */ -export type ProtectSettings = Record< string, unknown >; - -export type WafConfig = { - waf_supported?: boolean; - automatic_rules_available?: boolean; -}; - -export type ProtectSettingsData = { - /** Null until `load()` has finished, or partial when a save ran first. */ - settings: ProtectSettings | null; - /** Whether `load()` has fetched the full settings. */ - isLoaded: boolean; - waf: WafConfig | null; - error: string | null; - dismissError: () => void; - isSaving: ( key: string ) => boolean; - /** Fetches the settings once; later calls do nothing unless the fetch failed. */ - load: () => void; - /** Saves to `path` (default `/jetpack/v4/settings`) and merges the patch into `settings`. */ - save: ( patch: ProtectSettings, path?: string ) => Promise< void >; -}; - -/** - * Read and save the Jetpack settings behind Protect's features, the same way Jetpack Settings does. - * - * Nothing is fetched until `load()` runs. Saves apply optimistically and roll back if the request fails. - * - * @return The settings, the firewall's capabilities, and load and save functions. - */ -export default function useProtectSettings(): ProtectSettingsData { - const [ settings, setSettingsState ] = useState< ProtectSettings | null >( null ); - const [ waf, setWaf ] = useState< WafConfig | null >( null ); - const [ isLoaded, setIsLoaded ] = useState( false ); - const [ error, setError ] = useState< string | null >( null ); - const [ saving, setSaving ] = useState< string[] >( [] ); - const settingsRef = useRef< ProtectSettings | null >( null ); - const loadStarted = useRef( false ); - // Keys saved so far; the initial GET's response must not overwrite them. - const savedKeys = useRef( new Set< string >() ); - - const setSettings = useCallback( ( next: ProtectSettings ) => { - settingsRef.current = next; - setSettingsState( next ); - }, [] ); - - const load = useCallback( () => { - if ( loadStarted.current ) { - return; - } - loadStarted.current = true; - setError( null ); - - Promise.all( [ - apiFetch< ProtectSettings >( { path: '/jetpack/v4/settings' } ), - apiFetch< WafConfig >( { path: '/jetpack/v4/waf' } ).catch( () => null ), - ] ) - .then( ( [ nextSettings, nextWaf ] ) => { - const current = settingsRef.current ?? {}; - const kept = [ ...savedKeys.current ] - .filter( key => key in current ) - .map( key => [ key, current[ key ] ] ); - setSettings( { ...nextSettings, ...Object.fromEntries( kept ) } ); - setWaf( nextWaf ); - setIsLoaded( true ); - } ) - .catch( () => { - loadStarted.current = false; - setError( __( 'Your settings couldn’t be loaded.', 'jetpack' ) ); - } ); - }, [ setSettings ] ); - - const save = useCallback( - async ( patch: ProtectSettings, path = '/jetpack/v4/settings' ) => { - const keys = Object.keys( patch ); - const before = settingsRef.current; - const previous = Object.fromEntries( - keys.filter( key => before && key in before ).map( key => [ key, before?.[ key ] ] ) - ); - - keys.forEach( key => savedKeys.current.add( key ) ); - setSettings( { ...before, ...patch } ); - setSaving( current => [ ...current, ...keys ] ); - setError( null ); - - try { - await apiFetch( { path, method: 'POST', data: patch } ); - } catch ( e ) { - const rolledBack = { ...settingsRef.current }; - keys.forEach( key => { - savedKeys.current.delete( key ); - if ( key in previous ) { - rolledBack[ key ] = previous[ key ]; - } else { - delete rolledBack[ key ]; - } - } ); - setSettings( rolledBack ); - setError( - ( e as { message?: string } )?.message || - __( 'Your change couldn’t be saved. Try again.', 'jetpack' ) - ); - } finally { - setSaving( current => current.filter( key => ! keys.includes( key ) ) ); - } - }, - [ setSettings ] - ); - - const isSaving = useCallback( ( key: string ) => saving.includes( key ), [ saving ] ); - const dismissError = useCallback( () => setError( null ), [] ); - - return useMemo( - () => ( { settings, isLoaded, waf, error, dismissError, isSaving, load, save } ), - [ settings, isLoaded, waf, error, dismissError, isSaving, load, save ] - ); -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/package.json b/projects/plugins/jetpack/routes/protect-dashboard/package.json deleted file mode 100644 index 640ef0362958..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "version": "1.0.0", - "private": true, - "dependencies": { - "@automattic/jetpack-base-styles": "workspace:*", - "@automattic/jetpack-components": "workspace:*", - "@automattic/jetpack-scan": "workspace:*", - "@testing-library/react": "16.3.3", - "@testing-library/user-event": "14.6.7", - "@wordpress/admin-ui": "2.10.0", - "@wordpress/api-fetch": "7.55.0", - "@wordpress/components": "40.1.0", - "@wordpress/dataviews": "19.0.0", - "@wordpress/date": "5.55.0", - "@wordpress/element": "8.7.0", - "@wordpress/i18n": "6.28.0", - "@wordpress/icons": "16.0.0", - "@wordpress/route": "0.21.0", - "@wordpress/ui": "0.22.0" - }, - "route": { - "path": "/", - "page": "jetpack-protect-hub" - } -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/route.scss b/projects/plugins/jetpack/routes/protect-dashboard/route.scss deleted file mode 100644 index 023ea545067a..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/route.scss +++ /dev/null @@ -1,51 +0,0 @@ -@use "sass:meta"; -@use "@automattic/jetpack-base-styles/admin-page-layout" as *; -@include meta.load-css("@wordpress/dataviews/build-style/style.css"); - -// Shared Jetpack layout: fixed header and tab strip, scrolling middle. -body.jetpack_page_jetpack-protect, -body.toplevel_page_jetpack-protect { - - @include jetpack-admin-page-layout-wp-build; -} - -.jp-protect-dashboard__body { - box-sizing: border-box; - inline-size: 100%; - max-inline-size: 1184px; - margin-inline: auto; - padding: 24px; -} - -.jp-protect-dashboard__cards { - display: flex; - flex-direction: column; - gap: var(--wpds-dimension-gap-lg); -} - -.jp-protect-card { - - // The row is full-bleed for its divider; the padding re-aligns its contents. - &__row { - box-sizing: border-box; - padding: 16px var(--wp-ui-card-padding); - border-block-start: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); - } - - &__muted { - color: var(--wpds-color-foreground-content-neutral-weak); - } - - &__title { - margin: 0; - } - - &__subheading { - margin: 0; - font-weight: 500; - } - - &__stat-value.is-warning { - color: var(--wpds-color-foreground-content-warning); - } -} diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/firewall/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/firewall/index.tsx deleted file mode 100644 index 797b5da81c13..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/firewall/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ProtectSection } from '../types'; - -const section: ProtectSection = { key: 'firewall' }; - -export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/history/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/history/index.tsx deleted file mode 100644 index e691af472b7b..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/history/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ProtectSection } from '../types'; - -const section: ProtectSection = { key: 'history' }; - -export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/index.ts b/projects/plugins/jetpack/routes/protect-dashboard/sections/index.ts deleted file mode 100644 index 5a9caa79b8ab..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import firewall from './firewall'; -import history from './history'; -import loginProtection from './login-protection'; -import monitor from './monitor'; -import scan from './scan'; -import type { ProtectSection } from './types'; - -/** Sections in the order their cards appear. */ -const sections: ProtectSection[] = [ scan, monitor, firewall, loginProtection, history ]; - -export default sections; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx deleted file mode 100644 index 153dc0392bf2..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/login-protection/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import LoginProtectionCard from './login-protection-card'; -import LoginProtectionSettingsCard from './login-protection-settings-card'; -import type { ProtectSection } from '../types'; -import type { LoginProtectionState } from './types'; - -const section: ProtectSection< LoginProtectionState | undefined > = { - key: 'loginProtection', - OverviewCard: LoginProtectionCard, - SettingsCard: LoginProtectionSettingsCard, -}; - -export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/monitor/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/monitor/index.tsx deleted file mode 100644 index 96a7c5a4384f..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/monitor/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ProtectSection } from '../types'; - -const section: ProtectSection = { key: 'monitor' }; - -export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/scan/index.tsx b/projects/plugins/jetpack/routes/protect-dashboard/sections/scan/index.tsx deleted file mode 100644 index 738f17c094d1..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/scan/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ProtectSection } from '../types'; - -const section: ProtectSection = { key: 'scan' }; - -export default section; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts b/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts deleted file mode 100644 index 63b702680310..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/sections/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ProtectSettingsData } from '../data/use-protect-settings'; -import type { ComponentType } from 'react'; - -export type DashboardContext< S = unknown > = { - /** This section's state from PHP, or undefined when PHP registered no section by this key. */ - state: S; - /** Jetpack settings and modules, shared by every Settings card. */ - settings: ProtectSettingsData; - /** Switches to the Settings tab. */ - openSettings: () => void; -}; - -export type SectionTab< S = unknown > = { - value: string; - label: () => string; - /** Whether the tab shows, for example only with a paid plan. */ - isAvailable: ( context: DashboardContext< S > ) => boolean; - Panel: ComponentType< DashboardContext< S > >; -}; - -/** - * One feature of the dashboard. Every part is optional, so a section can land one piece at a time. - */ -export type ProtectSection< S = unknown > = { - key: string; - OverviewCard?: ComponentType< DashboardContext< S > >; - SettingsCard?: ComponentType< DashboardContext< S > >; - tab?: SectionTab< S >; -}; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx b/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx deleted file mode 100644 index 79e9c192c3da..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/stage.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import AdminPage from '@automattic/jetpack-components/admin-page'; -import { useCallback, useEffect, useMemo } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { useNavigate, useSearch } from '@wordpress/route'; -import { Tabs } from '@wordpress/ui'; -import Overview from './components/overview'; -import SettingsTab from './components/settings-tab'; -import useProtectSettings from './data/use-protect-settings'; -import sections from './sections'; -import './route.scss'; - -/** - * Boot stage for the Protect dashboard: Overview, any section tabs, then Settings; the active one in `?tab=`. - * - * @return The Protect page. - */ -const Stage = () => { - // `@wordpress/route` types no route tree, so `from` and the navigate argument below need a cast. - const search: Record< string, unknown > = useSearch( { from: '/' as never, strict: false } ); - const requestedTab = typeof search.tab === 'string' ? search.tab : ''; - const navigate = useNavigate(); - const goTo = useCallback( - ( next: string ) => { - navigate( { - search: ( prev: Record< string, unknown > ) => ( { - ...prev, - tab: next === 'overview' ? undefined : next, - } ), - } as Parameters< typeof navigate >[ 0 ] ); - }, - [ navigate ] - ); - const openSettings = useCallback( () => goTo( 'settings' ), [ goTo ] ); - const settings = useProtectSettings(); - const context = useMemo( () => ( { settings, openSettings } ), [ settings, openSettings ] ); - - const state = window.jetpackProtectDashboard ?? {}; - const sectionTabs = sections.flatMap( section => - section.tab && section.tab.isAvailable( { ...context, state: state[ section.key ] } ) - ? [ { key: section.key, ...section.tab } ] - : [] - ); - const tabValues = [ 'overview', ...sectionTabs.map( tab => tab.value ), 'settings' ]; - const activeTab = tabValues.includes( requestedTab ) ? requestedTab : 'overview'; - const { load } = settings; - - useEffect( () => { - if ( activeTab === 'settings' ) { - load(); - } - }, [ activeTab, load ] ); - - return ( - - -
- - { __( 'Overview', 'jetpack' ) } - { sectionTabs.map( tab => ( - - { tab.label() } - - ) ) } - { __( 'Settings', 'jetpack' ) } - -
-
- - { activeTab === 'overview' && } - - { sectionTabs.map( ( { key, value, Panel } ) => ( - - { activeTab === value && } - - ) ) } - - { activeTab === 'settings' && } - -
-
-
- ); -}; - -export { Stage as stage }; diff --git a/projects/plugins/jetpack/routes/protect-dashboard/types.ts b/projects/plugins/jetpack/routes/protect-dashboard/types.ts deleted file mode 100644 index f1fcda4e4b8a..000000000000 --- a/projects/plugins/jetpack/routes/protect-dashboard/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Each section's state from PHP, keyed by section key. */ -export type ProtectDashboardState = Record< string, unknown >; - -declare global { - interface Window { - jetpackProtectDashboard?: ProtectDashboardState; - } -} diff --git a/projects/plugins/jetpack/tests/jest.config.client.js b/projects/plugins/jetpack/tests/jest.config.client.js index be6be6b60d5a..271a05e4f292 100644 --- a/projects/plugins/jetpack/tests/jest.config.client.js +++ b/projects/plugins/jetpack/tests/jest.config.client.js @@ -8,7 +8,6 @@ module.exports = { '/_inc/shared/', '/_inc/content-guidelines-ai/', '/modules/', - '/routes/', ], setupFilesAfterEnv: [ ...baseConfig.setupFilesAfterEnv, '/tests/jest-globals.gui.js' ], coverageDirectory: baseConfig.coverageDirectory + '/client', @@ -18,7 +17,6 @@ module.exports = { '/_inc/shared/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', '/_inc/content-guidelines-ai/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', '/modules/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', - '/routes/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', ...baseConfig.collectCoverageFrom.slice( 3 ), ], }; diff --git a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php deleted file mode 100644 index fb3ab5a5917f..000000000000 --- a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Test.php +++ /dev/null @@ -1,182 +0,0 @@ -setAccessible( true ); - } - $sections->setValue( null, array() ); - } - - /** - * Build a section double. - * - * @param string $key Section key. - * @param array $state Section state. - * @param bool $expects_routes Whether the section must be asked for its routes once. - * @return Jetpack_Protect_Dashboard_Section - */ - private function make_section( $key, $state, $expects_routes = false ) { - $section = $expects_routes - ? $this->createMock( Jetpack_Protect_Dashboard_Section::class ) - : $this->createStub( Jetpack_Protect_Dashboard_Section::class ); - $section->method( 'get_key' )->willReturn( $key ); - $section->method( 'get_state' )->willReturn( $state ); - if ( $expects_routes ) { - $section->expects( $this->once() )->method( 'register_routes' ); - } - return $section; - } - - /** - * Queue an item on the Protect slug, as the Jetpack Protect plugin does. - */ - private function register_plugin_item() { - Admin_Menu::add_menu( 'Protect', 'Protect', 'manage_options', Jetpack_Protect_Dashboard::MENU_SLUG, '__return_null' ); - } - - /** - * How many items are already queued on the Protect slug. - * - * @return array[] - */ - public static function provide_registered_item_counts() { - return array( - 'nothing registered' => array( 0 ), - 'the slug registered twice' => array( 2 ), - ); - } - - /** - * @dataProvider provide_registered_item_counts - * - * @param int $registered Items already queued on the Protect slug. - */ - #[DataProvider( 'provide_registered_item_counts' )] - public function test_add_menu_leaves_one_item_that_renders_the_dashboard( $registered ) { - for ( $i = 0; $i < $registered; $i++ ) { - $this->register_plugin_item(); - } - - Jetpack_Protect_Dashboard::add_menu(); - - $item = Admin_Menu::remove_menu( Jetpack_Protect_Dashboard::MENU_SLUG ); - $this->assertIsArray( $item ); - $this->assertSame( array( Jetpack_Protect_Dashboard::class, 'render' ), $item['function'] ); - $this->assertFalse( Admin_Menu::remove_menu( Jetpack_Protect_Dashboard::MENU_SLUG ) ); - } - - /** - * Both names core can give the page. - * - * @return array[] - */ - public static function provide_load_hooks() { - return array( - array( 'load-jetpack_page_' . Jetpack_Protect_Dashboard::MENU_SLUG ), - array( 'load-admin_page_' . Jetpack_Protect_Dashboard::MENU_SLUG ), - ); - } - - /** - * @dataProvider provide_load_hooks - * - * @param string $hook The page's load hook. - */ - #[DataProvider( 'provide_load_hooks' )] - public function test_add_menu_drops_the_callbacks_already_on_the_load_hook( $hook ) { - $this->register_plugin_item(); - add_action( $hook, '__return_null' ); - - Jetpack_Protect_Dashboard::add_menu(); - - $this->assertFalse( has_action( $hook, '__return_null' ) ); - // Admin_Menu::add_menu() puts its own callback back for the new item. - $this->assertNotFalse( has_action( $hook, array( Admin_Menu::class, 'hide_core_admin_notices' ) ) ); - } - - public function test_render_says_so_when_the_build_is_missing() { - if ( function_exists( 'jetpack_plugin_jetpack_protect_hub_wp_admin_render_page' ) ) { - $this->markTestSkipped( 'Needs the wp-build output not to be loaded.' ); - } - - ob_start(); - Jetpack_Protect_Dashboard::render(); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'notice-error', $output ); - } - - /** - * Test that a second section with a registered key is refused. - */ - public function test_register_section_keeps_the_first_section_for_a_key() { - $this->setExpectedIncorrectUsage( 'Jetpack_Protect_Dashboard::register_section' ); - - Jetpack_Protect_Dashboard::register_section( $this->make_section( 'scan', array( 'order' => 'first' ) ) ); - Jetpack_Protect_Dashboard::register_section( $this->make_section( 'scan', array( 'order' => 'second' ) ) ); - - $this->assertSame( array( 'scan' => array( 'order' => 'first' ) ), Jetpack_Protect_Dashboard::get_initial_state() ); - } - - /** - * Test that every section contributes its state and its REST routes. - */ - public function test_sections_provide_state_by_key_and_register_routes() { - $scan = $this->make_section( 'scan', array( 'threats' => 2 ), true ); - $monitor = $this->make_section( 'monitor', array( 'active' => true ), true ); - - Jetpack_Protect_Dashboard::register_section( $scan ); - Jetpack_Protect_Dashboard::register_section( $monitor ); - Jetpack_Protect_Dashboard::register_rest_routes(); - - $this->assertSame( - array( - 'scan' => array( 'threats' => 2 ), - 'monitor' => array( 'active' => true ), - ), - Jetpack_Protect_Dashboard::get_initial_state() - ); - } -} diff --git a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Threats_Test.php b/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Threats_Test.php deleted file mode 100644 index 8ac50eef93cc..000000000000 --- a/projects/plugins/jetpack/tests/php/modules/protect-dashboard/Jetpack_Protect_Dashboard_Threats_Test.php +++ /dev/null @@ -1,107 +0,0 @@ - 'update' ); - - return array( - 'plugin type is pluralized' => array( - array( 'extension' => (object) array( 'type' => 'plugin' ) ), - array( 'extension', 'type' ), - 'plugins', - ), - 'theme type is pluralized' => array( - array( 'extension' => (object) array( 'type' => 'theme' ) ), - array( 'extension', 'type' ), - 'themes', - ), - 'core type is kept' => array( - array( 'extension' => (object) array( 'type' => 'core' ) ), - array( 'extension', 'type' ), - 'core', - ), - 'no extension is null' => array( - array(), - array( 'extension' ), - null, - ), - 'empty fixable is false' => array( - array( 'fixable' => array() ), - array( 'fixable' ), - false, - ), - 'populated fixable is unchanged' => array( - array( 'fixable' => $fixable ), - array( 'fixable' ), - $fixable, - ), - ); - } - - /** - * Test format(). - * - * @dataProvider provider_format - * @param array $threat The threat's properties. - * @param string[] $path Keys leading to the value under test. - * @param mixed $expected The expected value. - */ - #[DataProvider( 'provider_format' )] - public function test_format( $threat, $path, $expected ) { - $actual = Jetpack_Protect_Dashboard_Threats::format( (object) $threat ); - foreach ( $path as $key ) { - $this->assertArrayHasKey( $key, $actual ); - $actual = $actual[ $key ]; - } - - $this->assertSame( $expected, $actual ); - } - - /** - * Inputs for format_all(), and the ids of the threats it should return. - * - * @return array[] - */ - public static function provider_format_all() { - return array( - 'a Traversable is walked' => array( - new ArrayIterator( array( (object) array( 'id' => 1 ), (object) array( 'id' => 2 ) ) ), - array( 1, 2 ), - ), - 'a non-iterable gives no threats' => array( null, array() ), - ); - } - - /** - * Test format_all(). - * - * @dataProvider provider_format_all - * @param mixed $threats The threats to format. - * @param int[] $expected The ids of the formatted threats. - */ - #[DataProvider( 'provider_format_all' )] - public function test_format_all( $threats, $expected ) { - $this->assertSame( $expected, array_column( Jetpack_Protect_Dashboard_Threats::format_all( $threats ), 'id' ) ); - } -} From 1d0b785900cd8ba9d8b1490fa4fcbc8eef8d8cd7 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 9 Oct 2026 12:59:29 -0500 Subject: [PATCH 22/24] Protect Dashboard: Link the Login protection card to Settings with TabLink --- .../login-protection/login-protection-card.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx index ae7b6cb2dbda..f82f2d4ad8f7 100644 --- a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx @@ -2,7 +2,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { lock } from '@wordpress/icons'; import { Badge, Stack, Text } from '@wordpress/ui'; import { CardRow, ProtectCard, Stat } from '../../components/card'; -import SettingsLink from '../../components/settings-link'; +import TabLink from '../../components/tab-link'; import isModuleActive from '../../data/is-module-active'; import type { LoginProtectionContext, ModuleState } from './types'; import type { CardStatus } from '../../components/card'; @@ -64,16 +64,16 @@ function getStatusIntent( on: number, available: number ): CardStatus[ 'intent' /** * The Login protection card: brute force protection, account protection and WordPress.com login. * - * @param props - The dashboard context. - * @param props.state - This section's state from PHP. - * @param props.settings - Jetpack settings, for live module state. - * @param props.openSettings - Switches to the Settings tab. + * @param props - The dashboard context. + * @param props.state - This section's state from PHP. + * @param props.settings - Jetpack settings, for live module state. + * @param props.openTab - Switches dashboard tabs, for the link to Settings. * @return The card, or nothing without the section's state. */ export default function LoginProtectionCard( { state: login, settings, - openSettings, + openTab, }: LoginProtectionContext ) { if ( ! login ) { return null; @@ -138,9 +138,9 @@ export default function LoginProtectionCard( { state={ sso } /> - + { __( 'Configure login protection', 'jetpack-protect-pkg' ) } - +
); From d6a99bbb90c8e1692cc195f2778fc32627d67244 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 9 Oct 2026 13:55:08 -0500 Subject: [PATCH 23/24] Protect Dashboard: Add tests for the Login protection section --- .../test/login-protection-card.test.tsx | 76 ++++++++++++ .../login-protection-settings-card.test.tsx | 114 ++++++++++++++++++ .../php/Login_Protection_Section_Test.php | 97 +++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx create mode 100644 projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-settings-card.test.tsx create mode 100644 projects/packages/protect/tests/php/Login_Protection_Section_Test.php diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx new file mode 100644 index 000000000000..650dbbed562b --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LoginProtectionCard from '../login-protection-card'; +import type { ProtectSettingsData } from '../../../data/use-protect-settings'; +import type { LoginProtectionState } from '../types'; + +const on = { available: true, active: true }; +const off = { available: true, active: false }; +const unavailable = { available: false, active: false }; + +const renderCard = ( + state: Partial< LoginProtectionState > = {}, + settings: ProtectSettingsData[ 'settings' ] = null, + openTab = jest.fn() +) => + render( + + ); + +describe( 'LoginProtectionCard', () => { + it.each( [ + [ 'every method is on', {}, null, '3 of 3 on', true ], + [ 'one method is off', { sso: off }, null, '2 of 3 on', true ], + [ 'a method is unavailable', { sso: unavailable }, null, '2 of 2 on', true ], + [ 'brute force protection is off', { bruteForce: off }, null, '2 of 3 on', false ], + [ 'a method was turned off since page load', {}, { protect: false }, '2 of 3 on', false ], + [ 'a method was turned on since page load', { sso: off }, { sso: true }, '3 of 3 on', true ], + [ + 'an unavailable method is still set', + { sso: unavailable }, + { sso: true }, + '2 of 2 on', + true, + ], + ] )( 'counts the methods that are on when %s', ( _name, state, settings, badge, hasCount ) => { + renderCard( state, settings ); + + expect( screen.getByText( badge ) ).toBeInTheDocument(); + expect( screen.queryByText( '1,234' ) !== null ).toBe( hasCount ); + } ); + + it( 'renders nothing when PHP registered no section', () => { + const { container } = render( + + ); + + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'opens the Settings tab from its link', async () => { + const openTab = jest.fn(); + renderCard( {}, null, openTab ); + + await userEvent + .setup() + .click( screen.getByRole( 'link', { name: 'Configure login protection' } ) ); + + expect( openTab ).toHaveBeenCalledWith( 'settings', undefined ); + } ); +} ); diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-settings-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-settings-card.test.tsx new file mode 100644 index 000000000000..acc6d8b04fce --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-settings-card.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LoginProtectionSettingsCard from '../login-protection-settings-card'; +import type { ProtectSettings, ProtectSettingsData } from '../../../data/use-protect-settings'; +import type { LoginProtectionState } from '../types'; + +const SSO = 'Allow users to log in with their WordPress.com account'; +const MATCH_BY_EMAIL = 'Match accounts using email addresses'; +const TWO_STEP = 'Require accounts to use WordPress.com Two-Step Authentication'; +const ALLOW_LIST = 'Always allowed IP addresses'; + +const available = { available: true, active: true }; + +const renderCard = ( { + state = {}, + settings = {}, + save = jest.fn(), +}: { + state?: Partial< LoginProtectionState >; + settings?: ProtectSettings; + save?: jest.Mock; +} = {} ) => + render( + false, + save, + } as unknown as ProtectSettingsData + } + openTab={ jest.fn() } + /> + ); + +describe( 'LoginProtectionSettingsCard', () => { + it( 'asks before turning WordPress.com login off', async () => { + const save = jest.fn(); + const user = userEvent.setup(); + renderCard( { save } ); + + await user.click( screen.getByLabelText( SSO ) ); + + expect( save ).not.toHaveBeenCalled(); + + await user.click( screen.getByRole( 'button', { name: 'Turn off' } ) ); + + expect( save ).toHaveBeenCalledWith( { sso: false } ); + } ); + + it( 'turns WordPress.com login on without asking', async () => { + const save = jest.fn(); + renderCard( { save, settings: { sso: false } } ); + + await userEvent.setup().click( screen.getByLabelText( SSO ) ); + + expect( save ).toHaveBeenCalledWith( { sso: true } ); + } ); + + it.each( [ + [ 'it is usable', {}, {}, true, true ], + [ 'it is off', {}, { sso: false }, true, false ], + [ 'no owner is connected', { ssoUsable: false }, {}, false, true ], + [ 'the module is unavailable', { sso: { available: false, active: false } }, {}, false, true ], + ] )( + 'enables the right WordPress.com login toggles when %s', + ( _name, state, settings, canToggle, canSetOptions ) => { + renderCard( { state, settings } ); + + const enabled = ( label: string ) => + ! screen.getByLabelText( label ).hasAttribute( 'disabled' ); + expect( enabled( SSO ) ).toBe( canToggle ); + expect( enabled( MATCH_BY_EMAIL ) ).toBe( canSetOptions ); + expect( enabled( TWO_STEP ) ).toBe( canSetOptions ); + } + ); + + it( 'locks a forced option to the value WordPress.com login applies', () => { + renderCard( { + state: { + ssoLocks: { matchByEmail: false, twoStep: true }, + ssoEffective: { matchByEmail: false, twoStep: true }, + }, + settings: { jetpack_sso_require_two_step: false }, + } ); + + expect( screen.getByLabelText( TWO_STEP ) ).toBeDisabled(); + expect( screen.getByLabelText( TWO_STEP ) ).toBeChecked(); + expect( + screen.getByText( 'Two-Step Authentication is enforced by your site’s configuration.' ) + ).toBeInTheDocument(); + expect( screen.getByLabelText( MATCH_BY_EMAIL ) ).toBeEnabled(); + } ); + + it.each( [ + [ 'shows', true ], + [ 'hides', false ], + ] )( '%s the allowed addresses with their toggle', ( _name, enabled ) => { + renderCard( { settings: { jetpack_waf_ip_allow_list_enabled: enabled } } ); + + expect( screen.queryByLabelText( ALLOW_LIST ) !== null ).toBe( enabled ); + } ); +} ); diff --git a/projects/packages/protect/tests/php/Login_Protection_Section_Test.php b/projects/packages/protect/tests/php/Login_Protection_Section_Test.php new file mode 100644 index 000000000000..8a825de9c087 --- /dev/null +++ b/projects/packages/protect/tests/php/Login_Protection_Section_Test.php @@ -0,0 +1,97 @@ +assertSame( '12,345', ( new Login_Protection() )->get_state()['blockedCount'] ); + } + + /** + * Test that WordPress.com login can't be turned on without a connected owner. + */ + public function test_sso_is_not_usable_without_a_connected_owner() { + $this->assertFalse( ( new Login_Protection() )->get_state()['ssoUsable'] ); + } + + /** + * Test that stored SSO options are reported as changeable. + */ + public function test_stored_sso_options_are_not_locked() { + update_option( 'jetpack_sso_match_by_email', 0 ); + update_option( 'jetpack_sso_require_two_step', 1 ); + + $state = ( new Login_Protection() )->get_state(); + + $unlocked = array( + 'matchByEmail' => false, + 'twoStep' => false, + ); + $this->assertSame( $unlocked, $state['ssoLocks'] ); + $this->assertSame( + array( + 'matchByEmail' => false, + 'twoStep' => true, + ), + $state['ssoEffective'] + ); + } + + /** + * Test that a filter locks an SSO option and its value wins over the stored one. + */ + public function test_filtered_sso_options_are_locked_to_the_filtered_value() { + update_option( 'jetpack_sso_match_by_email', 1 ); + update_option( 'jetpack_sso_require_two_step', 0 ); + add_filter( 'jetpack_sso_match_by_email', '__return_false' ); + add_filter( 'jetpack_sso_require_two_step', '__return_true' ); + + $state = ( new Login_Protection() )->get_state(); + + $this->assertSame( + array( + 'matchByEmail' => true, + 'twoStep' => true, + ), + $state['ssoLocks'] + ); + $this->assertSame( + array( + 'matchByEmail' => false, + 'twoStep' => true, + ), + $state['ssoEffective'] + ); + } +} From d2a57287fd50d4ea1c1d30abb13a3ef2422cb664 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 13 Oct 2026 14:33:16 -0500 Subject: [PATCH 24/24] Protect Dashboard: Report WordPress.com login accurately when it can't run * The Overview card counted WordPress.com login as on whenever its module was active. With no connected owner, or in offline mode, the module doesn't run, so the card now shows it as unavailable and leaves it out of the count, matching the Settings card. * The disabled toggle blamed a missing owner connection in offline mode too. PHP now reports offline mode separately and each cause gets its own text, including a module that isn't available. * The allow list field was unmounted while its toggle was off, so unsaved addresses were lost when the toggle went off or its save failed. It stays mounted and is hidden instead. * Require automattic/jetpack-ip directly, since the section uses its IP utilities, as every other package that uses them does. * Tests: WordPress.com login usability with a connected owner and in offline mode, module toggles locked when unavailable, and the per-method badges on the Overview card. --- projects/packages/protect/composer.json | 1 + .../login-protection-card.tsx | 3 +- .../login-protection-settings-card.tsx | 42 ++++--- .../test/login-protection-card.test.tsx | 15 +++ .../login-protection-settings-card.test.tsx | 104 +++++++++++++----- .../sections/login-protection/types.ts | 2 + .../src/sections/class-login-protection.php | 5 +- .../php/Login_Protection_Section_Test.php | 51 ++++++++- projects/plugins/jetpack/composer.lock | 3 +- 9 files changed, 177 insertions(+), 49 deletions(-) diff --git a/projects/packages/protect/composer.json b/projects/packages/protect/composer.json index cfe3c49e8b72..63d669f4c533 100644 --- a/projects/packages/protect/composer.json +++ b/projects/packages/protect/composer.json @@ -7,6 +7,7 @@ "php": ">=7.4", "automattic/jetpack-admin-ui": "@dev", "automattic/jetpack-connection": "@dev", + "automattic/jetpack-ip": "@dev", "automattic/jetpack-protect-models": "@dev", "automattic/jetpack-protect-status": "@dev", "automattic/jetpack-status": "@dev", diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx index f82f2d4ad8f7..62107a7311f9 100644 --- a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx @@ -85,7 +85,8 @@ export default function LoginProtectionCard( { } ); const bruteForce = live( 'protect', login.bruteForce ); const accountProtection = live( 'account-protection', login.accountProtection ); - const sso = live( 'sso', login.sso ); + // The module can stay active after the owner disconnects, but WordPress.com login doesn't run then. + const sso = live( 'sso', { ...login.sso, available: login.sso.available && login.ssoUsable } ); const methods = [ bruteForce, accountProtection, sso ]; const on = methods.filter( method => method.available && method.active ).length; diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx index 86d63f3cef9f..560ac88349ce 100644 --- a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx @@ -58,6 +58,31 @@ function SsoOptionToggle( { ); } +/** + * What the WordPress.com login toggle says under it: what it does, or why it can't be changed. + * + * @param login - This section's state from PHP. + * @return The help text. + */ +function getSsoHelp( login: LoginProtectionState | undefined ): string { + if ( login && ! login.sso.available ) { + return __( 'WordPress.com login isn’t available on this site.', 'jetpack-protect-pkg' ); + } + if ( login?.isOffline ) { + return __( 'WordPress.com login isn’t available in offline mode.', 'jetpack-protect-pkg' ); + } + if ( ! login?.ssoUsable ) { + return __( + 'Needs the site owner’s WordPress.com account to be connected.', + 'jetpack-protect-pkg' + ); + } + return __( + 'If you have several sites with this on, you can log in to all of them with the same credentials.', + 'jetpack-protect-pkg' + ); +} + /** * The Login protection settings: brute force protection, account protection and WordPress.com login. * @@ -110,7 +135,8 @@ export default function LoginProtectionSettingsCard( { 'jetpack-protect-pkg' ) } /> - { Boolean( settings.jetpack_waf_ip_allow_list_enabled ) && ( + { /* Hidden, not unmounted, so unsaved addresses survive the toggle going off or a failed save. */ } +
@@ -149,17 +175,7 @@ export default function LoginProtectionSettingsCard( { 'Allow users to log in with their WordPress.com account', 'jetpack-protect-pkg' ) } - help={ - ssoUsable - ? __( - 'If you have several sites with this on, you can log in to all of them with the same credentials.', - 'jetpack-protect-pkg' - ) - : __( - 'Needs the site owner’s WordPress.com account to be connected.', - 'jetpack-protect-pkg' - ) - } + help={ getSsoHelp( login ) } checked={ ssoOn } // Mirrors Jetpack Settings, which locks the toggle without a connected owner. disabled={ ! login?.sso.available || ! ssoUsable || data.isSaving( 'sso' ) } diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx index 650dbbed562b..d332aed8f1fa 100644 --- a/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx @@ -21,6 +21,7 @@ const renderCard = ( accountProtection: on, sso: on, blockedCount: '1,234', + ssoUsable: true, ...state, } as LoginProtectionState } @@ -37,6 +38,7 @@ describe( 'LoginProtectionCard', () => { [ 'brute force protection is off', { bruteForce: off }, null, '2 of 3 on', false ], [ 'a method was turned off since page load', {}, { protect: false }, '2 of 3 on', false ], [ 'a method was turned on since page load', { sso: off }, { sso: true }, '3 of 3 on', true ], + [ 'WordPress.com login is set but can’t run', { ssoUsable: false }, null, '2 of 2 on', true ], [ 'an unavailable method is still set', { sso: unavailable }, @@ -51,6 +53,19 @@ describe( 'LoginProtectionCard', () => { expect( screen.queryByText( '1,234' ) !== null ).toBe( hasCount ); } ); + it.each( [ + [ 'on', { sso: on }, { On: 1, Off: 2, Unavailable: 0 } ], + [ 'off', { sso: off }, { On: 0, Off: 3, Unavailable: 0 } ], + [ 'unavailable', { sso: unavailable }, { On: 0, Off: 2, Unavailable: 1 } ], + [ 'on but unable to run', { sso: on, ssoUsable: false }, { On: 0, Off: 2, Unavailable: 1 } ], + ] )( 'badges a method that is %s', ( _name, state, badges ) => { + renderCard( { bruteForce: off, accountProtection: off, ...state } ); + + Object.entries( badges ).forEach( ( [ text, count ] ) => + expect( screen.queryAllByText( text ) ).toHaveLength( count ) + ); + } ); + it( 'renders nothing when PHP registered no section', () => { const { container } = render( ; settings?: ProtectSettings; save?: jest.Mock; -} = {} ) => - render( - false, - save, - } as unknown as ProtectSettingsData - } - openTab={ jest.fn() } - /> - ); +}; + +const card = ( { state = {}, settings = {}, save = jest.fn() }: CardOptions = {} ) => ( + false, + save, + } as unknown as ProtectSettingsData + } + openTab={ jest.fn() } + /> +); + +const renderCard = ( options: CardOptions = {} ) => render( card( options ) ); describe( 'LoginProtectionSettingsCard', () => { it( 'asks before turning WordPress.com login off', async () => { @@ -86,6 +88,37 @@ describe( 'LoginProtectionSettingsCard', () => { } ); + it.each( [ + [ + 'no owner is connected', + { ssoUsable: false }, + 'Needs the site owner’s WordPress.com account to be connected.', + ], + [ + 'the site is in offline mode', + { ssoUsable: false, isOffline: true }, + 'WordPress.com login isn’t available in offline mode.', + ], + [ + 'the module is unavailable', + { sso: { available: false, active: false } }, + 'WordPress.com login isn’t available on this site.', + ], + ] )( 'says why WordPress.com login can’t be changed when %s', ( _name, state, reason ) => { + renderCard( { state } ); + + expect( screen.getByText( reason ) ).toBeInTheDocument(); + } ); + + it.each( [ + [ BRUTE_FORCE, 'bruteForce' ], + [ ACCOUNT_PROTECTION, 'accountProtection' ], + ] )( 'locks "%s" when its module is unavailable', ( label, key ) => { + renderCard( { state: { [ key ]: { available: false, active: false } } } ); + + expect( screen.getByLabelText( label ) ).toBeDisabled(); + } ); + it( 'locks a forced option to the value WordPress.com login applies', () => { renderCard( { state: { @@ -109,6 +142,17 @@ describe( 'LoginProtectionSettingsCard', () => { ] )( '%s the allowed addresses with their toggle', ( _name, enabled ) => { renderCard( { settings: { jetpack_waf_ip_allow_list_enabled: enabled } } ); - expect( screen.queryByLabelText( ALLOW_LIST ) !== null ).toBe( enabled ); + // Hidden rather than unmounted, so only an accessible query tells the two apart. + expect( screen.queryByRole( 'textbox', { name: ALLOW_LIST } ) !== null ).toBe( enabled ); + } ); + + it( 'keeps unsaved addresses when the allow list toggle goes off and back on', async () => { + const { rerender } = renderCard( { settings: { jetpack_waf_ip_allow_list_enabled: true } } ); + await userEvent.setup().type( screen.getByLabelText( ALLOW_LIST ), '203.0.113.9' ); + + rerender( card( { settings: { jetpack_waf_ip_allow_list_enabled: false } } ) ); + rerender( card( { settings: { jetpack_waf_ip_allow_list_enabled: true } } ) ); + + expect( screen.getByLabelText( ALLOW_LIST ) ).toHaveValue( '203.0.113.9' ); } ); } ); diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/types.ts b/projects/packages/protect/routes/dashboard/sections/login-protection/types.ts index 52f749ff0d2d..02367261dba6 100644 --- a/projects/packages/protect/routes/dashboard/sections/login-protection/types.ts +++ b/projects/packages/protect/routes/dashboard/sections/login-protection/types.ts @@ -15,6 +15,8 @@ export type LoginProtectionState = { blockedCount: string; /** Whether WordPress.com login can be turned on: it needs a connected owner and no offline mode. */ ssoUsable: boolean; + /** Whether the site is in offline mode, one of the two reasons WordPress.com login can't be used. */ + isOffline: boolean; /** Settings a filter or constant forces, so they can't be changed here. */ ssoLocks: SsoOptions; /** The values SSO actually applies, after filters and constants. */ diff --git a/projects/packages/protect/src/sections/class-login-protection.php b/projects/packages/protect/src/sections/class-login-protection.php index 26e519d74e37..b4ee42665165 100644 --- a/projects/packages/protect/src/sections/class-login-protection.php +++ b/projects/packages/protect/src/sections/class-login-protection.php @@ -40,13 +40,16 @@ public function get_key() { * @return array */ public function get_state() { + $is_offline = ( new Status() )->is_offline_mode(); + return array( 'bruteForce' => Dashboard::get_module_state( 'protect' ), 'accountProtection' => Dashboard::get_module_state( 'account-protection' ), 'sso' => Dashboard::get_module_state( 'sso' ), 'blockedCount' => number_format_i18n( (int) get_site_option( 'jetpack_protect_blocked_attempts', 0 ) ), // WordPress.com login needs a connected owner and doesn't work in offline mode. - 'ssoUsable' => ( new Connection_Manager( 'jetpack-protect-pkg' ) )->has_connected_owner() && ! ( new Status() )->is_offline_mode(), + 'ssoUsable' => ! $is_offline && ( new Connection_Manager( 'jetpack-protect-pkg' ) )->has_connected_owner(), + 'isOffline' => $is_offline, // A filter or constant can force these, and Jetpack Settings then locks the toggle. 'ssoLocks' => array( 'matchByEmail' => method_exists( SSO_Helpers::class, 'is_match_by_email_checkbox_disabled' ) && SSO_Helpers::is_match_by_email_checkbox_disabled(), diff --git a/projects/packages/protect/tests/php/Login_Protection_Section_Test.php b/projects/packages/protect/tests/php/Login_Protection_Section_Test.php index 8a825de9c087..2a3a8b36ecc4 100644 --- a/projects/packages/protect/tests/php/Login_Protection_Section_Test.php +++ b/projects/packages/protect/tests/php/Login_Protection_Section_Test.php @@ -7,8 +7,12 @@ namespace Automattic\Jetpack\Protect; +use Automattic\Jetpack\Connection\Manager as Connection_Manager; use Automattic\Jetpack\Protect\Sections\Login_Protection; +use Automattic\Jetpack\Status\Cache as Status_Cache; +use Jetpack_Options; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use WorDBless\BaseTestCase; /** @@ -26,6 +30,11 @@ public function tear_down() { delete_option( 'jetpack_sso_require_two_step' ); remove_all_filters( 'jetpack_sso_require_two_step' ); remove_all_filters( 'jetpack_sso_match_by_email' ); + remove_all_filters( 'jetpack_offline_mode' ); + Jetpack_Options::delete_option( array( 'master_user', 'user_tokens' ) ); + // Both answers are memoized for the request. + Status_Cache::clear(); + ( new Connection_Manager() )->reset_connection_status(); parent::tear_down(); } @@ -39,10 +48,46 @@ public function test_blocked_count_is_formatted() { } /** - * Test that WordPress.com login can't be turned on without a connected owner. + * Test that WordPress.com login needs a connected owner and a site that isn't in offline mode. + * + * @dataProvider provide_sso_conditions + * + * @param bool $has_owner Whether the site has a connected owner. + * @param bool $is_offline Whether the site is in offline mode. + * @param bool $usable Whether WordPress.com login can be turned on. */ - public function test_sso_is_not_usable_without_a_connected_owner() { - $this->assertFalse( ( new Login_Protection() )->get_state()['ssoUsable'] ); + #[DataProvider( 'provide_sso_conditions' )] + public function test_sso_is_usable_with_a_connected_owner_and_not_offline( $has_owner, $is_offline, $usable ) { + if ( $has_owner ) { + $user_id = wp_insert_user( + array( + 'user_login' => 'owner', + 'user_pass' => 'password', + 'role' => 'administrator', + ) + ); + Jetpack_Options::update_option( 'master_user', $user_id ); + Jetpack_Options::update_option( 'user_tokens', array( $user_id => "token.secret.$user_id" ) ); + } + add_filter( 'jetpack_offline_mode', $is_offline ? '__return_true' : '__return_false' ); + + $state = ( new Login_Protection() )->get_state(); + + $this->assertSame( $usable, $state['ssoUsable'] ); + $this->assertSame( $is_offline, $state['isOffline'] ); + } + + /** + * Data provider for the conditions WordPress.com login depends on. + * + * @return array [ $has_owner, $is_offline, $usable ] + */ + public static function provide_sso_conditions() { + return array( + 'connected owner' => array( true, false, true ), + 'no connected owner' => array( false, false, false ), + 'connected owner, offline' => array( true, true, false ), + ); } /** diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 873c4e8746ab..61fd26437967 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -3236,11 +3236,12 @@ "dist": { "type": "path", "url": "../../packages/protect", - "reference": "bfe0e82f7a2d9fac6019ec6ea1c07fe797fcf5ec" + "reference": "e77538b1f6a9c17f87c62e22e0542f0d1d3e17d6" }, "require": { "automattic/jetpack-admin-ui": "@dev", "automattic/jetpack-connection": "@dev", + "automattic/jetpack-ip": "@dev", "automattic/jetpack-protect-models": "@dev", "automattic/jetpack-protect-status": "@dev", "automattic/jetpack-status": "@dev",