diff --git a/includes/api/class-plugins-controller.php b/includes/api/class-plugins-controller.php
index 3bd0d107c9..0bc2abaae3 100644
--- a/includes/api/class-plugins-controller.php
+++ b/includes/api/class-plugins-controller.php
@@ -147,6 +147,19 @@ public function register_routes() {
]
);
+ // Register newspack/v1/handoff endpoint for URL-based handoff.
+ register_rest_route(
+ $this->namespace,
+ '/handoff',
+ [
+ [
+ 'methods' => 'POST',
+ 'callback' => [ $this, 'handoff_to_url' ],
+ 'permission_callback' => [ $this, 'handoff_item_permissions_check' ],
+ ],
+ ]
+ );
+
// Register newspack/v1/plugins/some-plugin/handoff endpoint.
register_rest_route(
$this->namespace,
@@ -314,6 +327,51 @@ public function uninstall_item( $request ) {
return $this->get_item( $request );
}
+ /**
+ * Handoff to an arbitrary admin URL.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function handoff_to_url( $request ) {
+ $destination_url = $request->get_param( 'destinationUrl' );
+ if ( empty( $destination_url ) ) {
+ return new \WP_Error( 'newspack_handoff_missing_url', __( 'destinationUrl is required.', 'newspack-plugin' ), [ 'status' => 400 ] );
+ }
+
+ // Reject external URLs to prevent open-redirect attacks.
+ $parsed_destination = wp_parse_url( $destination_url );
+ if ( ! empty( $parsed_destination['host'] ) ) {
+ $site_host = wp_parse_url( admin_url(), PHP_URL_HOST );
+ if ( $parsed_destination['host'] !== $site_host ) {
+ return new \WP_Error( 'newspack_handoff_invalid_url', __( 'destinationUrl must be a same-site URL.', 'newspack-plugin' ), [ 'status' => 400 ] );
+ }
+ }
+
+ $handoff_return_url = $request->get_param( 'handoffReturnUrl' );
+ $show_on_block_editor = $request->get_param( 'showOnBlockEditor' );
+ $banner_text = (string) $request->get_param( 'bannerText' );
+ $banner_button_text = (string) $request->get_param( 'bannerButtonText' );
+
+ update_option( NEWSPACK_HANDOFF, 'url' );
+ update_option( NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR, (bool) $show_on_block_editor );
+ update_option( NEWSPACK_HANDOFF_BANNER_TEXT, sanitize_text_field( $banner_text ) );
+ update_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, sanitize_text_field( $banner_button_text ) );
+ if ( ! empty( $handoff_return_url ) ) {
+ update_option( NEWSPACK_HANDOFF_RETURN_URL, esc_url( $handoff_return_url ) );
+ }
+
+ $parsed_url = wp_parse_url( $destination_url );
+ if ( ! empty( $parsed_url['query'] ) ) {
+ wp_parse_str( $parsed_url['query'], $query_params );
+ if ( ! empty( $query_params['page'] ) ) {
+ update_option( NEWSPACK_HANDOFF_DESTINATION_PAGE, sanitize_text_field( $query_params['page'] ) );
+ }
+ }
+
+ return rest_ensure_response( [ 'HandoffLink' => esc_url_raw( $destination_url ) ] );
+ }
+
/**
* Handoff to a managed plugin.
*
@@ -329,7 +387,9 @@ public function handoff_item( $request ) {
}
$show_on_block_editor = $request->get_param( 'showOnBlockEditor' );
- Handoff_Banner::register_handoff_for_plugin( $slug, (bool) $show_on_block_editor );
+ $banner_text = (string) $request->get_param( 'bannerText' );
+ $banner_button_text = (string) $request->get_param( 'bannerButtonText' );
+ Handoff_Banner::register_handoff_for_plugin( $slug, (bool) $show_on_block_editor, $banner_text, $banner_button_text );
$managed_plugins = Plugin_Manager::get_managed_plugins();
$response = $managed_plugins[ $slug ];
diff --git a/includes/class-handoff-banner.php b/includes/class-handoff-banner.php
index ebbeb8e489..4161ff358a 100644
--- a/includes/class-handoff-banner.php
+++ b/includes/class-handoff-banner.php
@@ -12,6 +12,9 @@
define( 'NEWSPACK_HANDOFF', 'newspack_handoff' );
define( 'NEWSPACK_HANDOFF_RETURN_URL', 'newspack_handoff_return_url' );
define( 'NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR', 'newspack_handoff_show_on_block_editor' );
+define( 'NEWSPACK_HANDOFF_BANNER_TEXT', 'newspack_handoff_banner_text' );
+define( 'NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT', 'newspack_handoff_banner_button_text' );
+define( 'NEWSPACK_HANDOFF_DESTINATION_PAGE', 'newspack_handoff_destination_page' );
/**
* Manages the API as a whole.
@@ -25,11 +28,13 @@ public function __construct() {
add_action( 'current_screen', [ $this, 'clear_handoff_url' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_styles' ], 1 );
add_action( 'enqueue_block_editor_assets', [ $this, 'insert_block_editor_handoff_banner' ] );
- add_action( 'admin_notices', [ $this, 'insert_handoff_banner' ], -9998 );
+ add_action( 'in_admin_header', [ $this, 'insert_handoff_banner' ] );
+ add_action( 'newspack_before_wizard_content', [ $this, 'insert_handoff_banner_static' ] );
}
/**
- * Render element into which Handoff Banner will be rendered.
+ * Render element into which Handoff Banner will be rendered via JS.
+ * Used on non-wizard admin pages.
*
* @return void.
*/
@@ -38,7 +43,73 @@ public function insert_handoff_banner() {
return;
}
- printf( "
", esc_url( get_option( NEWSPACK_HANDOFF_RETURN_URL ) ) );
+ // On Newspack wizard pages the static banner is rendered via newspack_before_wizard_content.
+ $screen = get_current_screen();
+ if ( $screen && stristr( $screen->id, 'newspack' ) ) {
+ return;
+ }
+
+ printf(
+ "",
+ esc_url( get_option( NEWSPACK_HANDOFF_RETURN_URL ) ),
+ esc_attr( get_option( NEWSPACK_HANDOFF_BANNER_TEXT, '' ) ),
+ esc_attr( get_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, '' ) )
+ );
+ }
+
+ /**
+ * Render a fully server-side Handoff Banner.
+ * Used on Newspack wizard pages where the JS-based banner cannot be used.
+ *
+ * @return void.
+ */
+ public function insert_handoff_banner_static() {
+ if ( ! self::needs_handoff_return_ui() ) {
+ return;
+ }
+
+ $return_url = esc_url( get_option( NEWSPACK_HANDOFF_RETURN_URL ) );
+ $banner_text = get_option( NEWSPACK_HANDOFF_BANNER_TEXT, '' );
+ $button_text = get_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, '' );
+
+ if ( empty( $banner_text ) ) {
+ $banner_text = __( 'Return to Newspack after completing configuration', 'newspack-plugin' );
+ }
+ if ( empty( $button_text ) ) {
+ $button_text = __( 'Back to Newspack', 'newspack-plugin' );
+ }
+ ?>
+
+
+ __( 'Return to Newspack after completing configuration', 'newspack' ),
- 'buttonText' => __( 'Back to Newspack', 'newspack' ),
+ $banner_text = get_option( NEWSPACK_HANDOFF_BANNER_TEXT, '' );
+ $banner_button_text = get_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, '' );
+ $script_info = [
+ 'text' => $banner_text ? $banner_text : __( 'Return to Newspack after completing configuration', 'newspack' ),
+ 'buttonText' => $banner_button_text ? $banner_button_text : __( 'Back to Newspack', 'newspack' ),
'returnURL' => esc_url( get_option( NEWSPACK_HANDOFF_RETURN_URL, '' ) ),
];
wp_localize_script( $handle, 'newspack_handoff', $script_info );
@@ -80,18 +153,19 @@ public function enqueue_styles() {
wp_register_style(
$handle,
Newspack::plugin_url() . '/dist/handoff-banner.css',
- [],
+ [ 'wp-components' ],
NEWSPACK_PLUGIN_VERSION
);
wp_enqueue_style( $handle );
Newspack::load_common_assets();
+ $asset = include NEWSPACK_ABSPATH . 'dist/handoff-banner.asset.php';
wp_register_script(
$handle,
Newspack::plugin_url() . '/dist/handoff-banner.js',
- [ 'wp-element', 'wp-editor', 'wp-components' ],
- NEWSPACK_PLUGIN_VERSION,
+ $asset['dependencies'],
+ $asset['version'],
true
);
wp_enqueue_script( $handle );
@@ -102,11 +176,16 @@ public function enqueue_styles() {
*
* @param array $plugin Slug of plugin to be visited.
* @param boolean $show_on_block_editor Whether to show on block editor.
+ * @param string $banner_text Custom banner body text.
+ * @param string $banner_button_text Custom banner button text.
* @return void
*/
- public static function register_handoff_for_plugin( $plugin, $show_on_block_editor = false ) {
+ public static function register_handoff_for_plugin( $plugin, $show_on_block_editor = false, $banner_text = '', $banner_button_text = '' ) {
update_option( NEWSPACK_HANDOFF, $plugin );
update_option( NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR, (bool) $show_on_block_editor );
+ update_option( NEWSPACK_HANDOFF_BANNER_TEXT, sanitize_text_field( $banner_text ) );
+ update_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, sanitize_text_field( $banner_button_text ) );
+ update_option( NEWSPACK_HANDOFF_DESTINATION_PAGE, '' );
}
/**
@@ -115,10 +194,6 @@ public static function register_handoff_for_plugin( $plugin, $show_on_block_edit
* @return bool
*/
public static function needs_handoff_return_ui() {
- if ( get_option( NEWSPACK_SETUP_COMPLETE, true ) ) {
- return false;
- }
-
return get_option( NEWSPACK_HANDOFF ) ? true : false;
}
@@ -132,15 +207,52 @@ public static function needs_block_editor_handoff_return_ui() {
}
/**
- * If the current admin page is part of the Newspack dashboard, clear the handoff URL. This ensures the handoff banner won't be shown on Newspack admin pages.
+ * Clear all handoff-related options.
+ *
+ * @return void
+ */
+ private function clear_all_handoff_options() {
+ update_option( NEWSPACK_HANDOFF, null );
+ update_option( NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR, false );
+ update_option( NEWSPACK_HANDOFF_BANNER_TEXT, '' );
+ update_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, '' );
+ update_option( NEWSPACK_HANDOFF_DESTINATION_PAGE, '' );
+ }
+
+ /**
+ * Clear the handoff state when navigating away from the destination page.
+ * Clears on any Newspack screen that isn't the destination, and on any
+ * non-Newspack screen when a destination page was registered (preventing
+ * the banner from lingering on unrelated admin pages).
*
* @param WP_Screen $current_screen The current screen object.
* @return void
*/
public function clear_handoff_url( $current_screen ) {
+ if ( ! self::needs_handoff_return_ui() ) {
+ return;
+ }
+
+ $destination_page = get_option( NEWSPACK_HANDOFF_DESTINATION_PAGE, '' );
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $current_page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
+
+ // Don't clear if we're on the intended destination page.
+ if ( $destination_page && $current_page === $destination_page ) {
+ return;
+ }
+
+ // Clear on any Newspack screen that isn't the destination.
if ( stristr( $current_screen->id, 'newspack' ) ) {
- update_option( NEWSPACK_HANDOFF, null );
- update_option( NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR, false );
+ $this->clear_all_handoff_options();
+ return;
+ }
+
+ // For URL-based handoffs with a known destination page, also clear on any
+ // non-Newspack page that isn't the destination, to prevent the banner from
+ // persisting across unrelated admin screens.
+ if ( $destination_page ) {
+ $this->clear_all_handoff_options();
}
}
}
diff --git a/includes/wizards/class-wizard.php b/includes/wizards/class-wizard.php
index f6448ce7b0..e9921450a6 100644
--- a/includes/wizards/class-wizard.php
+++ b/includes/wizards/class-wizard.php
@@ -118,6 +118,7 @@ public function add_page() {
* Render the container for the wizard.
*/
public function render_wizard() {
+ do_action( 'newspack_before_wizard_content' );
?>
diff --git a/packages/components/src/action-card/index.js b/packages/components/src/action-card/index.js
index c4a25608c0..2ef0cf9b4d 100644
--- a/packages/components/src/action-card/index.js
+++ b/packages/components/src/action-card/index.js
@@ -39,6 +39,9 @@ const ActionCard = ( {
heading = 2,
description,
handoff,
+ handoffUrl,
+ bannerText,
+ bannerButtonText,
editLink,
href,
notification,
@@ -187,7 +190,18 @@ const ActionCard = ( {
{ actionContent && actionContent }
{ actionText &&
( handoff ? (
-
+
+ { actionText }
+
+ ) : handoffUrl ? (
+
{ actionText }
) : onClick || hasInternalLink ? (
diff --git a/packages/components/src/handoff/README.md b/packages/components/src/handoff/README.md
new file mode 100644
index 0000000000..22072e68e0
--- /dev/null
+++ b/packages/components/src/handoff/README.md
@@ -0,0 +1,93 @@
+# Handoff
+
+Navigates the user to another admin page and displays a return banner at the top of the destination so they can find their way back.
+
+## How it works
+
+1. The user clicks the Handoff button.
+2. A POST request registers the current URL as the return destination and stores optional banner customisations.
+3. The user is redirected to the destination page.
+4. A banner appears at the top of that page with a "Back to Newspack" button (or custom text) that returns them to the origin.
+
+## Usage
+
+### With a URL
+
+Use the `url` prop to hand off to any WordPress admin URL. No plugin needs to be installed.
+
+```jsx
+
+ Go to Dashboard
+
+```
+
+### With a managed plugin slug
+
+Use the `plugin` prop to hand off to a Newspack-managed plugin. The button is automatically disabled if the plugin is not installed or inactive.
+
+```jsx
+
+ Configure Jetpack
+
+```
+
+Use `editLink` to override the destination to a specific page within the plugin:
+
+```jsx
+
+ Configure Yoast SEO
+
+```
+
+### Via ActionCard
+
+`ActionCard` accepts `handoffUrl` (URL-based) or `handoff` (plugin slug) as convenience props that render a `Handoff` as the action button. `bannerText` and `bannerButtonText` are supported on both:
+
+```jsx
+
+
+
+```
+
+## Props
+
+| Prop | Type | Description |
+|---|---|---|
+| `url` | `string` | Admin URL to hand off to. Use this or `plugin`, not both. |
+| `plugin` | `string` | Slug of a Newspack-managed plugin to hand off to. |
+| `editLink` | `string` | Overrides the destination URL when using `plugin`. |
+| `bannerText` | `string` | Custom body text for the return banner. Defaults to "Return to Newspack after completing configuration". |
+| `bannerButtonText` | `string` | Custom label for the return button. Defaults to "Back to Newspack". |
+| `showOnBlockEditor` | `bool` | Show the return banner inside the block editor. Default `false`. |
+| `useModal` | `bool` | Show a confirmation modal before navigating. |
+| `modalTitle` | `string` | Title for the confirmation modal. |
+| `modalBody` | `string` | Body text for the confirmation modal. |
+| `onReady` | `func` | Called with plugin info once loaded (plugin mode only). |
+| `children` | `node` | Button label. Falls back to "Manage {Plugin Name}" in plugin mode. |
+
+All other props are passed through to the underlying `Button` component (`isPrimary`, `isLink`, `isTertiary`, `className`, etc.).
+
+## Banner customisation
+
+```jsx
+
+ Open Settings
+
+```
diff --git a/packages/components/src/handoff/index.js b/packages/components/src/handoff/index.js
index 439ce4961b..471088561e 100644
--- a/packages/components/src/handoff/index.js
+++ b/packages/components/src/handoff/index.js
@@ -31,8 +31,10 @@ class Handoff extends Component {
componentDidMount = () => {
this._isMounted = true;
- const { plugin } = this.props;
- this.retrievePluginInfo( plugin );
+ const { plugin, url } = this.props;
+ if ( plugin && ! url ) {
+ this.retrievePluginInfo( plugin );
+ }
};
componentWillUnmount = () => {
@@ -60,8 +62,25 @@ class Handoff extends Component {
return assign( defaults, this.props );
};
+ goToUrl = () => {
+ const { url, showOnBlockEditor, bannerText, bannerButtonText } = this.props;
+ apiFetch( {
+ path: '/newspack/v1/handoff',
+ method: 'POST',
+ data: {
+ destinationUrl: url,
+ handoffReturnUrl: window && window.location.href,
+ showOnBlockEditor: showOnBlockEditor ? true : false,
+ bannerText,
+ bannerButtonText,
+ },
+ } ).then( response => {
+ window.location.href = response.HandoffLink;
+ } );
+ };
+
goToPlugin = plugin => {
- const { editLink, showOnBlockEditor } = this.props;
+ const { editLink, showOnBlockEditor, bannerText, bannerButtonText } = this.props;
apiFetch( {
path: '/newspack/v1/plugins/' + plugin + '/handoff',
method: 'POST',
@@ -69,6 +88,8 @@ class Handoff extends Component {
editLink,
handoffReturnUrl: window && window.location.href,
showOnBlockEditor: showOnBlockEditor ? true : false,
+ bannerText,
+ bannerButtonText,
},
} ).then( response => {
window.location.href = response.HandoffLink;
@@ -92,30 +113,47 @@ class Handoff extends Component {
onReady,
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
editLink,
+ // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
+ bannerText,
+ // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
+ bannerButtonText,
+ // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
+ url,
...otherProps
} = this.props;
const { pluginInfo, showModal } = this.state;
const { modalBody, modalTitle, primaryButton, primaryModalButton, dismissModalButton } = this.textForPlugin( pluginInfo );
const { Configured, Name, Slug, Status } = pluginInfo;
const classes = classnames( Configured && 'is-configured', className );
+ const goTo = () => ( url ? this.goToUrl() : this.goToPlugin( Slug ) );
return (
- { Name && 'active' === Status && (
+ { url && (
+
+ ) }
+ { ! url && Name && 'active' === Status && (
) }
- { Name && 'active' !== Status && (
+ { ! url && Name && 'active' !== Status && (
) }
- { ! Name && (
+ { ! url && ! Name && (
-