Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Threats DataViews: Add a layouts option to limit which layouts are offered.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Threats list: Show a default icon for threats of an unknown type instead of crashing the list layout.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import {
import styles from './styles.module.scss';
import ThreatsStatusToggleGroupControl from './threats-status-toggle-group-control.tsx';

export type ThreatsDataViewsLayout = 'table' | 'list';

/**
* DataViews component for displaying security threats.
*
Expand Down Expand Up @@ -79,6 +81,7 @@ import ThreatsStatusToggleGroupControl from './threats-status-toggle-group-contr
* @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 {Array} [props.layouts] - DataViews layouts to offer, first one is the default. Defaults to `[ 'table', 'list' ]`.
* @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.
Expand All @@ -101,6 +104,7 @@ export default function ThreatsDataViews( {
showStatusFilter = true,
onTrackEvent,
persistKey,
layouts = [ 'table', 'list' ],
}: {
data: Threat[];
filters?: Filter[];
Expand All @@ -119,6 +123,7 @@ export default function ThreatsDataViews( {
showStatusFilter?: boolean;
onTrackEvent?: ( event: string, properties?: Record< string, unknown > ) => void;
persistKey?: string;
layouts?: ThreatsDataViewsLayout[];
} ): JSX.Element {
const baseView = {
sort: {
Expand All @@ -138,7 +143,7 @@ export default function ThreatsDataViews( {
*
* @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#defaultlayouts-record-string-view
*/
const defaultLayouts = {
const allLayouts = {
table: {
...baseView,
fields: [ THREAT_FIELD_SEVERITY, THREAT_FIELD_TYPE, THREAT_FIELD_AUTO_FIX ],
Expand All @@ -159,6 +164,9 @@ export default function ThreatsDataViews( {
showMedia: true,
},
};
const defaultLayouts = Object.fromEntries(
layouts.map( layout => [ layout, allLayouts[ layout ] ] )
) as Partial< typeof allLayouts >;

/**
* DataView view object - configures how the dataset is visible to the user.
Expand All @@ -171,14 +179,16 @@ export default function ThreatsDataViews( {
* @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#view-object
*/
const [ view, setView ] = useState< View >( () => {
const fallback: View = { type: 'table', ...defaultLayouts.table };
const fallback = { type: layouts[ 0 ], ...allLayouts[ layouts[ 0 ] ] } as View;
if ( ! persistKey || typeof window === 'undefined' ) {
return fallback;
}
try {
const stored = window.localStorage.getItem( persistKey );
if ( stored ) {
return JSON.parse( stored ) as View;
const parsed = JSON.parse( stored ) as View;
// A view saved in a layout this instance no longer offers would render nothing.
return layouts.includes( parsed.type as ThreatsDataViewsLayout ) ? parsed : fallback;
}
} catch {
// localStorage may be disabled (privacy mode, full disk) — no-op.
Expand Down Expand Up @@ -301,7 +311,10 @@ export default function ThreatsDataViews( {
render( { item }: { item: Threat } ) {
return (
<div className={ styles.threat__media }>
<Icon icon={ THREAT_ICONS[ getThreatType( item ) ] } size={ 20 } />
<Icon
icon={ THREAT_ICONS[ getThreatType( item ) ] ?? THREAT_ICONS.default }
size={ 20 }
/>
</div>
);
},
Expand Down
4 changes: 4 additions & 0 deletions projects/packages/scan/changelog/add-jetpack-protect-module
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Allow the active threats and history lists to limit their DataViews layouts.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import { useThreatActions } from './use-threat-actions';
import { ViewDetailsModal } from './view-details-modal';
import type { FC } from 'react';

type Props = {
/** DataViews layouts to offer; defaults to both table and list. */
layouts?: ( 'table' | 'list' )[];
};

/**
* Active threats panel — lists the un-ignored, un-fixed threats from the
* most recent scan. Wraps the existing `ThreatsDataViews` component from
Expand All @@ -30,9 +35,11 @@ import type { FC } from 'react';
* "no items" body so reviewers always see column headers + filter
* controls (Phase 1+ wires up search / sort persistence on top).
*
* @param root0 - Component props.
* @param root0.layouts - DataViews layouts to offer.
* @return The active threats panel.
*/
const ActiveThreats: FC = () => {
const ActiveThreats: FC< Props > = ( { layouts } ) => {
const { data, isLoading, error } = useQuery( siteScanQuery() );
const { onFixThreats } = useThreatActions();
const setHeaderActions = useSetHeaderActions();
Expand Down Expand Up @@ -109,6 +116,7 @@ const ActiveThreats: FC = () => {
RenderViewModal={ ViewDetailsModal }
showStatusFilter={ false }
persistKey="jetpack-scan:active-threats:view"
layouts={ layouts }
onTrackEvent={ onTrackDataViewsEvent }
empty={
<EmptyState
Expand Down
10 changes: 9 additions & 1 deletion projects/packages/scan/src/js/screens/overview/scan-history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { UnignoreThreatModal } from './unignore-threat-modal';
import { ViewDetailsModal } from './view-details-modal';
import type { FC } from 'react';

type Props = {
/** DataViews layouts to offer; defaults to both table and list. */
layouts?: ( 'table' | 'list' )[];
};

/**
* Scan history panel — lists past threats (fixed + ignored) from the
* `/site/scan/history` bridge. Reuses `ThreatsDataViews` from
Expand All @@ -23,9 +28,11 @@ import type { FC } from 'react';
* "no items" body so reviewers always see column headers + filter
* controls.
*
* @param root0 - Component props.
* @param root0.layouts - DataViews layouts to offer.
* @return The history panel.
*/
const ScanHistory: FC = () => {
const ScanHistory: FC< Props > = ( { layouts } ) => {
const { data, isLoading, error } = useQuery( siteScanHistoryQuery() );
const trackEvent = useTrackEvent();

Expand Down Expand Up @@ -56,6 +63,7 @@ const ScanHistory: FC = () => {
RenderViewModal={ ViewDetailsModal }
showStatusFilter={ false }
persistKey="jetpack-scan:scan-history:view"
layouts={ layouts }
onTrackEvent={ onTrackDataViewsEvent }
empty={
<EmptyState
Expand Down
197 changes: 197 additions & 0 deletions projects/plugins/jetpack/_inc/client/protect-dashboard/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';

export type SecurityFeature = {
module: string;
title: string;
description: string;
};

// The security modules this dashboard controls, in display order.
export const SECURITY_FEATURES: SecurityFeature[] = [
{
module: 'waf',
title: __( 'Firewall', 'jetpack' ),
description: __( 'Block malicious requests before they reach your site.', 'jetpack' ),
},
{
module: 'protect',
title: __( 'Brute force protection', 'jetpack' ),
description: __( 'Stop repeated failed login attempts from the same IP.', 'jetpack' ),
},
{
module: 'account-protection',
title: __( 'Account protection', 'jetpack' ),
description: __(
'Require strong passwords and verify suspicious logins for privileged users.',
'jetpack'
),
},
{
module: 'monitor',
title: __( 'Downtime monitoring', 'jetpack' ),
description: __( 'Get an alert as soon as your site goes down.', 'jetpack' ),
},
{
module: 'sso',
title: __( 'Secure sign-on', 'jetpack' ),
description: __( 'Let users log in with their WordPress.com account and 2FA.', 'jetpack' ),
},
];

export type ModuleInfo = {
module: string;
name: string;
activated: boolean;
available?: boolean;
override?: string;
};

export type WafConfig = {
waf_supported: boolean;
jetpack_waf_automatic_rules: boolean;
jetpack_waf_ip_allow_list: string;
jetpack_waf_ip_allow_list_enabled: boolean;
jetpack_waf_ip_block_list: string;
jetpack_waf_ip_block_list_enabled: boolean;
automatic_rules_available?: boolean;
[ key: string ]: unknown;
};

export type SiteCounts = {
plugins: number;
themes: number;
wpVersion: string;
};

export type FirewallBlocks = {
current_day: number;
thirty_days: number;
all_time: number;
};

/**
* Requests the firewall has blocked, printed by PHP; null while the firewall is off.
*
* @return The block counts, or null.
*/
export const getFirewallBlocks = (): FirewallBlocks | null =>
window.JP_PROTECT_DASHBOARD?.firewallBlocks ?? null;

declare global {
interface Window {
JP_PROTECT_DASHBOARD?: { counts?: SiteCounts; firewallBlocks?: FirewallBlocks | null };
}
}

/**
* What a scan covers on this site, printed by PHP.
*
* @return Plugin and theme counts and the WordPress version.
*/
export const getSiteCounts = (): SiteCounts => ( {
plugins: Number( window.JP_PROTECT_DASHBOARD?.counts?.plugins ?? 0 ),
themes: Number( window.JP_PROTECT_DASHBOARD?.counts?.themes ?? 0 ),
wpVersion: String( window.JP_PROTECT_DASHBOARD?.counts?.wpVersion ?? '' ),
} );

export const settingsQuery = () =>
queryOptions( {
queryKey: [ 'jetpack', 'settings' ] as const,
queryFn: () => apiFetch< Record< string, unknown > >( { path: '/jetpack/v4/settings' } ),
} );

// `/data` 404s while the module is off, so callers enable this only when it's on.
export const moduleDataQuery = < T >( module: string ) =>
queryOptions( {
queryKey: [ 'jetpack', 'module-data', module ] as const,
queryFn: () => apiFetch< T >( { path: `/jetpack/v4/module/${ module }/data` } ),
retry: false,
} );

export const modulesQuery = () =>
queryOptions( {
queryKey: [ 'jetpack', 'modules' ] as const,
queryFn: () => apiFetch< Record< string, ModuleInfo > >( { path: '/jetpack/v4/module/all' } ),
} );

export const wafQuery = () =>
queryOptions( {
queryKey: [ 'jetpack', 'waf' ] as const,
queryFn: () => apiFetch< WafConfig >( { path: '/jetpack/v4/waf' } ),
} );

/**
* Activate or deactivate one Jetpack module, then refresh everything that reads module state.
*
* @return The mutation.
*/
export function useToggleModule() {
const queryClient = useQueryClient();
return useMutation( {
mutationFn: ( { module, active }: { module: string; active: boolean } ) =>
apiFetch( {
path: `/jetpack/v4/module/${ module }/active`,
method: 'POST',
data: { active },
} ),
onSuccess: () => {
queryClient.invalidateQueries( { queryKey: [ 'jetpack', 'modules' ] } );
queryClient.invalidateQueries( { queryKey: [ 'jetpack', 'waf' ] } );
queryClient.invalidateQueries( { queryKey: [ 'jetpack', 'settings' ] } );
queryClient.invalidateQueries( { queryKey: [ 'jetpack', 'module-data' ] } );
},
} );
}

/**
* Save Jetpack settings (e.g. monitor notifications, SSO options).
*
* @return The mutation.
*/
export function useUpdateSettings() {
const queryClient = useQueryClient();
return useMutation( {
mutationFn: ( data: Record< string, unknown > ) =>
apiFetch( { path: '/jetpack/v4/settings', method: 'POST', data } ),
onSuccess: () => queryClient.invalidateQueries( { queryKey: [ 'jetpack', 'settings' ] } ),
} );
}

/**
* Save part of the firewall configuration.
*
* @return The mutation.
*/
export function useUpdateWaf() {
const queryClient = useQueryClient();
return useMutation( {
mutationFn: ( data: Partial< WafConfig > ) =>
apiFetch( { path: '/jetpack/v4/waf', method: 'POST', data } ),
onSuccess: () => queryClient.invalidateQueries( { queryKey: [ 'jetpack', 'waf' ] } ),
} );
}

type RawScan = {
state?: string;
most_recent?: { timestamp?: string };
mostRecent?: { timestamp?: string };
current?: { progress?: number };
};

/**
* Read a `/site/scan` response. It is WordPress.com's payload passed through as-is, so the
* fields are snake_case and a running scan reports `scanning` or `provisioning`.
*
* @param data - The scan response.
* @return The last scan time, whether a scan is running, and its progress.
*/
export function readScan( data: unknown ) {
const scan = ( data ?? {} ) as RawScan;
return {
lastScan: scan.most_recent?.timestamp ?? scan.mostRecent?.timestamp ?? null,
scanning: [ 'scanning', 'provisioning', 'enqueued', 'running' ].includes( scan.state ?? '' ),
progress: scan.current?.progress ?? 0,
};
}
Loading
Loading