From 21f57029c8e93b54775f02a114216452aed44fcc Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Fri, 28 Jun 2024 09:35:19 -0700 Subject: [PATCH] Protect: Add Scan History UI (#37988) --- .../changelog/add-protect-scan-history-ui-alt | 4 + .../protect/src/class-jetpack-protect.php | 1 + .../protect/src/class-rest-controller.php | 51 +++ .../protect/src/class-scan-history.php | 14 + .../plugins/protect/src/class-threats.php | 4 + projects/plugins/protect/src/js/api.js | 7 + .../js/components/paid-accordion/index.jsx | 55 ++- .../paid-accordion/styles.module.scss | 58 ++- .../src/js/components/scan-page/index.jsx | 339 +++++++++++------- .../components/scan-page/styles.module.scss | 6 + .../src/js/components/summary/index.jsx | 121 +++++-- .../js/components/summary/styles.module.scss | 4 +- .../src/js/components/threats-list/empty.jsx | 31 +- .../src/js/components/threats-list/index.jsx | 21 +- .../js/components/threats-list/navigation.jsx | 1 + .../js/components/threats-list/paid-list.jsx | 28 +- .../threats-list/use-threats-list.js | 1 - .../src/js/hooks/use-protect-data/index.js | 55 +-- .../src/js/hooks/use-scan-history/index.js | 74 ++++ .../plugins/protect/src/js/state/actions.js | 14 + .../plugins/protect/src/js/state/reducers.js | 20 ++ .../plugins/protect/src/js/state/selectors.js | 2 + .../protect/src/models/class-threat-model.php | 122 +++++++ 23 files changed, 822 insertions(+), 211 deletions(-) create mode 100644 projects/plugins/protect/changelog/add-protect-scan-history-ui-alt create mode 100644 projects/plugins/protect/src/js/hooks/use-scan-history/index.js create mode 100644 projects/plugins/protect/src/models/class-threat-model.php diff --git a/projects/plugins/protect/changelog/add-protect-scan-history-ui-alt b/projects/plugins/protect/changelog/add-protect-scan-history-ui-alt new file mode 100644 index 0000000000000..557cbed04c102 --- /dev/null +++ b/projects/plugins/protect/changelog/add-protect-scan-history-ui-alt @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add Scan History UI diff --git a/projects/plugins/protect/src/class-jetpack-protect.php b/projects/plugins/protect/src/class-jetpack-protect.php index 26730756a952c..854978215ee46 100644 --- a/projects/plugins/protect/src/class-jetpack-protect.php +++ b/projects/plugins/protect/src/class-jetpack-protect.php @@ -212,6 +212,7 @@ public function initial_state() { 'apiNonce' => wp_create_nonce( 'wp_rest' ), 'registrationNonce' => wp_create_nonce( 'jetpack-registration-nonce' ), 'status' => Status::get_status( $refresh_status_from_wpcom ), + 'viewingScanHistory' => false, 'scanHistory' => Scan_History::get_scan_history( $refresh_status_from_wpcom ), 'installedPlugins' => Plugins_Installer::get_plugins(), 'installedThemes' => Sync_Functions::get_themes(), diff --git a/projects/plugins/protect/src/class-rest-controller.php b/projects/plugins/protect/src/class-rest-controller.php index 6e082b5a8bd68..0e11a3d08bd24 100644 --- a/projects/plugins/protect/src/class-rest-controller.php +++ b/projects/plugins/protect/src/class-rest-controller.php @@ -198,6 +198,30 @@ public static function register_rest_endpoints() { }, ) ); + + register_rest_route( + 'jetpack-protect/v1', + 'scan-history', + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::api_get_scan_history', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ) + ); + + register_rest_route( + 'jetpack-protect/v1', + 'clear-scan-history-cache', + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::api_clear_scan_history_cache', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ) + ); } /** @@ -409,4 +433,31 @@ public static function api_complete_onboarding_steps( $request ) { return new WP_REST_Response( 'Onboarding step(s) completed.' ); } + + /** + * Return Scan History for the API endpoint + * + * @param WP_REST_Request $request The request object. + * + * @return WP_REST_Response + */ + public static function api_get_scan_history( $request ) { + $scan_history = Scan_History::get_scan_history( false, $request['filter'] ); + return rest_ensure_response( $scan_history, 200 ); + } + + /** + * Clear the Scan_History cache for the API endpoint + * + * @return WP_REST_Response + */ + public static function api_clear_scan_history_cache() { + $cache_cleared = Scan_History::delete_option(); + + if ( ! $cache_cleared ) { + return new WP_REST_Response( 'An error occured while attempting to clear the Jetpack Scan history cache.', 500 ); + } + + return new WP_REST_Response( 'Jetpack Scan history cache cleared.' ); + } } diff --git a/projects/plugins/protect/src/class-scan-history.php b/projects/plugins/protect/src/class-scan-history.php index 9636dfb6404d1..b671c83c30bf0 100644 --- a/projects/plugins/protect/src/class-scan-history.php +++ b/projects/plugins/protect/src/class-scan-history.php @@ -9,6 +9,8 @@ use Automattic\Jetpack\Connection\Client; use Automattic\Jetpack\Connection\Manager as Connection_Manager; +use Automattic\Jetpack\Protect_Models\Extension_Model; +use Automattic\Jetpack\Protect_Status\Plan; use Jetpack_Options; use WP_Error; @@ -96,6 +98,18 @@ public static function update_history_option( $history ) { update_option( static::OPTION_TIMESTAMP_NAME, time() + static::OPTION_EXPIRES_AFTER ); } + /** + * Delete the cached history and its timestamp + * + * @return bool Whether all related history options were successfully deleted. + */ + public static function delete_option() { + $option_deleted = delete_option( static::OPTION_NAME ); + $option_timestamp_deleted = delete_option( static::OPTION_TIMESTAMP_NAME ); + + return $option_deleted && $option_timestamp_deleted; + } + /** * Gets the current history of the Jetpack Protect checks * diff --git a/projects/plugins/protect/src/class-threats.php b/projects/plugins/protect/src/class-threats.php index 0f77d1bc33523..63a20f9ef3c89 100644 --- a/projects/plugins/protect/src/class-threats.php +++ b/projects/plugins/protect/src/class-threats.php @@ -65,6 +65,7 @@ public static function update_threat( $threat_id, $updates ) { // clear the now out-of-date cache Scan_Status::delete_option(); + Scan_History::delete_option(); return true; } @@ -113,6 +114,7 @@ public static function fix_threats( $threat_ids ) { // clear the now out-of-date cache Scan_Status::delete_option(); + Scan_History::delete_option(); $parsed_response = json_decode( $response['body'] ); @@ -158,6 +160,7 @@ public static function fix_threats_status( $threat_ids ) { // clear the potentially out-of-date cache Scan_Status::delete_option(); + Scan_History::delete_option(); return $parsed_response; } @@ -197,6 +200,7 @@ public static function scan() { // clear the now out-of-date cache Scan_Status::delete_option(); + Scan_History::delete_option(); return true; } diff --git a/projects/plugins/protect/src/js/api.js b/projects/plugins/protect/src/js/api.js index 4b5afaedc2877..3b75d4246b604 100644 --- a/projects/plugins/protect/src/js/api.js +++ b/projects/plugins/protect/src/js/api.js @@ -45,6 +45,13 @@ const API = { method: 'POST', data: { step_ids: stepIds }, } ), + + fetchScanHistory: $filter => + apiFetch( { + path: 'jetpack-protect/v1/scan-history', + method: 'POST', + data: { filter: $filter }, + } ), }; export default API; diff --git a/projects/plugins/protect/src/js/components/paid-accordion/index.jsx b/projects/plugins/protect/src/js/components/paid-accordion/index.jsx index ef0a38d91932b..1839ef6c26a8b 100644 --- a/projects/plugins/protect/src/js/components/paid-accordion/index.jsx +++ b/projects/plugins/protect/src/js/components/paid-accordion/index.jsx @@ -1,9 +1,11 @@ import { Spinner, Text, useBreakpointMatch } from '@automattic/jetpack-components'; import { useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { dateI18n } from '@wordpress/date'; +import { sprintf, __ } from '@wordpress/i18n'; import { Icon, check, chevronDown, chevronUp } from '@wordpress/icons'; import clsx from 'clsx'; import React, { useState, useCallback, useContext } from 'react'; +import useScanHistory from '../../hooks/use-scan-history'; import { STORE_ID } from '../../state/store'; import ThreatSeverityBadge from '../severity'; import styles from './styles.module.scss'; @@ -18,12 +20,15 @@ export const PaidAccordionItem = ( { fixable, severity, children, + firstDetected, + fixedOn, onOpen, } ) => { const accordionData = useContext( PaidAccordionContext ); const open = accordionData?.open === id; const setOpen = accordionData?.setOpen; const threatsAreFixing = useSelect( select => select( STORE_ID ).getThreatsAreFixing() ); + const { viewingScanHistory } = useScanHistory(); const bodyClassNames = clsx( styles[ 'accordion-body' ], { [ styles[ 'accordion-body-open' ] ]: open, @@ -41,6 +46,49 @@ export const PaidAccordionItem = ( { const [ isSmall ] = useBreakpointMatch( [ 'sm', 'lg' ], [ null, '<' ] ); + const FixDetails = ( { date, isFixed } ) => ( + + { isFixed + ? sprintf( + /* translators: %s: Fixed on date */ + __( 'Threat fixed %s', 'jetpack-protect' ), + dateI18n( 'M j, Y', date ) + ) + : __( 'Threat ignored', 'jetpack-protect' ) } + + ); + + const ScanHistoryDetails = ( { viewingHistory, detectedAt, fixedAt } ) => { + if ( ! viewingHistory ) { + return null; + } + + return ( + <> + { detectedAt && ( + + { sprintf( + /* translators: %s: First detected date */ + __( 'Threat found %s', 'jetpack-protect' ), + dateI18n( 'M j, Y', detectedAt ) + ) } + + + + ) } + + + ); + }; + + const StatusBadge = ( { status } ) => ( +
+ { 'fixed' === status + ? __( 'Fixed', 'jetpack-protect' ) + : __( 'Ignored', 'jetpack-protect', /* dummy arg to avoid bad minification */ 0 ) } +
+ ); + return (
diff --git a/projects/plugins/protect/src/js/components/paid-accordion/styles.module.scss b/projects/plugins/protect/src/js/components/paid-accordion/styles.module.scss index 7dcdc4bb61425..ec6049085fe23 100644 --- a/projects/plugins/protect/src/js/components/paid-accordion/styles.module.scss +++ b/projects/plugins/protect/src/js/components/paid-accordion/styles.module.scss @@ -61,6 +61,21 @@ margin-bottom: var( --spacing-base ); // 8px } +.accordion-header-status { + font-size: var( --font-body-small ); + font-weight: normal; + margin-left: calc( var( --spacing-base ) * 4 ); // 32px + margin-bottom: var( --spacing-base ); // 8px +} + +.accordion-header-status-separator { + display: inline-block; + height: 4px; + margin: 2px 12px; + width: 4px; + background-color: var( --jp-gray-50 ); +} + .accordion-header-button { align-items: center; } @@ -88,6 +103,34 @@ fill: var( --jp-green-40 ); } +.status-badge { + border-radius: 32px; + flex-shrink: 0; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 16px; + padding: calc( var( --spacing-base ) / 2 ); // 4px + position: relative; + text-align: center; + width: 60px; + margin-left: calc( var( --spacing-base ) * 4 ); // 32px + + &.fixed { + color: var( --jp-white ); + background-color: #008a20; + } + + &.ignored { + color: var( --jp-white ); + background-color: var( --jp-gray-50 ); + } +} + +.is-fixed { + color: #008a20; +} + @media ( max-width: 599px ) { .accordion-header { display: grid; @@ -118,4 +161,17 @@ } } -} \ No newline at end of file + .status-badge { + display: none; + } +} + +@media ( max-width: 1200px ) { + .accordion-header-status { + display: grid; + } + + .accordion-header-status-separator { + display: none; + } +} diff --git a/projects/plugins/protect/src/js/components/scan-page/index.jsx b/projects/plugins/protect/src/js/components/scan-page/index.jsx index 1eded9d655846..73426e0cb367e 100644 --- a/projects/plugins/protect/src/js/components/scan-page/index.jsx +++ b/projects/plugins/protect/src/js/components/scan-page/index.jsx @@ -1,12 +1,13 @@ -import { AdminSectionHero, Container, Col, H3, Text } from '@automattic/jetpack-components'; +import { AdminSectionHero, Container, Col, H3, Text, Button } from '@automattic/jetpack-components'; import { useConnectionErrorNotice, ConnectionError } from '@automattic/jetpack-connection'; import { Spinner } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; -import React, { useEffect } from 'react'; +import { sprintf, __ } from '@wordpress/i18n'; +import React, { useEffect, useMemo } from 'react'; import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; import { OnboardingContext } from '../../hooks/use-onboarding'; import useProtectData from '../../hooks/use-protect-data'; +import useScanHistory from '../../hooks/use-scan-history'; import { STORE_ID } from '../../state/store'; import AdminPage from '../admin-page'; import AlertSVGIcon from '../alert-icon'; @@ -21,18 +22,176 @@ import styles from './styles.module.scss'; import useCredentials from './use-credentials'; import useStatusPolling from './use-status-polling'; -const ScanPage = () => { - const { lastChecked, currentStatus, errorCode, errorMessage, hasRequiredPlan } = useProtectData(); +const ConnectionErrorCol = () => { const { hasConnectionError } = useConnectionErrorNotice(); + + return ( + <> + { hasConnectionError && ( + + + + ) } + +
+ + + ); +}; + +const ButtonCol = ( { + viewingScanHistory, + handleHistoryClick, + handleCurrentClick, + allScanHistoryIsLoading, +} ) => { + return ( + + { ! viewingScanHistory ? ( + + ) : ( + + ) } + + ); +}; + +const HeaderContainer = ( { displayButtonCol } ) => { + const { viewingScanHistory, handleCurrentClick, handleHistoryClick, allScanHistoryIsLoading } = + useScanHistory(); + + return ( + + + { displayButtonCol && ( + + ) } + + ); +}; + +const ErrorSection = ( { viewingScanHistory, errorMessage, errorCode } ) => { + const activityContext = viewingScanHistory + ? 'retrieving your scan history' + : 'scanning your site'; + const baseErrorMessage = sprintf( + /* translators: %s is the activity context, like "scanning your site" or "retrieving your scan history" */ + __( 'We are having problems %s.', 'jetpack-protect' ), + activityContext + ); + + let displayErrorMessage = errorMessage ? `${ errorMessage } (${ errorCode }).` : baseErrorMessage; + displayErrorMessage += ' ' + __( 'Try again in a few minutes.', 'jetpack-protect' ); + + return ( + <> + + + +

{ baseErrorMessage }

+ { displayErrorMessage } +
+ } + secondary={ +
+ +
+ } + preserveSecondaryOnMobile={ false } + /> + + ); +}; + +const ScanningSection = ( { currentProgress } ) => { + return ( + <> + + + + + + { __( 'Scanning your site…', 'jetpack-protect' ) } + + +

+ { __( 'Your results will be ready soon', 'jetpack-protect' ) } +

+ { currentProgress !== null && currentProgress >= 0 && ( + + ) } + + { __( + 'We are scanning for security threats from our more than 22,000 listed vulnerabilities, powered by WPScan. This could take a minute or two.', + 'jetpack-protect' + ) } + + +
+
+ } + secondary={ +
+ +
+ } + preserveSecondaryOnMobile={ false } + /> + + ); +}; + +const DefaultSection = () => { + return ( + <> + + + + + + + + + + + ); +}; + +const ScanPage = () => { + const { viewingScanHistory } = useScanHistory(); + const { lastChecked, error, errorCode, errorMessage, hasRequiredPlan } = useProtectData(); const { refreshStatus } = useDispatch( STORE_ID ); const { statusIsFetching, scanIsUnavailable, status } = useSelect( select => ( { statusIsFetching: select( STORE_ID ).getStatusIsFetching(), scanIsUnavailable: select( STORE_ID ).getScanIsUnavailable(), status: select( STORE_ID ).getStatus(), } ) ); - const { currentProgress } = status; + let currentScanStatus; - if ( 'error' === currentStatus || scanIsUnavailable ) { + if ( status.error || scanIsUnavailable ) { currentScanStatus = 'error'; } else if ( ! lastChecked ) { currentScanStatus = 'in_progress'; @@ -40,6 +199,15 @@ const ScanPage = () => { currentScanStatus = 'active'; } + // Track view for Protect admin page. + useAnalyticsTracks( { + pageViewEventName: 'protect_admin', + pageViewEventProperties: { + check_status: currentScanStatus, + has_plan: hasRequiredPlan, + }, + } ); + useStatusPolling(); useCredentials(); @@ -50,141 +218,40 @@ const ScanPage = () => { } }, [ statusIsFetching, status.status, refreshStatus, scanIsUnavailable ] ); - // Track view for Protect admin page. - useAnalyticsTracks( { - pageViewEventName: 'protect_admin', - pageViewEventProperties: { - check_status: currentScanStatus, - has_plan: hasRequiredPlan, - }, - } ); - - // Error - if ( 'error' === currentStatus || scanIsUnavailable ) { - let displayErrorMessage = errorMessage - ? `${ errorMessage } (${ errorCode }).` - : __( 'We are having problems scanning your site.', 'jetpack-protect' ); - displayErrorMessage += ' ' + __( 'Try again in a few minutes.', 'jetpack-protect' ); + const renderSection = useMemo( () => { + // Error + if ( error || ( ! viewingScanHistory && scanIsUnavailable ) ) { + return ( + + ); + } - return ( - - - - { hasConnectionError && ( - - - - ) } - -
- - - - -

{ __( 'We’re having problems scanning your site', 'jetpack-protect' ) }

- { displayErrorMessage } -
- } - secondary={ -
- -
- } - preserveSecondaryOnMobile={ false } - /> -
- -
- ); - } + // Scanning + const scanningStatuses = new Set( [ 'scheduled', 'scanning', 'optimistically_scanning' ] ); + if ( ! viewingScanHistory && ( scanningStatuses.has( status.status ) || ! lastChecked ) ) { + return ; + } - // When there's no information yet. Usually when the plugin was just activated - if ( - [ 'scheduled', 'scanning', 'optimistically_scanning' ].indexOf( status.status ) >= 0 || - ! lastChecked - ) { - return ( - - - - { hasConnectionError && ( - - - - ) } - -
- - - - - - - { __( 'Scanning your site…', 'jetpack-protect' ) } - - -

- { __( 'Your results will be ready soon', 'jetpack-protect' ) } -

- { currentProgress !== null && currentProgress >= 0 && ( - - ) } - - { __( - 'We are scanning for security threats from our more than 22,000 listed vulnerabilities, powered by WPScan. This could take a minute or two.', - 'jetpack-protect' - ) } - - -
-
- } - secondary={ -
- -
- } - preserveSecondaryOnMobile={ false } - /> -
- -
- ); - } + return ; + }, [ + error, + errorMessage, + errorCode, + viewingScanHistory, + scanIsUnavailable, + status.status, + status.currentProgress, + lastChecked, + ] ); return ( - - - { hasConnectionError && ( - - - - ) } - -
- - - - - - - - - - - + { renderSection } diff --git a/projects/plugins/protect/src/js/components/scan-page/styles.module.scss b/projects/plugins/protect/src/js/components/scan-page/styles.module.scss index 80b0a1f9a6c47..bfbef2df43d0c 100644 --- a/projects/plugins/protect/src/js/components/scan-page/styles.module.scss +++ b/projects/plugins/protect/src/js/components/scan-page/styles.module.scss @@ -22,3 +22,9 @@ .connection-error-col { margin-top: calc( var( --spacing-base ) * 3 + 1px ); // 25px } + +.history-button-col { + display: flex; + justify-content: flex-end; + margin-top: calc( var( --spacing-base ) * 3 + 1px ); // 25px +} diff --git a/projects/plugins/protect/src/js/components/summary/index.jsx b/projects/plugins/protect/src/js/components/summary/index.jsx index 818645891662e..fd416021cc138 100644 --- a/projects/plugins/protect/src/js/components/summary/index.jsx +++ b/projects/plugins/protect/src/js/components/summary/index.jsx @@ -12,12 +12,25 @@ import { dateI18n } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; import React, { useState } from 'react'; import useProtectData from '../../hooks/use-protect-data'; +import useScanHistory from '../../hooks/use-scan-history'; import { STORE_ID } from '../../state/store'; import OnboardingPopover from '../onboarding-popover'; import styles from './styles.module.scss'; const Summary = () => { const [ isSm ] = useBreakpointMatch( 'sm' ); + const { + filter, + viewingScanHistory, + allScanHistoryIsLoading, + ignoredScanHistoryIsLoading, + fixedScanHistoryIsLoading, + toggleAllScanHistory, + toggleIgnoredScanHistory, + toggleFixedScanHistory, + handleHistoryClick, + handleCurrentClick, + } = useScanHistory(); const { numThreats, lastChecked, hasRequiredPlan } = useProtectData(); const scanIsEnqueuing = useSelect( select => select( STORE_ID ).getScanIsEnqueuing() ); const { scan } = useDispatch( STORE_ID ); @@ -35,6 +48,72 @@ const Summary = () => { }; }; + const renderScanOptions = () => ( + <> + +