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
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

20 changes: 20 additions & 0 deletions projects/packages/seo/_inc/data/google-verify-types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
110 changes: 110 additions & 0 deletions projects/packages/seo/_inc/data/use-google-verify.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
@@ -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 = (
<TextControl
label={ __( 'Google verification code', 'jetpack-seo' ) }
value={ value }
onChange={ onChange }
onBlur={ onCommit }
help={ manualHelp }
disabled={ disabled }
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
);

// Disconnected self-hosted site: no keyring popup, so manual entry only.
if ( ! isConnected ) {
return (
<div className="jetpack-seo-settings__google-verification">
<TextControl
label={ __( 'Google', 'jetpack-seo' ) }
value={ value }
onChange={ onChange }
onBlur={ onCommit }
help={ manualHelp }
disabled={ disabled }
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
</div>
);
}

return (
<Stack direction="column" gap="sm" className="jetpack-seo-settings__google-verification">
<Stack direction="row" justify="space-between" align="center" gap="sm">
<strong>{ __( 'Google', 'jetpack-seo' ) }</strong>
{ state === 'verified' && (
<Badge intent="stable">{ __( 'Verified', 'jetpack-seo' ) }</Badge>
) }
{ state === 'unverified' && (
<Badge intent="draft">{ __( 'Not verified', 'jetpack-seo' ) }</Badge>
) }
</Stack>

{ state === 'verified' && isOwner && !! searchConsoleUrl && (
<Link href={ searchConsoleUrl } openInNewTab rel="noopener noreferrer">
{ __( 'View in Google Search Console', 'jetpack-seo' ) }
</Link>
) }

{ state !== 'verified' && (
<Stack direction="row" gap="sm" align="center">
<Button
variant="primary"
onClick={ autoVerify }
isBusy={ isVerifying }
disabled={ disabled || isVerifying || state === 'loading' }
>
{ __( 'Verify with Google', 'jetpack-seo' ) }
</Button>
<Button
variant="tertiary"
onClick={ () => setManualOpen( current => ! current ) }
disabled={ disabled }
>
{ __( 'Enter a code manually', 'jetpack-seo' ) }
</Button>
</Stack>
) }

{ /* Reveal the manual field on request, or whenever a code is already set. */ }
{ state !== 'verified' && ( manualOpen || !! value ) && manualField }
</Stack>
);
};

export default GoogleVerificationField;
42 changes: 27 additions & 15 deletions projects/packages/seo/_inc/screens/settings/verification-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,21 +66,32 @@ const VerificationCard: FC< Props > = ( {
</Stack>
</CollapsibleCard.Header>
<CollapsibleCard.Content>
<div className="jetpack-seo-settings__verification-grid">
{ VERIFICATION_SERVICES.map( ( { key, label } ) => (
<TextControl
key={ key }
label={ label }
value={ value[ key ] }
onChange={ next => onChange( key, next ) }
onBlur={ onCommit }
help={ HINTS[ key ] }
disabled={ disabled }
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
) ) }
</div>
<Stack direction="column" gap="lg">
{ /* Google gets the keyring auto-verify flow; the rest are simple code fields. */ }
<GoogleVerificationField
value={ value.google }
onChange={ next => onChange( 'google', next ) }
onCommit={ onCommit }
disabled={ disabled }
/>
<div className="jetpack-seo-settings__verification-grid">
{ VERIFICATION_SERVICES.filter( ( { key } ) => key !== 'google' ).map(
( { key, label } ) => (
<TextControl
key={ key }
label={ label }
value={ value[ key ] }
onChange={ next => onChange( key, next ) }
onBlur={ onCommit }
help={ HINTS[ key ] }
disabled={ disabled }
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
)
) }
</div>
</Stack>
</CollapsibleCard.Content>
</CollapsibleCard.Root>
);
Expand Down
4 changes: 4 additions & 0 deletions projects/packages/seo/changelog/add-google-auto-verify
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions projects/packages/seo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 38 additions & 2 deletions projects/packages/seo/src/class-initializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
);
}
}
Loading
Loading