diff --git a/projects/js-packages/components/changelog/add-components-threats-data-views-modal-integration b/projects/js-packages/components/changelog/add-components-threats-data-views-modal-integration new file mode 100644 index 0000000000000..809bb1cd3a788 --- /dev/null +++ b/projects/js-packages/components/changelog/add-components-threats-data-views-modal-integration @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Integrates ThreatModal in ThreatsDataViews diff --git a/projects/js-packages/components/components/threat-modal/fixer-state-notice.tsx b/projects/js-packages/components/components/threat-modal/fixer-state-notice.tsx index 8f130fba87421..861095cf0c363 100644 --- a/projects/js-packages/components/components/threat-modal/fixer-state-notice.tsx +++ b/projects/js-packages/components/components/threat-modal/fixer-state-notice.tsx @@ -1,5 +1,8 @@ +import { CONTACT_SUPPORT_URL } from '@automattic/jetpack-scan'; +import { createInterpolateElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useMemo } from 'react'; +import { Button } from '@automattic/jetpack-components'; import styles from './styles.module.scss'; import ThreatNotice from './threat-notice'; @@ -19,14 +22,28 @@ const FixerStateNotice = ( { }: { fixerState: { inProgress: boolean; error: boolean; stale: boolean }; } ) => { + const getInterpolatedContent = (): JSX.Element => { + return createInterpolateElement( + __( 'Please try again or contact support.', 'jetpack-components' ), + { + supportLink: - { isOpen ? : null } + { isOpen ? ( + + ) : null } ); }; diff --git a/projects/js-packages/components/components/threat-modal/styles.module.scss b/projects/js-packages/components/components/threat-modal/styles.module.scss index 1720d59b1f90c..a3cfb182c4eb3 100644 --- a/projects/js-packages/components/components/threat-modal/styles.module.scss +++ b/projects/js-packages/components/components/threat-modal/styles.module.scss @@ -8,19 +8,19 @@ display: flex; flex-direction: column; gap: calc( var( --spacing-base ) * 2 ); // 16px -} - -.section .section__toggle { - text-decoration: none; - - &:hover { - text-decoration: underline; - } - &__content { - display: flex; - gap: calc( var( --spacing-base ) / 2 ); // 4px - align-items: center; + .section__toggle { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &__content { + display: flex; + gap: calc( var( --spacing-base ) / 2 ); // 4px + align-items: center; + } } } @@ -43,6 +43,7 @@ .threat-actions { display: flex; justify-content: flex-end; + flex-wrap: wrap; gap: calc( var( --spacing-base ) * 2 ); // 16px; } } @@ -55,10 +56,6 @@ &__title { display: flex; gap: calc( var( --spacing-base ) / 2 ); // 4px - - p { - font-weight: bold; - } } &__actions { @@ -77,5 +74,4 @@ svg.spinner { width: 20px; margin-left: calc( var( --spacing-base ) / 2 ); // 4px; margin-right: 6px; - } \ No newline at end of file diff --git a/projects/js-packages/components/components/threat-modal/threat-actions.tsx b/projects/js-packages/components/components/threat-modal/threat-actions.tsx index e73f0450cc83f..f9feae4f694dc 100644 --- a/projects/js-packages/components/components/threat-modal/threat-actions.tsx +++ b/projects/js-packages/components/components/threat-modal/threat-actions.tsx @@ -15,6 +15,7 @@ const ThreatActions = (): JSX.Element => { const { closeModal, threat, + actionToConfirm, handleFixThreatClick, handleIgnoreThreatClick, handleUnignoreThreatClick, @@ -64,15 +65,17 @@ const ThreatActions = (): JSX.Element => { ) } { threat.status === 'current' && ( <> - - { threat.fixable && ( + { [ 'all', 'ignore' ].includes( actionToConfirm ) && ( + + ) } + { threat.fixable && [ 'all', 'fix' ].includes( actionToConfirm ) && ( - ) } - { siteCredentialsNeeded && ( - - ) } - + { showActions && ( +
+ { userConnectionNeeded && ( + + ) } + { siteCredentialsNeeded && ( + + ) } +
+ ) } } /> diff --git a/projects/js-packages/components/components/threat-modal/threat-technical-details.tsx b/projects/js-packages/components/components/threat-modal/threat-technical-details.tsx index 0e46fb8c38f6f..e2b7c5caa70eb 100644 --- a/projects/js-packages/components/components/threat-modal/threat-technical-details.tsx +++ b/projects/js-packages/components/components/threat-modal/threat-technical-details.tsx @@ -17,6 +17,11 @@ const ThreatTechnicalDetails = (): JSX.Element => { const [ open, setOpen ] = useState( false ); + let toggleContent = __( 'Show the technical details', 'jetpack-components' ); + if ( open ) { + toggleContent = __( 'Hide the technical details', 'jetpack-components' ); + } + const toggleOpen = useCallback( () => { setOpen( ! open ); }, [ open ] ); @@ -37,9 +42,7 @@ const ThreatTechnicalDetails = (): JSX.Element => { >
- { open - ? __( 'Hide the technical details', 'jetpack-components' ) - : __( 'Show the technical details', 'jetpack-components' ) } + { toggleContent }
diff --git a/projects/js-packages/components/components/threats-data-views/index.tsx b/projects/js-packages/components/components/threats-data-views/index.tsx index 27cbfa23935fe..cbbc3b88cecd4 100644 --- a/projects/js-packages/components/components/threats-data-views/index.tsx +++ b/projects/js-packages/components/components/threats-data-views/index.tsx @@ -1,7 +1,6 @@ import { getThreatType, type Threat } from '@automattic/jetpack-scan'; import { type Action, - type ActionButton, type Field, type FieldType, type Filter, @@ -15,11 +14,12 @@ import { dateI18n } from '@wordpress/date'; import { __ } from '@wordpress/i18n'; import { Icon } from '@wordpress/icons'; import { useCallback, useMemo, useState } from 'react'; +import { Button } from '@automattic/jetpack-components'; import Badge from '../badge'; import ThreatFixerButton from '../threat-fixer-button'; +import ThreatModal from '../threat-modal'; import ThreatSeverityBadge from '../threat-severity-badge'; import { - THREAT_ACTION_FIX, THREAT_ACTION_IGNORE, THREAT_ACTION_UNIGNORE, THREAT_FIELD_AUTO_FIX, @@ -49,35 +49,67 @@ import ThreatsStatusToggleGroupControl from './threats-status-toggle-group-contr * @param {Array} props.data - Threats data. * @param {Array} props.filters - Initial DataView filters. * @param {Function} props.onChangeSelection - Callback function run when an item is selected. + * @param {boolean} props.isSupportedEnvironment - Whether the environment is supported. + * @param {Function} props.handleUpgradeClick - Callback function run when the upgrade button is clicked. * @param {Function} props.onFixThreats - Threat fix action callback. * @param {Function} props.onIgnoreThreats - Threat ignore action callback. * @param {Function} props.onUnignoreThreats - Threat unignore action callback. * @param {Function} props.isThreatEligibleForFix - Function to determine if a threat is eligible for fixing. * @param {Function} props.isThreatEligibleForIgnore - Function to determine if a threat is eligible for ignoring. * @param {Function} props.isThreatEligibleForUnignore - Function to determine if a threat is eligible for unignoring. - * + * @param {boolean} props.isUserConnected - Whether the user is connected. + * @param {boolean} props.hasConnectedOwner - Whether the site has a connected owner. + * @param {boolean} props.userIsConnecting - Whether the user is connecting. + * @param {Function} props.handleConnectUser - Function to handle the user connection process. + * @param {object[]} props.credentials - The credentials. + * @param {boolean} props.credentialsIsFetching - Whether the credentials are fetching. + * @param {string} props.credentialsRedirectUrl - The credentials redirect URL. + * @param {Function} props.onModalOpen - Callback function on modal open. + * @param {Function} props.onModalClose - Callback function on modal close. * @return {JSX.Element} The ThreatsDataViews component. */ export default function ThreatsDataViews( { data, filters, onChangeSelection, - isThreatEligibleForFix, - isThreatEligibleForIgnore, - isThreatEligibleForUnignore, + isSupportedEnvironment, + handleUpgradeClick, onFixThreats, onIgnoreThreats, onUnignoreThreats, + isThreatEligibleForFix, + isThreatEligibleForIgnore, + isThreatEligibleForUnignore, + isUserConnected, + hasConnectedOwner, + userIsConnecting, + handleConnectUser, + credentials, + credentialsIsFetching, + credentialsRedirectUrl, + onModalOpen, + onModalClose, }: { data: Threat[]; filters?: Filter[]; onChangeSelection?: ( selectedItemIds: string[] ) => void; + isSupportedEnvironment: boolean; + handleUpgradeClick?: () => void; + onFixThreats?: ( threats: Threat[] ) => void; + onIgnoreThreats?: ( threats: Threat[] ) => void; + onUnignoreThreats?: ( threats: Threat[] ) => void; isThreatEligibleForFix?: ( threat: Threat ) => boolean; isThreatEligibleForIgnore?: ( threat: Threat ) => boolean; isThreatEligibleForUnignore?: ( threat: Threat ) => boolean; - onFixThreats?: ( threats: Threat[] ) => void; - onIgnoreThreats?: ActionButton< Threat >[ 'callback' ]; - onUnignoreThreats?: ActionButton< Threat >[ 'callback' ]; + isUserConnected: boolean; + hasConnectedOwner: boolean; + userIsConnecting: boolean; + handleConnectUser: () => void; + credentials: false | Record< string, unknown >[]; + credentialsIsFetching: boolean; + credentialsRedirectUrl: string; + onModalOpen: () => void; + onModalClose: () => void; } ): JSX.Element { const baseView = { sort: { @@ -129,6 +161,24 @@ export default function ThreatsDataViews( { ...defaultLayouts.table, } ); + const [ openThreat, setOpenThreat ] = useState< Threat | null >( null ); + const [ actionToConfirm, setActionToConfirm ] = useState< string >( 'all' ); + + const showThreatModal = useCallback( + ( threat: Threat, action: string ) => () => { + onModalOpen?.(); + setOpenThreat( threat ); + setActionToConfirm( action ); + }, + [ onModalOpen ] + ); + + const hideThreatModal = useCallback( () => { + onModalClose?.(); + setOpenThreat( null ); + setActionToConfirm( 'all' ); + }, [ onModalClose ] ); + /** * Compute values from the provided threats data. * @@ -211,7 +261,17 @@ export default function ThreatsDataViews( { enableGlobalSearch: true, enableHiding: false, render: ( { item }: { item: Threat } ) => ( -
{ item.title }
+
+ +
), }, { @@ -390,7 +450,13 @@ export default function ThreatsDataViews( { return null; } - return ; + if ( isThreatEligibleForFix && ! isThreatEligibleForFix( item ) ) { + return null; + } + + return ( + + ); }, }, ] @@ -398,7 +464,7 @@ export default function ThreatsDataViews( { ]; return result; - }, [ dataFields, plugins, themes, signatures, onFixThreats ] ); + }, [ plugins, themes, dataFields, signatures, isThreatEligibleForFix, showThreatModal ] ); /** * DataView actions - collection of operations that can be performed upon each record. @@ -408,31 +474,13 @@ export default function ThreatsDataViews( { const actions = useMemo( () => { const result: Action< Threat >[] = []; - if ( dataFields.includes( 'fixable' ) ) { - result.push( { - id: THREAT_ACTION_FIX, - label: __( 'Auto-fix', 'jetpack-components' ), - isPrimary: true, - callback: onFixThreats, - isEligible( item ) { - if ( ! onFixThreats ) { - return false; - } - if ( isThreatEligibleForFix ) { - return isThreatEligibleForFix( item ); - } - return !! item.fixable; - }, - } ); - } - if ( dataFields.includes( 'status' ) ) { result.push( { id: THREAT_ACTION_IGNORE, label: __( 'Ignore', 'jetpack-components' ), - isPrimary: true, - isDestructive: true, - callback: onIgnoreThreats, + callback: ( items: Threat[] ) => { + showThreatModal( items[ 0 ], 'ignore' )(); + }, isEligible( item ) { if ( ! onIgnoreThreats ) { return false; @@ -449,9 +497,9 @@ export default function ThreatsDataViews( { result.push( { id: THREAT_ACTION_UNIGNORE, label: __( 'Unignore', 'jetpack-components' ), - isPrimary: true, - isDestructive: true, - callback: onUnignoreThreats, + callback: ( items: Threat[] ) => { + showThreatModal( items[ 0 ], 'unignore' )(); + }, isEligible( item ) { if ( ! onUnignoreThreats ) { return false; @@ -467,10 +515,9 @@ export default function ThreatsDataViews( { return result; }, [ dataFields, - onFixThreats, + showThreatModal, onIgnoreThreats, onUnignoreThreats, - isThreatEligibleForFix, isThreatEligibleForIgnore, isThreatEligibleForUnignore, ] ); @@ -501,23 +548,44 @@ export default function ThreatsDataViews( { const getItemId = useCallback( ( item: Threat ) => item.id.toString(), [] ); return ( - + + } + /> + { openThreat ? ( + - } - /> + ) : null } + ); } diff --git a/projects/js-packages/components/components/threats-data-views/stories/data.tsx b/projects/js-packages/components/components/threats-data-views/stories/data.tsx new file mode 100644 index 0000000000000..9563a7f594f58 --- /dev/null +++ b/projects/js-packages/components/components/threats-data-views/stories/data.tsx @@ -0,0 +1,126 @@ +export const data = [ + { + id: 185869885, + signature: 'EICAR_AV_Test', + title: 'Malicious code found in file: index.php', + description: + "This is the standard EICAR antivirus test code, and not a real infection. If your site contains this code when you don't expect it to, contact Jetpack support for some help.", + firstDetected: '2024-10-07T20:45:06.000Z', + fixedIn: null, + severity: 8, + fixable: { fixer: 'delete' }, + fixer: { status: 'not_started' }, + status: 'current', + filename: '/var/www/html/wp-content/index.php', + context: { + '1': 'echo << ; -Default.args = { - data: [ - { - id: 185869885, - signature: 'EICAR_AV_Test', - title: 'Malicious code found in file: index.php', - description: - "This is the standard EICAR antivirus test code, and not a real infection. If your site contains this code when you don't expect it to, contact Jetpack support for some help.", - firstDetected: '2024-10-07T20:45:06.000Z', - fixedIn: null, - severity: 8, - fixable: { fixer: 'delete' }, - fixer: { status: 'not_started' }, - status: 'current', - filename: '/var/www/html/wp-content/index.php', - context: { - '1': 'echo << - alert( 'Threat fix action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert - onIgnoreThreats: () => - alert( 'Ignore threat action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert - onUnignoreThreats: () => - // eslint-disable-next-line no-alert - alert( - 'Unignore threat action callback triggered! This is handled by the component consumer.' - ), -}; - export const FixerStatuses = args => ; FixerStatuses.args = { data: [ @@ -267,6 +122,16 @@ FixerStatuses.args = { value: [ 'current' ], }, ], + isSupportedEnvironment: true, + isUserConnected: true, + hasConnectedOwner: true, + userIsConnecting: false, + handleConnectUser: () => + alert( 'Connect user action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + credentials: [ { type: 'managed', role: 'main', still_valid: true } ], + credentialsIsFetching: false, + credentialsRedirectUrl: '', + isThreatEligibleForFix: () => true, onFixThreats: () => alert( 'Fix threat action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert onIgnoreThreats: () => @@ -278,6 +143,109 @@ FixerStatuses.args = { ), }; +export const Default = args => ; +Default.args = { + data: data, + filters: [ + { + field: 'status', + operator: 'isAny', + value: [ 'current' ], + }, + ], + isSupportedEnvironment: true, + isUserConnected: true, + hasConnectedOwner: true, + handleConnectUser: () => + alert( 'Connect user action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + credentials: [ { type: 'managed', role: 'main', still_valid: true } ], + isThreatEligibleForFix: () => true, + onFixThreats: () => + alert( 'Threat fix action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + onIgnoreThreats: () => + alert( 'Ignore threat action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + onUnignoreThreats: () => + // eslint-disable-next-line no-alert + alert( + 'Unignore threat action callback triggered! This is handled by the component consumer.' + ), +}; + +export const AdditionalConnectionsNeeded = args => ; +AdditionalConnectionsNeeded.args = { + data: data, + filters: [ + { + field: 'status', + operator: 'isAny', + value: [ 'current' ], + }, + ], + isSupportedEnvironment: true, + isUserConnected: false, + handleConnectUser: () => + alert( 'Connect user action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + isThreatEligibleForFix: () => true, + onFixThreats: () => + alert( 'Threat fix action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + onIgnoreThreats: () => + alert( 'Ignore threat action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + onUnignoreThreats: () => + // eslint-disable-next-line no-alert + alert( + 'Unignore threat action callback triggered! This is handled by the component consumer.' + ), +}; + +export const UserConnectionNeeded = args => ; +UserConnectionNeeded.args = { + data: data, + filters: [ + { + field: 'status', + operator: 'isAny', + value: [ 'current' ], + }, + ], + isSupportedEnvironment: true, + isUserConnected: false, + handleConnectUser: () => + alert( 'Connect user action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + credentials: [ { type: 'managed', role: 'main', still_valid: true } ], + isThreatEligibleForFix: () => true, + onFixThreats: () => + alert( 'Threat fix action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + onIgnoreThreats: () => + alert( 'Ignore threat action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + onUnignoreThreats: () => + // eslint-disable-next-line no-alert + alert( + 'Unignore threat action callback triggered! This is handled by the component consumer.' + ), +}; + +export const CredentialsRequired = args => ; +CredentialsRequired.args = { + data: data, + isSupportedEnvironment: true, + isUserConnected: true, + hasConnectedOwner: true, + handleConnectUser: () => + alert( 'Connect user action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + credentials: false, + credentialsRedirectUrl: '#', + isThreatEligibleForFix: () => true, + onFixThreats: () => + alert( 'Threat fix action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + onIgnoreThreats: () => + alert( 'Ignore threat action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert + onUnignoreThreats: () => + // eslint-disable-next-line no-alert + alert( + 'Unignore threat action callback triggered! This is handled by the component consumer.' + ), +}; + export const FreeResults = args => ; FreeResults.args = { data: [ @@ -325,4 +293,7 @@ FreeResults.args = { }, }, ], + isSupportedEnvironment: true, + handleUpgradeClick: () => + alert( 'Upgrade action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert }; diff --git a/projects/js-packages/components/components/threats-data-views/styles.module.scss b/projects/js-packages/components/components/threats-data-views/styles.module.scss index 7d97f69b25ccd..fcb2f65b1dca9 100644 --- a/projects/js-packages/components/components/threats-data-views/styles.module.scss +++ b/projects/js-packages/components/components/threats-data-views/styles.module.scss @@ -1,15 +1,26 @@ @import '@wordpress/dataviews/build-style/style.css'; +:global { + .dataviews-view-list .dataviews-view-list__primary-field { + overflow: visible; + } +} + +:root { + --spacing-base: 8px; /* Base spacing unit */ +} + .threat__title { - color: var( --jp-gray-80 ); - font-weight: 510; - white-space: initial; + max-width: fit-content; + + .threat__title__link { + text-decoration: none; + } } .threat__description { - color: var( --jp-gray-80 ); font-size: 12px; - white-space: initial; + white-space: wrap; } .threat__fixedOn, diff --git a/projects/js-packages/components/components/threats-data-views/test/index.test.tsx b/projects/js-packages/components/components/threats-data-views/test/index.test.tsx index e5154b472664d..882186382702e 100644 --- a/projects/js-packages/components/components/threats-data-views/test/index.test.tsx +++ b/projects/js-packages/components/components/threats-data-views/test/index.test.tsx @@ -45,9 +45,31 @@ const data = [ }, ]; +const mockProps = { + filters: [], + onChangeSelection: () => {}, + isSupportedEnvironment: true, + handleUpgradeClick: () => {}, + onFixThreats: () => {}, + onIgnoreThreats: () => {}, + onUnignoreThreats: () => {}, + isThreatEligibleForFix: () => true, + isThreatEligibleForIgnore: () => true, + isThreatEligibleForUnignore: () => true, + isUserConnected: true, + hasConnectedOwner: true, + userIsConnecting: false, + handleConnectUser: () => {}, + credentials: [], + credentialsIsFetching: false, + credentialsRedirectUrl: '/redirect-url', + onModalOpen: () => {}, + onModalClose: () => {}, +}; + describe( 'ThreatsDataViews', () => { it( 'renders threat data', () => { - render( ); + render( ); expect( screen.getByText( 'Malicious code found in file: index.php' ) ).toBeInTheDocument(); expect( screen.getByText( 'WooCommerce <= 3.2.3 - Authenticated PHP Object Injection' ) diff --git a/projects/js-packages/scan/changelog/add-components-threats-data-views-modal-integration b/projects/js-packages/scan/changelog/add-components-threats-data-views-modal-integration new file mode 100644 index 0000000000000..ccbcd7068d542 --- /dev/null +++ b/projects/js-packages/scan/changelog/add-components-threats-data-views-modal-integration @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Adds utility for retrieving a detailed action description diff --git a/projects/plugins/protect/src/js/components/ignore-threat-modal/index.jsx b/projects/plugins/protect/src/js/components/ignore-threat-modal/index.jsx index 0788eb8bd7a41..1d6f4457b39a4 100644 --- a/projects/plugins/protect/src/js/components/ignore-threat-modal/index.jsx +++ b/projects/plugins/protect/src/js/components/ignore-threat-modal/index.jsx @@ -5,10 +5,12 @@ import { createInterpolateElement, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import useIgnoreThreatMutation from '../../data/scan/use-ignore-threat-mutation'; import useModal from '../../hooks/use-modal'; +import useWafData from '../../hooks/use-waf-data'; import UserConnectionGate from '../user-connection-gate'; import styles from './styles.module.scss'; const IgnoreThreatModal = ( { threat } ) => { + const { wafSupported } = useWafData(); const { setModal } = useModal(); const ignoreThreatMutation = useIgnoreThreatMutation(); const codeableURL = getRedirectUrl( 'jetpack-protect-codeable-referral' ); @@ -54,15 +56,20 @@ const IgnoreThreatModal = ( { threat } ) => { - { createInterpolateElement( - __( - 'By choosing to ignore this threat, you acknowledge that you have reviewed the detected code. You are accepting the risks of maintaining a potentially malicious or vulnerable file on your site. If you are unsure, please request an estimate with Codeable.', - 'jetpack-protect' - ), - { - codeableLink: