api = $api; $this->connect = $connect; } /** * Hooks the page into the admin. */ public function register(): void { // Priority 13: after the parent menu (11) and Maps (12), so // Settings sits last. add_action( 'admin_menu', array( $this, 'add_menu' ), 13 ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'admin_post_mapsemble_connect_finish', array( $this, 'handle_connect_finish' ) ); add_action( 'wp_ajax_mapsemble_connect_check', array( $this, 'ajax_connect_check' ) ); // Dropped from the address bar after the notice shows, like // settings-updated, so a reload cannot repeat it. add_filter( 'removable_query_args', static function ( array $args ): array { $args[] = self::CONNECT_FLAG; return $args; } ); } /** * Adds the screen under the Mapsemble menu. */ public function add_menu(): void { $this->hook_suffix = (string) add_submenu_page( Menu::SLUG, __( 'Mapsemble settings', 'mapsemble' ), __( 'Settings', 'mapsemble' ), Capabilities::MANAGE, self::PAGE_SLUG, array( $this, 'render_page' ) ); } /** * Loads the connect script and styles, on this screen only. * * The script whenever the connect section renders — a connected site * can reconnect, and that promises the same automation. A site * configured through constants cannot be connected from here at all. * * @param string $hook_suffix Current admin page. */ public function enqueue( string $hook_suffix ): void { if ( '' === $this->hook_suffix || $hook_suffix !== $this->hook_suffix ) { return; } // The stylesheet also lays out the collapsed sections, so it is // wanted whatever the state; the script only while there is // something to poll for. wp_enqueue_style( 'mapsemble-admin-connect', plugins_url( 'assets/css/admin-connect.css', MAPSEMBLE_PLUGIN_FILE ), array(), MAPSEMBLE_VERSION ); if ( $this->connect->credentials_are_constants() ) { return; } wp_enqueue_script( 'mapsemble-admin-connect', plugins_url( 'assets/js/admin-connect.js', MAPSEMBLE_PLUGIN_FILE ), array( 'wp-i18n' ), MAPSEMBLE_VERSION, true ); wp_set_script_translations( 'mapsemble-admin-connect', 'mapsemble', MAPSEMBLE_PLUGIN_DIR . '/languages' ); wp_localize_script( 'mapsemble-admin-connect', 'mapsembleConnect', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'mapsemble_connect_check' ), ) ); } /** * Finishes a connect attempt by hand: the no-JavaScript path. * * With scripting on, the page polls ajax_connect_check() and follows * it to the same notice, and the button posting here is hidden — but the script puts it * back if it gives up waiting, so there is always a way to finish. */ public function handle_connect_finish(): void { check_admin_referer( 'mapsemble_connect_finish' ); if ( ! current_user_can( Capabilities::MANAGE ) ) { wp_die( esc_html__( 'You are not allowed to change the Mapsemble settings.', 'mapsemble' ) ); } if ( $this->connect->collect() ) { $this->park_connected_notice(); } else { add_settings_error( 'mapsemble', 'mapsemble_not_connected', __( 'Nothing authenticated yet. Open "Authenticate on Mapsemble", press Connect there, then try again.', 'mapsemble' ), 'warning' ); $this->park_notices(); } wp_safe_redirect( add_query_arg( self::CONNECT_FLAG, '1', self::url() ) ); exit; } /** * Reports whether the approval has landed yet, collecting it if so. * * Polled by the settings page while an admin approves on Mapsemble, so * the approval completes on its own. Nothing the caller supplies is * trusted, because the caller supplies nothing: the state is read from * this site's own option. */ public function ajax_connect_check(): void { check_ajax_referer( 'mapsemble_connect_check', 'nonce' ); if ( ! current_user_can( Capabilities::MANAGE ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to change the Mapsemble settings.', 'mapsemble' ) ), 403 ); } // While an attempt is pending the honest answer is whether *it* has // landed — a connected site reconnecting must not be told "yes" for // the credentials it is trying to replace. Without one (rare: every // render of the section starts one) there is nothing to collect. $pending = $this->connect->has_pending_attempt(); $connected = $pending ? $this->connect->collect() : $this->connect->is_connected(); // An approval that landed on this very poll gets the same success // notice as the manual finish, and the script is sent to the URL // that shows it. Otherwise there is nothing new to announce. $redirect = ''; if ( $pending && $connected ) { $this->park_connected_notice(); $redirect = add_query_arg( self::CONNECT_FLAG, '1', self::url() ); } wp_send_json( array( 'connected' => $connected, 'redirect' => $redirect, ) ); } /** * Queues the "connected" success notice and parks it for the next page. */ private function park_connected_notice(): void { add_settings_error( 'mapsemble', 'mapsemble_connected', __( 'Connected to Mapsemble. Your maps are now listed when you place a map block.', 'mapsemble' ), 'success' ); $this->park_notices(); } /** * Parks the queued settings notices for the page the flag leads to. * * The same hand-off core's options.php uses: the notices ride a * short-lived transient across the redirect, and render_page() picks * them up when it sees the flag. */ private function park_notices(): void { set_transient( 'settings_errors', get_settings_errors(), 30 ); } /** * Registers the mapsemble_settings option. */ public function register_settings(): void { register_setting( 'mapsemble', 'mapsemble_settings', array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => array(), ) ); } /** * Sanitizes submitted settings; blank secret keeps the saved one, and so * does an absent client ID. * * @param mixed $input Raw submitted value. * @return array Clean settings array. */ public function sanitize_settings( $input ): array { $input = is_array( $input ) ? $input : array(); $saved = get_option( 'mapsemble_settings', array() ); $saved = is_array( $saved ) ? $saved : array(); $secret = trim( (string) ( $input['client_secret'] ?? '' ) ); if ( '' === $secret ) { $secret = (string) ( $saved['client_secret'] ?? '' ); } // Absent is not empty: a disabled input (MAPSEMBLE_CLIENT_ID defined) // does not submit, and saving the uninstall choice then must not // blank the saved ID behind the constant. A field sent empty is a // deliberate clear and still clears. $client_id = array_key_exists( 'client_id', $input ) ? sanitize_text_field( (string) $input['client_id'] ) : (string) ( $saved['client_id'] ?? '' ); // Fresh credentials invalidate everything derived from the old ones; // see forget_account_caches() for what and why. Only fresh ones: a // save that just flips the uninstall choice leaves the token and the // map list valid, and dropping them would cost the next screen a // round trip for nothing. Credentials set through the // MAPSEMBLE_CLIENT_ID / MAPSEMBLE_CLIENT_SECRET constants never pass // through here; see readme.txt for the transients to delete by hand // in that case. if ( (string) ( $saved['client_id'] ?? '' ) !== $client_id || (string) ( $saved['client_secret'] ?? '' ) !== $secret ) { Client::forget_account_caches(); } return array( 'client_id' => $client_id, 'client_secret' => $secret, 'delete_data_on_uninstall' => ! empty( $input['delete_data_on_uninstall'] ), ); } /** * Renders the screen. */ public function render_page(): void { if ( ! current_user_can( Capabilities::MANAGE ) ) { return; } // This screen hangs off a custom admin.php menu, and core only renders // settings notices on its own options-*.php screens — so without this, // options.php redirects back here with ?settings-updated=true and the // page shows nothing, leaving a completed save looking like a no-op. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only read of the redirect marker. if ( isset( $_GET['settings-updated'] ) ) { add_settings_error( 'mapsemble', 'mapsemble_settings_updated', __( 'Settings saved.', 'mapsemble' ), 'success' ); } // The connect-finish handler parks its notice the way options.php // does; core only reads that transient back under // ?settings-updated, which would also announce "Settings saved." // for a connect that found nothing. So it is read back here, under // the handler's own flag. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only read of the redirect marker. if ( isset( $_GET[ self::CONNECT_FLAG ] ) ) { $parked = get_transient( 'settings_errors' ); delete_transient( 'settings_errors' ); foreach ( is_array( $parked ) ? $parked : array() as $error ) { if ( 'mapsemble' === ( $error['setting'] ?? '' ) ) { add_settings_error( 'mapsemble', (string) $error['code'], (string) $error['message'], (string) $error['type'] ); } } } // Unconditional, so any validation errors registered against the // 'mapsemble' setting surface here too, not just the saved notice. settings_errors( 'mapsemble' ); echo '
' . esc_html__( 'This site is connected. Reconnect only if you have revoked the credentials on Mapsemble or switched accounts.', 'mapsemble' ) . '
'; // Collapsed once it has done its job: the controls below are then // for the rare reconnect, not for reading. echo '' . esc_html( $this->connect->credentials_are_constants() ? __( 'Enter your Mapsemble client ID and secret to connect.', 'mapsemble' ) : __( 'Authenticate on Mapsemble above, or enter your client ID and secret, to connect.', 'mapsemble' ) ) . '
'; return; } $maps = $this->api->get_remote_maps(); $error = $this->api->get_last_error(); if ( '' !== $error ) { printf( '%s
%s
%s