diff --git a/projects/plugins/protect/src/js/components/paid-plan-gate/index.tsx b/projects/plugins/protect/src/js/components/paid-plan-gate/index.tsx
new file mode 100644
index 0000000000000..e6a521675198b
--- /dev/null
+++ b/projects/plugins/protect/src/js/components/paid-plan-gate/index.tsx
@@ -0,0 +1,29 @@
+import { Navigate } from 'react-router-dom';
+import useProtectData from '../../hooks/use-protect-data';
+
+/**
+ * Paid Plan Gate
+ *
+ * Custom route that only renders when the user has a paid plan.
+ *
+ * @param {object} props - The component props.
+ * @param {JSX.Element} props.children - The component to render if the user has a paid plan.
+ * @param {string} props.redirect - The alternate route to redirect to if the user does not have a paid plan.
+ *
+ * @returns {JSX.Element} The PaidPlanRoute component.
+ */
+export default function PaidPlanGate( {
+ children,
+ redirect = '/',
+}: {
+ children?: JSX.Element;
+ redirect?: string;
+} ): JSX.Element {
+ const { hasRequiredPlan } = useProtectData();
+
+ if ( ! hasRequiredPlan ) {
+ return ;
+ }
+
+ return children;
+}
diff --git a/projects/plugins/protect/src/js/components/summary/index.jsx b/projects/plugins/protect/src/js/components/summary/index.jsx
index d9a0d3623e57a..60abb79dba7a3 100644
--- a/projects/plugins/protect/src/js/components/summary/index.jsx
+++ b/projects/plugins/protect/src/js/components/summary/index.jsx
@@ -8,7 +8,13 @@ import OnboardingPopover from '../onboarding-popover';
const Summary = () => {
const [ isSm ] = useBreakpointMatch( 'sm' );
- const { numThreats, lastChecked, hasRequiredPlan } = useProtectData();
+ const {
+ counts: {
+ current: { threats: numThreats },
+ },
+ lastChecked,
+ hasRequiredPlan,
+ } = useProtectData();
// Popover anchors
const [ dailyScansPopoverAnchor, setDailyScansPopoverAnchor ] = useState( null );
diff --git a/projects/plugins/protect/src/js/components/threats-list/index.jsx b/projects/plugins/protect/src/js/components/threats-list/index.jsx
index d984fea2ce81d..f86f77e463c06 100644
--- a/projects/plugins/protect/src/js/components/threats-list/index.jsx
+++ b/projects/plugins/protect/src/js/components/threats-list/index.jsx
@@ -58,7 +58,7 @@ const ThreatsList = () => {
__( 'All %s threats', 'jetpack-protect' ),
list.length
);
- case 'wordpress':
+ case 'core':
return sprintf(
/* translators: placeholder is the amount of WordPress threats found on the site. */
__( '%1$s WordPress %2$s', 'jetpack-protect' ),
diff --git a/projects/plugins/protect/src/js/components/threats-list/navigation.jsx b/projects/plugins/protect/src/js/components/threats-list/navigation.jsx
index df237b77df28e..dbab3a326703d 100644
--- a/projects/plugins/protect/src/js/components/threats-list/navigation.jsx
+++ b/projects/plugins/protect/src/js/components/threats-list/navigation.jsx
@@ -15,14 +15,17 @@ import Navigation, { NavigationItem, NavigationGroup } from '../navigation';
const ThreatsNavigation = ( { selected, onSelect, sourceType = 'scan', statusFilter = 'all' } ) => {
const {
- plugins,
- themes,
- numThreats,
- numCoreThreats,
- numFilesThreats,
- numDatabaseThreats,
+ results: { plugins, themes },
+ counts: {
+ current: {
+ threats: numThreats,
+ core: numCoreThreats,
+ files: numFilesThreats,
+ database: numDatabaseThreats,
+ },
+ },
hasRequiredPlan,
- } = useProtectData( { sourceType, statusFilter } );
+ } = useProtectData( { sourceType, filter: { status: statusFilter } } );
const { recordEvent } = useAnalyticsTracks();
const [ isSmallOrLarge ] = useBreakpointMatch( 'lg', '<' );
@@ -82,7 +85,7 @@ const ThreatsNavigation = ( { selected, onSelect, sourceType = 'scan', statusFil
checked={ true }
/>
{
firstDetected,
fixedIn,
fixedOn,
- fixed_on,
icon,
fixable,
id,
@@ -210,7 +209,7 @@ const PaidList = ( { list } ) => {
filename={ filename }
firstDetected={ firstDetected }
fixedIn={ fixedIn }
- fixedOn={ fixedOn ?? fixed_on }
+ fixedOn={ fixedOn }
icon={ icon }
fixable={ fixable }
id={ id }
diff --git a/projects/plugins/protect/src/js/components/threats-list/use-threats-list.js b/projects/plugins/protect/src/js/components/threats-list/use-threats-list.js
index 962268adca92f..df364a8af52a4 100644
--- a/projects/plugins/protect/src/js/components/threats-list/use-threats-list.js
+++ b/projects/plugins/protect/src/js/components/threats-list/use-threats-list.js
@@ -55,9 +55,11 @@ const flattenThreats = ( data, newData ) => {
*/
const useThreatsList = ( { source, status } = { source: 'scan', status: 'all' } ) => {
const [ selected, setSelected ] = useState( 'all' );
- const { plugins, themes, core, files, database } = useProtectData( {
+ const {
+ results: { plugins, themes, core, files, database },
+ } = useProtectData( {
sourceType: source,
- statusFilter: status,
+ filter: { status, key: selected },
} );
const { unsortedList, item } = useMemo( () => {
@@ -66,19 +68,19 @@ const useThreatsList = ( { source, status } = { source: 'scan', status: 'all' }
// Core, files, and database data threats are already grouped together,
// so we just need to flatten them and add the appropriate icon.
switch ( selected ) {
- case 'wordpress':
+ case 'core':
return {
unsortedList: flattenThreats( core, { icon: coreIcon } ),
item: core,
};
case 'files':
return {
- unsortedList: flattenThreats( files, { icon: filesIcon } ),
+ unsortedList: flattenThreats( { threats: files }, { icon: filesIcon } ),
item: files,
};
case 'database':
return {
- unsortedList: flattenThreats( database, { icon: databaseIcon } ),
+ unsortedList: flattenThreats( { threats: database }, { icon: databaseIcon } ),
item: database,
};
default:
@@ -109,8 +111,8 @@ const useThreatsList = ( { source, status } = { source: 'scan', status: 'all' }
...flattenThreats( core, { icon: coreIcon } ),
...flattenThreats( plugins, { icon: pluginsIcon } ),
...flattenThreats( themes, { icon: themesIcon } ),
- ...flattenThreats( files, { icon: filesIcon } ),
- ...flattenThreats( database, { icon: databaseIcon } ),
+ ...flattenThreats( { threats: files }, { icon: filesIcon } ),
+ ...flattenThreats( { threats: database }, { icon: databaseIcon } ),
],
item: null,
};
diff --git a/projects/plugins/protect/src/js/hooks/use-protect-data/index.js b/projects/plugins/protect/src/js/hooks/use-protect-data/index.js
index 5ddac7374ccd1..f8c8e449a6401 100644
--- a/projects/plugins/protect/src/js/hooks/use-protect-data/index.js
+++ b/projects/plugins/protect/src/js/hooks/use-protect-data/index.js
@@ -1,16 +1,55 @@
import { useSelect } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
import { useMemo } from 'react';
import { STORE_ID } from '../../state/store';
+// Valid "key" values for filtering.
+const KEY_FILTERS = [ 'all', 'core', 'plugins', 'themes', 'files', 'database' ];
+
+/**
+ * Filter Extension Threats
+ *
+ * @param {Array} threats - The threats to filter.
+ * @param {object} filter - The filter to apply to the data.
+ * @param {string} filter.status - The status to filter: 'all', 'fixed', or 'ignored'.
+ * @param {string} filter.key - The key to filter: 'all', 'core', 'files', 'database', or an extension name.
+ * @param {string} key - The threat's key: 'all', 'core', 'files', 'database', or an extension name.
+ *
+ * @returns {Array} The filtered threats.
+ */
+const filterThreats = ( threats, filter, key ) => {
+ if ( ! Array.isArray( threats ) ) {
+ return [];
+ }
+
+ return threats.filter( threat => {
+ if ( filter.status && filter.status !== 'all' && threat.status !== filter.status ) {
+ return false;
+ }
+ if ( filter.key && filter.key !== 'all' && filter.key !== key ) {
+ return false;
+ }
+ return true;
+ } );
+};
+
/**
* Get parsed data from the initial state
*
- * @param {object} options - The options to use when getting the data.
- * @param {string} options.sourceType - 'scan' or 'history'.
- * @param {string} options.statusFilter - 'all', 'fixed', or 'ignored'.
+ * @param {object} options - The options to use when getting the data.
+ * @param {string} options.sourceType - 'scan' or 'history'.
+ * @param {object} options.filter - The filter to apply to the data.
+ * _param {string} options.filter.status - 'all', 'fixed', or 'ignored'.
+ * _param {string} options.filter.key - 'all', 'core', 'files', 'database', or an extension name.
+ *
* @returns {object} The information available in Protect's initial state.
*/
-export default function useProtectData( { sourceType = 'scan', statusFilter = 'all' } = {} ) {
+export default function useProtectData(
+ { sourceType, filter } = {
+ sourceType: 'scan',
+ filter: { status: null, key: null },
+ }
+) {
const { status, scanHistory, jetpackScan, hasRequiredPlan } = useSelect( select => ( {
status: select( STORE_ID ).getStatus(),
scanHistory: select( STORE_ID ).getScanHistory(),
@@ -18,90 +57,114 @@ export default function useProtectData( { sourceType = 'scan', statusFilter = 'a
hasRequiredPlan: select( STORE_ID ).hasRequiredPlan(),
} ) );
- const source = useMemo( () => {
+ const { counts, results, error, lastChecked, hasUncheckedItems } = useMemo( () => {
+ // This hook can provide data from two sources: the current scan or the scan history.
const data = sourceType === 'history' ? { ...scanHistory } : { ...status };
- // Filter the threats based on the status filter.
- if ( statusFilter === 'all' ) {
- return data;
- }
+ // Prepare the result object.
+ const result = {
+ results: {
+ core: [],
+ plugins: [],
+ themes: [],
+ files: [],
+ database: [],
+ },
+ counts: {
+ all: {
+ threats: 0,
+ core: 0,
+ plugins: 0,
+ themes: 0,
+ files: 0,
+ database: 0,
+ },
+ current: {
+ threats: 0,
+ core: 0,
+ plugins: 0,
+ themes: 0,
+ files: 0,
+ database: 0,
+ },
+ },
+ error: null,
+ lastChecked: data.lastChecked || null,
+ hasUncheckedItems: data.hasUncheckedItems || false,
+ };
+
+ // Loop through the provided extensions, and update the result object.
+ const processExtensions = ( extensions, key ) => {
+ if ( ! Array.isArray( extensions ) ) {
+ return [];
+ }
+ extensions.forEach( extension => {
+ // Update the total counts.
+ result.counts.all[ key ] += extension?.threats?.length || 0;
+ result.counts.all.threats += extension?.threats?.length || 0;
+
+ // Filter the extension's threats based on the current filters.
+ const filteredThreats = filterThreats(
+ extension?.threats,
+ filter,
+ KEY_FILTERS.includes( filter.key ) ? key : extension?.name
+ );
- return {
- core: ( data.core || [] )
- .map( core => {
- const threats = core.threats.filter( threat => threat.status === statusFilter );
- return { ...core, threats };
- } )
- .filter( core => core.threats.length > 0 ),
- plugins: ( data.plugins || [] ).reduce( ( acc, plugin ) => {
- const threats = plugin.threats.filter( threat => threat.status === statusFilter );
- if ( threats.length > 0 ) {
- acc.push( { ...plugin, threats } );
- }
- return acc;
- }, [] ),
- themes: ( data.themes || [] ).reduce( ( acc, theme ) => {
- const threats = theme.threats.filter( threat => threat.status === statusFilter );
- if ( threats.length > 0 ) {
- acc.push( { ...theme, threats } );
- }
- return acc;
- }, [] ),
- files: ( data.files || [] ).filter( threat => threat.status === statusFilter ),
- database: ( data.database || [] ).filter( threat => threat.status === statusFilter ),
+ // Update the result object with the extension and its filtered threats.
+ result.results[ key ].push( { ...extension, threats: filteredThreats } );
+
+ // Update the current counts.
+ result.counts.current[ key ] += filteredThreats.length;
+ result.counts.current.threats += filteredThreats.length;
+ } );
};
- }, [ sourceType, status, scanHistory, statusFilter ] );
-
- const numCoreThreats = useMemo( () => {
- if ( 'history' === sourceType ) {
- return ( source.core || [] ).reduce(
- ( numThreats, core ) => numThreats + core.threats.length,
- 0
- );
- }
- return source.core?.threats?.length || 0;
- }, [ sourceType, source.core ] );
- const numPluginsThreats = useMemo(
- () =>
- ( source.plugins || [] ).reduce( ( numThreats, plugin ) => {
- return numThreats + plugin.threats.length;
- }, 0 ),
- [ source.plugins ]
- );
+ // Loop through the provided threats, and update the result object.
+ const processThreats = ( threatsToProcess, key ) => {
+ if ( ! Array.isArray( threatsToProcess ) ) {
+ return [];
+ }
- const numThemesThreats = useMemo(
- () =>
- ( source.themes || [] ).reduce( ( numThreats, theme ) => {
- return numThreats + theme.threats.length;
- }, 0 ),
- [ source.themes ]
- );
+ result.counts.all[ key ] += threatsToProcess.length;
+ result.counts.all.threats += threatsToProcess.length;
- const numFilesThreats = useMemo( () => source.files?.length || 0, [ source.files ] );
+ const filteredThreats = filterThreats( threatsToProcess, filter, key );
- const numDatabaseThreats = useMemo( () => source.database?.length || 0, [ source.database ] );
+ result.results[ key ] = [ ...result.results[ key ], ...filteredThreats ];
+ result.counts.current[ key ] += filteredThreats.length;
+ result.counts.current.threats += filteredThreats.length;
+ };
+
+ // Core data may be either a single object or an array of multiple objects.
+ let cores = Array.isArray( data.core ) ? data.core : [];
+ if ( data.core.threats ) {
+ cores = [ data.core ];
+ }
+
+ // Process the data
+ processExtensions( cores, 'core' );
+ processExtensions( data?.plugins, 'plugins' );
+ processExtensions( data?.themes, 'themes' );
+ processThreats( data?.files, 'files' );
+ processThreats( data?.database, 'database' );
+
+ // Handle errors
+ if ( data.error ) {
+ result.error = {
+ message: data.errorMessage || __( 'An error occurred.', 'jetpack-protect' ),
+ code: data.errorCode || 500,
+ };
+ }
- const numThreats =
- numCoreThreats + numPluginsThreats + numThemesThreats + numFilesThreats + numDatabaseThreats;
+ return result;
+ }, [ scanHistory, sourceType, status, filter ] );
return {
- numThreats,
- numCoreThreats,
- numPluginsThreats,
- numThemesThreats,
- numFilesThreats,
- numDatabaseThreats,
- lastChecked: source.lastChecked || null,
- error: source.error || false,
- errorCode: source.errorCode || null,
- errorMessage: source.errorMessage || null,
- core: source.core || {},
- plugins: source.plugins || [],
- themes: source.themes || [],
- files: { threats: source.files || [] },
- database: { threats: source.database || [] },
- hasUncheckedItems: source.hasUncheckedItems,
+ results,
+ counts,
+ error,
+ lastChecked,
+ hasUncheckedItems,
jetpackScan,
hasRequiredPlan,
};
diff --git a/projects/plugins/protect/src/js/index.tsx b/projects/plugins/protect/src/js/index.tsx
index 5db2fab38eab3..8b5c507c3d995 100644
--- a/projects/plugins/protect/src/js/index.tsx
+++ b/projects/plugins/protect/src/js/index.tsx
@@ -3,6 +3,7 @@ import * as WPElement from '@wordpress/element';
import React, { useEffect } from 'react';
import { HashRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom';
import Modal from './components/modal';
+import PaidPlanGate from './components/paid-plan-gate';
import { OnboardingRenderedContextProvider } from './hooks/use-onboarding';
import FirewallRoute from './routes/firewall';
import ScanRoute from './routes/scan';
@@ -42,8 +43,22 @@ function render() {
} />
- } />
- } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
} />
} />
diff --git a/projects/plugins/protect/src/js/routes/scan/history/index.jsx b/projects/plugins/protect/src/js/routes/scan/history/index.jsx
index 73fcde3db9f15..639512a4a866e 100644
--- a/projects/plugins/protect/src/js/routes/scan/history/index.jsx
+++ b/projects/plugins/protect/src/js/routes/scan/history/index.jsx
@@ -20,14 +20,30 @@ const ScanHistoryRoute = () => {
useAnalyticsTracks( { pageViewEventName: 'protect_scan_history' } );
const { filter = 'all' } = useParams();
- const { numThreats, error, errorMessage, errorCode, hasRequiredPlan } = useProtectData( {
- sourceType: 'history',
- } );
+
const { item, list, selected, setSelected } = useThreatsList( {
source: 'history',
status: filter,
} );
+ const { counts, error, hasRequiredPlan } = useProtectData( {
+ sourceType: 'history',
+ filter: { status: filter },
+ } );
+ const { threats: numAllThreats } = counts.all;
+
+ const { counts: fixedCounts } = useProtectData( {
+ sourceType: 'history',
+ filter: { status: 'fixed', key: selected },
+ } );
+ const { threats: numFixed } = fixedCounts.current;
+
+ const { counts: ignoredCounts } = useProtectData( {
+ sourceType: 'history',
+ filter: { status: 'ignored', key: selected },
+ } );
+ const { threats: numIgnored } = ignoredCounts.current;
+
/**
* Get the title for the threats list based on the selected filters and the amount of threats.
*/
@@ -68,7 +84,7 @@ const ScanHistoryRoute = () => {
list.length
);
}
- case 'wordpress':
+ case 'core':
switch ( filter ) {
case 'fixed':
return sprintf(
@@ -216,6 +232,11 @@ const ScanHistoryRoute = () => {
return ;
}
+ // Remove the filter if there are no threats to show.
+ if ( list.length === 0 && filter !== 'all' ) {
+ return ;
+ }
+
return (
@@ -229,8 +250,8 @@ const ScanHistoryRoute = () => {
: sprintf(
/* translators: %s: Total number of threats */
__( '%1$s previously active %2$s', 'jetpack-protect' ),
- numThreats,
- numThreats === 1 ? 'threat' : 'threats'
+ numAllThreats,
+ numAllThreats === 1 ? 'threat' : 'threats'
)
}
/>
@@ -242,8 +263,8 @@ const ScanHistoryRoute = () => {
"An error occurred loading your site's threat history.",
'jetpack-protect'
) }
- errorMessage={ errorMessage }
- errorCode={ errorCode }
+ errorMessage={ error.message }
+ errorCode={ error.code }
/>
) : (
@@ -263,7 +284,7 @@ const ScanHistoryRoute = () => {
diff --git a/projects/plugins/protect/src/js/routes/scan/history/status-filters.jsx b/projects/plugins/protect/src/js/routes/scan/history/status-filters.jsx
index 4671ebb16e60e..e661980c3b96c 100644
--- a/projects/plugins/protect/src/js/routes/scan/history/status-filters.jsx
+++ b/projects/plugins/protect/src/js/routes/scan/history/status-filters.jsx
@@ -6,9 +6,13 @@ import ButtonGroup from '../../../components/button-group';
/**
* Status Filters component.
*
+ * @param {object} props - Component props.
+ * @param {number} props.numFixed - Number of fixed threats.
+ * @param {number} props.numIgnored - Number of ignored threats.
+ *
* @returns {React.ReactNode} StatusFilters component.
*/
-export default function StatusFilters() {
+export default function StatusFilters( { numFixed, numIgnored } ) {
const navigate = useNavigate();
const { filter = 'all' } = useParams();
const navigateOnClick = useCallback( path => () => navigate( path ), [ navigate ] );
@@ -24,12 +28,14 @@ export default function StatusFilters() {
{ __( 'Fixed', 'jetpack-protect' ) }
{ __( 'Ignored', 'jetpack-protect' ) }
diff --git a/projects/plugins/protect/src/js/state/selectors.js b/projects/plugins/protect/src/js/state/selectors.js
index a438eb48cf69d..267a7d75c11da 100644
--- a/projects/plugins/protect/src/js/state/selectors.js
+++ b/projects/plugins/protect/src/js/state/selectors.js
@@ -8,7 +8,7 @@ import { SCAN_IN_PROGRESS_STATUSES, SCAN_STATUS_OPTIMISTICALLY_SCANNING } from '
* @returns {boolean} Whether a scan is in progress.
*/
const scanInProgress = state => {
- const { status, error, lastChecked } = selectors.getStatus( state );
+ const { status, lastChecked, error } = selectors.getStatus( state );
const unavailable = selectors.getScanIsUnavailable( state );
// When "optimistically" scanning, ignore any other status or error.
@@ -16,13 +16,18 @@ const scanInProgress = state => {
return true;
}
- // If there is an error or the scan is unavailable, scanning is not in progress.
- if ( error || unavailable ) {
+ // If the scan is unavailable, scanning is not in progress.
+ if ( unavailable ) {
return false;
}
- // If the status is one of the scanning statuses, or if we have never checked, we are scanning.
- if ( SCAN_IN_PROGRESS_STATUSES.includes( status.status ) || ! lastChecked ) {
+ // If the status is one of the scanning statuses, we are scanning.
+ if ( SCAN_IN_PROGRESS_STATUSES.includes( status ) ) {
+ return true;
+ }
+
+ // If we have no record of a previous scan, we must be queueing up the initial scan.
+ if ( ! lastChecked && ! error ) {
return true;
}
@@ -45,16 +50,6 @@ const scanError = state => {
const unavailable = selectors.getScanIsUnavailable( state );
const isFetching = selectors.getStatusIsFetching( state );
- // When "optimistically" scanning, ignore any errors.
- if ( SCAN_STATUS_OPTIMISTICALLY_SCANNING === status ) {
- return null;
- }
-
- // While still fetching the status, ignore any errors.
- if ( isFetching ) {
- return null;
- }
-
// If the scan results include an error, return it.
if ( error ) {
return { code: errorCode, message: errorMessage };
@@ -68,6 +63,14 @@ const scanError = state => {
};
}
+ // If there is no status and we are not requesting it, return an error.
+ if ( ! status && ! isFetching ) {
+ return {
+ code: 'scan_unavailable',
+ message: __( 'We are having problems scanning your site.', 'jetpack-protect' ),
+ };
+ }
+
return null;
};