*/ private static $hooks = array(); /** * Hooks. */ public static function register(): void { add_action( 'admin_init', array( Tables::class, 'maybe_upgrade' ) ); add_action( 'admin_menu', array( self::class, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue' ) ); add_action( 'admin_post_flitagent_decide', array( self::class, 'decide' ) ); add_action( 'admin_post_flitagent_settings', array( self::class, 'save_settings' ) ); add_action( 'admin_init', array( self::class, 'onboarding_redirect' ) ); // Fires just before wp-admin refuses an unregistered page; admin_init never runs for one. add_action( 'admin_page_access_denied', array( self::class, 'change_link' ) ); add_action( 'admin_post_' . Onboarding::ACTION, array( self::class, 'save_onboarding' ) ); add_filter( 'plugin_action_links_' . plugin_basename( FLITAGENT_FILE ), array( self::class, 'action_links' ) ); } /** * Adds the menu, with a count of pending changes while writes are on (only for users who can open Approvals). */ public static function menu(): void { $title = __( 'FlitAgent', 'flitagent' ); if ( current_user_can( self::CAPABILITY ) && ( new WpPolicy() )->writes_enabled() ) { $pending = Screens::waiting( new WpStore(), ( new WpClock() )->now() ); if ( $pending > 0 ) { $title .= ' ' . esc_html( number_format_i18n( $pending ) ) . ''; } } // No parent and no menu title: reachable at admin.php?page=flitagent-onboarding, not in the menu. $guide = add_submenu_page( '', __( 'Set up FlitAgent', 'flitagent' ), '', self::CAPABILITY, Onboarding::SLUG, array( self::class, 'onboarding' ) ); if ( is_string( $guide ) ) { add_action( 'load-' . $guide, array( self::class, 'onboarding_load' ) ); } self::$hooks = array_values( array_filter( array( add_menu_page( __( 'FlitAgent approvals', 'flitagent' ), $title, self::CAPABILITY, 'flitagent-approvals', array( self::class, 'approvals' ), 'dashicons-shield', 81 ), add_submenu_page( 'flitagent-approvals', __( 'FlitAgent approvals', 'flitagent' ), __( 'Approvals', 'flitagent' ), self::CAPABILITY, 'flitagent-approvals', array( self::class, 'approvals' ) ), add_submenu_page( 'flitagent-approvals', __( 'Connect to flitagent.com', 'flitagent' ), __( 'Connect', 'flitagent' ), self::CAPABILITY, ConnectScreen::SLUG, array( self::class, 'connect' ) ), add_submenu_page( 'flitagent-approvals', __( 'FlitAgent activity', 'flitagent' ), __( 'Activity', 'flitagent' ), self::CAPABILITY, 'flitagent-activity', array( self::class, 'activity' ) ), add_submenu_page( 'flitagent-approvals', __( 'FlitAgent settings', 'flitagent' ), __( 'Settings', 'flitagent' ), self::CAPABILITY, 'flitagent-settings', array( self::class, 'settings' ) ), $guide, ), 'is_string' ) ); } /** * Runs when the setup guide loads (`load-{hook}`). The page has no menu entry, so WordPress knows neither its title * nor which menu to highlight: set the title and highlight FlitAgent → Connect. */ public static function onboarding_load(): void { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- the admin page title core reads in admin-header.php; a hidden page has none. $GLOBALS['title'] = __( 'Set up FlitAgent', 'flitagent' ); add_filter( 'parent_file', array( self::class, 'onboarding_parent_file' ) ); add_filter( 'submenu_file', array( self::class, 'onboarding_submenu_file' ) ); add_action( 'admin_head', array( self::class, 'onboarding_unlist' ) ); } /** * Takes the setup guide out of the submenu list once WordPress has checked access to it. Otherwise * get_admin_page_parent(), which menu-header.php calls after the parent_file filter, finds it under the empty parent * and clears the menu highlight. Runs on `admin_head`, before the menu is drawn. */ public static function onboarding_unlist(): void { remove_submenu_page( '', Onboarding::SLUG ); } /** * Highlights the FlitAgent menu on the setup guide. Runs on `parent_file`, replacing core's choice. * * @return string */ public static function onboarding_parent_file(): string { return 'flitagent-approvals'; } /** * Highlights FlitAgent → Connect on the setup guide. Runs on `submenu_file`, replacing core's choice. * * @return string */ public static function onboarding_submenu_file(): string { return ConnectScreen::SLUG; } /** * Adds the compiled admin script and styles (see Assets) on FlitAgent's screens only. Runs on `admin_enqueue_scripts`. * * @param string $hook_suffix Hook suffix of the current admin page. */ public static function enqueue( $hook_suffix ): void { if ( ! is_string( $hook_suffix ) || '' === $hook_suffix || ! in_array( $hook_suffix, self::$hooks, true ) ) { return; } $asset = Assets::meta( 'admin' ); wp_register_style( self::STYLE, Assets::url( 'admin.css' ), array(), $asset['version'] ); wp_enqueue_style( self::STYLE ); wp_register_script( self::STYLE, Assets::url( 'admin.js' ), $asset['dependencies'], $asset['version'], true ); wp_enqueue_script( self::STYLE ); } /** * Approvals screen. */ public static function approvals(): void { echo wp_kses( self::screens()->approvals( Request::query( array( 'change' ) ) ), Ui::allowed_html() ); } /** * Connect screen. */ public static function connect(): void { echo wp_kses( ConnectScreen::render(), Ui::allowed_html() ); } /** * Setup guide (#134). */ public static function onboarding(): void { echo wp_kses( OnboardingScreen::render( Request::query( array( 'step', 'setup', ConnectScreen::DONE, 'run' ) ) ), Ui::allowed_html() ); } /** * Sends an administrator to the setup guide once after a new install. Runs on `admin_init`. */ public static function onboarding_redirect(): void { $url = self::onboarding_redirect_url(); if ( null !== $url ) { add_filter( 'allowed_redirect_hosts', array( self::class, 'allow_admin_host' ) ); wp_safe_redirect( $url ); exit; } } /** * Where to redirect this request to, once, or null (see Onboarding::redirect_target()). * * @return string|null */ public static function onboarding_redirect_url(): ?string { return Onboarding::redirect_target(); } /** * Sends the link an AI app was given for a change (WpPolicy::approve_url()) on to that change. Runs on * `admin_page_access_denied`. The link carries no nonce, so this reads core's `$plugin_page` and never the request. */ public static function change_link(): void { global $plugin_page; $url = self::change_link_url( $plugin_page, new WpStore() ); if ( null !== $url ) { add_filter( 'allowed_redirect_hosts', array( self::class, 'allow_admin_host' ) ); wp_safe_redirect( $url ); exit; } } /** * Where a change's link goes: the change, with a nonce made for this session, or the Approvals list for a change the * site does not have. Null, so core refuses as usual, for any other page name and for a user who cannot open Approvals, * whether or not the change exists. * * @param mixed $page The page asked for (core's `$plugin_page`). * @param Store $store Store. * @return string|null */ public static function change_link_url( $page, Store $store ): ?string { if ( ! is_string( $page ) || 1 !== preg_match( '/^' . preg_quote( WpPolicy::CHANGE_PAGE, '/' ) . '([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/D', $page, $match ) ) { return null; } if ( ! current_user_can( self::CAPABILITY ) ) { return null; } return null === $store->change( $match[1] ) ? admin_url( 'admin.php?page=flitagent-approvals' ) : Screens::change_url( $match[1] ); } /** * `allowed_redirect_hosts` for the handlers: adds this site's admin host (siteurl's), so they still reach their screen * when siteurl and home are on different hosts (core allows only the home host, and sends anything else to the Dashboard). * * @param mixed $hosts Allowed hosts. * @return array */ public static function allow_admin_host( $hosts ): array { $hosts = is_array( $hosts ) ? $hosts : array(); $host = wp_parse_url( admin_url(), PHP_URL_HOST ); return is_string( $host ) && '' !== $host ? array_merge( $hosts, array( $host ) ) : $hosts; } /** * Adds Connect first among FlitAgent's links on the Plugins screen. Runs on `plugin_action_links_{file}`. * * @param array $links Action links. * @return array */ public static function action_links( array $links ): array { return array_merge( array( 'connect' => '' . esc_html__( 'Connect', 'flitagent' ) . '' ), $links ); } /** * Activity screen. */ public static function activity(): void { echo wp_kses( self::screens()->activity( Request::query( array( 'tool', 'status', 'paged' ) ) ), Ui::allowed_html() ); } /** * Settings screen. */ public static function settings(): void { $post = Request::post( array( DiagnosticsScreen::ACTION, '_wpnonce' ), DiagnosticsScreen::ACTION ); if ( array() === $post && Request::is_post() ) { // This screen's only POST form failed its nonce: the check below answers that the link expired. $post = array( DiagnosticsScreen::ACTION => '1' ); } $connection = ( new GatewayStore() )->connection(); echo wp_kses( self::screens()->settings( DiagnosticsScreen::render( $post ), null === $connection ? '' : $connection->scope() ), Ui::allowed_html() ); } /** * Handles admin-post.php: approve or reject. */ public static function decide(): void { add_filter( 'allowed_redirect_hosts', array( self::class, 'allow_admin_host' ) ); wp_safe_redirect( ( new Actions( Factory::gateway() ) )->decide( self::decide_fields() ) ); exit; } /** * Handles admin-post.php: save settings. */ public static function save_settings(): void { add_filter( 'allowed_redirect_hosts', array( self::class, 'allow_admin_host' ) ); wp_safe_redirect( ( new Actions( Factory::gateway() ) )->save_settings( self::settings_fields() ) ); exit; } /** * Handles admin-post.php: the setup guide's forms. */ public static function save_onboarding(): void { add_filter( 'allowed_redirect_hosts', array( self::class, 'allow_admin_host' ) ); wp_safe_redirect( ( new Actions( Factory::gateway() ) )->onboarding( self::onboarding_fields() ) ); exit; } /** * The approve or reject form's fields. Its nonce names no change, so it can be checked before the change is read; * Actions::decide() then checks the second nonce, which is bound to that change. * * @return array Sanitized POST fields. */ public static function decide_fields(): array { $post = Request::post( array( '_wpnonce', Actions::CHANGE_NONCE, 'change', 'decision', 'plan_items' ), Actions::DECIDE ); if ( isset( $post['plan_items'] ) ) { $post['items'] = Request::post_list( 'items', Actions::DECIDE ); } return $post; } /** * The settings form's fields. * * @return array Sanitized POST fields. */ public static function settings_fields(): array { $post = Request::post( array( '_wpnonce', WpPolicy::OPTION_ENABLED, WpPolicy::OPTION_TTL, WpPolicy::OPTION_REMOVE_DATA ), Actions::SETTINGS ); if ( array() !== $post ) { $post[ WpPolicy::OPTION_AUTO ] = Request::post_list( WpPolicy::OPTION_AUTO, Actions::SETTINGS ); } return $post; } /** * The setup guide's form fields. * * @return array Sanitized POST fields. */ public static function onboarding_fields(): array { return Request::post( array( '_wpnonce', 'do', 'mode' ), Onboarding::ACTION ); } /** * Screens for this request. * * @return Screens */ private static function screens(): Screens { return new Screens( Factory::gateway(), new WpStore(), new WpClock() ); } }