' . '', esc_attr( self::ROOT_ID ), esc_html__( 'The Integrations screen requires JavaScript to be enabled.', 'acrossai-abilities-manager' ) ); } /** * Capability every opt-in change requires, whatever the filter returns. * * The admin page is gated at `manage_options`, but `sanitize_option()` is a public * `sanitize_option_{$option}` callback: any code that writes the option directly runs it without * that gate. So the floor is layered here too, ahead of the filtered check, per * PATTERN-FILTERABLE-CAPABILITY-RAISE-ONLY — the filter may raise the bar, never lower it. * * @var string */ public const CAPABILITY_FLOOR = 'manage_options'; /** * Singleton instance. * * @var Integrations_Menu|null */ protected static $_instance = null; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore -- plugin-wide singleton convention. /** * Retrieve the singleton. * * @since 0.0.34 * @return Integrations_Menu */ public static function instance(): self { if ( null === self::$_instance ) { self::$_instance = new self(); } return self::$_instance; } /** * Private constructor. * * @since 0.0.34 */ private function __construct() {} /** * Register the opt-in option. * * No settings page, section or field goes with it. `register_setting()` is called purely for * its side effect of hooking `sanitize_option_{$option}`, which is where the capability check * lives — see `sanitize_option()`. * * @since 0.0.34 * @return void */ public function register_settings(): void { register_setting( self::OPTION_GROUP, AcrossAI_Integration_Settings::OPTION_KEY, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_option' ), 'default' => array(), ) ); } /** * Enqueue the React bundle on this page. * * Hooked to `admin_enqueue_scripts`. * * @since 0.0.42 * @param string $hook_suffix Current admin page hook. * @return void */ public function enqueue_assets( string $hook_suffix ): void { if ( ! $this->is_integrations_page( $hook_suffix ) ) { return; } $build_url = defined( 'ACROSSAI_ABILITIES_MANAGER_PLUGIN_URL' ) ? \ACROSSAI_ABILITIES_MANAGER_PLUGIN_URL . 'build/' : plugins_url( 'build/', dirname( __DIR__, 2 ) . '/acrossai-abilities-manager.php' ); $build_path = defined( 'ACROSSAI_ABILITIES_MANAGER_PLUGIN_PATH' ) ? \ACROSSAI_ABILITIES_MANAGER_PLUGIN_PATH . 'build/' : dirname( __DIR__, 2 ) . '/build/'; $asset_file = $build_path . 'js/integrations.asset.php'; if ( ! file_exists( $asset_file ) ) { // Bundle not built — the noscript message stands in. return; } $asset = require $asset_file; $version = (string) ( $asset['version'] ?? '0.0.0' ); wp_enqueue_script( 'acrossai-integrations', $build_url . 'js/integrations.js', (array) ( $asset['dependencies'] ?? array() ), $version, true ); wp_localize_script( 'acrossai-integrations', 'acrossaiIntegrations', array( 'restNamespace' => 'acrossai/v1', 'restBase' => 'integrations', 'nonce' => wp_create_nonce( 'wp_rest' ), 'proUrl' => 'https://acrossai.co/pricing/', 'requestUrl' => 'https://acrossai.co/request-integration', 'abilitiesUrl' => admin_url( 'admin.php?page=' . Menu::ABILITIES_SLUG ), ) ); if ( file_exists( $build_path . 'css/integrations.css' ) ) { wp_enqueue_style( 'acrossai-integrations', $build_url . 'css/integrations.css', array( 'wp-components' ), $version ); } } /** * Whether the current admin request is this page. * * Matched on the hook suffix containing the menu slug rather than on an exact string: the * prefix WordPress builds depends on the parent menu's own slug and sanitisation, which belongs * to acrossai-co/main-menu and is not ours to predict. * * @since 0.0.42 * @param string $hook_suffix Current admin page hook. * @return bool */ private function is_integrations_page( string $hook_suffix ): bool { return false !== strpos( $hook_suffix, self::MENU_SLUG ); } /** * Sanitize and authorise the submitted opt-ins. * * The per-integration capability check sits here so that the filtered capability can only raise * the bar (PATTERN-FILTERABLE-CAPABILITY-RAISE-ONLY). A denied change leaves the stored value * untouched and fires `acrossai_integration_toggle_denied` so sites can audit it, matching what * `Integrations_Directory_Controller::set_enabled()` does for the screen's own writes. * * Written for a checkbox form that no longer exists, and kept because it is the guard on the * option itself: anything calling `update_site_option()` on it still runs through here. The * absent-means-off reading of the input is a consequence of that origin — a caller handing this * a partial array is treated as having unticked the rest. * * @since 0.0.34 * @param mixed $input Raw value from the settings POST. * @return array */ public function sanitize_option( $input ): array { $submitted = is_array( $input ) ? $input : array(); $stored = get_site_option( AcrossAI_Integration_Settings::OPTION_KEY, array() ); $stored = is_array( $stored ) ? $stored : array(); $clean = array(); // Unconditional floor, ahead of the per-integration filtered capability. Fail closed by // handing back exactly what is stored, so a denied request changes nothing. if ( ! current_user_can( self::CAPABILITY_FLOOR ) ) { return $stored; } foreach ( array_keys( AcrossAI_Integration_Settings::discover() ) as $slug ) { $slug = (string) $slug; $desired = ! empty( $submitted[ $slug ] ); $current = ! empty( $stored[ $slug ] ); if ( $desired === $current ) { $clean[ $slug ] = $current; continue; } $required_cap = AcrossAI_Integration_Settings::required_capability( $slug ); if ( '' === $required_cap || ! current_user_can( $required_cap ) ) { /** * Fires when a user is denied permission to change an integration opt-in. * * @since 0.1.0 * @param string $integration_slug Integration slug. * @param string $required_cap Capability that would have been required. * @param int $user_id Current user ID (0 for guests). */ do_action( 'acrossai_integration_toggle_denied', $slug, $required_cap, get_current_user_id() ); add_settings_error( AcrossAI_Integration_Settings::OPTION_KEY, 'acrossai_integration_forbidden_' . sanitize_key( $slug ), sprintf( /* translators: %s is an integration name. */ esc_html__( 'You are not allowed to change the %s integration.', 'acrossai-abilities-manager' ), esc_html( $slug ) ), 'error' ); $clean[ $slug ] = $current; continue; } $clean[ $slug ] = $desired; } // Preserve opt-ins for integrations whose plugin is inactive right now, so deactivating a // plugin does not silently discard the operator's choice for when it returns. foreach ( $stored as $slug => $value ) { if ( ! array_key_exists( (string) $slug, $clean ) ) { $clean[ (string) $slug ] = (bool) $value; } } return $clean; } }