diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8a41c4643e4..6a12e4121b19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4574,6 +4574,9 @@ importers: '@automattic/jetpack-wp-build-polyfills': specifier: workspace:* version: link:../wp-build-polyfills + '@automattic/request-external-access': + specifier: 1.0.1 + version: 1.0.1 '@wordpress/api-fetch': specifier: 7.46.0 version: 7.46.0 diff --git a/projects/packages/seo/_inc/data/google-verify-types.ts b/projects/packages/seo/_inc/data/google-verify-types.ts new file mode 100644 index 000000000000..db074093ee1c --- /dev/null +++ b/projects/packages/seo/_inc/data/google-verify-types.ts @@ -0,0 +1,20 @@ +// Google site-verification state. +// +// The bootstrap (keyring connect URL + whether the current user is connected) is +// injected onto `window.JetpackScriptData.seo.google_verify` by +// `Initializer::get_google_verify_data()`. The live verified status is fetched +// client-side from `/jetpack/v4/verify-site/google` (a wpcom round-trip). + +export interface GoogleVerifyBootstrap { + /** WordPress.com keyring OAuth URL that opens the auto-verify popup. */ + connect_url: string; + /** Whether the current user is connected to WordPress.com (keyring available). */ + is_connected: boolean; +} + +/** Shape returned by GET/POST `/jetpack/v4/verify-site/google`. */ +export interface GoogleVerifyStatus { + verified: boolean; + is_owner: boolean; + google_search_console_url: string; +} diff --git a/projects/packages/seo/_inc/data/use-google-verify.ts b/projects/packages/seo/_inc/data/use-google-verify.ts new file mode 100644 index 000000000000..a03bfb732afd --- /dev/null +++ b/projects/packages/seo/_inc/data/use-google-verify.ts @@ -0,0 +1,110 @@ +import { getScriptData } from '@automattic/jetpack-script-data'; +import requestExternalAccess from '@automattic/request-external-access'; +import apiFetch from '@wordpress/api-fetch'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import type { GoogleVerifyBootstrap, GoogleVerifyStatus } from './google-verify-types'; + +const ENDPOINT = '/jetpack/v4/verify-site/google'; + +type SeoScriptData = { + seo?: { + google_verify?: GoogleVerifyBootstrap; + }; +}; + +/** + * Read the Google-verification bootstrap from `window.JetpackScriptData.seo.google_verify`. + * + * @return The bootstrap, or `null` when unavailable. + */ +export function getGoogleVerifyBootstrap(): GoogleVerifyBootstrap | null { + const scriptData = getScriptData() as SeoScriptData | undefined; + return scriptData?.seo?.google_verify ?? null; +} + +/** `loading` while the initial status request is in flight; `unavailable` when disconnected. */ +export type GoogleVerifyState = 'loading' | 'verified' | 'unverified' | 'unavailable'; + +export interface GoogleVerify { + state: GoogleVerifyState; + isConnected: boolean; + isOwner: boolean; + searchConsoleUrl: string; + isVerifying: boolean; + /** Open the WordPress.com keyring popup and verify with the returned keyring id. */ + autoVerify: () => void; +} + +/** + * Owns the Google auto-verify flow. On a connected site it fetches the live verified + * status on mount, and `autoVerify()` opens the keyring OAuth popup and POSTs the + * returned keyring id to verify the site — both via the existing + * `/jetpack/v4/verify-site/google` endpoint. On a disconnected site there's no keyring, + * so it reports `unavailable` and the UI falls back to manual code entry. + * + * @return The Google-verification controller. + */ +export function useGoogleVerify(): GoogleVerify { + const bootstrap = useMemo( () => getGoogleVerifyBootstrap(), [] ); + const isConnected = bootstrap?.is_connected ?? false; + const connectUrl = bootstrap?.connect_url ?? ''; + + const [ state, setState ] = useState< GoogleVerifyState >( + isConnected ? 'loading' : 'unavailable' + ); + const [ isOwner, setIsOwner ] = useState( false ); + const [ searchConsoleUrl, setSearchConsoleUrl ] = useState( '' ); + const [ isVerifying, setIsVerifying ] = useState( false ); + + const applyStatus = useCallback( ( status: GoogleVerifyStatus ) => { + setState( status.verified ? 'verified' : 'unverified' ); + setIsOwner( !! status.is_owner ); + setSearchConsoleUrl( status.google_search_console_url ?? '' ); + }, [] ); + + // Fetch the live verified status once, on a connected site. + useEffect( () => { + if ( ! isConnected ) { + return undefined; + } + let cancelled = false; + apiFetch< GoogleVerifyStatus >( { path: ENDPOINT } ) + .then( status => { + if ( ! cancelled ) { + applyStatus( status ); + } + } ) + .catch( () => { + // Treat a failed status check as "not verified" so the verify button + // and manual fallback stay available. + if ( ! cancelled ) { + setState( 'unverified' ); + } + } ); + return () => { + cancelled = true; + }; + }, [ isConnected, applyStatus ] ); + + const autoVerify = useCallback( () => { + if ( ! connectUrl || isVerifying ) { + return; + } + requestExternalAccess( connectUrl, ( keyringId: number | null ) => { + if ( ! keyringId ) { + return; + } + setIsVerifying( true ); + apiFetch< GoogleVerifyStatus >( { + path: ENDPOINT, + method: 'POST', + data: { keyring_id: keyringId }, + } ) + .then( applyStatus ) + .catch( () => setState( 'unverified' ) ) + .finally( () => setIsVerifying( false ) ); + } ); + }, [ connectUrl, isVerifying, applyStatus ] ); + + return { state, isConnected, isOwner, searchConsoleUrl, isVerifying, autoVerify }; +} diff --git a/projects/packages/seo/_inc/screens/settings/google-verification-field.tsx b/projects/packages/seo/_inc/screens/settings/google-verification-field.tsx new file mode 100644 index 000000000000..42a5db846e23 --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/google-verification-field.tsx @@ -0,0 +1,116 @@ +/* eslint-disable react/jsx-no-bind */ + +import { Button, TextControl } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Badge, Link, Stack } from '@wordpress/ui'; +import { useGoogleVerify } from '../../data/use-google-verify'; +import type { FC } from 'react'; + +interface Props { + /** The manual Google verification code (meta-tag content). */ + value: string; + onChange: ( value: string ) => void; + /** Save the manual code — called on blur (auto-save, no Save button). */ + onCommit?: () => void; + disabled?: boolean; +} + +const manualHelp = __( + 'Paste the "content" attribute from the Google Search Console meta tag.', + 'jetpack-seo' +); + +/** + * Google verification: a WordPress.com keyring auto-verify flow on connected sites, + * with manual meta-tag entry as a fallback (and the only option when disconnected). + * The other services stay simple code fields in the parent card. + * + * @param props - Component props. + * @param props.value - The manual Google verification code. + * @param props.onChange - Update the manual code locally. + * @param props.onCommit - Save the manual code (on blur). + * @param props.disabled - Whether the controls are disabled. + * @return The Google verification controls. + */ +const GoogleVerificationField: FC< Props > = ( { value, onChange, onCommit, disabled } ) => { + const { state, isConnected, isOwner, searchConsoleUrl, isVerifying, autoVerify } = + useGoogleVerify(); + const [ manualOpen, setManualOpen ] = useState( false ); + + const manualField = ( + + ); + + // Disconnected self-hosted site: no keyring popup, so manual entry only. + if ( ! isConnected ) { + return ( +
+ +
+ ); + } + + return ( + + + { __( 'Google', 'jetpack-seo' ) } + { state === 'verified' && ( + { __( 'Verified', 'jetpack-seo' ) } + ) } + { state === 'unverified' && ( + { __( 'Not verified', 'jetpack-seo' ) } + ) } + + + { state === 'verified' && isOwner && !! searchConsoleUrl && ( + + { __( 'View in Google Search Console', 'jetpack-seo' ) } + + ) } + + { state !== 'verified' && ( + + + + + ) } + + { /* Reveal the manual field on request, or whenever a code is already set. */ } + { state !== 'verified' && ( manualOpen || !! value ) && manualField } + + ); +}; + +export default GoogleVerificationField; diff --git a/projects/packages/seo/_inc/screens/settings/verification-card.tsx b/projects/packages/seo/_inc/screens/settings/verification-card.tsx index 5cc62964b7ae..964ef7d3e70a 100644 --- a/projects/packages/seo/_inc/screens/settings/verification-card.tsx +++ b/projects/packages/seo/_inc/screens/settings/verification-card.tsx @@ -4,6 +4,7 @@ import { TextControl } from '@wordpress/components'; import { __, sprintf, _n } from '@wordpress/i18n'; import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; import { VERIFICATION_SERVICES } from '../../data/verification-services'; +import GoogleVerificationField from './google-verification-field'; import './style.scss'; import type { SettingsResponse, VerificationKey } from '../../data/settings-types'; import type { FC } from 'react'; @@ -65,21 +66,32 @@ const VerificationCard: FC< Props > = ( { -
- { VERIFICATION_SERVICES.map( ( { key, label } ) => ( - onChange( key, next ) } - onBlur={ onCommit } - help={ HINTS[ key ] } - disabled={ disabled } - __next40pxDefaultSize - __nextHasNoMarginBottom - /> - ) ) } -
+ + { /* Google gets the keyring auto-verify flow; the rest are simple code fields. */ } + onChange( 'google', next ) } + onCommit={ onCommit } + disabled={ disabled } + /> +
+ { VERIFICATION_SERVICES.filter( ( { key } ) => key !== 'google' ).map( + ( { key, label } ) => ( + onChange( key, next ) } + onBlur={ onCommit } + help={ HINTS[ key ] } + disabled={ disabled } + __next40pxDefaultSize + __nextHasNoMarginBottom + /> + ) + ) } +
+
); diff --git a/projects/packages/seo/changelog/add-google-auto-verify b/projects/packages/seo/changelog/add-google-auto-verify new file mode 100644 index 000000000000..ddfa02d18b6c --- /dev/null +++ b/projects/packages/seo/changelog/add-google-auto-verify @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add Google site auto-verification to the Settings tab: connected sites can verify with Google through a WordPress.com keyring popup (with manual meta-tag entry as a fallback), replacing the legacy Traffic-page UI. diff --git a/projects/packages/seo/package.json b/projects/packages/seo/package.json index 34d76bfb20cf..acc7796da94b 100644 --- a/projects/packages/seo/package.json +++ b/projects/packages/seo/package.json @@ -40,6 +40,7 @@ "@automattic/jetpack-components": "workspace:*", "@automattic/jetpack-script-data": "workspace:*", "@automattic/jetpack-wp-build-polyfills": "workspace:*", + "@automattic/request-external-access": "1.0.1", "@wordpress/api-fetch": "7.46.0", "@wordpress/components": "33.1.0", "@wordpress/data": "10.46.0", diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index f3512a3e55fb..039575ad747f 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -220,8 +220,9 @@ public static function inject_script_data( $data ) { $data = array(); } - $data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data(); - $data[ self::SCRIPT_DATA_KEY ]['settings'] = self::get_settings_data(); + $data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data(); + $data[ self::SCRIPT_DATA_KEY ]['settings'] = self::get_settings_data(); + $data[ self::SCRIPT_DATA_KEY ]['google_verify'] = self::get_google_verify_data(); return $data; } @@ -365,4 +366,39 @@ public static function get_settings_data() { ), ); } + + /** + * Build the Google site-verification state for the Settings tab. + * + * The Settings verification card lets a connected user verify with Google via a + * WordPress.com keyring OAuth popup (in addition to pasting a meta-tag code). This + * bootstraps the keyring connect URL and whether the current user is connected — + * the live verified status is fetched client-side from `/jetpack/v4/verify-site/google` + * (a wpcom round-trip we don't want to make on every page load). + * + * Both `Keyring_Helper` (Publicize package) and the connection `Manager` are provided + * by the host Jetpack plugin, so they're guarded with `class_exists` like the + * `Jetpack_SEO_*` helpers. On a disconnected self-hosted site `is_connected` is false + * and the UI falls back to manual code entry only. + * + * @return array + */ + public static function get_google_verify_data() { + $connect_url = ''; + if ( class_exists( 'Automattic\\Jetpack\\Publicize\\Keyring_Helper' ) ) { + // @phan-suppress-next-line PhanUndeclaredClassMethod -- guarded; Publicize package is provided by the host plugin. + $connect_url = (string) \Automattic\Jetpack\Publicize\Keyring_Helper::connect_url( 'google_site_verification', 'other' ); + } + + $is_connected = false; + if ( class_exists( 'Automattic\\Jetpack\\Connection\\Manager' ) ) { + // @phan-suppress-next-line PhanUndeclaredClassMethod -- guarded; Connection package is provided by the host plugin. + $is_connected = ( new \Automattic\Jetpack\Connection\Manager() )->is_user_connected(); + } + + return array( + 'connect_url' => $connect_url, + 'is_connected' => (bool) $is_connected, + ); + } } diff --git a/projects/packages/seo/tests/php/InitializerTest.php b/projects/packages/seo/tests/php/InitializerTest.php index 457e1ed0bb37..5c6bef5de45e 100644 --- a/projects/packages/seo/tests/php/InitializerTest.php +++ b/projects/packages/seo/tests/php/InitializerTest.php @@ -36,4 +36,21 @@ public function test_package_version_constant_is_defined() { public function test_feature_filter_constant_is_defined() { $this->assertSame( 'rsm_jetpack_seo', Initializer::FEATURE_FILTER ); } + + /** + * The Google-verification bootstrap exposes the connect URL + connection flag the + * React app expects, with the right types. Without the host plugin's Keyring/Manager + * classes present (the package test context) it degrades to an empty URL and not + * connected, so the UI falls back to manual entry. + */ + public function test_get_google_verify_data_shape() { + $data = Initializer::get_google_verify_data(); + + $this->assertArrayHasKey( 'connect_url', $data ); + $this->assertArrayHasKey( 'is_connected', $data ); + $this->assertIsString( $data['connect_url'] ); + $this->assertIsBool( $data['is_connected'] ); + $this->assertSame( '', $data['connect_url'] ); + $this->assertFalse( $data['is_connected'] ); + } }