'
. '',
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;
}
}