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 && ( - diff --git a/src/wizards/componentsDemo/index.js b/src/wizards/componentsDemo/index.js index 08be7d6703..b5bb7450fe 100644 --- a/src/wizards/componentsDemo/index.js +++ b/src/wizards/componentsDemo/index.js @@ -231,21 +231,19 @@ class ComponentsDemo extends Component {

{ __( 'Handoff Buttons', 'newspack-plugin' ) }

- { __( 'Specific Yoast Page', 'newspack-plugin' ) } + + { __( 'Go to Dashboard', 'newspack-plugin' ) } +
@@ -490,6 +488,14 @@ class ComponentsDemo extends Component { handoff="jetpack" editLink="admin.php?page=jetpack#/settings" /> +
{ bodyText }
-