diff --git a/eslint.config.mjs b/eslint.config.mjs index 9da5ed52a571..cbe1a0e1ab26 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,51 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import autoProjects from 'jetpack-js-tools/eslintrc/auto-projects.mjs'; import { makeBaseConfig, defineConfig } from 'jetpack-js-tools/eslintrc/base.mjs'; -export default defineConfig( makeBaseConfig( import.meta.url ), autoProjects ); +const rootdir = fileURLToPath( new URL( '.', import.meta.url ) ); + +/** + * `projects/packages/scan` uses nested `routes//package.json` for wp-build. + * Root ESLint skips per-project configs; merge those deps for import/no-extraneous-dependencies. + * + * @return {string[]} Absolute paths of route folders that contain a `package.json`. + */ +function getJetpackScanRoutePackageDirs() { + const routesRoot = path.join( rootdir, 'projects/packages/scan/routes' ); + if ( ! fs.existsSync( routesRoot ) ) { + return []; + } + return fs + .readdirSync( routesRoot, { withFileTypes: true } ) + .filter( dirent => dirent.isDirectory() ) + .map( dirent => path.join( routesRoot, dirent.name ) ) + .filter( dir => fs.existsSync( path.join( dir, 'package.json' ) ) ); +} + +const jetpackScanRoutePackageDirs = getJetpackScanRoutePackageDirs(); + +export default defineConfig( + makeBaseConfig( import.meta.url ), + autoProjects, + ...( jetpackScanRoutePackageDirs.length > 0 + ? [ + { + name: 'Jetpack Scan (wp-build routes): merge route package.json for import deps', + files: [ 'projects/packages/scan/routes/**/*.{js,jsx,ts,tsx,mjs,cjs}' ], + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + packageDir: [ + path.join( rootdir, 'projects/packages/scan' ), + ...jetpackScanRoutePackageDirs, + ], + }, + ], + }, + }, + ] + : [] ) +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b7f282dcfc6..6ee061220b24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3893,31 +3893,127 @@ importers: projects/packages/scan: dependencies: + '@automattic/babel-plugin-replace-textdomain': + specifier: workspace:* + version: link:../../js-packages/babel-plugin-replace-textdomain + '@automattic/jetpack-analytics': + specifier: workspace:* + version: link:../../js-packages/analytics + '@automattic/jetpack-base-styles': + specifier: workspace:* + version: link:../../js-packages/base-styles + '@automattic/jetpack-components': + specifier: workspace:* + version: link:../../js-packages/components + '@automattic/jetpack-connection': + specifier: workspace:* + version: link:../../js-packages/connection + '@automattic/jetpack-scan': + specifier: workspace:* + version: link:../../js-packages/scan + '@automattic/jetpack-script-data': + specifier: workspace:* + version: link:../../js-packages/script-data '@automattic/jetpack-wp-build-polyfills': specifier: workspace:* version: link:../wp-build-polyfills + '@tanstack/react-query': + specifier: 5.90.8 + version: 5.90.8(react@18.3.1) + '@wordpress/admin-ui': + specifier: 1.12.0 + version: 1.12.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/api-fetch': + specifier: 7.44.0 + version: 7.44.0 + '@wordpress/base-styles': + specifier: 6.20.0 + version: 6.20.0 + '@wordpress/boot': + specifier: 0.11.0 + version: 0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/components': + specifier: 32.6.0 + version: 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': + specifier: 7.44.0 + version: 7.44.0(react@18.3.1) + '@wordpress/data': + specifier: 10.44.0 + version: 10.44.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.1.0 + version: 14.1.0(@types/react@18.3.28)(react@18.3.1) + '@wordpress/date': + specifier: 5.44.0 + version: 5.44.0 '@wordpress/element': specifier: 6.44.0 version: 6.44.0 '@wordpress/i18n': specifier: 6.17.0 version: 6.17.0 + '@wordpress/icons': + specifier: 12.2.0 + version: 12.2.0(react@18.3.1) + '@wordpress/notices': + specifier: 5.44.0 + version: 5.44.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/route': + specifier: 0.10.0 + version: 0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/theme': + specifier: 0.11.0 + version: 0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/ui': + specifier: 0.11.0 + version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.44.0 + version: 4.44.0 devDependencies: '@babel/core': specifier: 7.29.0 version: 7.29.0 + '@babel/preset-env': + specifier: 7.29.2 + version: 7.29.2(@babel/core@7.29.0) + '@testing-library/dom': + specifier: 10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 '@types/react': specifier: 18.3.28 version: 18.3.28 + '@types/react-dom': + specifier: 18.3.7 + version: 18.3.7(@types/react@18.3.28) + '@typescript/native-preview': + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 '@wordpress/browserslist-config': specifier: 6.44.0 version: 6.44.0 '@wordpress/build': specifier: 0.13.0 - version: 0.13.0(@babel/core@7.29.0)(browserslist@4.28.2) + version: 0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: ^4.24.0 version: 4.28.2 + jest: + specifier: 30.3.0 + version: 30.3.0 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) projects/packages/search: dependencies: @@ -23742,28 +23838,6 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.13.0(@babel/core@7.29.0)(browserslist@4.28.2)': - dependencies: - '@emotion/babel-plugin': 11.13.5 - autoprefixer: 10.4.27(postcss@8.5.10) - browserslist-to-esbuild: 2.1.1(browserslist@4.28.2) - change-case: 4.1.2 - chokidar: 4.0.3 - cssnano: 7.1.4(postcss@8.5.10) - esbuild: 0.27.4 - esbuild-plugin-babel: 0.2.3(@babel/core@7.29.0) - esbuild-sass-plugin: 3.3.1(esbuild@0.27.4)(sass-embedded@1.97.3) - fast-glob: 3.3.3 - moment-timezone: 0.5.48 - postcss: 8.5.10 - postcss-modules: 6.0.1(postcss@8.5.10) - rtlcss: 4.3.0 - sass-embedded: 1.97.3 - transitivePeerDependencies: - - '@babel/core' - - browserslist - - supports-color - '@wordpress/commands@1.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/base-styles': 6.20.0 diff --git a/projects/js-packages/scan/changelog/add-empty-prop b/projects/js-packages/scan/changelog/add-empty-prop new file mode 100644 index 000000000000..0feb99c5517e --- /dev/null +++ b/projects/js-packages/scan/changelog/add-empty-prop @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add an optional `empty` prop to `ThreatsDataViews` that's forwarded to the underlying `DataViews` so consumers can render their own empty-state node (heading, body, CTA) instead of DataViews' built-in "no items" body. diff --git a/projects/js-packages/scan/changelog/add-on-track-event-prop b/projects/js-packages/scan/changelog/add-on-track-event-prop new file mode 100644 index 000000000000..d32deb00bf4f --- /dev/null +++ b/projects/js-packages/scan/changelog/add-on-track-event-prop @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +`ThreatsDataViews`: add `onTrackEvent?: ( event: string, properties?: Record< string, unknown > ) => void` prop. When supplied, the component fires DataViews-canonical event names on view transitions (`search` with `{ has_query }`, `layout_changed` with `{ layout }`, `page_change` with `{ page }`, `filter_change`, and a generic `view_change`) by diffing the previous view against the next one in `onChangeView`. Consumers add their own prefix (e.g. `jetpack_scan_*`, `jetpack_protect_*`) and forward to their analytics client. Backwards compatible — when `onTrackEvent` is omitted no events fire. diff --git a/projects/js-packages/scan/changelog/add-persist-key-prop b/projects/js-packages/scan/changelog/add-persist-key-prop new file mode 100644 index 000000000000..e684fa9547c2 --- /dev/null +++ b/projects/js-packages/scan/changelog/add-persist-key-prop @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +`ThreatsDataViews`: add `persistKey?: string` prop. When set, the component hydrates its initial view (filters, sort, search, pagination, layout) from `localStorage[persistKey]` and writes back on every change. Use stable, namespaced keys per panel (e.g. `jetpack-scan:active-threats:view`) so consumer panels don't collide. Quietly no-ops when `localStorage` is unavailable (privacy mode, full disk). diff --git a/projects/js-packages/scan/changelog/add-render-view-modal-prop b/projects/js-packages/scan/changelog/add-render-view-modal-prop new file mode 100644 index 000000000000..b48db2d0c421 --- /dev/null +++ b/projects/js-packages/scan/changelog/add-render-view-modal-prop @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +`ThreatsDataViews`: add `RenderViewModal?: ( props: RenderModalProps< Threat > ) => ReactElement` prop. Unlike the existing `RenderFixModal` / `RenderIgnoreModal` / `RenderUnignoreModal`, the resulting "View details" row action is always eligible (not gated by `fixable` / `status`) so the user can drill into any row regardless of state. Renders inside a `large` DataViews modal. diff --git a/projects/js-packages/scan/changelog/add-show-status-filter-prop b/projects/js-packages/scan/changelog/add-show-status-filter-prop new file mode 100644 index 000000000000..ac4ace0878a0 --- /dev/null +++ b/projects/js-packages/scan/changelog/add-show-status-filter-prop @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +`ThreatsDataViews`: add `showStatusFilter?: boolean` prop (defaults to `true`). Lets consumers that already filter the dataset by status outside the component (e.g. page-level Active threats / History tabs in Scan) opt out of the in-table active/historic toggle. Existing callers (Protect) keep the toggle by default. diff --git a/projects/js-packages/scan/changelog/add-threats-data-views-render-modal-props b/projects/js-packages/scan/changelog/add-threats-data-views-render-modal-props new file mode 100644 index 000000000000..e5da637bda1a --- /dev/null +++ b/projects/js-packages/scan/changelog/add-threats-data-views-render-modal-props @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +`ThreatsDataViews`: accept `RenderFixModal` / `RenderIgnoreModal` / `RenderUnignoreModal` props so consumers can route the row actions through DataViews-managed confirmation modals (`RenderModalProps< Threat >`) instead of fire-and-forget callbacks. The existing `onFixThreats` / `onIgnoreThreats` / `onUnignoreThreats` callbacks are still honoured when no render-modal is supplied; render-modal props take precedence when both are passed for the same action. diff --git a/projects/js-packages/scan/changelog/anchor-empty-state-min-height b/projects/js-packages/scan/changelog/anchor-empty-state-min-height new file mode 100644 index 000000000000..951346adee58 --- /dev/null +++ b/projects/js-packages/scan/changelog/anchor-empty-state-min-height @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +`ThreatsDataViews`: anchor the DataViews empty body (`.dataviews-no-results`) to `calc(100vh - 320px)` so it always reads as a full-height empty state. The internal `.dataviews-no-results` element already grows via `flex-grow: 1`, but only when its parent has a defined height — pinning the min-block-size from inside the component means consumers (Scan page, Protect) get the full-height layout without having to wire a custom flex chain. diff --git a/projects/js-packages/scan/changelog/drop-build-step b/projects/js-packages/scan/changelog/drop-build-step new file mode 100644 index 000000000000..3bd5fe1e2977 --- /dev/null +++ b/projects/js-packages/scan/changelog/drop-build-step @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Switch the package to source-exports (mirrors `@automattic/jetpack-connection`): consumers resolve `@automattic/jetpack-scan` directly to `./src/index.ts`, so `tsgo` compile is no longer needed. `@wordpress/build` (esbuild) consumers process the TS + `*.module.scss` natively; webpack consumers (Protect plugin) keep working through the same `jetpack:src` resolution condition the rest of the monorepo already uses. diff --git a/projects/js-packages/scan/composer.json b/projects/js-packages/scan/composer.json index ebec8e5f5baa..18adc587e9ba 100644 --- a/projects/js-packages/scan/composer.json +++ b/projects/js-packages/scan/composer.json @@ -5,16 +5,6 @@ "license": "GPL-2.0-or-later", "require": {}, "scripts": { - "build-development": [ - "pnpm run build" - ], - "build-production": [ - "NODE_ENV=production BABEL_ENV=production pnpm run build" - ], - "watch": [ - "Composer\\Config::disableProcessTimeout", - "pnpm run watch" - ], "test-coverage": "pnpm run test-coverage", "test-js": [ "pnpm run test" diff --git a/projects/js-packages/scan/package.json b/projects/js-packages/scan/package.json index 5c52eb90a00c..92b3050097bb 100644 --- a/projects/js-packages/scan/package.json +++ b/projects/js-packages/scan/package.json @@ -13,20 +13,15 @@ }, "license": "GPL-2.0-or-later", "author": "Automattic", + "sideEffects": [ + "*.css", + "*.scss" + ], "type": "module", "exports": { - ".": { - "jetpack:src": "./src/index.ts", - "types": "./build/index.d.ts", - "default": "./build/index.js" - } + ".": "./src/index.ts" }, - "main": "./build/index.js", - "types": "./build/index.d.ts", "scripts": { - "build": "pnpm run clean && pnpm run compile-ts", - "clean": "rm -rf build/", - "compile-ts": "tsgo --pretty", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "test-coverage": "pnpm run test --coverage", "typecheck": "tsgo --noEmit" diff --git a/projects/js-packages/scan/src/components/threats-data-views/constants.ts b/projects/js-packages/scan/src/components/threats-data-views/constants.ts index 4e479ef3eb4d..8c87fe895d19 100644 --- a/projects/js-packages/scan/src/components/threats-data-views/constants.ts +++ b/projects/js-packages/scan/src/components/threats-data-views/constants.ts @@ -46,3 +46,4 @@ export const THREAT_FIELD_AUTO_FIX = 'auto-fix'; export const THREAT_ACTION_FIX = 'fix'; export const THREAT_ACTION_IGNORE = 'ignore'; export const THREAT_ACTION_UNIGNORE = 'unignore'; +export const THREAT_ACTION_VIEW = 'view'; diff --git a/projects/js-packages/scan/src/components/threats-data-views/index.tsx b/projects/js-packages/scan/src/components/threats-data-views/index.tsx index fc7978ead570..a51a6730f8f9 100644 --- a/projects/js-packages/scan/src/components/threats-data-views/index.tsx +++ b/projects/js-packages/scan/src/components/threats-data-views/index.tsx @@ -4,6 +4,7 @@ import { type Field, type FieldTypeName, type Filter, + type RenderModalProps, type SortDirection, type View, DataViews, @@ -13,13 +14,22 @@ import { dateI18n } from '@wordpress/date'; import { __ } from '@wordpress/i18n'; import { Icon } from '@wordpress/icons'; import { Badge } from '@wordpress/ui'; -import { useCallback, useMemo, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, +} from 'react'; import { ThreatSeverityBadge, getThreatType, type Threat } from '@automattic/jetpack-scan'; import ThreatFixerButton from '../threat-fixer-button/index.tsx'; import { THREAT_ACTION_FIX, THREAT_ACTION_IGNORE, THREAT_ACTION_UNIGNORE, + THREAT_ACTION_VIEW, THREAT_FIELD_AUTO_FIX, THREAT_FIELD_DESCRIPTION, THREAT_FIELD_EXTENSION, @@ -43,16 +53,32 @@ import ThreatsStatusToggleGroupControl from './threats-status-toggle-group-contr /** * DataViews component for displaying security threats. * - * @param {object} props - Component props. - * @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 {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. + * Each row action (Auto-fix / Ignore / Unignore) supports two wiring shapes: + * pass a callback (`onFixThreats` etc.) for the existing fire-and-forget + * behaviour, or pass a React component via the matching `Render*Modal` prop + * to open a confirmation modal — DataViews renders it inline when the + * action is invoked, and consumers receive `{ items, closeModal }` from + * `RenderModalProps< Threat >`. Render-modal props take precedence when + * both are supplied for the same action. + * + * @param {object} props - Component props. + * @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 {Function} props.onFixThreats - Threat fix action callback (used when no `RenderFixModal`). + * @param {Function} props.onIgnoreThreats - Threat ignore action callback (used when no `RenderIgnoreModal`). + * @param {Function} props.onUnignoreThreats - Threat unignore action callback (used when no `RenderUnignoreModal`). + * @param {Function} props.RenderFixModal - Optional component rendered as the fix-action modal. + * @param {Function} props.RenderIgnoreModal - Optional component rendered as the ignore-action modal. + * @param {Function} props.RenderUnignoreModal - Optional component rendered as the unignore-action modal. + * @param {Function} props.RenderViewModal - Optional component rendered as the view-details modal. Unlike the fix / ignore / unignore actions, this one is always eligible for any row. + * @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 {ReactNode} [props.empty] - Empty-state node forwarded to DataViews when `data` is empty. Defaults to DataViews' built-in "no items" body. + * @param {boolean} [props.showStatusFilter] - Whether to render the active/historic status toggle above the table. Defaults to `true`. Set to `false` when the consumer already filters the dataset by status outside the component (e.g. page-level tabs). + * @param {Function} [props.onTrackEvent] - Optional callback that receives DataViews-canonical event names (`view_change`, `filter_change`, `search`, `page_change`, `layout_changed`) on the matching view transitions. Consumers prefix and forward to their own analytics client. + * @param {string} [props.persistKey] - Optional `localStorage` key. When set, the component hydrates its initial view state from `localStorage[persistKey]` and writes back on every change. Use stable, namespaced keys (e.g. `jetpack-scan:active-threats:view`) so consumer panels don't collide. Quietly no-ops when `localStorage` is unavailable. * * @return {JSX.Element} The ThreatsDataViews component. */ @@ -66,6 +92,14 @@ export default function ThreatsDataViews( { onFixThreats, onIgnoreThreats, onUnignoreThreats, + RenderFixModal, + RenderIgnoreModal, + RenderUnignoreModal, + RenderViewModal, + empty, + showStatusFilter = true, + onTrackEvent, + persistKey, }: { data: Threat[]; filters?: Filter[]; @@ -76,6 +110,14 @@ export default function ThreatsDataViews( { onFixThreats?: ( threats: Threat[] ) => void; onIgnoreThreats?: ActionButton< Threat >[ 'callback' ]; onUnignoreThreats?: ActionButton< Threat >[ 'callback' ]; + RenderFixModal?: ( props: RenderModalProps< Threat > ) => ReactElement; + RenderIgnoreModal?: ( props: RenderModalProps< Threat > ) => ReactElement; + RenderUnignoreModal?: ( props: RenderModalProps< Threat > ) => ReactElement; + RenderViewModal?: ( props: RenderModalProps< Threat > ) => ReactElement; + empty?: ReactNode; + showStatusFilter?: boolean; + onTrackEvent?: ( event: string, properties?: Record< string, unknown > ) => void; + persistKey?: string; } ): JSX.Element { const baseView = { sort: { @@ -120,13 +162,40 @@ export default function ThreatsDataViews( { /** * DataView view object - configures how the dataset is visible to the user. * + * When `persistKey` is supplied, the initial view hydrates from + * `localStorage[persistKey]` so reloads, tab changes, and drill-ins + * preserve the user's filters / sort / pagination / layout. Falls + * back to the default table view on parse failure or first load. + * * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#view-object */ - const [ view, setView ] = useState< View >( { - type: 'table', - ...defaultLayouts.table, + const [ view, setView ] = useState< View >( () => { + const fallback: View = { type: 'table', ...defaultLayouts.table }; + if ( ! persistKey || typeof window === 'undefined' ) { + return fallback; + } + try { + const stored = window.localStorage.getItem( persistKey ); + if ( stored ) { + return JSON.parse( stored ) as View; + } + } catch { + // localStorage may be disabled (privacy mode, full disk) — no-op. + } + return fallback; } ); + useEffect( () => { + if ( ! persistKey || typeof window === 'undefined' ) { + return; + } + try { + window.localStorage.setItem( persistKey, JSON.stringify( view ) ); + } catch { + // localStorage may be disabled (privacy mode, full disk) — no-op. + } + }, [ persistKey, view ] ); + /** * Compute values from the provided threats data. * @@ -418,56 +487,109 @@ export default function ThreatsDataViews( { const result: Action< Threat >[] = []; if ( dataFields.includes( 'fixable' ) ) { - result.push( { - id: THREAT_ACTION_FIX, - label: __( 'Auto-fix', 'jetpack-scan' ), - isPrimary: true, - callback: onFixThreats, - isEligible( item ) { - if ( ! onFixThreats ) { - return false; - } - if ( isThreatEligibleForFix ) { - return isThreatEligibleForFix( item ); - } - return !! item.fixable; - }, - } ); + const isEligible = ( item: Threat ) => { + if ( ! RenderFixModal && ! onFixThreats ) { + return false; + } + if ( isThreatEligibleForFix ) { + return isThreatEligibleForFix( item ); + } + return !! item.fixable; + }; + if ( RenderFixModal ) { + result.push( { + id: THREAT_ACTION_FIX, + label: __( 'Auto-fix', 'jetpack-scan' ), + isPrimary: true, + modalHeader: __( 'Fix threat', 'jetpack-scan' ), + RenderModal: RenderFixModal, + isEligible, + } ); + } else { + result.push( { + id: THREAT_ACTION_FIX, + label: __( 'Auto-fix', 'jetpack-scan' ), + isPrimary: true, + callback: onFixThreats, + isEligible, + } ); + } } if ( dataFields.includes( 'status' ) ) { - result.push( { - id: THREAT_ACTION_IGNORE, - label: __( 'Ignore', 'jetpack-scan' ), - isPrimary: true, - callback: onIgnoreThreats, - isEligible( item ) { - if ( ! onIgnoreThreats ) { - return false; - } - if ( isThreatEligibleForIgnore ) { - return isThreatEligibleForIgnore( item ); - } - return item.status === 'current'; - }, - } ); + const isEligible = ( item: Threat ) => { + if ( ! RenderIgnoreModal && ! onIgnoreThreats ) { + return false; + } + if ( isThreatEligibleForIgnore ) { + return isThreatEligibleForIgnore( item ); + } + return item.status === 'current'; + }; + if ( RenderIgnoreModal ) { + result.push( { + id: THREAT_ACTION_IGNORE, + label: __( 'Ignore', 'jetpack-scan' ), + isPrimary: true, + modalHeader: __( 'Ignore threat', 'jetpack-scan' ), + RenderModal: RenderIgnoreModal, + isEligible, + } ); + } else { + result.push( { + id: THREAT_ACTION_IGNORE, + label: __( 'Ignore', 'jetpack-scan' ), + isPrimary: true, + callback: onIgnoreThreats, + isEligible, + } ); + } } if ( dataFields.includes( 'status' ) ) { + const isEligible = ( item: Threat ) => { + if ( ! RenderUnignoreModal && ! onUnignoreThreats ) { + return false; + } + if ( isThreatEligibleForUnignore ) { + return isThreatEligibleForUnignore( item ); + } + return item.status === 'ignored'; + }; + if ( RenderUnignoreModal ) { + result.push( { + id: THREAT_ACTION_UNIGNORE, + label: __( 'Unignore', 'jetpack-scan' ), + isPrimary: true, + modalHeader: __( 'Unignore threat', 'jetpack-scan' ), + RenderModal: RenderUnignoreModal, + isEligible, + } ); + } else { + result.push( { + id: THREAT_ACTION_UNIGNORE, + label: __( 'Unignore', 'jetpack-scan' ), + isPrimary: true, + callback: onUnignoreThreats, + isEligible, + } ); + } + } + + // View details — always-eligible row action that opens the + // supplied `RenderViewModal`. Unlike fix / ignore / unignore, it + // is NOT gated by threat status or capability, so the user can + // always drill in for the full file context, fix description, + // and metadata. + if ( RenderViewModal ) { result.push( { - id: THREAT_ACTION_UNIGNORE, - label: __( 'Unignore', 'jetpack-scan' ), - isPrimary: true, - callback: onUnignoreThreats, - isEligible( item ) { - if ( ! onUnignoreThreats ) { - return false; - } - if ( isThreatEligibleForUnignore ) { - return isThreatEligibleForUnignore( item ); - } - return item.status === 'ignored'; - }, + id: THREAT_ACTION_VIEW, + label: __( 'View details', 'jetpack-scan' ), + isPrimary: false, + modalHeader: __( 'Threat details', 'jetpack-scan' ), + modalSize: 'large', + RenderModal: RenderViewModal, + isEligible: () => true, } ); } @@ -477,6 +599,10 @@ export default function ThreatsDataViews( { onFixThreats, onIgnoreThreats, onUnignoreThreats, + RenderFixModal, + RenderIgnoreModal, + RenderUnignoreModal, + RenderViewModal, isThreatEligibleForFix, isThreatEligibleForIgnore, isThreatEligibleForUnignore, @@ -492,13 +618,42 @@ export default function ThreatsDataViews( { }, [ data, view, fields ] ); /** - * Callback function to update the view state. + * Callback function to update the view state. When `onTrackEvent` is + * supplied, diff the previous view against the new one and fire the + * matching DataViews-canonical event names so consumer analytics can + * track which dimension actually changed (search vs filter vs page + * vs layout). The generic `view_change` event always fires last so + * consumers can choose between the granular events and an "anything + * changed" hook. * * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#onchangeview-function */ - const onChangeView = useCallback( ( newView: View ) => { - setView( newView ); - }, [] ); + const previousViewRef = useRef< View >( view ); + const onChangeView = useCallback( + ( newView: View ) => { + if ( onTrackEvent ) { + const previous = previousViewRef.current; + if ( previous.search !== newView.search ) { + onTrackEvent( 'search', { has_query: !! newView.search } ); + } + if ( previous.type !== newView.type ) { + onTrackEvent( 'layout_changed', { layout: newView.type } ); + } + if ( previous.page !== newView.page ) { + onTrackEvent( 'page_change', { page: newView.page } ); + } + if ( + JSON.stringify( previous.filters ?? [] ) !== JSON.stringify( newView.filters ?? [] ) + ) { + onTrackEvent( 'filter_change' ); + } + onTrackEvent( 'view_change' ); + } + previousViewRef.current = newView; + setView( newView ); + }, + [ onTrackEvent ] + ); /** * DataView getItemId function - returns the unique ID for each record in the dataset. @@ -508,23 +663,28 @@ export default function ThreatsDataViews( { const getItemId = useCallback( ( item: Threat ) => item.id.toString(), [] ); return ( - - } - /> +
+ + ) : undefined + } + /> +
); } diff --git a/projects/js-packages/scan/src/components/threats-data-views/styles.module.scss b/projects/js-packages/scan/src/components/threats-data-views/styles.module.scss index 7d53faffe2d8..9a59991e5f45 100644 --- a/projects/js-packages/scan/src/components/threats-data-views/styles.module.scss +++ b/projects/js-packages/scan/src/components/threats-data-views/styles.module.scss @@ -1,6 +1,29 @@ @import "@wordpress/theme/design-tokens.css"; @import "@wordpress/dataviews/build-style/style.css"; +// Wrapper carries the flex chain forward to `` (whose root +// `.dataviews-wrapper` is `height: 100%`) and scopes the empty-state +// override below — `:global(.dataviews-no-results)` only matches inside +// this wrapper because the outer class is module-hashed. +.threats-data-views { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-block-size: 0; + + // DataViews ships `.dataviews-no-results` with `flex-grow: 1`, so it + // fills its parent — but only if that parent has a defined height. + // Anchor the empty body to the viewport (minus admin bar + page + // header + tab row + footer + a small breathing margin) so the empty + // state always reads as full-height and the surrounding page chain + // gets pushed to the bottom of the viewport even when DataViews is + // the only thing in the panel. Min-height (not height) so a populated + // table still scrolls naturally. + :global(.dataviews-no-results) { + min-block-size: calc(100vh - 320px); + } +} + .threat__title { color: var(--jp-gray-80); font-weight: 510; diff --git a/projects/js-packages/scan/tsconfig.json b/projects/js-packages/scan/tsconfig.json index e3f46ac29075..f528e588ec66 100644 --- a/projects/js-packages/scan/tsconfig.json +++ b/projects/js-packages/scan/tsconfig.json @@ -1,10 +1,4 @@ { - "extends": "jetpack-js-tools/tsconfig.tsc.json", - "include": [ "src" ], - "compilerOptions": { - "sourceMap": false, - "outDir": "./build/", - "rootDir": "./src", - "target": "esnext" - } + "extends": "jetpack-js-tools/tsconfig.base.json", + "include": [ "src" ] } diff --git a/projects/packages/scan/AGENTS.md b/projects/packages/scan/AGENTS.md index 3126ccd9b8f4..3b51df64b166 100644 --- a/projects/packages/scan/AGENTS.md +++ b/projects/packages/scan/AGENTS.md @@ -1,9 +1,22 @@ # Scan -The Scan UI for the Jetpack plugin's wp-admin. Currently an empty -`wp-build` scaffold gated behind the `rsm_jetpack_ui_modernization_scan` -filter (default off); follow-up PRs port Calypso's Scan dashboard onto -this native wp-admin page. +The Scan UI for the Jetpack plugin's wp-admin. Ports the Calypso +Dashboard's Scan overview onto a native wp-admin page so customers see +the same active-threats and scan-history experience without leaving +their site. + +## Calypso source pin + +This package is a port of: + +- `client/dashboard/sites/scan/` +- `client/dashboard/sites/scan-active/` +- `client/dashboard/sites/scan-history/` + +Pinned at Calypso commit: `<>`. + +When re-syncing from upstream, update the pin above and note any +behaviour deltas in the relevant changelog entry. ## UI primitives @@ -17,10 +30,39 @@ packages in this order: Predates the design system. Use only when `@wordpress/ui` doesn't have a stable equivalent, and still check Status in Storybook. 3. **`@wordpress/dataviews`** — higher-level data presentation (tables, - lists, grids). + lists, grids). The backbone of Active Threats and History tabs. + Extend via its sub-components (`DataViews.Search`, + `DataViews.FiltersToggle`, `DataViews.Layout`, `DataViews.Footer`) + before reaching for lower-level primitives. 4. **`@wordpress/admin-ui`** — page layout primitives, accessed via `AdminPage` from `@automattic/jetpack-components` (which wraps admin-ui's `Page`). Rationale: WordPress is moving new work to `@wordpress/ui`; -`@wordpress/components` is being kept as a legacy fallback. +`@wordpress/components` is being kept as a legacy fallback. Guidance +from the WordPress Design System P2 (April 2026). + +## Design-system lookup + +A dedicated MCP server is wired into this project's local Claude Code +config: `@wordpress/design-system-mcp`. It exposes the authoritative +list of stable `@wordpress/ui` + `@wordpress/components` components and +`--wpds-*` design tokens. Prefer querying it over spelunking through +`node_modules/@wordpress/components/src/**` for component metadata. + +## Reused threat primitives + +`ThreatSeverityBadge`, the `Threat` type, and the lower-level +`ThreatsDataViews` view live in `@automattic/jetpack-scan` (the existing +js-package). Reuse those building blocks rather than re-inventing them +here. The Calypso source ships richer modals (fix / ignore / unignore / +view-details / bulk-fix) than the js-package — those are ported into +this package. + +## Mock mode + +Append `?jps-mock=1` to the wp-admin URL to short-circuit every gate and +render the overview against fixture threats from +`src/js/data/mock/fixtures.ts`. No server requests are made in this +mode. Useful for design iteration on Jurassic Tube / Docker without a +Scan plan or WPCOM connection. diff --git a/projects/packages/scan/README.md b/projects/packages/scan/README.md index 11b5b0fe5e25..97ad9334a043 100644 --- a/projects/packages/scan/README.md +++ b/projects/packages/scan/README.md @@ -1,8 +1,29 @@ # Scan UI for Jetpack -This package will host the wp-admin Scan UI for the Jetpack plugin. +This package hosts the wp-admin Scan UI for the Jetpack plugin. It +ports Calypso's Scan dashboard (`client/dashboard/sites/scan/`) onto a +native wp-admin page so customers see active threats, scan history, and +the fix / ignore / view-details flows without leaving their site. -The dashboard is currently an empty `wp-build` scaffold gated behind the -`rsm_jetpack_ui_modernization_scan` filter (default off). Follow-up PRs -port Calypso's Scan dashboard (`client/dashboard/sites/scan/`) onto this -native wp-admin page. +The package mirrors the architecture of `projects/packages/activity-log/` +and `projects/packages/backup/`: a TanStack Query data layer, a hash +router, an `AdminPage` shell, and `@wordpress/dataviews` lists. + +## Architecture + +- `src/class-jetpack-scan.php` — wp-admin submenu under Jetpack + (`?page=jetpack-scan`), asset enqueue, REST registration. +- `src/class-initial-state.php` — `JPSCAN_INITIAL_STATE` hydration global. +- `src/class-rest-controller.php` — `jetpack/v4/site/scan/*` bridges + proxied to WPCOM via the site's Jetpack connection. +- `src/js/admin.tsx` — `createHashRouter` + `RouterProvider`. +- `src/js/shell.tsx` — `AdminPage` chrome + `HeaderActionsProvider` + + `Outlet`. +- `src/js/providers.tsx` — `QueryClient` + `ThemeProvider`. +- `src/js/gates.tsx` — connection / capabilities gates with mock-mode + short-circuit. + +## Mock mode + +Append `?jps-mock=1` to the wp-admin URL to short-circuit every gate +and render the overview against fixtures. No server requests are made. diff --git a/projects/packages/scan/_inc/components/scan-page.scss b/projects/packages/scan/_inc/components/scan-page.scss new file mode 100644 index 000000000000..d3a1425348aa --- /dev/null +++ b/projects/packages/scan/_inc/components/scan-page.scss @@ -0,0 +1,11 @@ +@use "@automattic/jetpack-base-styles/admin-page-layout" as *; + +// Apply the shared Jetpack admin-page layout mixin so `` controls +// the full content area (fixed header, scrollable middle, pinned footer) and +// the `.jp-admin-page-tabs` strip gets its sticky positioning, hairline, and +// header-aligned inline padding. +body.jetpack_page_jetpack-scan, +body.toplevel_page_jetpack-scan { + + @include jetpack-admin-page-layout; +} diff --git a/projects/packages/scan/_inc/components/scan-page.tsx b/projects/packages/scan/_inc/components/scan-page.tsx new file mode 100644 index 000000000000..d0f0e69a108f --- /dev/null +++ b/projects/packages/scan/_inc/components/scan-page.tsx @@ -0,0 +1,68 @@ +import AdminPage from '@automattic/jetpack-components/admin-page'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { useNavigate } from '@wordpress/route'; +import { Tabs } from '@wordpress/ui'; +import { useHeaderActions } from '../../src/js/header-actions-context'; +import './scan-page.scss'; +import type { ReactNode } from 'react'; + +export type ScanTab = 'active' | 'history'; + +type Props = { + activeTab: ScanTab; + children: ReactNode; +}; + +/** + * Shared chrome for the Scan page — wraps the Jetpack `AdminPage` + * (header + footer + the `.jp-admin-page` selector hook the + * `jetpack-admin-page-layout` mixin keys off) and hosts a single + * `Tabs.Root` so the active-tab indicator slides between Active and + * History instead of remounting on each route hop. + * + * @param props - Component props. + * @param props.activeTab - Which tab the current route represents. + * @param props.children - Tab panel content (Tabs.Panel siblings). + * @return The Scan page shell. + */ +export default function ScanPage( { activeTab, children }: Props ): JSX.Element { + const navigate = useNavigate(); + const headerActions = useHeaderActions(); + + const onTabChange = useCallback( + ( next: string | null ) => { + if ( next !== 'active' && next !== 'history' ) { + return; + } + navigate( { + search: ( prev: Record< string, unknown > ) => ( { + ...prev, + tab: next === 'history' ? 'history' : undefined, + } ), + } as unknown as Parameters< typeof navigate >[ 0 ] ); + }, + [ navigate ] + ); + + return ( + + +
+ + { __( 'Active threats', 'jetpack-scan-page' ) } + { __( 'History', 'jetpack-scan-page' ) } + +
+ { children } +
+
+ ); +} diff --git a/projects/packages/scan/changelog/add-active-threats-and-history b/projects/packages/scan/changelog/add-active-threats-and-history new file mode 100644 index 000000000000..e2b515205cd2 --- /dev/null +++ b/projects/packages/scan/changelog/add-active-threats-and-history @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Phase 1: wire WPCOM bridges for `/scan`, `/scan/history`, `/scan/counts`, and replace the Phase 0 placeholder with a tabbed Active threats / History overview backed by `ThreatsDataViews` from `@automattic/jetpack-scan`. diff --git a/projects/packages/scan/changelog/add-bulk-fix-modal b/projects/packages/scan/changelog/add-bulk-fix-modal new file mode 100644 index 000000000000..4ab812f5cf59 --- /dev/null +++ b/projects/packages/scan/changelog/add-bulk-fix-modal @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Phase 4: surface an "Auto-fix N threats" header CTA on the Active threats tab and add a bulk-fix modal that confirms the list, kicks `useFixThreatsMutation`, polls `useFixThreatsStatusQuery` every 2 s until every threat reaches a terminal state, and emits a summary snackbar when the fixer settles. diff --git a/projects/packages/scan/changelog/add-package-scaffold b/projects/packages/scan/changelog/add-package-scaffold index 82278cf788c4..ecd1f598adf2 100644 --- a/projects/packages/scan/changelog/add-package-scaffold +++ b/projects/packages/scan/changelog/add-package-scaffold @@ -1,3 +1,4 @@ -Significance: patch +Significance: minor Type: added -Comment: Initial scaffold — empty wp-build dashboard gated behind the rsm_jetpack_ui_modernization_scan filter; no user-visible change unless the flag is enabled. + +Initial release of the Scan package: hosts the in-wp-admin Scan UI shell, data-layer skeleton, and REST namespace placeholder. diff --git a/projects/packages/scan/changelog/add-row-action-modals b/projects/packages/scan/changelog/add-row-action-modals new file mode 100644 index 000000000000..65e5ce60c4b7 --- /dev/null +++ b/projects/packages/scan/changelog/add-row-action-modals @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Phase 3: port the per-threat fix / ignore / unignore confirmation modals from Calypso. The row-action variants of these flows now open a `RenderModal` inside the DataViews shell (mirrors `client/dashboard/sites/scan/components/{fix,ignore,unignore}-threat-modal.tsx`), giving the user a chance to review the threat + see a destructive-action warning before committing. The fix modal also polls `useFixThreatsStatusQuery` and waits for a terminal state before closing, surfacing fixed / not_fixed in a snackbar. The inline auto-fix button keeps its existing fire-and-forget snackbar behaviour, since DataViews offers no programmatic way to trigger a row action's modal from a custom field renderer. diff --git a/projects/packages/scan/changelog/add-scan-now-and-status b/projects/packages/scan/changelog/add-scan-now-and-status new file mode 100644 index 000000000000..7fd192dc40fb --- /dev/null +++ b/projects/packages/scan/changelog/add-scan-now-and-status @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Phase 5: add a "Scan now" header button (always available on the Active tab, disabled while a scan is running) backed by a new `POST /jetpack/v4/site/scan/enqueue` REST bridge, and surface a `ScanStatus` panel with a spinner + progress percentage in place of the threats table while the scanner is `enqueued` or `running`. diff --git a/projects/packages/scan/changelog/add-tests b/projects/packages/scan/changelog/add-tests new file mode 100644 index 000000000000..4b4737f2eaa0 --- /dev/null +++ b/projects/packages/scan/changelog/add-tests @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Phase 8: scaffold the test surface. PHPUnit bridge tests cover the admin-only permission callback + route registration for every `/jetpack/v4/site/scan/*` endpoint; Jest unit tests cover the `isFixComplete` polling-terminator from `useFixThreatsStatusQuery`. Broader bridge coverage and an e2e Playwright pass land in a follow-up PR. diff --git a/projects/packages/scan/changelog/add-threat-mutations b/projects/packages/scan/changelog/add-threat-mutations new file mode 100644 index 000000000000..a3075096a73e --- /dev/null +++ b/projects/packages/scan/changelog/add-threat-mutations @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Phase 3: wire single-threat fix / ignore / unignore actions on the Active threats and Scan history tabs. New REST bridges (`/threat/{id}/ignore`, `/threat/{id}/unignore`, `/threats/fix`, `/threats/fix-status`) proxy to WPCOM's `/sites/:siteId/alerts/*` surface; `useFixThreatsMutation` / `useIgnoreThreatMutation` / `useUnignoreThreatMutation` hand stable callbacks to `ThreatsDataViews`'s row-action props with `core/notices` snackbar feedback on success and failure. The 2 s fix-status polling hook is wired but not yet rendered — Phase 4's bulk-fix modal consumes it. diff --git a/projects/packages/scan/changelog/anchor-admin-page-to-viewport b/projects/packages/scan/changelog/anchor-admin-page-to-viewport new file mode 100644 index 000000000000..f382cdf52dac --- /dev/null +++ b/projects/packages/scan/changelog/anchor-admin-page-to-viewport @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Anchor `.admin-ui-page` to `calc(100vh - var(--wp-admin-bar-height, 32px))` so the page chain has a viewport-tall floor (was `min-block-size: 100%`, which collapses against `#wpbody-content`'s content-driven height). Together with the `flex-grow: 1` chain through `[role="tabpanel"]` → `ThreatsDataViews`, this pins the JetpackFooter to the bottom of the viewport and lets the DataViews empty body fill the area above it. Mobile (≤ 782px) bumps the admin-bar reservation to 46px to match wp-admin's mobile bar height. diff --git a/projects/packages/scan/changelog/dataviews-empty-state b/projects/packages/scan/changelog/dataviews-empty-state new file mode 100644 index 000000000000..5100975e57a8 --- /dev/null +++ b/projects/packages/scan/changelog/dataviews-empty-state @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Defer empty-state rendering on the Active threats / Scan history panels to `ThreatsDataViews` itself — passing `data={ [] }` shows DataViews' built-in "no items" body inside the table chrome, so reviewers always see the column headers + filter controls instead of a bare paragraph. diff --git a/projects/packages/scan/changelog/fill-page-with-dataviews b/projects/packages/scan/changelog/fill-page-with-dataviews new file mode 100644 index 000000000000..02af4c226049 --- /dev/null +++ b/projects/packages/scan/changelog/fill-page-with-dataviews @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Fix the DataViews table collapsing to content height (leaving an empty gray strip below the page footer) by passing `unwrapped` to `AdminPage` to skip its default `` wrappers, and forcing the flex chain from `.admin-ui-page` down through `Tabs.Root` and the active `[role="tabpanel"]` so DataViews' built-in `flex-grow: 1` no-results body centers in the available vertical space. diff --git a/projects/packages/scan/changelog/fix-build-deps-recursive b/projects/packages/scan/changelog/fix-build-deps-recursive new file mode 100644 index 000000000000..73e8f70f0d40 --- /dev/null +++ b/projects/packages/scan/changelog/fix-build-deps-recursive @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Make the wp-build pipeline build cleanly from a fresh checkout. The previous `build:deps` step only built the two direct workspace deps (`@automattic/jetpack-components`, `@automattic/jetpack-scan`), but `jetpack-components` itself depends on workspace packages whose outputs are also missing on a fresh checkout (`@automattic/jetpack-boost-score-api`, `social-logos`, `@automattic/number-formatters`, etc.). Switch the filter to `pnpm --filter '@automattic/jetpack-scan-page...' --filter '!@automattic/jetpack-scan-page' run build`, which walks the full transitive workspace dependency graph in topological order and excludes the package itself (the outer `pnpm run build` already covers it). Also adds `"name": "@automattic/jetpack-scan-page"` to the package's `package.json` so the filter selector resolves. diff --git a/projects/packages/scan/changelog/fix-stylelint-quotes b/projects/packages/scan/changelog/fix-stylelint-quotes new file mode 100644 index 000000000000..623ff03c78e3 --- /dev/null +++ b/projects/packages/scan/changelog/fix-stylelint-quotes @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Use double quotes in the `[role="tabpanel"]` selector to satisfy `@stylistic/string-quotes`. diff --git a/projects/packages/scan/changelog/follow-newsletter-page-pattern b/projects/packages/scan/changelog/follow-newsletter-page-pattern new file mode 100644 index 000000000000..b562301d43ff --- /dev/null +++ b/projects/packages/scan/changelog/follow-newsletter-page-pattern @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Switch the page-level layout from the `jetpack-admin-page-layout` mixin (Activity Log's pattern) to a Newsletter-style stylesheet (#48420 phase 3) — sticky page header + tab row sticky-stacked beneath it via a `ResizeObserver`-tracked height variable, off-white page surface so the white DataViews chrome stands out, single hairline owned by the tab row instead of the page header. Cleans up the divider line that appeared between the empty state and the page footer when the mixin's `> :not(.admin-ui-page__header):not(.jetpack-footer) { overflow: auto }` rule kicked in. diff --git a/projects/packages/scan/changelog/forms-style-empty-state b/projects/packages/scan/changelog/forms-style-empty-state new file mode 100644 index 000000000000..0823de599f1a --- /dev/null +++ b/projects/packages/scan/changelog/forms-style-empty-state @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Wire a Forms-style centered empty state (heading + muted body, mirroring `EmptyWrapper` from `projects/packages/forms/src/dashboard/components/empty-responses/`) into the Active threats and Scan history panels via the new `empty` prop on `ThreatsDataViews`. diff --git a/projects/packages/scan/changelog/full-page-tabs-and-aligned-padding b/projects/packages/scan/changelog/full-page-tabs-and-aligned-padding new file mode 100644 index 000000000000..ee75a02af58e --- /dev/null +++ b/projects/packages/scan/changelog/full-page-tabs-and-aligned-padding @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Make the Active threats / History tab panels fill the full page height and align the DataViews search row with the tab nav above it. The empty state now centers in the remaining vertical space (DataViews' built-in `flex-grow: 1` no-results body), and dropping the inner content padding lets the search row sit at the same 24 px inset as the tab labels rather than 48 px in. diff --git a/projects/packages/scan/changelog/hide-redundant-status-toggle b/projects/packages/scan/changelog/hide-redundant-status-toggle new file mode 100644 index 000000000000..5095f132ad43 --- /dev/null +++ b/projects/packages/scan/changelog/hide-redundant-status-toggle @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Pass `showStatusFilter={ false }` to `ThreatsDataViews` on both panels. The page-level "Active threats / History" tabs already filter the dataset by threat status, so the in-table "Active threats (N) / History (N)" toggle was duplicate UI for the same dimension. diff --git a/projects/packages/scan/changelog/migrate-to-wordpress-ui b/projects/packages/scan/changelog/migrate-to-wordpress-ui new file mode 100644 index 000000000000..74b4bfb8a61f --- /dev/null +++ b/projects/packages/scan/changelog/migrate-to-wordpress-ui @@ -0,0 +1,13 @@ +Significance: patch +Type: changed + +Migrate the four modal surfaces (`bulk-fix-modal`, `fix-threat-modal`, `ignore-threat-modal`, `unignore-threat-modal`) from `@wordpress/components` to `@wordpress/ui` per the CIAB component-priority guide (`@wordpress/ui` > `@automattic/design-system` > `@wordpress/components`). Specifically: + +- `bulk-fix-modal` swaps `Modal` for `Dialog` (namespace: `Dialog.Root` / `Dialog.Popup` / `Dialog.Header` / `Dialog.Title` / `Dialog.CloseIcon` / `Dialog.Footer`). The other three are DataViews-managed (`RenderModalProps< Threat >`) so DataViews supplies the outer Modal — only the inner content swaps. +- `Button` → `Button` from `@wordpress/ui` (`variant="primary"` → `variant="solid"`, `variant="secondary"` → `variant="outline"`, `isBusy` → `loading`, `__next40pxDefaultSize` removed since the new size system handles defaults). +- `__experimentalText as Text` → `Text`. +- `__experimentalVStack as VStack` → `Stack` with `direction="column" gap="lg|xs|sm"`. +- Inline `display: flex` divs replaced with `Stack direction="row" justify="flex-end"`. +- Ignore / unignore destructive notices: `Notice` → `Notice.Root` + `Notice.Description` (the new namespace-based API). + +`Spinner` and the `info`-variant `Notice` in `bulk-fix-modal`'s confirm step stay on `@wordpress/components` for now (no `@wordpress/ui` equivalent ships in the version bundled with Jetpack). diff --git a/projects/packages/scan/changelog/migrate-to-wp-build b/projects/packages/scan/changelog/migrate-to-wp-build new file mode 100644 index 000000000000..0aa01dae376e --- /dev/null +++ b/projects/packages/scan/changelog/migrate-to-wp-build @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Migrate the Scan package's build pipeline from webpack + `@automattic/jetpack-webpack-config` to `@wordpress/build` (mirrors Newsletter / Forms). The page now ships as a wp-build route at `routes/index/` (route.tsx + stage.tsx + route.scss + package.json), with the page chrome moved to `_inc/components/scan-page.{tsx,scss}`. URL routing switches from `react-router`'s `useSearchParams` to `@wordpress/route`'s `useSearch` / `useNavigate`; the active tab is read from `?tab=` and tab-changes call `navigate({ search })`. PHP-side, `Jetpack_Scan` now loads `build/build.php`, registers polyfills via `WP_Build_Polyfills`, bridges the user-facing `?page=jetpack-scan` slug onto wp-build's auto-generated `jetpack-scan-wp-admin` enqueue, and applies the import-map ordering fix Newsletter uses. Drops `react-router`, `@automattic/jetpack-webpack-config`, and the standalone `shell.tsx` / `admin.tsx` / `providers.tsx` / `routes.ts` / `index.js`. diff --git a/projects/packages/scan/changelog/p1-build-and-runtime-fixes b/projects/packages/scan/changelog/p1-build-and-runtime-fixes new file mode 100644 index 000000000000..a17ef4504136 --- /dev/null +++ b/projects/packages/scan/changelog/p1-build-and-runtime-fixes @@ -0,0 +1,8 @@ +Significance: patch +Type: fixed + +Address P1 review feedback on the wp-build migration: + +- `composer.json`'s `build-production` hook called `pnpm run build-production-concurrently`, which was deleted alongside the webpack pipeline. Switch to `pnpm run build-production` (matches Newsletter / Forms) so `composer run-script build-production` no longer fails with `ERR_PNPM_NO_SCRIPT`. +- `pnpm run build` now pre-builds `@automattic/jetpack-components` and `@automattic/jetpack-scan` via `pnpm --filter ... run build` before invoking `wp-build`. Without this, a clean checkout fails because esbuild resolves workspace packages through their `default` export (`./build/index.js`) rather than the `jetpack:src` source condition, so the route bundle errors on missing dependency outputs. +- Drop the `useConnection`/`@automattic/jetpack-connection` store read from `gates.tsx`. The store is no longer registered in the wp-build chassis (the package's runtime registration was removed when we replaced the component-package imports), so `Object.keys( undefined ).length` was throwing during render and stranding the page on a blank screen. Connection gating happens server-side in `Jetpack_Scan::is_available()` already — by the time the page mounts the user is, by definition, connected. Removes `src/js/hooks/use-connection.ts`. diff --git a/projects/packages/scan/changelog/p2-review-fixes b/projects/packages/scan/changelog/p2-review-fixes new file mode 100644 index 000000000000..b6ad5743baa7 --- /dev/null +++ b/projects/packages/scan/changelog/p2-review-fixes @@ -0,0 +1,10 @@ +Significance: patch +Type: fixed + +Address P2 review feedback on #48458: + +- REST controller: site-level reads (`/scan`, `/scan/history`, `/scan/counts`) and the `/scan/enqueue` mutation now sign with `wpcom_json_api_request_as_blog()` instead of `as_user()`, matching Protect plugin's `Threats::*` contract for those endpoints. Alert / fix-status routes keep user auth so per-user permissions on threat mutations carry through. `proxy_get` / `proxy_post` gain an `$as_blog` flag (default false) so backwards compatibility with existing callers is preserved. + +- `FixThreatModal`: handle `useFixThreatsStatusQuery`'s `isError` state — previously a poll error left `isFixing` stuck true and stranded the modal at "Fixing threat…". Now an error closes the modal, fires `jetpack_scan_fix_threat_failed`, and surfaces a snackbar. + +- `BulkFixModal`: on initial fix-mutation rejection, close the modal instead of advancing to the `done` step — the previous behaviour rendered "Auto-fix complete" with "0 of 0 threats fixed" alongside the error snackbar. Same `statusQuery.isError` guard added for poll failures during bulk progress. diff --git a/projects/packages/scan/changelog/p3-review-fixes b/projects/packages/scan/changelog/p3-review-fixes new file mode 100644 index 000000000000..f7a4f0f6a54b --- /dev/null +++ b/projects/packages/scan/changelog/p3-review-fixes @@ -0,0 +1,9 @@ +Significance: patch +Type: changed + +Address P3 review feedback on #48458: + +- Scan page chrome now wraps the shared `` from `@automattic/jetpack-components/admin-page` (matching the wp-build VideoPress dashboard) instead of `Page` from `@wordpress/admin-ui` directly. Drops the bespoke `ResizeObserver` that measured the header height — the `jetpack-admin-page-layout` mixin's `:has(.jp-admin-page-tabs)` rule now handles the sticky tab strip. +- `empty-state` switches to `EmptyState.Root` / `Title` / `Description` from `@wordpress/ui` (semantic `

`/`

` instead of styled spans). +- `active-threats` swaps the loading `__experimentalVStack` and the auto-fix CTA `Button` for `Stack` and `Button` from `@wordpress/ui`. +- `bulk-fix-modal` swaps the remaining `Notice` for `Notice.Root` + `Notice.Description` from `@wordpress/ui`. diff --git a/projects/packages/scan/changelog/persist-dataviews-view b/projects/packages/scan/changelog/persist-dataviews-view new file mode 100644 index 000000000000..24d68b7072e5 --- /dev/null +++ b/projects/packages/scan/changelog/persist-dataviews-view @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Persist the DataViews view state across reloads on both panels. Active threats and Scan history each pass their own `persistKey` to `ThreatsDataViews` (`jetpack-scan:active-threats:view`, `jetpack-scan:scan-history:view`), so filters, sort direction, search query, page, and layout (table vs list) round-trip across page reloads, tab switches, and drill-ins. Closes the last unfinished bullet in Phase 1 of #48456. diff --git a/projects/packages/scan/changelog/suppress-admin-notices b/projects/packages/scan/changelog/suppress-admin-notices new file mode 100644 index 000000000000..ceedc089a507 --- /dev/null +++ b/projects/packages/scan/changelog/suppress-admin-notices @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Phase 6: silence the standard wp-admin notice channels (`admin_notices` and `all_admin_notices`) on the Scan page so JITMs and plugin-update messages don't reflow the focused layout mid-scan or while a fix modal is open. Mirrors the same Forms-style pattern Jetpack Forms uses on its dashboard. diff --git a/projects/packages/scan/changelog/use-jetpack-admin-page-layout b/projects/packages/scan/changelog/use-jetpack-admin-page-layout new file mode 100644 index 000000000000..7a289eb7ab87 --- /dev/null +++ b/projects/packages/scan/changelog/use-jetpack-admin-page-layout @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Adopt the `jetpack-admin-page-layout` mixin from `@automattic/jetpack-base-styles` so the Scan page fills the full wp-admin viewport, the JetpackFooter pins to the bottom, and the tabs strip uses the canonical `.jp-admin-page-tabs` wrapper. Same convention Activity Log uses — replaces the hand-rolled tab/content scaffolding from earlier phases. diff --git a/projects/packages/scan/changelog/use-wordpress-ui-tabs b/projects/packages/scan/changelog/use-wordpress-ui-tabs new file mode 100644 index 000000000000..1f3a8fa702c4 --- /dev/null +++ b/projects/packages/scan/changelog/use-wordpress-ui-tabs @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Switch the Scan overview's Active threats / History tab nav from `@wordpress/components` `TabPanel` to the new `@wordpress/ui` `Tabs.Root` / `Tabs.List` / `Tabs.Panel` pattern (matches Newsletter's unified page in #48420 phase 3 — minimal underline variant + sliding active-tab indicator). diff --git a/projects/packages/scan/changelog/wire-dataviews-canonical-tracks-events b/projects/packages/scan/changelog/wire-dataviews-canonical-tracks-events new file mode 100644 index 000000000000..b806eba24fca --- /dev/null +++ b/projects/packages/scan/changelog/wire-dataviews-canonical-tracks-events @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Wire DataViews-canonical Tracks events on both panels. Forwards the upstream `ThreatsDataViews` `onTrackEvent` callback to `useTrackEvent()` with a `jetpack_scan_` prefix, so Tracks now records `jetpack_scan_search` (`{ has_query }`), `jetpack_scan_layout_changed` (`{ layout }`), `jetpack_scan_page_change` (`{ page }`), `jetpack_scan_filter_change`, and `jetpack_scan_view_change` whenever the user manipulates the in-table view. diff --git a/projects/packages/scan/changelog/wire-tracks-events b/projects/packages/scan/changelog/wire-tracks-events new file mode 100644 index 000000000000..40ac80653d04 --- /dev/null +++ b/projects/packages/scan/changelog/wire-tracks-events @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Phase 7: wire `jetpack_scan_*` Tracks events for the Scan-now CTA, the Auto-fix N header CTA, and the bulk-fix modal lifecycle (`_open` / `_click` / `_success` / `_failed` with `threat_count` / `fixed_count` / `failed_count` properties). Switches `data/use-track-event.ts` from a hand-rolled `_tkq` shim to `@automattic/jetpack-analytics`, the canonical Jetpack tracking client used by Forms / Backup / Activity Log. diff --git a/projects/packages/scan/changelog/wire-view-details-modal b/projects/packages/scan/changelog/wire-view-details-modal new file mode 100644 index 000000000000..4349af4d61f5 --- /dev/null +++ b/projects/packages/scan/changelog/wire-view-details-modal @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Phase 4: port the read-only view-details modal from Calypso. New `view-details-modal.tsx` renders the threat title + severity + signature + description, plus the file path / context block / extension version / first-detected / fixed-on metadata when present, and a fix-description summary tailored to the threat's `fixable.fixer` (`update`, `replace`, `delete`, or non-fixable). Wired into both panels via `RenderViewModal` on the upstream `ThreatsDataViews` (always-eligible row action), and fires `jetpack_scan_view_details_modal_open` on mount. diff --git a/projects/packages/scan/composer.json b/projects/packages/scan/composer.json index fff63444b5aa..5a1f26e7b061 100644 --- a/projects/packages/scan/composer.json +++ b/projects/packages/scan/composer.json @@ -10,10 +10,14 @@ "automattic/jetpack-autoloader": "@dev", "automattic/jetpack-composer-plugin": "@dev", "automattic/jetpack-connection": "@dev", + "automattic/jetpack-status": "@dev", "automattic/jetpack-wp-build-polyfills": "@dev" }, "require-dev": { - "automattic/jetpack-changelogger": "@dev" + "automattic/jetpack-changelogger": "@dev", + "yoast/phpunit-polyfills": "^4.0.0", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev" }, "suggest": { "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." @@ -30,6 +34,12 @@ "build-production": [ "pnpm run build-production" ], + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ], "watch": [ "Composer\\Config::disableProcessTimeout", "pnpm run watch" diff --git a/projects/packages/scan/package.json b/projects/packages/scan/package.json index 91abe4c244ea..6076d816faef 100644 --- a/projects/packages/scan/package.json +++ b/projects/packages/scan/package.json @@ -24,6 +24,8 @@ "build:wp-build": "wp-build", "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build && pnpm run validate", "clean": "rm -rf build/", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --config=tests/jest.config.js", + "typecheck": "tsgo --noEmit", "validate": "pnpm exec validate-es --no-error-on-unmatched-pattern build/", "watch": "wp-build --watch" }, @@ -31,16 +33,48 @@ "extends @wordpress/browserslist-config" ], "dependencies": { + "@automattic/babel-plugin-replace-textdomain": "workspace:*", + "@automattic/jetpack-analytics": "workspace:*", + "@automattic/jetpack-base-styles": "workspace:*", + "@automattic/jetpack-components": "workspace:*", + "@automattic/jetpack-connection": "workspace:*", + "@automattic/jetpack-scan": "workspace:*", + "@automattic/jetpack-script-data": "workspace:*", "@automattic/jetpack-wp-build-polyfills": "workspace:*", + "@tanstack/react-query": "5.90.8", + "@wordpress/admin-ui": "1.12.0", + "@wordpress/api-fetch": "7.44.0", + "@wordpress/base-styles": "6.20.0", + "@wordpress/boot": "0.11.0", + "@wordpress/components": "32.6.0", + "@wordpress/compose": "7.44.0", + "@wordpress/data": "10.44.0", + "@wordpress/dataviews": "14.1.0", + "@wordpress/date": "5.44.0", "@wordpress/element": "6.44.0", - "@wordpress/i18n": "6.17.0" + "@wordpress/i18n": "6.17.0", + "@wordpress/icons": "12.2.0", + "@wordpress/notices": "5.44.0", + "@wordpress/route": "0.10.0", + "@wordpress/theme": "0.11.0", + "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0" }, "devDependencies": { "@babel/core": "7.29.0", + "@babel/preset-env": "7.29.2", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "@types/jest": "30.0.0", "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/browserslist-config": "6.44.0", "@wordpress/build": "0.13.0", - "browserslist": "^4.24.0" + "browserslist": "^4.24.0", + "jest": "30.3.0", + "react": "18.3.1", + "react-dom": "18.3.1" }, "wpPlugin": { "name": "jetpack_scan", diff --git a/projects/packages/scan/phpunit.11.xml.dist b/projects/packages/scan/phpunit.11.xml.dist new file mode 100644 index 000000000000..bf8fd28e5758 --- /dev/null +++ b/projects/packages/scan/phpunit.11.xml.dist @@ -0,0 +1,32 @@ + + + + + tests/php + + + + + + src + + + + + diff --git a/projects/packages/scan/phpunit.12.xml.dist b/projects/packages/scan/phpunit.12.xml.dist new file mode 100644 index 000000000000..24f0f1e361af --- /dev/null +++ b/projects/packages/scan/phpunit.12.xml.dist @@ -0,0 +1,34 @@ + + + + + tests/php + + + + + + src + + + + + diff --git a/projects/packages/scan/phpunit.8.xml.dist b/projects/packages/scan/phpunit.8.xml.dist new file mode 100644 index 000000000000..41d8037d67db --- /dev/null +++ b/projects/packages/scan/phpunit.8.xml.dist @@ -0,0 +1,23 @@ + + + + + tests/php + + + + + + src + + + diff --git a/projects/packages/scan/phpunit.9.xml.dist b/projects/packages/scan/phpunit.9.xml.dist new file mode 100644 index 000000000000..41d8037d67db --- /dev/null +++ b/projects/packages/scan/phpunit.9.xml.dist @@ -0,0 +1,23 @@ + + + + + tests/php + + + + + + src + + + diff --git a/projects/packages/scan/routes/index/package.json b/projects/packages/scan/routes/index/package.json index c1205bc84164..3112dbf9f63e 100644 --- a/projects/packages/scan/routes/index/package.json +++ b/projects/packages/scan/routes/index/package.json @@ -3,9 +3,26 @@ "version": "1.0.0", "private": true, "dependencies": { + "@automattic/jetpack-analytics": "workspace:*", + "@automattic/jetpack-base-styles": "workspace:*", + "@automattic/jetpack-components": "workspace:*", + "@automattic/jetpack-scan": "workspace:*", + "@automattic/jetpack-script-data": "workspace:*", + "@tanstack/react-query": "5.90.8", "@types/react": "18.3.28", + "@wordpress/admin-ui": "1.12.0", + "@wordpress/api-fetch": "7.44.0", + "@wordpress/components": "32.6.0", + "@wordpress/data": "10.44.0", + "@wordpress/dataviews": "14.1.0", + "@wordpress/date": "5.44.0", "@wordpress/element": "6.44.0", - "@wordpress/i18n": "6.17.0" + "@wordpress/i18n": "6.17.0", + "@wordpress/icons": "12.2.0", + "@wordpress/notices": "5.44.0", + "@wordpress/route": "0.10.0", + "@wordpress/theme": "0.11.0", + "@wordpress/ui": "0.11.0" }, "route": { "path": "/", diff --git a/projects/packages/scan/routes/index/route.scss b/projects/packages/scan/routes/index/route.scss new file mode 100644 index 000000000000..cd11c284b804 --- /dev/null +++ b/projects/packages/scan/routes/index/route.scss @@ -0,0 +1,3 @@ +@use "sass:meta"; +@include meta.load-css("@wordpress/admin-ui/build-style/style.css"); +@include meta.load-css("@wordpress/dataviews/build-style/style.css"); diff --git a/projects/packages/scan/routes/index/stage.tsx b/projects/packages/scan/routes/index/stage.tsx index 1ad3c67c04bb..0e09ebc71ccd 100644 --- a/projects/packages/scan/routes/index/stage.tsx +++ b/projects/packages/scan/routes/index/stage.tsx @@ -1,5 +1,64 @@ -const Stage = () => { - return

Scan

; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useSearch } from '@wordpress/route'; +import { Tabs } from '@wordpress/ui'; +import ScanPage, { type ScanTab } from '../../_inc/components/scan-page'; +import Gates from '../../src/js/gates'; +import { HeaderActionsProvider } from '../../src/js/header-actions-context'; +import MockBanner from '../../src/js/mock-banner'; +import NoticesList from '../../src/js/notices-list'; +import ActiveThreats from '../../src/js/screens/overview/active-threats'; +import ScanHistory from '../../src/js/screens/overview/scan-history'; +import './route.scss'; + +type StageSearch = Record< string, unknown > & { + tab?: string; }; +const queryClient = new QueryClient( { + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + }, + }, +} ); + +/** + * Single stage that owns the Scan page chrome. Reads the active tab from + * `?tab=`, mounts the React Query client + ThemeProvider once, and lets + * `` render the matching `Tabs.Panel` content. Mirrors + * Newsletter's unified-page Stage (#48420 phase 3): one `Tabs.Root` so + * the active-tab indicator slides between Active threats and Scan + * history rather than remounting on each tab change. + * + * @return Stage content. + */ +function Stage(): JSX.Element { + const search = useSearch( { + from: '/' as unknown as never, + strict: false, + } ) as StageSearch; + + const activeTab: ScanTab = search.tab === 'history' ? 'history' : 'active'; + + return ( + + + + + + + { activeTab === 'active' ? : null } + + + { activeTab === 'history' ? : null } + + + + + + + ); +} + export { Stage as stage }; diff --git a/projects/packages/scan/src/class-initial-state.php b/projects/packages/scan/src/class-initial-state.php new file mode 100644 index 000000000000..76cef1385014 --- /dev/null +++ b/projects/packages/scan/src/class-initial-state.php @@ -0,0 +1,69 @@ + array( + 'WP_API_root' => esc_url_raw( rest_url() ), + 'WP_API_nonce' => wp_create_nonce( 'wp_rest' ), + ), + 'jetpackStatus' => array( + 'calypsoSlug' => ( new Status() )->get_site_suffix(), + ), + 'siteData' => array( + 'id' => Jetpack_Options::get_option( 'id' ), + 'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(), + 'adminUrl' => esc_url_raw( admin_url() ), + 'slug' => is_string( $home_host ) ? $home_host : '', + 'gmtOffset' => is_numeric( $gmt_offset ) ? (float) $gmt_offset : 0.0, + 'timezoneString' => is_string( $timezone_string ) ? $timezone_string : '', + 'locale' => str_replace( '_', '-', (string) get_locale() ), + ), + 'assets' => array( + 'buildUrl' => plugins_url( '../build/', __FILE__ ), + ), + ); + } + + /** + * Render the initial state into a JavaScript variable. + * + * @return string + */ + public function render() { + return 'var JPSCAN_INITIAL_STATE=' . wp_json_encode( $this->get_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';'; + } +} diff --git a/projects/packages/scan/src/class-rest-controller.php b/projects/packages/scan/src/class-rest-controller.php index 7a417d5dbdc4..5e7fc12f6581 100644 --- a/projects/packages/scan/src/class-rest-controller.php +++ b/projects/packages/scan/src/class-rest-controller.php @@ -1,29 +1,460 @@ WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_site_scan' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/' . self::REST_ROUTE_PREFIX . '/history', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_site_scan_history' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/' . self::REST_ROUTE_PREFIX . '/counts', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_site_scan_counts' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/' . self::REST_ROUTE_PREFIX . '/threat/(?P[\w\-]+)/ignore', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'post_threat_ignore' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + 'args' => array( + 'id' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/' . self::REST_ROUTE_PREFIX . '/threat/(?P[\w\-]+)/unignore', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'post_threat_unignore' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + 'args' => array( + 'id' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/' . self::REST_ROUTE_PREFIX . '/threats/fix', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'post_threats_fix' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + 'args' => array( + 'threat_ids' => array( + 'type' => 'array', + 'required' => true, + 'items' => array( + 'type' => 'string', + ), + ), + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/' . self::REST_ROUTE_PREFIX . '/enqueue', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'post_scan_enqueue' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/' . self::REST_ROUTE_PREFIX . '/threats/fix-status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_threats_fix_status' ), + 'permission_callback' => array( __CLASS__, 'permissions_check' ), + 'args' => array( + 'threat_ids' => array( + 'type' => 'array', + 'required' => true, + 'items' => array( + 'type' => 'string', + ), + ), + ), + ) + ); + } + + /** + * Permission callback: admin-only. Mirrors the gate in + * `Jetpack_Scan::is_available()`. + * + * @return bool|WP_Error + */ + public static function permissions_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'rest_forbidden', + esc_html__( 'You do not have permission to access this resource.', 'jetpack-scan-page' ), + array( 'status' => 401 ) + ); + } + + return true; + } + + /** + * GET /site/scan — current scan state + active threats. + * + * Proxies WPCOM `/sites/:siteId/scan` with blog auth (matches Protect + * plugin's `Threats::fetch_status()`). + * + * @return \WP_REST_Response|WP_Error + */ + public static function get_site_scan() { + return self::proxy_get( '/scan', 'scan', true ); + } + + /** + * GET /site/scan/history — past scan runs and their threats. + * + * Proxies WPCOM `/sites/:siteId/scan/history` with blog auth (matches + * Protect plugin's `Threats::history()`). + * + * @return \WP_REST_Response|WP_Error + */ + public static function get_site_scan_history() { + return self::proxy_get( '/scan/history', 'scan_history', true ); + } + + /** + * GET /site/scan/counts — threat counts for the overview tabs. + * + * Proxies WPCOM `/sites/:siteId/scan/counts` with blog auth. + * + * @return \WP_REST_Response|WP_Error + */ + public static function get_site_scan_counts() { + return self::proxy_get( '/scan/counts', 'scan_counts', true ); + } + + /** + * POST /site/scan/threat/{id}/ignore — mark a threat as ignored. + * + * Proxies WPCOM `POST /sites/:siteId/alerts/:threatId` with + * `{ ignore: true }`. Same shape Protect plugin's + * `Threats::ignore_threat()` already uses. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function post_threat_ignore( WP_REST_Request $request ) { + $threat_id = (string) $request->get_param( 'id' ); + return self::proxy_post( + sprintf( '/alerts/%s', rawurlencode( $threat_id ) ), + array( 'ignore' => true ), + 'scan_threat_ignore' + ); + } + + /** + * POST /site/scan/threat/{id}/unignore — reactivate a previously-ignored + * threat. + * + * Proxies WPCOM `POST /sites/:siteId/alerts/:threatId` with + * `{ unignore: true }`. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function post_threat_unignore( WP_REST_Request $request ) { + $threat_id = (string) $request->get_param( 'id' ); + return self::proxy_post( + sprintf( '/alerts/%s', rawurlencode( $threat_id ) ), + array( 'unignore' => true ), + 'scan_threat_unignore' + ); + } + + /** + * POST /site/scan/threats/fix — kick auto-fix for one or more threats. + * + * Proxies WPCOM `POST /sites/:siteId/alerts/fix` with + * `{ threat_ids: [...] }`. Same endpoint handles single + bulk fix. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function post_threats_fix( WP_REST_Request $request ) { + $ids = (array) $request->get_param( 'threat_ids' ); + $ids = array_values( array_filter( array_map( 'strval', $ids ) ) ); + return self::proxy_post( + '/alerts/fix', + array( 'threat_ids' => $ids ), + 'scan_threats_fix' + ); + } + + /** + * POST /site/scan/enqueue — trigger an immediate scan run. + * + * Proxies WPCOM `POST /sites/:siteId/scan/enqueue` with blog auth + * (matches Protect plugin's `Threats::scan()`). + * + * @return \WP_REST_Response|WP_Error + */ + public static function post_scan_enqueue() { + return self::proxy_post( '/scan/enqueue', array(), 'scan_enqueue', true ); + } + + /** + * GET /site/scan/threats/fix-status — poll the auto-fixer for the + * current state of one or more threats. + * + * Proxies WPCOM `GET /sites/:siteId/alerts/fix?threat_ids[]=…`. Body + * shape mirrors `post_threats_fix` so the UI hook can poll until each + * threat reaches a terminal state. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_threats_fix_status( WP_REST_Request $request ) { + $ids = (array) $request->get_param( 'threat_ids' ); + $ids = array_values( array_filter( array_map( 'strval', $ids ) ) ); + + $path = add_query_arg( array( 'threat_ids' => $ids ), '/alerts/fix' ); + return self::proxy_get( $path, 'scan_threats_fix_status' ); + } + + /** + * Proxy a GET request to the WPCOM v2 Scan endpoint and pass the JSON + * body through (or surface a WP_Error mapping the upstream status + * code). + * + * Site-level reads (`/scan`, `/scan/history`, `/scan/counts`) sign + * with blog auth — the same contract Protect plugin's `Threats::*` + * helpers use, and what WPCOM expects for these endpoints. Alert / + * fix-status endpoints stay on user auth so per-user permissions on + * threat mutations carry through. + * + * Forwarding the visitor IP keeps WPCOM-side audit logs aligned with + * the existing `/jetpack/v4/site/activity` proxy in `activity-log`. + * + * @param string $upstream_path WPCOM path suffix (e.g. `/scan`, `/alerts/fix`). + * @param string $error_slug Slug used when synthesising WP_Error codes. + * @param bool $as_blog Sign with blog auth instead of user auth. + * @return \WP_REST_Response|WP_Error + */ + private static function proxy_get( $upstream_path, $error_slug, $as_blog = false ) { + $path = self::resolve_blog_path( $upstream_path ); + if ( is_wp_error( $path ) ) { + return $path; + } + + $args = array( + 'method' => 'GET', + 'headers' => array( + 'X-Forwarded-For' => ( new Visitor() )->get_ip( true ), + ), + ); + + $response = $as_blog + ? Client::wpcom_json_api_request_as_blog( + $path, + '2', + $args, + null, + 'wpcom' + ) + : Client::wpcom_json_api_request_as_user( + $path, + '2', + $args, + null, + 'wpcom' + ); + + return self::map_response( $response, $error_slug ); + } + + /** + * Proxy a POST request to the user-scoped WPCOM v2 Scan endpoint + * with a JSON body and pass the response through (or surface a + * WP_Error mapping the upstream status code). + * + * @param string $upstream_path WPCOM path suffix (e.g. `/alerts/fix`). + * @param array $body Body payload sent as JSON. + * @param string $error_slug Slug used when synthesising WP_Error codes. + * @param bool $as_blog Sign with blog auth instead of user auth. + * @return \WP_REST_Response|WP_Error + */ + private static function proxy_post( $upstream_path, array $body, $error_slug, $as_blog = false ) { + $path = self::resolve_blog_path( $upstream_path ); + if ( is_wp_error( $path ) ) { + return $path; + } + + $args = array( + 'method' => 'POST', + 'headers' => array( + 'Content-Type' => 'application/json', + 'X-Forwarded-For' => ( new Visitor() )->get_ip( true ), + ), + ); + $encoded_body = wp_json_encode( $body, JSON_UNESCAPED_SLASHES ); + + $response = $as_blog + ? Client::wpcom_json_api_request_as_blog( + $path, + '2', + $args, + $encoded_body, + 'wpcom' + ) + : Client::wpcom_json_api_request_as_user( + $path, + '2', + $args, + $encoded_body, + 'wpcom' + ); + + return self::map_response( $response, $error_slug ); + } + + /** + * Resolve the connected blog id and prefix it onto the WPCOM path + * suffix. Surfaces a 400 WP_Error if the site isn't connected. + * + * @param string $upstream_path WPCOM path suffix. + * @return string|WP_Error + */ + private static function resolve_blog_path( $upstream_path ) { + $blog_id = (int) Jetpack_Options::get_option( 'id' ); + if ( $blog_id <= 0 ) { + return new WP_Error( + 'jetpack_scan_no_blog_id', + esc_html__( 'Site is not connected to WordPress.com.', 'jetpack-scan-page' ), + array( 'status' => 400 ) + ); + } + + return sprintf( '/sites/%d%s', $blog_id, $upstream_path ); + } + + /** + * Translate a WPCOM HTTP response into a `WP_REST_Response` / + * `WP_Error` for the local `/jetpack/v4/*` route to return. + * + * @param array|WP_Error $response Result of `wpcom_json_api_request_as_user`. + * @param string $error_slug Slug used when synthesising WP_Error codes. + * @return \WP_REST_Response|WP_Error + */ + private static function map_response( $response, $error_slug ) { + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'jetpack_' . $error_slug . '_request_failed', + $response->get_error_message(), + array( 'status' => 500 ) + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $status < 200 || $status >= 300 ) { + return new WP_Error( + 'jetpack_' . $error_slug . '_request_failed', + isset( $body['message'] ) ? (string) $body['message'] : esc_html__( 'Unable to fetch Scan data.', 'jetpack-scan-page' ), + array( 'status' => $status > 0 ? $status : 500 ) + ); + } + + return rest_ensure_response( $body ); } } diff --git a/projects/packages/scan/src/js/data/fetchers.ts b/projects/packages/scan/src/js/data/fetchers.ts new file mode 100644 index 000000000000..fa6e95c4c609 --- /dev/null +++ b/projects/packages/scan/src/js/data/fetchers.ts @@ -0,0 +1,140 @@ +/* eslint-disable jsdoc/require-description, jsdoc/require-param-description, jsdoc/require-returns */ + +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { isMockMode, mockSiteScan, mockSiteScanCounts, mockSiteScanHistory } from './mock'; +import type { + FixThreatsResponse, + FixThreatsStatusResponse, + SiteScanCountsResponse, + SiteScanHistoryResponse, + SiteScanResponse, +} from './types'; + +// All fetchers target local `/jetpack/v4/site/scan/*` endpoints that +// proxy to WPCOM using the site's Jetpack connection. `siteId` is +// resolved server-side, so it is not part of any path or argument here. +// +// When `isMockMode()` is true (via the `?jps-mock=1` URL param) the +// fetchers short-circuit to fixtures from `./mock` so the overview can +// be designed and QAed without a Scan plan on the site. Mutations in +// mock mode short-circuit to a resolved promise — no real requests fire. + +/** + * + */ +export async function fetchSiteScan(): Promise< SiteScanResponse > { + if ( isMockMode() ) { + return mockSiteScan; + } + return apiFetch< SiteScanResponse >( { path: '/jetpack/v4/site/scan' } ); +} + +/** + * + */ +export async function fetchSiteScanHistory(): Promise< SiteScanHistoryResponse > { + if ( isMockMode() ) { + return mockSiteScanHistory; + } + return apiFetch< SiteScanHistoryResponse >( { + path: '/jetpack/v4/site/scan/history', + } ); +} + +/** + * + */ +export async function fetchSiteScanCounts(): Promise< SiteScanCountsResponse > { + if ( isMockMode() ) { + return mockSiteScanCounts; + } + return apiFetch< SiteScanCountsResponse >( { + path: '/jetpack/v4/site/scan/counts', + } ); +} + +/** + * Trigger a fresh scan via `POST /jetpack/v4/site/scan/enqueue`. Resolves + * to the WPCOM acknowledgement (typically `{ success: true }`); the + * `siteScanQuery` cache picks up the new state on its next refetch. + */ +export async function enqueueScan(): Promise< unknown > { + if ( isMockMode() ) { + return Promise.resolve( { success: true } ); + } + return apiFetch( { + path: '/jetpack/v4/site/scan/enqueue', + method: 'POST', + } ); +} + +/** + * + * @param threatId + */ +export async function ignoreThreat( threatId: string | number ): Promise< unknown > { + if ( isMockMode() ) { + return Promise.resolve( { ok: true } ); + } + return apiFetch( { + path: `/jetpack/v4/site/scan/threat/${ encodeURIComponent( String( threatId ) ) }/ignore`, + method: 'POST', + } ); +} + +/** + * + * @param threatId + */ +export async function unignoreThreat( threatId: string | number ): Promise< unknown > { + if ( isMockMode() ) { + return Promise.resolve( { ok: true } ); + } + return apiFetch( { + path: `/jetpack/v4/site/scan/threat/${ encodeURIComponent( String( threatId ) ) }/unignore`, + method: 'POST', + } ); +} + +/** + * + * @param threatIds + */ +export async function fixThreats( + threatIds: ReadonlyArray< string | number > +): Promise< FixThreatsResponse > { + if ( isMockMode() ) { + return Promise.resolve( { + ok: true, + threats: Object.fromEntries( + threatIds.map( id => [ String( id ), { status: 'in_progress' } ] ) + ), + } ); + } + return apiFetch< FixThreatsResponse >( { + path: '/jetpack/v4/site/scan/threats/fix', + method: 'POST', + data: { threat_ids: threatIds.map( id => String( id ) ) }, + } ); +} + +/** + * + * @param threatIds + */ +export async function fetchFixThreatsStatus( + threatIds: ReadonlyArray< string | number > +): Promise< FixThreatsStatusResponse > { + if ( isMockMode() ) { + return Promise.resolve( { + ok: true, + threats: Object.fromEntries( threatIds.map( id => [ String( id ), { status: 'fixed' } ] ) ), + } ); + } + return apiFetch< FixThreatsStatusResponse >( { + path: addQueryArgs( '/jetpack/v4/site/scan/threats/fix-status', { + threat_ids: threatIds.map( id => String( id ) ), + } ), + } ); +} diff --git a/projects/packages/scan/src/js/data/mock/fixtures.ts b/projects/packages/scan/src/js/data/mock/fixtures.ts new file mode 100644 index 000000000000..d744103cf38d --- /dev/null +++ b/projects/packages/scan/src/js/data/mock/fixtures.ts @@ -0,0 +1,143 @@ +import type { + SiteScanCountsResponse, + SiteScanHistoryResponse, + SiteScanResponse, + Threat, +} from '../types'; + +/** + * Fixture threats used by `?jps-mock=1`. Spread across severities, types, + * signatures, and fix states so the DataViews chrome (filters, sort, + * status pills, fix buttons) all render with real-looking data — useful + * for design reviews on a JN site without a Scan plan. + */ +const mockThreats: Threat[] = [ + { + id: 'mock-threat-1', + title: 'WooCommerce <= 3.2.3 — Authenticated PHP Object Injection', + description: + 'Versions 3.2.3 and earlier are affected by an issue where cached queries within shortcodes could lead to object injection.', + status: 'current', + severity: 9, + signature: 'CVE-2017-1000564', + firstDetected: '2026-04-30T12:00:00.000Z', + fixable: { + fixer: 'update', + target: '3.2.4', + }, + extension: { + slug: 'woocommerce', + name: 'WooCommerce', + version: '3.2.3', + type: 'plugins', + }, + }, + { + id: 'mock-threat-2', + title: 'Malicious code in: index.php', + description: + 'A heuristic match for the EICAR antivirus test string was detected in this file. Review the source and remove if unexpected.', + status: 'current', + severity: 8, + signature: 'EICAR_AV_Test', + firstDetected: '2026-04-29T08:30:00.000Z', + fixable: { + fixer: 'delete', + target: '/wp-content/uploads/index.php', + }, + filename: '/wp-content/uploads/index.php', + context: { + 1: 'echo << threat.status === 'fixed' ).length, + ignored: mockHistoryThreats.filter( threat => threat.status === 'ignored' ).length, +}; diff --git a/projects/packages/scan/src/js/data/mock/index.ts b/projects/packages/scan/src/js/data/mock/index.ts new file mode 100644 index 000000000000..b9dea5b47e7d --- /dev/null +++ b/projects/packages/scan/src/js/data/mock/index.ts @@ -0,0 +1,30 @@ +// Opt-in mock mode for designing and QAing the Scan overview without a +// real Jetpack connection or a Scan plan on the site. Activate by adding +// `?jps-mock=1` to the wp-admin URL (query string is preserved across +// the hash router's navigation). +// +// When active: +// - `Gates` skip the connection + capabilities check and render the +// overview directly. +// - The `fetchers.ts` functions return the fixtures from `./fixtures` +// instead of hitting `/jetpack/v4/site/scan/*`. + +export const MOCK_URL_PARAM = 'jps-mock'; + +/** + * Whether mock mode is active in the current page load. + * + * @return True when the `?jps-mock=1` query param is present. + */ +export function isMockMode(): boolean { + if ( typeof window === 'undefined' ) { + return false; + } + try { + return new URLSearchParams( window.location.search ).has( MOCK_URL_PARAM ); + } catch { + return false; + } +} + +export * from './fixtures'; diff --git a/projects/packages/scan/src/js/data/query-options.ts b/projects/packages/scan/src/js/data/query-options.ts new file mode 100644 index 000000000000..1986f225dd2a --- /dev/null +++ b/projects/packages/scan/src/js/data/query-options.ts @@ -0,0 +1,39 @@ +import { queryOptions } from '@tanstack/react-query'; +import { fetchSiteScan, fetchSiteScanCounts, fetchSiteScanHistory } from './fetchers'; + +// TanStack Query factory functions. Names mirror the Calypso source so +// future phases can port hooks 1:1. + +/** + * Active scan query — returns the current scan state and the active + * (un-ignored, un-fixed) threats. + * + * @return queryOptions + */ +export const siteScanQuery = () => + queryOptions( { + queryKey: [ 'jetpack', 'site', 'scan' ] as const, + queryFn: () => fetchSiteScan(), + } ); + +/** + * Scan-history query — list of past scans and their threats. + * + * @return queryOptions + */ +export const siteScanHistoryQuery = () => + queryOptions( { + queryKey: [ 'jetpack', 'site', 'scan', 'history' ] as const, + queryFn: () => fetchSiteScanHistory(), + } ); + +/** + * Threat-counts query — drives the tab counts in the overview header. + * + * @return queryOptions + */ +export const siteScanCountsQuery = () => + queryOptions( { + queryKey: [ 'jetpack', 'site', 'scan', 'counts' ] as const, + queryFn: () => fetchSiteScanCounts(), + } ); diff --git a/projects/packages/scan/src/js/data/test/use-fix-threats-status.test.ts b/projects/packages/scan/src/js/data/test/use-fix-threats-status.test.ts new file mode 100644 index 000000000000..3b4bedc91374 --- /dev/null +++ b/projects/packages/scan/src/js/data/test/use-fix-threats-status.test.ts @@ -0,0 +1,47 @@ +import { isFixComplete } from '../use-fix-threats-status'; +import type { FixThreatsStatusResponse } from '../types'; + +describe( 'isFixComplete', () => { + it( 'returns false when the response is undefined (no poll yet)', () => { + expect( isFixComplete( undefined ) ).toBe( false ); + } ); + + it( 'returns true when the threat map is empty (nothing to fix)', () => { + const response: FixThreatsStatusResponse = { ok: true, threats: {} }; + expect( isFixComplete( response ) ).toBe( true ); + } ); + + it( 'returns false while any threat is still in_progress', () => { + const response: FixThreatsStatusResponse = { + ok: true, + threats: { + a: { status: 'fixed' }, + b: { status: 'in_progress' }, + }, + }; + expect( isFixComplete( response ) ).toBe( false ); + } ); + + it( 'returns true when every threat has reached a terminal status', () => { + const response: FixThreatsStatusResponse = { + ok: true, + threats: { + a: { status: 'fixed' }, + b: { status: 'not_fixed' }, + c: { status: 'not_found' }, + }, + }; + expect( isFixComplete( response ) ).toBe( true ); + } ); + + it( 'tolerates an unexpected status string and treats it as non-terminal', () => { + const response: FixThreatsStatusResponse = { + ok: true, + threats: { + a: { status: 'fixed' }, + b: { status: 'queued_unknown' }, + }, + }; + expect( isFixComplete( response ) ).toBe( false ); + } ); +} ); diff --git a/projects/packages/scan/src/js/data/types.ts b/projects/packages/scan/src/js/data/types.ts new file mode 100644 index 000000000000..8424299d28b8 --- /dev/null +++ b/projects/packages/scan/src/js/data/types.ts @@ -0,0 +1,83 @@ +import type { Threat } from '@automattic/jetpack-scan'; + +export type { Threat }; + +/** + * Site-data hydration shape rendered into `JPSCAN_INITIAL_STATE` by + * `class-initial-state.php`. Mirror any field changes there in this + * type so consumers stay typed. + */ +export interface JetpackScanInitialState { + API: { + WP_API_root: string; + WP_API_nonce: string; + }; + jetpackStatus: { + calypsoSlug: string; + }; + siteData: { + id: number | string; + title: string; + adminUrl: string; + slug: string; + gmtOffset: number; + timezoneString: string; + locale: string; + }; + assets: { + buildUrl: string; + }; +} + +/** + * Active-scan response shape. Mirrors the WPCOM `wpcom/v2 /sites/:siteId/scan` + * surface that the `jetpack/v4/site/scan` REST bridge proxies to. Kept + * intentionally narrow for Phase 0; later phases extend this with the + * full Calypso shape. + */ +export interface SiteScanResponse { + state: 'idle' | 'enqueued' | 'running' | 'success' | 'error' | 'unavailable'; + threats: Threat[]; + hasNeverRun?: boolean; + mostRecent?: { + timestamp: string; + isInitial: boolean; + }; + current?: { + isInitial: boolean; + progress: number; + }; +} + +/** + * Scan-history response shape. Each entry is a past scan run with its + * threat list. Phase 0 ships an empty default; Phase 2 wires the bridge. + */ +export interface SiteScanHistoryResponse { + threats: Threat[]; +} + +/** + * Scan threat-counts response shape. Drives the tab counts in the + * overview header. + */ +export interface SiteScanCountsResponse { + current: number; + fixed: number; + ignored: number; +} + +/** + * Auto-fixer status reported per threat by `/threats/fix-status`. + */ +export type ThreatFixStatus = 'in_progress' | 'fixed' | 'not_fixed' | 'not_found' | string; + +/** + * Response shape for `POST /threats/fix` and `GET /threats/fix-status`. + */ +export interface FixThreatsResponse { + ok: boolean; + threats: Record< string, { status: ThreatFixStatus; error?: string } >; +} + +export type FixThreatsStatusResponse = FixThreatsResponse; diff --git a/projects/packages/scan/src/js/data/use-fix-threats-status.ts b/projects/packages/scan/src/js/data/use-fix-threats-status.ts new file mode 100644 index 000000000000..b1600febfafe --- /dev/null +++ b/projects/packages/scan/src/js/data/use-fix-threats-status.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchFixThreatsStatus } from './fetchers'; +import type { FixThreatsStatusResponse, ThreatFixStatus } from './types'; + +const POLL_INTERVAL_MS = 2_000; + +const TERMINAL_STATUSES: ReadonlySet< ThreatFixStatus > = new Set( [ + 'fixed', + 'not_fixed', + 'not_found', +] ); + +/** + * Whether every threat in the response has reached a terminal state + * (fixed / not_fixed / not_found). + * + * @param response - The status payload from `/threats/fix-status`. + * @return Whether the fixer is done for every requested threat. + */ +export function isFixComplete( response: FixThreatsStatusResponse | undefined ): boolean { + if ( ! response ) { + return false; + } + const statuses = Object.values( response.threats ?? {} ); + if ( statuses.length === 0 ) { + return true; + } + return statuses.every( entry => TERMINAL_STATUSES.has( entry.status ) ); +} + +/** + * Poll the fix-status endpoint every 2 s while the auto-fixer is running + * for any of the supplied threat ids. Stops polling once every threat + * has reached a terminal state — the consumer (Phase 4 bulk-fix modal) + * decides what to render based on the resolved statuses. + * + * @param threatIds - Threat ids to poll for. `null` / empty pauses polling. + * @return TanStack query handle. + */ +export function useFixThreatsStatusQuery( threatIds: ReadonlyArray< string | number > | null ) { + const ids = threatIds && threatIds.length > 0 ? threatIds : null; + + return useQuery< FixThreatsStatusResponse, Error >( { + queryKey: [ 'jetpack', 'site', 'scan', 'fix-status', ids ?? [] ] as const, + queryFn: () => fetchFixThreatsStatus( ids ?? [] ), + enabled: ids !== null, + refetchInterval: query => ( isFixComplete( query.state.data ) ? false : POLL_INTERVAL_MS ), + refetchOnWindowFocus: false, + } ); +} diff --git a/projects/packages/scan/src/js/data/use-site-data.ts b/projects/packages/scan/src/js/data/use-site-data.ts new file mode 100644 index 000000000000..59fcdc750cca --- /dev/null +++ b/projects/packages/scan/src/js/data/use-site-data.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import type { JetpackScanInitialState } from './types'; + +declare global { + interface Window { + JPSCAN_INITIAL_STATE?: JetpackScanInitialState; + } +} + +/** + * Convert a WordPress locale (e.g. `en_US`) into a BCP 47 language tag + * (e.g. `en-US`) that `Intl.*` APIs accept. WordPress uses underscores; + * passing `en_US` to `Intl.DateTimeFormat` throws `RangeError: Invalid + * language tag`. + * + * @param wpLocale - WordPress-style locale string. + * @return BCP 47 language tag. + */ +const toBcp47Locale = ( wpLocale: string ): string => wpLocale.replace( /_/g, '-' ); + +/** + * Read the site bootstrap that PHP renders into the page. Returns a stable + * reference via `useMemo` so it's safe to pass into query keys and effect + * dependency arrays. + * + * @return The hydrated site-data fields. + */ +export function useSiteData(): JetpackScanInitialState[ 'siteData' ] { + return useMemo( () => { + const data = window.JPSCAN_INITIAL_STATE?.siteData; + return { + id: Number( data?.id ?? 0 ), + title: String( data?.title ?? '' ), + adminUrl: String( data?.adminUrl ?? '' ), + slug: String( data?.slug ?? '' ), + gmtOffset: Number( data?.gmtOffset ?? 0 ), + timezoneString: String( data?.timezoneString ?? '' ), + locale: toBcp47Locale( String( data?.locale ?? 'en' ) ), + }; + }, [] ); +} diff --git a/projects/packages/scan/src/js/data/use-threat-mutations.ts b/projects/packages/scan/src/js/data/use-threat-mutations.ts new file mode 100644 index 000000000000..a07ff39d2c25 --- /dev/null +++ b/projects/packages/scan/src/js/data/use-threat-mutations.ts @@ -0,0 +1,74 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { enqueueScan, fixThreats, ignoreThreat, unignoreThreat } from './fetchers'; +import type { FixThreatsResponse } from './types'; + +// On any successful threat mutation we invalidate the three queries that +// drive the overview tabs so the table refreshes off the latest state +// instead of showing stale rows. +const SCAN_QUERY_PREFIX = [ 'jetpack', 'site', 'scan' ] as const; + +/** + * Mark a single threat as ignored. + * + * @return TanStack mutation handle. + */ +export function useIgnoreThreatMutation() { + const queryClient = useQueryClient(); + return useMutation< unknown, Error, string | number >( { + mutationFn: threatId => ignoreThreat( threatId ), + onSuccess: () => { + queryClient.invalidateQueries( { queryKey: SCAN_QUERY_PREFIX } ); + }, + } ); +} + +/** + * Re-activate a previously ignored threat. + * + * @return TanStack mutation handle. + */ +export function useUnignoreThreatMutation() { + const queryClient = useQueryClient(); + return useMutation< unknown, Error, string | number >( { + mutationFn: threatId => unignoreThreat( threatId ), + onSuccess: () => { + queryClient.invalidateQueries( { queryKey: SCAN_QUERY_PREFIX } ); + }, + } ); +} + +/** + * Trigger a fresh scan run. + * + * @return TanStack mutation handle. + */ +export function useEnqueueScanMutation() { + const queryClient = useQueryClient(); + return useMutation< unknown, Error, void >( { + mutationFn: () => enqueueScan(), + onSuccess: () => { + queryClient.invalidateQueries( { queryKey: SCAN_QUERY_PREFIX } ); + }, + } ); +} + +/** + * Kick the auto-fixer for one or more threats. The mutation resolves as + * soon as WPCOM accepts the request — the actual fixer status is polled + * via `useFixThreatsStatusQuery` (Phase 4 wires the modal that shows + * progress / success / failure). + * + * @return TanStack mutation handle. + */ +export function useFixThreatsMutation() { + const queryClient = useQueryClient(); + return useMutation< FixThreatsResponse, Error, ReadonlyArray< string | number > >( { + mutationFn: threatIds => fixThreats( threatIds ), + onSuccess: () => { + // Initial invalidation so the table reflects "fix in progress" rows; + // the polling status query keeps the cache in sync as the fixer + // runs on WPCOM's side. + queryClient.invalidateQueries( { queryKey: SCAN_QUERY_PREFIX } ); + }, + } ); +} diff --git a/projects/packages/scan/src/js/data/use-track-event.ts b/projects/packages/scan/src/js/data/use-track-event.ts new file mode 100644 index 000000000000..88b122a96ab7 --- /dev/null +++ b/projects/packages/scan/src/js/data/use-track-event.ts @@ -0,0 +1,18 @@ +import jetpackAnalytics from '@automattic/jetpack-analytics'; +import { useCallback } from '@wordpress/element'; + +/** + * Returns a stable callback for emitting `jetpack_scan_*` Tracks events + * from the Scan overview UI. Thin wrapper around + * `@automattic/jetpack-analytics` (the canonical Jetpack tracking + * client used by Forms, Backup, Activity Log, and the rest of the + * wp-admin product surface) so call sites don't need to know which + * underlying transport is in play. + * + * @return Stable callback that records a tracks event by name with optional properties. + */ +export function useTrackEvent() { + return useCallback( ( eventName: string, properties?: Record< string, unknown > ) => { + jetpackAnalytics.tracks.recordEvent( eventName, properties ); + }, [] ); +} diff --git a/projects/packages/scan/src/js/gates.tsx b/projects/packages/scan/src/js/gates.tsx new file mode 100644 index 000000000000..ff4365c52b42 --- /dev/null +++ b/projects/packages/scan/src/js/gates.tsx @@ -0,0 +1,20 @@ +import type { FC, ReactNode } from 'react'; + +/** + * Gate screen — pass-through wrapper for the overview tree. Connection + * gating already happens server-side in `Jetpack_Scan::is_available()` + * (the wp-admin menu doesn't register at all on disconnected sites), so + * by the time this component renders the user is, by definition, on a + * connected site. + * + * Kept as a thin component to leave a clear seam for future plan-level + * gating (Scan plan presence, single-site/multisite, etc.) without + * threading more conditional rendering through `stage.tsx`. + * + * @param root0 - Component props. + * @param root0.children - The wrapped overview tree. + * @return The wrapped tree. + */ +const Gates: FC< { children: ReactNode } > = ( { children } ) => <>{ children }; + +export default Gates; diff --git a/projects/packages/scan/src/js/header-actions-context.tsx b/projects/packages/scan/src/js/header-actions-context.tsx new file mode 100644 index 000000000000..6a36efb6f5f2 --- /dev/null +++ b/projects/packages/scan/src/js/header-actions-context.tsx @@ -0,0 +1,28 @@ +import { createContext, useCallback, useContext, useState } from 'react'; +import type { FC, ReactNode } from 'react'; + +interface HeaderActionsContextValue { + actions: ReactNode; + setActions: ( actions: ReactNode ) => void; +} + +const HeaderActionsContext = createContext< HeaderActionsContextValue >( { + actions: null, + setActions: () => {}, +} ); + +export const HeaderActionsProvider: FC< { children: ReactNode } > = ( { children } ) => { + const [ actions, setActions ] = useState< ReactNode >( null ); + return ( + + { children } + + ); +}; + +export const useHeaderActions = (): ReactNode => useContext( HeaderActionsContext ).actions; + +export const useSetHeaderActions = (): ( ( actions: ReactNode ) => void ) => { + const { setActions } = useContext( HeaderActionsContext ); + return useCallback( ( next: ReactNode ) => setActions( next ), [ setActions ] ); +}; diff --git a/projects/packages/scan/src/js/mock-banner.tsx b/projects/packages/scan/src/js/mock-banner.tsx new file mode 100644 index 000000000000..f8b6cf986242 --- /dev/null +++ b/projects/packages/scan/src/js/mock-banner.tsx @@ -0,0 +1,35 @@ +import { __ } from '@wordpress/i18n'; +import { isMockMode } from './data/mock'; +import type { FC } from 'react'; + +const style: React.CSSProperties = { + background: '#fef7c2', + borderBlockEnd: '1px solid #d3af00', + color: '#4a2d00', + fontSize: 12, + padding: '6px 16px', + textAlign: 'center', +}; + +/** + * Warn loudly when the overview is running on fixture data so the team + * doesn't mistake `?jps-mock=1` screenshots / video captures for real + * threats on the site. + * + * @return The banner or null if mock mode is off. + */ +const MockBanner: FC = () => { + if ( ! isMockMode() ) { + return null; + } + return ( +
+ { __( + 'Dev mode: the threat list below is fixture data (`?jps-mock=1`). No real requests are being made.', + 'jetpack-scan-page' + ) } +
+ ); +}; + +export default MockBanner; diff --git a/projects/packages/scan/src/js/notices-list.tsx b/projects/packages/scan/src/js/notices-list.tsx new file mode 100644 index 000000000000..79cfbc8af643 --- /dev/null +++ b/projects/packages/scan/src/js/notices-list.tsx @@ -0,0 +1,34 @@ +import { SnackbarList } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import type { FC } from 'react'; + +const MAX_VISIBLE_NOTICES = 3; + +/** + * Floating snackbar layer for the Scan overview. Mirrors Forms' + * `DashboardNotices`: subscribes to the core `notices` store and renders + * the trailing 3 snackbars via ``. Anywhere in the page + * can fire a snackbar via `useDispatch( noticesStore ).createSuccessNotice(…)` + * and it'll surface here. + * + * @return The snackbar list, or `null` when there are no snackbars. + */ +const NoticesList: FC = () => { + const notices = useSelect( select => select( noticesStore ).getNotices(), [] ); + const { removeNotice } = useDispatch( noticesStore ); + + const snackbarNotices = notices + .filter( ( { type } ) => type === 'snackbar' ) + .slice( -MAX_VISIBLE_NOTICES ); + + return ( + + ); +}; + +export default NoticesList; diff --git a/projects/packages/scan/src/js/screens/overview/active-threats.tsx b/projects/packages/scan/src/js/screens/overview/active-threats.tsx new file mode 100644 index 000000000000..ae7ce48bb781 --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/active-threats.tsx @@ -0,0 +1,128 @@ +import { ThreatsDataViews } from '@automattic/jetpack-scan'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner } from '@wordpress/components'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Button, Stack } from '@wordpress/ui'; +import { siteScanQuery } from '../../data/query-options'; +import { useTrackEvent } from '../../data/use-track-event'; +import { useSetHeaderActions } from '../../header-actions-context'; +import BulkFixModal from './bulk-fix-modal'; +import EmptyState from './empty-state'; +import { FixThreatModal } from './fix-threat-modal'; +import { IgnoreThreatModal } from './ignore-threat-modal'; +import ScanNowButton from './scan-now-button'; +import ScanStatus from './scan-status'; +import { useThreatActions } from './use-threat-actions'; +import { ViewDetailsModal } from './view-details-modal'; +import type { FC } from 'react'; + +/** + * Active threats panel — lists the un-ignored, un-fixed threats from the + * most recent scan. Wraps the existing `ThreatsDataViews` component from + * `@automattic/jetpack-scan` (the js-package) so the table fields, + * sort/search/pagination, and severity badge stay in sync with the + * legacy Protect surface. Action handlers are stubbed in Phase 1; the + * fix / ignore / unignore / view-details modals wire up in Phases 3–4. + * + * Empty + error states are handled inside the DataViews shell — passing + * `data={ [] }` renders the table chrome with DataViews' built-in + * "no items" body so reviewers always see column headers + filter + * controls (Phase 1+ wires up search / sort persistence on top). + * + * @return The active threats panel. + */ +const ActiveThreats: FC = () => { + const { data, isLoading, error } = useQuery( siteScanQuery() ); + const { onFixThreats } = useThreatActions(); + const setHeaderActions = useSetHeaderActions(); + + const threats = useMemo( () => data?.threats ?? [], [ data ] ); + const fixableCount = useMemo( + () => threats.filter( threat => !! threat.fixable ).length, + [ threats ] + ); + + const scanState = data?.state; + const isScanRunning = scanState === 'enqueued' || scanState === 'running'; + + const trackEvent = useTrackEvent(); + const onTrackDataViewsEvent = useCallback( + ( event: string, properties?: Record< string, unknown > ) => + trackEvent( `jetpack_scan_${ event }`, properties ), + [ trackEvent ] + ); + const [ isBulkFixOpen, setBulkFixOpen ] = useState( false ); + const openBulkFix = useCallback( () => { + trackEvent( 'jetpack_scan_fix_threats_cta_click', { threat_count: fixableCount } ); + trackEvent( 'jetpack_scan_bulk_fix_threats_modal_open', { threat_count: fixableCount } ); + setBulkFixOpen( true ); + }, [ trackEvent, fixableCount ] ); + const closeBulkFix = useCallback( () => setBulkFixOpen( false ), [] ); + + // Slot the "Scan now" + optional "Auto-fix N threats" CTAs into the + // AdminPage header. Cleared on tab switch / unmount so the History tab + // doesn't inherit them. + useEffect( () => { + setHeaderActions( + <> + + { fixableCount > 0 && ! isScanRunning && ( + + ) } + + ); + return () => setHeaderActions( null ); + }, [ fixableCount, isScanRunning, setHeaderActions, openBulkFix ] ); + + if ( isLoading ) { + return ( + + + + ); + } + + if ( error ) { + return ( +

{ __( 'Unable to load active threats. Please try again later.', 'jetpack-scan-page' ) }

+ ); + } + + if ( isScanRunning ) { + return ; + } + + return ( + <> + + } + /> + { isBulkFixOpen && } + + ); +}; + +export default ActiveThreats; diff --git a/projects/packages/scan/src/js/screens/overview/bulk-fix-modal.tsx b/projects/packages/scan/src/js/screens/overview/bulk-fix-modal.tsx new file mode 100644 index 000000000000..f89c3358e6e0 --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/bulk-fix-modal.tsx @@ -0,0 +1,243 @@ +import { type Threat } from '@automattic/jetpack-scan'; +import { useDispatch } from '@wordpress/data'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { Button, Dialog, Notice, Stack, Text } from '@wordpress/ui'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { isFixComplete, useFixThreatsStatusQuery } from '../../data/use-fix-threats-status'; +import { useFixThreatsMutation } from '../../data/use-threat-mutations'; +import { useTrackEvent } from '../../data/use-track-event'; +import type { FC } from 'react'; + +type ModalStep = 'confirm' | 'progress' | 'done'; + +interface BulkFixModalProps { + threats: Threat[]; + onClose: () => void; +} + +const fixableThreatsOf = ( threats: Threat[] ): Threat[] => + threats.filter( threat => !! threat.fixable ); + +/** + * Bulk auto-fix modal — confirms the threats to fix, kicks + * `useFixThreatsMutation`, then polls `useFixThreatsStatusQuery` every + * 2 s until every threat reaches a terminal state. Mirrors the spirit + * of Calypso's `bulk-fix-threats-modal` (issue #48456 phase 4): list → + * confirm → progress → done summary. + * + * Uses `Dialog` from `@wordpress/ui` per the CIAB component-priority + * guide (`@wordpress/ui` > `@automattic/design-system` > `@wordpress/components`). + * + * @param root0 - Component props. + * @param root0.threats - Threats to attempt auto-fix on. Non-fixable entries are filtered before submitting. + * @param root0.onClose - Close handler invoked when the modal should dismiss. + * @return The modal element, or `null` when there's nothing fixable to act on. + */ +const BulkFixModal: FC< BulkFixModalProps > = ( { threats, onClose } ) => { + const fixable = useMemo( () => fixableThreatsOf( threats ), [ threats ] ); + const fixableIds = useMemo( () => fixable.map( threat => String( threat.id ) ), [ fixable ] ); + + const fixMutation = useFixThreatsMutation(); + const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + const trackEvent = useTrackEvent(); + + const [ step, setStep ] = useState< ModalStep >( 'confirm' ); + const [ pollingIds, setPollingIds ] = useState< string[] | null >( null ); + + const statusQuery = useFixThreatsStatusQuery( pollingIds ); + const polling = statusQuery.data; + const isComplete = isFixComplete( polling ); + + const onConfirm = useCallback( async () => { + if ( fixable.length === 0 ) { + return; + } + trackEvent( 'jetpack_scan_bulk_fix_threats_modal_click', { threat_count: fixable.length } ); + setStep( 'progress' ); + try { + await fixMutation.mutateAsync( fixableIds ); + setPollingIds( fixableIds ); + } catch ( error ) { + trackEvent( 'jetpack_scan_bulk_fix_threats_modal_failed', { threat_count: fixable.length } ); + onClose(); + createErrorNotice( + error instanceof Error + ? error.message + : __( 'Auto-fix failed. Please try again.', 'jetpack-scan-page' ), + { type: 'snackbar' } + ); + } + }, [ fixable.length, fixableIds, fixMutation, createErrorNotice, trackEvent, onClose ] ); + + useEffect( () => { + if ( step !== 'progress' ) { + return; + } + if ( statusQuery.isError ) { + trackEvent( 'jetpack_scan_bulk_fix_threats_modal_failed', { + threat_count: pollingIds?.length ?? 0, + } ); + onClose(); + createErrorNotice( + statusQuery.error instanceof Error + ? statusQuery.error.message + : __( "Couldn't check fix status. Please refresh and try again.", 'jetpack-scan-page' ), + { type: 'snackbar' } + ); + return; + } + if ( ! isComplete ) { + return; + } + setStep( 'done' ); + const fixedCount = Object.values( polling?.threats ?? {} ).filter( + entry => entry.status === 'fixed' + ).length; + const totalCount = pollingIds?.length ?? 0; + const failedCount = totalCount - fixedCount; + trackEvent( 'jetpack_scan_bulk_fix_threats_modal_success', { + threat_count: totalCount, + fixed_count: fixedCount, + failed_count: failedCount, + } ); + createSuccessNotice( + sprintf( + /* translators: %1$d is the number of threats fixed; %2$d is the number that couldn't be fixed. */ + _n( + 'Auto-fix finished: %1$d fixed, %2$d not fixed.', + 'Auto-fix finished: %1$d fixed, %2$d not fixed.', + pollingIds?.length ?? 0, + 'jetpack-scan-page' + ), + fixedCount, + failedCount + ), + { type: 'snackbar' } + ); + }, [ + step, + isComplete, + statusQuery.isError, + statusQuery.error, + polling, + pollingIds, + createSuccessNotice, + createErrorNotice, + trackEvent, + onClose, + ] ); + + const title = useMemo( () => { + if ( step === 'progress' ) { + return __( 'Fixing threats…', 'jetpack-scan-page' ); + } + if ( step === 'done' ) { + return __( 'Auto-fix complete', 'jetpack-scan-page' ); + } + return __( 'Auto-fix threats', 'jetpack-scan-page' ); + }, [ step ] ); + + const renderConfirm = () => ( + + + { sprintf( + /* translators: %d is the number of threats Jetpack Scan can auto-fix. */ + _n( + 'Jetpack Scan can auto-fix %d threat. Continue?', + 'Jetpack Scan can auto-fix %d threats. Continue?', + fixable.length, + 'jetpack-scan-page' + ), + fixable.length + ) } + + { fixable.length < threats.length && ( + + + { __( 'Threats that cannot be auto-fixed will be skipped.', 'jetpack-scan-page' ) } + + + ) } +
    + { fixable.map( threat => ( +
  • { threat.title || threat.signature || threat.id }
  • + ) ) } +
+ + + + +
+ ); + + const renderProgress = () => ( + + + { __( + 'Hang tight — Jetpack is applying the fixes. This usually takes a few moments.', + 'jetpack-scan-page' + ) } + + + ); + + const renderDone = () => { + const entries = Object.entries( polling?.threats ?? {} ); + const fixedCount = entries.filter( ( [ , entry ] ) => entry.status === 'fixed' ).length; + const totalCount = entries.length; + + return ( + + + { sprintf( + /* translators: %1$d is the number of threats fixed; %2$d is the total threats. */ + __( '%1$d of %2$d threats fixed.', 'jetpack-scan-page' ), + fixedCount, + totalCount + ) } + + + + + + ); + }; + + // `Dialog.Root`'s `onOpenChange` fires for both successful close and + // outside-dismiss attempts. While the fixer is mid-poll we don't want + // to allow the user to close the modal and walk away from the + // in-flight request, so we only forward the close to the parent when + // the step is not `progress` — mirrors the previous `Modal`'s + // `shouldCloseOnEsc={ step !== 'progress' }`. + const handleOpenChange = useCallback( + ( open: boolean ) => { + if ( ! open && step !== 'progress' ) { + onClose(); + } + }, + [ step, onClose ] + ); + + return ( + + + + { title } + { step !== 'progress' && } + + { step === 'confirm' && renderConfirm() } + { step === 'progress' && renderProgress() } + { step === 'done' && renderDone() } + + + ); +}; + +export default BulkFixModal; diff --git a/projects/packages/scan/src/js/screens/overview/empty-state.tsx b/projects/packages/scan/src/js/screens/overview/empty-state.tsx new file mode 100644 index 000000000000..e36736f7632a --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/empty-state.tsx @@ -0,0 +1,32 @@ +import { EmptyState as UIEmptyState } from '@wordpress/ui'; +import type { FC, ReactNode } from 'react'; + +interface EmptyStateProps { + heading: string; + body?: string | ReactNode; + actions?: ReactNode; +} + +/** + * Centered DataViews empty state built on `@wordpress/ui`'s `EmptyState` + * primitive so the heading renders as a real `

` and the body as a + * `

` (correct semantics for screen readers + page outline). + * + * Forwarded to the underlying `DataViews` via the `empty` prop on + * `ThreatsDataViews`. + * + * @param root0 - Component props. + * @param root0.heading - Title line (e.g. "No active threats detected"). + * @param root0.body - Body copy. + * @param root0.actions - Optional CTA slot. + * @return The empty state node. + */ +const EmptyState: FC< EmptyStateProps > = ( { heading, body, actions } ) => ( + + { heading } + { body && { body } } + { actions && { actions } } + +); + +export default EmptyState; diff --git a/projects/packages/scan/src/js/screens/overview/fix-threat-modal.tsx b/projects/packages/scan/src/js/screens/overview/fix-threat-modal.tsx new file mode 100644 index 000000000000..05952022ae2c --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/fix-threat-modal.tsx @@ -0,0 +1,124 @@ +import { ThreatSeverityBadge, type Threat } from '@automattic/jetpack-scan'; +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { Button, Stack, Text } from '@wordpress/ui'; +import { useCallback, useEffect, useState } from 'react'; +import { isFixComplete, useFixThreatsStatusQuery } from '../../data/use-fix-threats-status'; +import { useFixThreatsMutation } from '../../data/use-threat-mutations'; +import { useTrackEvent } from '../../data/use-track-event'; +import type { RenderModalProps } from '@wordpress/dataviews'; + +/** + * Single-threat fix-confirmation modal — wired into `ThreatsDataViews`' + * row "Auto-fix" action via the `RenderFixModal` prop. DataViews wraps + * this content in its own `Modal`; this component renders only the body + * + action buttons. Mirrors Calypso's `fix-threat-modal.tsx`: confirm → + * kick the fix mutation → poll status → close with snackbar on terminal + * state. + * + * @param props - DataViews-supplied modal props. + * @param props.items - Selected threats. Single-threat row action, so always `[ threat ]`. + * @param props.closeModal - Close-modal callback supplied by DataViews. + * @return The modal body element. + */ +export function FixThreatModal( { items, closeModal }: RenderModalProps< Threat > ): JSX.Element { + const threat = items[ 0 ]; + const trackEvent = useTrackEvent(); + const fixMutation = useFixThreatsMutation(); + const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + + const [ pollingId, setPollingId ] = useState< string | null >( null ); + const statusQuery = useFixThreatsStatusQuery( pollingId ? [ pollingId ] : null ); + const isFixing = + fixMutation.isPending || ( pollingId !== null && ! isFixComplete( statusQuery.data ) ); + + useEffect( () => { + trackEvent( 'jetpack_scan_fix_threat_modal_open' ); + }, [ trackEvent ] ); + + useEffect( () => { + if ( ! pollingId ) { + return; + } + if ( statusQuery.isError ) { + closeModal?.(); + trackEvent( 'jetpack_scan_fix_threat_failed' ); + createErrorNotice( + statusQuery.error instanceof Error + ? statusQuery.error.message + : __( "Couldn't check fix status. Please refresh and try again.", 'jetpack-scan-page' ), + { type: 'snackbar' } + ); + return; + } + if ( ! isFixComplete( statusQuery.data ) ) { + return; + } + const entry = statusQuery.data?.threats?.[ pollingId ]; + const success = entry?.status === 'fixed'; + closeModal?.(); + if ( success ) { + trackEvent( 'jetpack_scan_fix_threat_success' ); + createSuccessNotice( __( 'Threat fixed.', 'jetpack-scan-page' ), { type: 'snackbar' } ); + } else { + trackEvent( 'jetpack_scan_fix_threat_failed' ); + createErrorNotice( __( 'Failed to fix threat. Please try again.', 'jetpack-scan-page' ), { + type: 'snackbar', + } ); + } + }, [ + pollingId, + statusQuery.data, + statusQuery.isError, + statusQuery.error, + closeModal, + trackEvent, + createSuccessNotice, + createErrorNotice, + ] ); + + const handleFix = useCallback( async () => { + trackEvent( 'jetpack_scan_fix_threat_click' ); + try { + await fixMutation.mutateAsync( [ threat.id ] ); + setPollingId( String( threat.id ) ); + } catch ( error ) { + closeModal?.(); + trackEvent( 'jetpack_scan_fix_threat_failed' ); + createErrorNotice( + error instanceof Error + ? error.message + : __( 'Failed to fix threat. Please try again.', 'jetpack-scan-page' ), + { type: 'snackbar' } + ); + } + }, [ threat.id, fixMutation, closeModal, trackEvent, createErrorNotice ] ); + + return ( + + + { __( 'Jetpack will be fixing the following threat:', 'jetpack-scan-page' ) } + + + + { threat.title } + { !! threat.severity && } + + { threat.description && { threat.description } } + + + + + + + ); +} + +export default FixThreatModal; diff --git a/projects/packages/scan/src/js/screens/overview/ignore-threat-modal.tsx b/projects/packages/scan/src/js/screens/overview/ignore-threat-modal.tsx new file mode 100644 index 000000000000..7285e4d8983b --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/ignore-threat-modal.tsx @@ -0,0 +1,102 @@ +import { ThreatSeverityBadge, type Threat } from '@automattic/jetpack-scan'; +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { Button, Notice, Stack, Text } from '@wordpress/ui'; +import { useCallback, useEffect } from 'react'; +import { useIgnoreThreatMutation } from '../../data/use-threat-mutations'; +import { useTrackEvent } from '../../data/use-track-event'; +import type { RenderModalProps } from '@wordpress/dataviews'; + +/** + * Single-threat ignore-confirmation modal — wired into `ThreatsDataViews`' + * row "Ignore" action via the `RenderIgnoreModal` prop. DataViews wraps + * this content in its own `Modal`; this component renders only the + * body + action buttons. Mirrors Calypso's `ignore-threat-modal.tsx`: + * warn the user that ignoring leaves a potentially malicious file in + * place, then fire the ignore mutation. + * + * @param props - DataViews-supplied modal props. + * @param props.items - Selected threats. Single-threat row action, so always `[ threat ]`. + * @param props.closeModal - Close-modal callback supplied by DataViews. + * @return The modal body element. + */ +export function IgnoreThreatModal( { + items, + closeModal, +}: RenderModalProps< Threat > ): JSX.Element { + const threat = items[ 0 ]; + const trackEvent = useTrackEvent(); + const ignoreMutation = useIgnoreThreatMutation(); + const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + + useEffect( () => { + trackEvent( 'jetpack_scan_ignore_threat_modal_open' ); + }, [ trackEvent ] ); + + const handleIgnore = useCallback( () => { + trackEvent( 'jetpack_scan_ignore_threat_click' ); + ignoreMutation.mutate( threat.id, { + onSuccess: () => { + closeModal?.(); + trackEvent( 'jetpack_scan_ignore_threat_success' ); + createSuccessNotice( __( 'Threat ignored.', 'jetpack-scan-page' ), { type: 'snackbar' } ); + }, + onError: error => { + closeModal?.(); + trackEvent( 'jetpack_scan_ignore_threat_failed' ); + createErrorNotice( + error instanceof Error + ? error.message + : __( 'Failed to ignore threat. Please try again.', 'jetpack-scan-page' ), + { type: 'snackbar' } + ); + }, + } ); + }, [ + threat.id, + ignoreMutation, + closeModal, + trackEvent, + createSuccessNotice, + createErrorNotice, + ] ); + + return ( + + + { __( 'Jetpack will be ignoring the following threat:', 'jetpack-scan-page' ) } + + + + { threat.title } + { !! threat.severity && } + + { threat.description && { threat.description } } + + + + { __( + 'By ignoring this threat you confirm that you have reviewed the detected code and assume the risks of keeping a potentially malicious file on your site.', + 'jetpack-scan-page' + ) } + + + + + + + + ); +} + +export default IgnoreThreatModal; diff --git a/projects/packages/scan/src/js/screens/overview/scan-history.tsx b/projects/packages/scan/src/js/screens/overview/scan-history.tsx new file mode 100644 index 000000000000..ed66280699db --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/scan-history.tsx @@ -0,0 +1,73 @@ +import { ThreatsDataViews } from '@automattic/jetpack-scan'; +import { useQuery } from '@tanstack/react-query'; +/* eslint-disable @wordpress/no-unsafe-wp-apis */ +import { Spinner, __experimentalVStack as VStack } from '@wordpress/components'; +/* eslint-enable @wordpress/no-unsafe-wp-apis */ +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { siteScanHistoryQuery } from '../../data/query-options'; +import { useTrackEvent } from '../../data/use-track-event'; +import EmptyState from './empty-state'; +import { UnignoreThreatModal } from './unignore-threat-modal'; +import { ViewDetailsModal } from './view-details-modal'; +import type { FC } from 'react'; + +/** + * Scan history panel — lists past threats (fixed + ignored) from the + * `/site/scan/history` bridge. Reuses `ThreatsDataViews` from + * `@automattic/jetpack-scan` (the js-package) and lets users search / + * filter / sort the same way Calypso's `scan-history/` does. + * + * Empty + error states are handled inside the DataViews shell — passing + * `data={ [] }` renders the table chrome with DataViews' built-in + * "no items" body so reviewers always see column headers + filter + * controls. + * + * @return The history panel. + */ +const ScanHistory: FC = () => { + const { data, isLoading, error } = useQuery( siteScanHistoryQuery() ); + const trackEvent = useTrackEvent(); + + const onTrackDataViewsEvent = useCallback( + ( event: string, properties?: Record< string, unknown > ) => + trackEvent( `jetpack_scan_${ event }`, properties ), + [ trackEvent ] + ); + + if ( isLoading ) { + return ( + + + + ); + } + + if ( error ) { + return ( +

{ __( 'Unable to load scan history. Please try again later.', 'jetpack-scan-page' ) }

+ ); + } + + return ( + + } + /> + ); +}; + +export default ScanHistory; diff --git a/projects/packages/scan/src/js/screens/overview/scan-now-button.tsx b/projects/packages/scan/src/js/screens/overview/scan-now-button.tsx new file mode 100644 index 000000000000..4edc52de3e3d --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/scan-now-button.tsx @@ -0,0 +1,58 @@ +import { Button } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useCallback } from 'react'; +import { useEnqueueScanMutation } from '../../data/use-threat-mutations'; +import { useTrackEvent } from '../../data/use-track-event'; +import type { FC } from 'react'; + +interface ScanNowButtonProps { + disabled?: boolean; + variant?: 'primary' | 'secondary' | 'tertiary'; +} + +/** + * Triggers a fresh scan run via `useEnqueueScanMutation`. Surfaces a + * snackbar on settle so the user gets confirmation even when the scan + * itself is asynchronous on WPCOM's side. + * + * @param root0 - Component props. + * @param root0.disabled - Whether the button is disabled (e.g. while a scan is already running). + * @param root0.variant - Optional Button `variant` override; defaults to `secondary`. + * @return The button element. + */ +const ScanNowButton: FC< ScanNowButtonProps > = ( { disabled = false, variant = 'secondary' } ) => { + const enqueueMutation = useEnqueueScanMutation(); + const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + const trackEvent = useTrackEvent(); + + const onClick = useCallback( async () => { + trackEvent( 'jetpack_scan_scan_now' ); + try { + await enqueueMutation.mutateAsync(); + createSuccessNotice( __( 'Scan started.', 'jetpack-scan-page' ), { type: 'snackbar' } ); + } catch ( error ) { + createErrorNotice( + error instanceof Error + ? error.message + : __( 'Could not start the scan. Please try again.', 'jetpack-scan-page' ), + { type: 'snackbar' } + ); + } + }, [ enqueueMutation, createSuccessNotice, createErrorNotice, trackEvent ] ); + + return ( + + ); +}; + +export default ScanNowButton; diff --git a/projects/packages/scan/src/js/screens/overview/scan-status.tsx b/projects/packages/scan/src/js/screens/overview/scan-status.tsx new file mode 100644 index 000000000000..46f223a76a0e --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/scan-status.tsx @@ -0,0 +1,86 @@ +/* eslint-disable @wordpress/no-unsafe-wp-apis */ +import { + ProgressBar, + Spinner, + __experimentalText as Text, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import type { SiteScanResponse } from '../../data/types'; +import type { FC } from 'react'; + +interface ScanStatusProps { + state: SiteScanResponse[ 'state' ]; + progress?: number; +} + +/** + * In-progress scan UI shown on the Active threats tab when the scanner + * is enqueued or running. Renders a spinner + a percentage indicator + * tied to `current.progress` from `siteScanQuery`. Mirrors the spirit + * of Calypso's `scan-status.tsx` while staying small — copy will get + * iterated on with design in Phase 6. + * + * @param root0 - Component props. + * @param root0.state - Top-level scan state from `/site/scan`. + * @param root0.progress - Optional 0-100 progress value from `current.progress`. + * @return The scan-status panel. + */ +const ScanStatus: FC< ScanStatusProps > = ( { state, progress } ) => { + const heading = + state === 'enqueued' + ? __( 'Scan queued…', 'jetpack-scan-page' ) + : __( 'Scanning your site…', 'jetpack-scan-page' ); + + const body = + state === 'enqueued' + ? __( + 'Your scan will start shortly. You can leave this page open or come back later.', + 'jetpack-scan-page' + ) + : __( + 'Jetpack is reviewing your site for vulnerabilities and suspicious files. This usually takes a few minutes.', + 'jetpack-scan-page' + ); + + const showProgress = state === 'running' && typeof progress === 'number'; + + return ( + + + + { heading } + + + { body } + + { showProgress && ( +
+ + + { sprintf( + /* translators: %d is the current scan progress as a percentage. */ + __( '%d%% complete', 'jetpack-scan-page' ), + Math.round( progress ?? 0 ) + ) } + +
+ ) } +
+ ); +}; + +export default ScanStatus; diff --git a/projects/packages/scan/src/js/screens/overview/unignore-threat-modal.tsx b/projects/packages/scan/src/js/screens/overview/unignore-threat-modal.tsx new file mode 100644 index 000000000000..138cc89e0019 --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/unignore-threat-modal.tsx @@ -0,0 +1,104 @@ +import { ThreatSeverityBadge, type Threat } from '@automattic/jetpack-scan'; +import { useDispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { Button, Notice, Stack, Text } from '@wordpress/ui'; +import { useCallback, useEffect } from 'react'; +import { useUnignoreThreatMutation } from '../../data/use-threat-mutations'; +import { useTrackEvent } from '../../data/use-track-event'; +import type { RenderModalProps } from '@wordpress/dataviews'; + +/** + * Single-threat unignore-confirmation modal — wired into + * `ThreatsDataViews`' row "Unignore" action via the `RenderUnignoreModal` + * prop. DataViews wraps this content in its own `Modal`; this component + * renders only the body + action buttons. Mirrors Calypso's + * `unignore-threat-modal.tsx`: warn the user that the threat will become + * active again, then fire the unignore mutation. + * + * @param props - DataViews-supplied modal props. + * @param props.items - Selected threats. Single-threat row action, so always `[ threat ]`. + * @param props.closeModal - Close-modal callback supplied by DataViews. + * @return The modal body element. + */ +export function UnignoreThreatModal( { + items, + closeModal, +}: RenderModalProps< Threat > ): JSX.Element { + const threat = items[ 0 ]; + const trackEvent = useTrackEvent(); + const unignoreMutation = useUnignoreThreatMutation(); + const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + + useEffect( () => { + trackEvent( 'jetpack_scan_unignore_threat_modal_open' ); + }, [ trackEvent ] ); + + const handleUnignore = useCallback( () => { + trackEvent( 'jetpack_scan_unignore_threat_click' ); + unignoreMutation.mutate( threat.id, { + onSuccess: () => { + closeModal?.(); + trackEvent( 'jetpack_scan_unignore_threat_success' ); + createSuccessNotice( __( 'Threat unignored.', 'jetpack-scan-page' ), { + type: 'snackbar', + } ); + }, + onError: error => { + closeModal?.(); + trackEvent( 'jetpack_scan_unignore_threat_failed' ); + createErrorNotice( + error instanceof Error + ? error.message + : __( 'Failed to unignore threat. Please try again.', 'jetpack-scan-page' ), + { type: 'snackbar' } + ); + }, + } ); + }, [ + threat.id, + unignoreMutation, + closeModal, + trackEvent, + createSuccessNotice, + createErrorNotice, + ] ); + + return ( + + + { __( 'Jetpack will be unignoring the following threat:', 'jetpack-scan-page' ) } + + + + { threat.title } + { !! threat.severity && } + + { threat.description && { threat.description } } + + + + { __( + 'By unignoring this threat you confirm that you have reviewed the detected code and assume the risks of treating a potentially malicious file as an active threat again.', + 'jetpack-scan-page' + ) } + + + + + + + + ); +} + +export default UnignoreThreatModal; diff --git a/projects/packages/scan/src/js/screens/overview/use-threat-actions.ts b/projects/packages/scan/src/js/screens/overview/use-threat-actions.ts new file mode 100644 index 000000000000..8e0670ac87f9 --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/use-threat-actions.ts @@ -0,0 +1,62 @@ +import { type Threat } from '@automattic/jetpack-scan'; +import { useDispatch } from '@wordpress/data'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useCallback } from 'react'; +import { useFixThreatsMutation } from '../../data/use-threat-mutations'; + +interface ThreatActionHandlers { + onFixThreats: ( threats: Threat[] ) => Promise< void >; +} + +/** + * Bundles the inline auto-fix mutation into a stable callback compatible + * with the in-table `ThreatFixerButton`'s `onClick`. Row-action fix / + * ignore / unignore flows route through dedicated `RenderModal` + * components on `ThreatsDataViews` (see `fix-threat-modal.tsx` / + * `ignore-threat-modal.tsx` / `unignore-threat-modal.tsx`); this hook + * keeps the in-cell "Auto-fix" button working with a fire-and-forget + * snackbar, since DataViews offers no programmatic way to trigger a row + * action's modal from a custom field renderer. + * + * @return Stable action callback ready to forward to `ThreatsDataViews`. + */ +export function useThreatActions(): ThreatActionHandlers { + const fixMutation = useFixThreatsMutation(); + const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + + const onFixThreats = useCallback( + async ( threats: Threat[] ) => { + if ( ! threats.length ) { + return; + } + const ids = threats.map( threat => threat.id ); + try { + await fixMutation.mutateAsync( ids ); + createSuccessNotice( + sprintf( + /* translators: %d is the number of threats being auto-fixed. */ + _n( + 'Auto-fix started for %d threat.', + 'Auto-fix started for %d threats.', + ids.length, + 'jetpack-scan-page' + ), + ids.length + ), + { type: 'snackbar' } + ); + } catch ( error ) { + createErrorNotice( + error instanceof Error + ? error.message + : __( 'Auto-fix failed. Please try again.', 'jetpack-scan-page' ), + { type: 'snackbar' } + ); + } + }, + [ fixMutation, createSuccessNotice, createErrorNotice ] + ); + + return { onFixThreats }; +} diff --git a/projects/packages/scan/src/js/screens/overview/view-details-modal.tsx b/projects/packages/scan/src/js/screens/overview/view-details-modal.tsx new file mode 100644 index 000000000000..7f766e73292e --- /dev/null +++ b/projects/packages/scan/src/js/screens/overview/view-details-modal.tsx @@ -0,0 +1,150 @@ +import { ThreatSeverityBadge, type Threat } from '@automattic/jetpack-scan'; +import { dateI18n } from '@wordpress/date'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import { useEffect } from 'react'; +import { useTrackEvent } from '../../data/use-track-event'; +import type { RenderModalProps } from '@wordpress/dataviews'; + +const codeBlockStyle = { + backgroundColor: 'var(--wpds-color-bg-surface-neutral-weak, #f6f7f7)', + border: '1px solid var(--wpds-color-stroke-surface-neutral, #e0e0e0)', + borderRadius: 4, + fontFamily: 'Menlo, Consolas, monaco, "Courier New", Courier, monospace', + fontSize: 12, + margin: 0, + overflowX: 'auto' as const, + padding: 12, + whiteSpace: 'pre' as const, +}; + +const labelStyle = { + color: 'var(--wpds-color-fg-content-neutral-weak, #50575e)', +}; + +/** + * Read-only view-details modal — wired into `ThreatsDataViews`' "View + * details" row action via the `RenderViewModal` prop. Mirrors the spirit + * of Calypso's `view-details-modal.tsx` (Phase 4): full title + severity + * + signature + description + filename / file-context / database-row + * payload, without the action buttons. Drilling in is always available + * regardless of the threat's status. + * + * @param props - DataViews-supplied modal props. + * @param props.items - Selected threats. Single-threat row action, so always `[ threat ]`. + * @return The modal body element. + */ +export function ViewDetailsModal( { items }: RenderModalProps< Threat > ): JSX.Element { + const threat = items[ 0 ]; + const trackEvent = useTrackEvent(); + + useEffect( () => { + trackEvent( 'jetpack_scan_view_details_modal_open' ); + }, [ trackEvent ] ); + + let fixDescription: string; + if ( ! threat.fixable ) { + fixDescription = __( + 'Jetpack Scan cannot automatically fix this threat. Update WordPress, the affected theme or plugin, or remove the offending code manually.', + 'jetpack-scan-page' + ); + } else if ( threat.fixable.fixer === 'delete' ) { + fixDescription = __( + 'Jetpack Scan will delete the affected file or directory. The site’s look-and-feel or features may be affected — verify your most recent backup before proceeding.', + 'jetpack-scan-page' + ); + } else { + fixDescription = __( + 'Jetpack Scan will replace the affected file with a clean version. The site’s look-and-feel or features may be affected — verify your most recent backup before proceeding.', + 'jetpack-scan-page' + ); + } + + const fileContext = + threat.context && + Object.entries( threat.context ) + .filter( ( [ key ] ) => key !== 'marks' ) + .map( ( [ line, code ] ) => `${ line }: ${ String( code ) }` ) + .join( '\n' ); + + return ( + + + + + { threat.title } + + { !! threat.severity && } + + { threat.signature && ( + + { threat.signature } + + ) } + + + { threat.description && { threat.description } } + + { threat.firstDetected && ( + + + { __( 'First detected', 'jetpack-scan-page' ) } + + { dateI18n( 'F j, Y', threat.firstDetected, false ) } + + ) } + + { threat.fixedOn && ( + + + { __( 'Fixed on', 'jetpack-scan-page' ) } + + { dateI18n( 'F j, Y', threat.fixedOn, false ) } + + ) } + + { threat.extension && ( + + + { threat.extension.type === 'themes' + ? __( 'Theme', 'jetpack-scan-page' ) + : __( 'Plugin', 'jetpack-scan-page' ) } + + + { threat.extension.name } { threat.extension.version } + { threat.fixedIn && ` → ${ threat.fixedIn }` } + + + ) } + + { threat.filename && ( + + + { __( 'File', 'jetpack-scan-page' ) } + +
{ threat.filename }
+
+ ) } + + { fileContext && ( + + + { __( 'Context', 'jetpack-scan-page' ) } + +
{ fileContext }
+
+ ) } + + + + { threat.status === 'fixed' + ? __( 'How was it fixed?', 'jetpack-scan-page' ) + : __( 'How will it be fixed?', 'jetpack-scan-page' ) } + + { fixDescription } + +
+ ); +} + +export default ViewDetailsModal; diff --git a/projects/packages/scan/tests/.phpcs.dir.xml b/projects/packages/scan/tests/.phpcs.dir.xml new file mode 100644 index 000000000000..6c65637bc511 --- /dev/null +++ b/projects/packages/scan/tests/.phpcs.dir.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/projects/packages/scan/tests/jest.config.js b/projects/packages/scan/tests/jest.config.js new file mode 100644 index 000000000000..fb86d54f3bfc --- /dev/null +++ b/projects/packages/scan/tests/jest.config.js @@ -0,0 +1,7 @@ +import path from 'path'; +import baseConfig from 'jetpack-js-tools/jest/config.base.js'; + +export default { + ...baseConfig, + rootDir: path.join( import.meta.dirname, '..' ), +}; diff --git a/projects/packages/scan/tests/php/Jetpack_Scan_Bridges_Test.php b/projects/packages/scan/tests/php/Jetpack_Scan_Bridges_Test.php new file mode 100644 index 000000000000..2b1f681d1193 --- /dev/null +++ b/projects/packages/scan/tests/php/Jetpack_Scan_Bridges_Test.php @@ -0,0 +1,191 @@ +server = $wp_rest_server; + + $this->admin_id = wp_insert_user( + array( + 'user_login' => 'scan_admin', + 'user_pass' => 'pass', + 'role' => 'administrator', + ) + ); + $this->subscriber_id = wp_insert_user( + array( + 'user_login' => 'scan_subscriber', + 'user_pass' => 'pass', + 'role' => 'subscriber', + ) + ); + + wp_set_current_user( 0 ); + + add_action( 'rest_api_init', array( REST_Controller::class, 'register_rest_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset shared state between tests so a stuck $_GET / current-user from + * one case can't leak into the next. + */ + public function tearDown(): void { + parent::tearDown(); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + } + + /** + * Every Scan route should be registered under `jetpack/v4` after the + * `rest_api_init` action fires. + */ + public function test_routes_are_registered() { + $routes = $this->server->get_routes( 'jetpack/v4' ); + + $this->assertArrayHasKey( '/jetpack/v4/site/scan', $routes ); + $this->assertArrayHasKey( '/jetpack/v4/site/scan/history', $routes ); + $this->assertArrayHasKey( '/jetpack/v4/site/scan/counts', $routes ); + $this->assertArrayHasKey( '/jetpack/v4/site/scan/enqueue', $routes ); + $this->assertArrayHasKey( + '/jetpack/v4/site/scan/threat/(?P[\w\-]+)/ignore', + $routes + ); + $this->assertArrayHasKey( + '/jetpack/v4/site/scan/threat/(?P[\w\-]+)/unignore', + $routes + ); + $this->assertArrayHasKey( '/jetpack/v4/site/scan/threats/fix', $routes ); + $this->assertArrayHasKey( '/jetpack/v4/site/scan/threats/fix-status', $routes ); + } + + /** + * Anonymous requests against any Scan route hit the permission callback + * and should be rejected with a 401, never reaching the WPCOM proxy. + */ + public function test_anonymous_request_is_rejected() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/site/scan' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Authenticated non-admin (subscriber) shouldn't sneak past the gate + * even though they are signed in. + */ + public function test_subscriber_request_is_rejected() { + wp_set_current_user( $this->subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/site/scan/history' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Admin gets past the permission callback. The downstream WPCOM + * proxy will fail (no blog id is set in the test environment), but + * that's the next layer — we only need to confirm the gate is + * permissive for admins. + * + * @param string $method HTTP method. + * @param string $path Local REST path. + * @dataProvider provide_admin_routes + */ + #[DataProvider( 'provide_admin_routes' )] + public function test_admin_request_passes_permission_check( $method, $path ) { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( $method, $path ); + $response = $this->server->dispatch( $request ); + + // Anything other than 401 means the permission callback let us through. + // The handler may still 400 (no blog id) or 500 (WPCOM proxy error) + // — those are valid downstream outcomes. Failing on 401 catches a + // regression in the gate without coupling the test to WPCOM I/O. + $this->assertNotSame( + 401, + $response->get_status(), + "Admin should pass the permission callback for $method $path" + ); + } + + /** + * Routes the admin-passes-permission check exercises. + * + * @return array + */ + public static function provide_admin_routes() { + return array( + 'GET /scan' => array( 'GET', '/jetpack/v4/site/scan' ), + 'GET /scan/history' => array( 'GET', '/jetpack/v4/site/scan/history' ), + 'GET /scan/counts' => array( 'GET', '/jetpack/v4/site/scan/counts' ), + 'POST /scan/enqueue' => array( 'POST', '/jetpack/v4/site/scan/enqueue' ), + 'POST /threat/abc/ignore' => array( 'POST', '/jetpack/v4/site/scan/threat/abc/ignore' ), + 'POST /threat/abc/unignore' => array( 'POST', '/jetpack/v4/site/scan/threat/abc/unignore' ), + ); + } +} diff --git a/projects/packages/scan/tests/php/bootstrap.php b/projects/packages/scan/tests/php/bootstrap.php new file mode 100644 index 000000000000..b74c616079aa --- /dev/null +++ b/projects/packages/scan/tests/php/bootstrap.php @@ -0,0 +1,16 @@ +=7.2" }, "require-dev": { - "automattic/jetpack-changelogger": "@dev" + "automattic/jetpack-changelogger": "@dev", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" }, "suggest": { "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." @@ -3078,6 +3082,12 @@ "build-production": [ "pnpm run build-production" ], + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ], "watch": [ "Composer\\Config::disableProcessTimeout", "pnpm run watch"