Auto-Firewall
diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/scan-threats.tsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/scan-threats.tsx
new file mode 100644
index 0000000000000..055f9f39976b7
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/scan-threats.tsx
@@ -0,0 +1,253 @@
+import { Gridicon } from '@automattic/jetpack-components';
+import { Popover } from '@wordpress/components';
+import { useViewportMatch } from '@wordpress/compose';
+import { __ } from '@wordpress/i18n';
+import { useMemo, useState, useCallback, useRef } from 'react';
+import useProduct from '../../../data/products/use-product';
+import { getMyJetpackWindowInitialState } from '../../../data/utils/get-my-jetpack-window-state';
+import useAnalytics from '../../../hooks/use-analytics';
+import useMyJetpackConnection from '../../../hooks/use-my-jetpack-connection';
+import ShieldOff from './assets/shield-off.svg';
+import ShieldPartial from './assets/shield-partial.svg';
+import ShieldSuccess from './assets/shield-success.svg';
+import { InfoTooltip } from './info-tooltip';
+import { useProtectTooltipCopy, type TooltipContent } from './use-protect-tooltip-copy';
+import type { ReactElement, PropsWithChildren } from 'react';
+
+export const ScanAndThreatStatus = () => {
+ const slug = 'protect';
+ const { detail } = useProduct( slug );
+ const { isPluginActive = false, hasPaidPlanForProduct: hasProtectPaidPlan } = detail || {};
+ const { isSiteConnected } = useMyJetpackConnection();
+ const { plugins, themes, scanData } = getMyJetpackWindowInitialState();
+ const {
+ plugins: fromScanPlugins,
+ themes: fromScanThemes,
+ num_threats: numThreats = 0,
+ } = scanData;
+
+ const pluginsCount = fromScanPlugins.length || Object.keys( plugins ).length;
+ const themesCount = fromScanThemes.length || Object.keys( themes ).length;
+ const tooltipContent = useProtectTooltipCopy( { pluginsCount, themesCount, numThreats } );
+ const { scanThreatsTooltip } = tooltipContent;
+
+ const criticalScanThreatCount = useMemo( () => {
+ const { core, database, files, num_plugins_threats, num_themes_threats } = scanData;
+ const pluginsThreats = num_plugins_threats
+ ? fromScanPlugins.reduce( ( accum, plugin ) => accum.concat( plugin.threats ), [] )
+ : [];
+ const themesThreats = num_themes_threats
+ ? fromScanThemes.reduce( ( accum, theme ) => accum.concat( theme.threats ), [] )
+ : [];
+ const allThreats = [
+ ...pluginsThreats,
+ ...themesThreats,
+ ...( core?.threats ?? [] ),
+ ...database,
+ ...files,
+ ];
+ return allThreats.reduce(
+ ( accum, threat ) => ( threat.severity >= 5 ? ( accum += 1 ) : accum ),
+ 0
+ );
+ }, [ fromScanPlugins, fromScanThemes, scanData ] );
+
+ if ( isPluginActive && isSiteConnected ) {
+ if ( hasProtectPaidPlan ) {
+ if ( numThreats ) {
+ return (
+
+ );
+ }
+ return
;
+ }
+ return numThreats ? (
+
+ ) : (
+
+ );
+ }
+
+ return
;
+};
+
+/**
+ * ThreatStatus component
+ *
+ * @param {PropsWithChildren} props - The component props
+ * @param {number} props.numThreats - The number of threats
+ * @param {number} props.criticalThreatCount - The number of critical threats
+ * @param {TooltipContent[ 'scanThreatsTooltip' ]} props.tooltipContent - The number of critical threats
+ * @returns {ReactElement} rendered component
+ */
+function ThreatStatus( {
+ numThreats,
+ criticalThreatCount,
+ tooltipContent,
+}: {
+ numThreats: number;
+ criticalThreatCount?: number;
+ tooltipContent?: TooltipContent[ 'scanThreatsTooltip' ];
+} ) {
+ const { recordEvent } = useAnalytics();
+ const useTooltipRef = useRef< HTMLButtonElement >();
+ const isMobileViewport: boolean = useViewportMatch( 'medium', '<' );
+ const [ isPopoverVisible, setIsPopoverVisible ] = useState( false );
+
+ const toggleTooltip = useCallback(
+ () =>
+ setIsPopoverVisible( prevState => {
+ if ( ! prevState === true ) {
+ recordEvent( 'jetpack_protect_card_tooltip_open', {
+ page: 'my-jetpack',
+ feature: 'jetpack-protect',
+ location: 'scan',
+ status: 'alert',
+ hasPaidPlan: true,
+ threats: numThreats,
+ } );
+ }
+ return ! prevState;
+ } ),
+ [ numThreats, recordEvent ]
+ );
+ const hideTooltip = useCallback( () => {
+ // Don't hide the tooltip here if it's the tooltip button that was clicked (the button
+ // becoming the document's activeElement). Instead let toggleTooltip() handle the closing.
+ if ( useTooltipRef.current && ! useTooltipRef.current.contains( document.activeElement ) ) {
+ setIsPopoverVisible( false );
+ }
+ }, [ setIsPopoverVisible, useTooltipRef ] );
+
+ if ( criticalThreatCount ) {
+ return (
+ <>
+
{ __( 'Threats', 'jetpack-my-jetpack' ) }
+
+
+
{ numThreats }
+
+
+ { isPopoverVisible && (
+
+ <>
+ { tooltipContent.title }
+ { tooltipContent.text }
+ >
+
+ ) }
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
{ __( 'Threats', 'jetpack-my-jetpack' ) }
+
+ >
+ );
+}
+
+/**
+ * ScanStatus component
+ *
+ * @param {PropsWithChildren} props - The component props
+ * @param {'success' | 'partial' | 'off'} props.status - The number of threats
+ * @param {TooltipContent[ 'scanThreatsTooltip' ]} props.tooltipContent - The number of critical threats
+ * @returns { ReactElement} rendered component
+ */
+function ScanStatus( {
+ status,
+ tooltipContent,
+}: {
+ status: 'success' | 'partial' | 'off';
+ tooltipContent?: TooltipContent[ 'scanThreatsTooltip' ];
+} ) {
+ if ( status === 'success' ) {
+ return (
+ <>
+
{ __( 'Scan', 'jetpack-my-jetpack' ) }
+
+
+
+
+
{ __( 'Secure', 'jetpack-my-jetpack' ) }
+
+ >
+ );
+ }
+ if ( status === 'partial' ) {
+ return (
+ <>
+
{ __( 'Scan', 'jetpack-my-jetpack' ) }
+
+
+
+
+
+ { __( 'Partial', 'jetpack-my-jetpack' ) }
+
+
+ <>
+ { tooltipContent.title }
+ { tooltipContent.text }
+ >
+
+
+ >
+ );
+ }
+ return (
+ <>
+
{ __( 'Scan', 'jetpack-my-jetpack' ) }
+
+
+
+
+
{ __( 'Off', 'jetpack-my-jetpack' ) }
+
+ >
+ );
+}
diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/style.scss b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/style.scss
index f2f6af7938b61..3b82be661559d 100644
--- a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/style.scss
+++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/style.scss
@@ -1,11 +1,14 @@
.value-section {
display: flex;
justify-content: space-between;
+ margin-top: calc(var(--spacing-base) / 2);
&__heading {
font-size: var(--font-body-extra-small);
color: var(--jp-gray-100);
font-weight: 500;
+ margin-bottom: calc(var(--spacing-base) + 2px);
+ line-height: var(--font-title-small);
}
&__last-scan {
@@ -15,7 +18,7 @@
margin-top: var(--spacing-base);
column-gap: 1px;
position: absolute;
- right: calc(var(--spacing-base)*3);
+ right: calc(var(--spacing-base) * 3);
width: calc(57% - (var(--spacing-base) * 3));
div {
font-size: var(--font-body-extra-small);
@@ -31,24 +34,11 @@
}
}
- &__tooltip-button {
- display: flex;
- align-items: center;
- background: transparent;
- border: none;
- color: var(--jp-gray-50);
- padding: 2px;
- cursor: pointer;
- svg {
- margin: 0 auto;
- }
- }
-
&__tooltip-heading {
font-size: var(--font-title-small);
- line-height: 30px;
+ line-height: calc(var(--font-title-small) + 6px);;
color: var(--jp-black);
- margin: 0 0 16px;
+ margin: 0 0 calc(var(--spacing-base) * 2);
font-weight: 500;
}
@@ -57,4 +47,53 @@
line-height: var(--font-title-small);
color: var(--jp-gray-70);
}
+
+ &__data {
+ display: flex;
+ align-items: center;
+ font-size: var(--font-body-extra-small);
+ line-height: var(--font-title-small);
+ color: var(--jp-gray-50);
+ font-weight: 500;
+ }
+
+ &__status-icon {
+ display: block;
+ margin-right: calc(var(--spacing-base) - 2px);
+ }
+
+ &__status-text {
+ margin-right: 1px;
+ // Reduce the letter spacing slightly.
+ // The specific letter spacing is per the design mockup.
+ letter-spacing: -0.24px; // https://wp.me/p1HpG7-rFA
+ }
}
+
+.scan-threats {
+ &__threat-count {
+ font-size: calc(var(--font-title-large) - 4px);
+ line-height: var(--font-title-large);
+ font-weight: 400;
+ color: var(--jp-black);
+ }
+
+ &__critical-threats {
+ display: flex;
+ align-items: center;
+ }
+
+ &__critical-threat-container {
+ margin-left: 1px;
+ > button > svg {
+ fill: var(--jp-red-50);
+ }
+ }
+
+ &__critical-threat-count {
+ color: var(--jp-red-50);
+ margin-left: calc(var(--spacing-base) / 4);
+ }
+}
+
+
diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/use-protect-tooltip-copy.ts b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/use-protect-tooltip-copy.ts
index 6503be581edf0..1f51776b2153f 100644
--- a/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/use-protect-tooltip-copy.ts
+++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/protect-card/use-protect-tooltip-copy.ts
@@ -1,4 +1,4 @@
-import { __, sprintf } from '@wordpress/i18n';
+import { __, _n, sprintf } from '@wordpress/i18n';
import useProduct from '../../../data/products/use-product';
import type { ReactElement } from 'react';
@@ -36,13 +36,21 @@ export function useProtectTooltipCopy( {
pluginsThemesTooltip: {
title: __( 'Improve site safety: secure plugins & themes', 'jetpack-my-jetpack' ),
text: sprintf(
- /* translators: %1$d is the number of plugins and %2$d is the number of themes installed on the site. */
+ /* translators: %1$s the singular or plural of number of plugin(s), and %2$s is the singular or plural of the number of theme(s). */
__(
- 'Your site has %1$d plugins and %2$d themes lacking security measures. Improve your site’s safety by adding protection at no cost.',
+ 'Your site has %1$s and %2$s lacking security measures. Improve your site’s safety by adding protection at no cost.',
'jetpack-my-jetpack'
),
- pluginsCount,
- themesCount
+ sprintf(
+ /* translators: %d is the number of plugins installed on the site. */
+ _n( '%d plugin', '%d plugins', pluginsCount, 'jetpack-my-jetpack' ),
+ pluginsCount
+ ),
+ sprintf(
+ /* translators: %d is the number of themes installed on the site. */
+ _n( '%d theme', '%d themes', themesCount, 'jetpack-my-jetpack' ),
+ themesCount
+ )
),
},
scanThreatsTooltip:
@@ -50,12 +58,21 @@ export function useProtectTooltipCopy( {
? {
title: __( 'Auto-fix threats', 'jetpack-my-jetpack' ),
text: sprintf(
- /* translators: %d is the number of detected scan threats on the site. */
+ /* translators: %s is the singular or plural of number of detected critical threats on the site. */
__(
- 'The last scan identified %d critical threats. But don’t worry, use the “Auto-fix” button in the product to automatically fix most threats.',
+ 'The last scan identified %s. But don’t worry, use the “Auto-fix” button in the product to automatically fix most threats.',
'jetpack-my-jetpack'
),
- numThreats
+ sprintf(
+ /* translators: %d is the number of detected scan threats on the site. */
+ _n(
+ '%d critical threat.',
+ '%d critical threats.',
+ numThreats,
+ 'jetpack-my-jetpack'
+ ),
+ numThreats
+ )
),
}
: {
diff --git a/projects/packages/my-jetpack/changelog/add-my-protect-card-scan-threats b/projects/packages/my-jetpack/changelog/add-my-protect-card-scan-threats
new file mode 100644
index 0000000000000..d8d47d70e31dc
--- /dev/null
+++ b/projects/packages/my-jetpack/changelog/add-my-protect-card-scan-threats
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Add scan/threat info to the Protect card in My Jetpack.
diff --git a/projects/packages/my-jetpack/global.d.ts b/projects/packages/my-jetpack/global.d.ts
index 8cdd63114d5bb..e8fc1136d62a1 100644
--- a/projects/packages/my-jetpack/global.d.ts
+++ b/projects/packages/my-jetpack/global.d.ts
@@ -27,11 +27,28 @@ type JetpackModule =
| 'stats'
| 'ai';
+type ThreatItem = {
+ // Protect API properties (free plan)
+ id: string;
+ title: string;
+ fixed_in: string;
+ description: string | null;
+ source: string | null;
+ // Scan API properties (paid plan)
+ context: string | null;
+ filename: string | null;
+ first_detected: string | null;
+ fixable: boolean | null;
+ severity: number | null;
+ signature: string | null;
+ status: number | null;
+};
+
type ScanItem = {
checked: boolean;
name: string;
slug: string;
- threats: string[];
+ threats: ThreatItem[];
type: string;
version: string;
};
diff --git a/projects/packages/my-jetpack/src/products/class-protect.php b/projects/packages/my-jetpack/src/products/class-protect.php
index 331ea2b46e58d..70624db3a0526 100644
--- a/projects/packages/my-jetpack/src/products/class-protect.php
+++ b/projects/packages/my-jetpack/src/products/class-protect.php
@@ -255,16 +255,37 @@ public static function get_pricing_for_ui() {
}
/**
- * Checks if the site has a paid plan for the product
+ * Checks whether the current plan (or purchases) of the site already supports the product
*
- * @return bool
+ * @return boolean
*/
public static function has_paid_plan_for_product() {
- $scan_data = static::get_state_from_wpcom();
- if ( is_wp_error( $scan_data ) ) {
+ $purchases_data = Wpcom_Products::get_site_current_purchases();
+ if ( is_wp_error( $purchases_data ) ) {
return false;
}
- return is_object( $scan_data ) && isset( $scan_data->state ) && 'unavailable' !== $scan_data->state;
+ if ( is_array( $purchases_data ) && ! empty( $purchases_data ) ) {
+ foreach ( $purchases_data as $purchase ) {
+ // Protect is available as jetpack_scan product and as part of the Security or Complete plan.
+ if (
+ strpos( $purchase->product_slug, 'jetpack_scan' ) !== false ||
+ str_starts_with( $purchase->product_slug, 'jetpack_security' ) ||
+ str_starts_with( $purchase->product_slug, 'jetpack_complete' )
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks whether the product can be upgraded - i.e. this shows the /#add-protect interstitial
+ *
+ * @return boolean
+ */
+ public static function is_upgradable() {
+ return ! self::has_paid_plan_for_product();
}
/**
@@ -292,6 +313,6 @@ public static function get_manage_url() {
* @return array Products bundle list.
*/
public static function is_upgradable_by_bundle() {
- return array( 'security' );
+ return array( 'security', 'complete' );
}
}