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 = () => {
{ getTitle() }
- +
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; };