PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.3
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.3
3.3.3 3.3.2 3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 158 releases
← All changes | includes/MCP/Manager.php +741 -105 3.3.2 → 3.3.3 View file →
@@ -53,14 +53,36 @@
53 53 add_action( 'parse_request', array( $this, 'handle_front_requests' ), 0 );
54 54
55 55 // Admin settings tab (pure PHP field schema; no JS rebuild needed).
56 56 add_filter( 'nx_settings_tab', array( $this, 'register_settings_tab' ), 20 );
57 + add_filter( 'nx_protected_settings', array( $this, 'protect_enable_setting' ) );
57 58
58 59 // CSS + JS for the MCP panel (copy / reveal / revoke controls).
59 60 add_action( 'admin_print_footer_scripts', array( $this, 'print_panel_assets' ) );
61 + add_action( 'admin_init', array( $this, 'redirect_hidden_tab' ) );
60 62 }
61 63
62 64 /**
65 + * Send a user who cannot see the MCP tab (see register_settings_tab()) from
66 + * a `?tab=tab-mcp` link to the settings screen's first tab, rather than to
67 + * an empty screen.
68 + *
69 + * @return void
70 + */
71 + public function redirect_hidden_tab() {
72 + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only navigation check.
73 + if ( wp_doing_ajax() || ! isset( $_GET['page'], $_GET['tab'] ) || 'nx-settings' !== $_GET['page'] || 'tab-mcp' !== $_GET['tab'] ) {
74 + return;
75 + }
76 + // phpcs:enable
77 + if ( current_user_can( 'manage_options' ) ) {
78 + return;
79 + }
80 + wp_safe_redirect( remove_query_arg( 'tab' ) );
81 + exit;
82 + }
83 +
84 + /**
63 85 * Whether MCP access is switched on.
64 86 *
65 87 * @return bool
66 88 */
@@ -151,8 +173,24 @@
151 173 'methods' => 'POST',
152 174 'callback' => array( $this, 'rest_self_test' ),
153 175 'permission_callback' => $admin,
154 176 ) );
177 + // The enable toggle persists through here rather than the settings form.
178 + // The settings endpoint replaces the whole settings blob with whatever the
179 + // admin app posts (see Admin\Settings::save_settings()), so a request
180 + // carrying only `enable_mcp` would wipe every other setting. This writes
181 + // the one key and leaves the rest alone.
182 + register_rest_route( $ns, '/mcp/enable', array(
183 + 'methods' => 'POST',
184 + 'callback' => array( $this, 'rest_set_enabled' ),
185 + 'permission_callback' => $admin,
186 + 'args' => array(
187 + 'enabled' => array(
188 + 'required' => true,
189 + 'type' => 'boolean',
190 + ),
191 + ),
192 + ) );
155 193 register_rest_route( $ns, '/mcp/apps/revoke', array(
156 194 'methods' => 'POST',
157 195 'callback' => array( $this, 'rest_revoke_app' ),
158 196 'permission_callback' => $admin,
@@ -281,8 +319,13 @@
281 319 *
282 320 * @return \WP_REST_Response
283 321 */
284 322 public function rest_connection() {
323 + // Whatever switched MCP on -- the toggle, or the settings form's Save --
324 + // the panel asks here for the state to display, so make sure there is a
325 + // token to hand back rather than reporting an empty one until a reload.
326 + $this->ensure_paired();
327 +
285 328 return new \WP_REST_Response( $this->connection_state(), 200 );
286 329 }
287 330
288 331 /**
@@ -321,13 +364,66 @@
321 364 *
322 365 * @return \WP_REST_Response
323 366 */
324 367 public function rest_self_test() {
368 + // A token is normally minted while the settings tab is built, which only
369 + // happens on a server-rendered request. Saving from the admin app never
370 + // rebuilds it, so a test run straight after switching MCP on used to
371 + // report "no connection token has been generated yet" until the page was
372 + // reloaded. Mint here too, so the test reflects the saved state.
373 + $this->ensure_paired();
374 +
325 375 $result = SelfTest::get_instance()->run();
326 376 return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 );
327 377 }
328 378
329 379 /**
380 + * Turn MCP access on or off.
381 + *
382 + * Writes only `settings.enable_mcp`: the settings endpoint replaces the whole
383 + * blob with the posted one, so persisting the toggle through there would
384 + * require the admin app to post every other setting alongside it.
385 + *
386 + * @param \WP_REST_Request $request Incoming request.
387 + * @return \WP_REST_Response
388 + */
389 + public function rest_set_enabled( $request ) {
390 + $enabled = (bool) $request->get_param( 'enabled' );
391 +
392 + $settings = Settings::get_instance()->get( 'settings' );
393 + if ( ! is_array( $settings ) ) {
394 + $settings = array();
395 + }
396 + $settings['enable_mcp'] = $enabled;
397 + Settings::get_instance()->set( 'settings', $settings );
398 +
399 + // Same courtesy the settings tab does: switching on should leave the UI
400 + // with a token to show and a connection that can actually be tested.
401 + $this->ensure_paired();
402 +
403 + return new \WP_REST_Response(
404 + array( 'status' => 'success' ) + $this->connection_state(),
405 + 200
406 + );
407 + }
408 +
409 + /**
410 + * Mint a pairing token if MCP is on and none exists yet. Idempotent, and a
411 + * no-op while MCP is off so turning it off never creates credentials.
412 + *
413 + * @return void
414 + */
415 + protected function ensure_paired() {
416 + if ( ! $this->is_enabled() ) {
417 + return;
418 + }
419 + $pairing = Pairing::get_instance();
420 + if ( ! $pairing->is_connected() ) {
421 + $pairing->connect();
422 + }
423 + }
424 +
425 + /**
330 426 * Summarise the connection for the admin UI.
331 427 *
332 428 * @return array
333 429 */
@@ -737,11 +833,19 @@
737 833 * @param array $tabs Existing tabs.
738 834 * @return array
739 835 */
740 836 public function register_settings_tab( $tabs ) {
741 - // If MCP is on, make sure a pairing token exists so the UI has one to show.
742 - if ( $this->is_enabled() && ! Pairing::get_instance()->is_connected() ) {
743 - Pairing::get_instance()->connect();
837 + // No token is minted here. This runs for every user who can open the
838 + // NotificationX admin (and for GET /builder), so minting here bound the
839 + // token to whoever loaded a page first, including users who cannot use
840 + // it. The manage_options routes (enable, connection, self-test) mint it.
841 +
842 + // The panel prints the connection token, which acts as the administrator
843 + // who paired it. Settings access can be delegated to other roles (Role
844 + // Management), and every MCP route already requires manage_options, so
845 + // the tab is not shown to anyone who could not use those routes.
846 + if ( ! current_user_can( 'manage_options' ) ) {
847 + return $tabs;
744 848 }
745 849
746 850 $tabs['tab-mcp'] = array(
747 851 'id' => 'tab-mcp',
@@ -753,12 +857,38 @@
753 857 return $tabs;
754 858 }
755 859
756 860 /**
861 + * Keep `enable_mcp` out of reach of users who cannot manage MCP.
862 + *
863 + * The settings form posts the whole blob, so without this a user with
864 + * settings access but without manage_options could switch MCP on or off,
865 + * although every MCP management route requires manage_options.
866 + *
867 + * @param array $keys Protected settings keys.
868 + * @return array
869 + */
870 + public function protect_enable_setting( $keys ) {
871 + if ( ! current_user_can( 'manage_options' ) ) {
872 + $keys = (array) $keys;
873 + $keys[] = 'enable_mcp';
874 + }
875 + return $keys;
876 + }
877 +
878 + /**
757 879 * Build the MCP settings field schema. The rich panels are server-rendered
758 880 * HTML delivered through quickbuilder `message` fields (html => true); the
759 - * action buttons use quickbuilder `button` fields for the ajax + toast.
881 + * action buttons are plain buttons wired to the globals printed by
882 + * {@see print_panel_assets()}.
760 883 *
884 + * Section order follows the reading order of someone who has never used the
885 + * feature: what it is (hero), what it would give them (capabilities), and
886 + * only then the plumbing. The capability section deliberately carries no
887 + * `rules`, so the one screen that answers "why would I turn this on?" is
888 + * also visible while the feature is still off — the rest stays hidden until
889 + * it has something real to show.
890 + *
761 891 * @return array
762 892 */
763 893 protected function settings_fields() {
764 894 $enabled_rule = Rules::is( 'enable_mcp', true );
@@ -772,8 +902,9 @@
772 902 'mcp_hero' => array(
773 903 'name' => 'mcp_hero',
774 904 'type' => 'message',
775 905 'html' => true,
906 + 'classes' => 'nx-mcp-field nx-mcp-field-flush',
776 907 'message' => $this->hero_html(),
777 908 ),
778 909 'enable_mcp' => array(
779 910 'name' => 'enable_mcp',
@@ -779,10 +910,18 @@
779 910 'name' => 'enable_mcp',
780 911 'type' => 'toggle',
781 912 'default' => false,
782 913 'label' => __( 'Enable MCP access', 'notificationx' ),
783 - 'help' => __( 'When enabled and saved, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
914 + 'help' => __( 'When enabled, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
784 915 ),
916 + 'mcp_stats' => array(
917 + 'name' => 'mcp_stats',
918 + 'type' => 'message',
919 + 'html' => true,
920 + 'classes' => 'nx-mcp-field',
921 + 'rules' => $enabled_rule,
922 + 'message' => $this->stats_html(),
923 + ),
785 924 ),
786 925 ),
787 926
788 927 'mcp_connection_section' => array(
@@ -794,8 +933,9 @@
794 933 'mcp_connection_html' => array(
795 934 'name' => 'mcp_connection_html',
796 935 'type' => 'message',
797 936 'html' => true,
937 + 'classes' => 'nx-mcp-field',
798 938 'message' => $this->connection_html(),
799 939 ),
800 940 ),
801 941 ),
@@ -809,8 +949,9 @@
809 949 'mcp_clients_html' => array(
810 950 'name' => 'mcp_clients_html',
811 951 'type' => 'message',
812 952 'html' => true,
953 + 'classes' => 'nx-mcp-field',
813 954 'message' => $this->clients_html(),
814 955 ),
815 956 ),
816 957 ),
@@ -824,8 +965,9 @@
824 965 'mcp_apps_html' => array(
825 966 'name' => 'mcp_apps_html',
826 967 'type' => 'message',
827 968 'html' => true,
969 + 'classes' => 'nx-mcp-field',
828 970 'message' => $this->connected_apps_html(),
829 971 ),
830 972 ),
831 973 ),
@@ -839,8 +981,9 @@
839 981 'mcp_health_html' => array(
840 982 'name' => 'mcp_health_html',
841 983 'type' => 'message',
842 984 'html' => true,
985 + 'classes' => 'nx-mcp-field',
843 986 'message' => $this->health_html(),
844 987 ),
845 988 ),
846 989 ),
@@ -864,31 +1007,126 @@
864 1007 return array( 'setup', __( 'Setup needed', 'notificationx' ) );
865 1008 }
866 1009
867 1010 /**
868 - * Hero header with the status badge.
1011 + * The abilities currently registered, split the way the panel reads them.
869 1012 *
1013 + * Read straight from the registry rather than a hand-kept list, so the tab
1014 + * can never claim a tool the server does not actually expose — and so Pro's
1015 + * abilities appear the moment Pro adds them through `nx_register_abilities`
1016 + * with no change here. `boot()` is idempotent, and calling it is what makes
1017 + * this safe to render on a request where nothing else has touched the
1018 + * registry yet.
1019 + *
1020 + * @return array { read: array[], write: array[] } each row: label, tool, pro.
1021 + */
1022 + protected function ability_rows() {
1023 + $registrar = Registrar::get_instance();
1024 + $registrar->boot();
1025 +
1026 + $rows = array(
1027 + 'read' => array(),
1028 + 'write' => array(),
1029 + );
1030 +
1031 + foreach ( $registrar->get_all() as $id => $ability ) {
1032 + $row = array(
1033 + 'label' => $ability->get_label(),
1034 + 'tool' => $ability->tool_name(),
1035 + 'pro' => ( 0 === strpos( (string) $id, 'notificationx-pro/' ) ),
1036 + );
1037 +
1038 + $rows[ $ability->is_write() ? 'write' : 'read' ][] = $row;
1039 + }
1040 +
1041 + return $rows;
1042 + }
1043 +
1044 + /**
1045 + * The three setup steps shown as a static how-to in the hero.
1046 + *
1047 + * @return array[] Each: icon, label, hint.
1048 + */
1049 + protected function setup_steps() {
1050 + return array(
1051 + array(
1052 + 'icon' => 'icon-step-power',
1053 + 'label' => __( 'Turn MCP on', 'notificationx' ),
1054 + 'hint' => __( 'Flip the switch below.', 'notificationx' ),
1055 + ),
1056 + array(
1057 + 'icon' => 'icon-step-copy',
1058 + 'label' => __( 'Copy your connector', 'notificationx' ),
1059 + 'hint' => __( 'One URL, and a token for clients that need one.', 'notificationx' ),
1060 + ),
1061 + array(
1062 + 'icon' => 'icon-step-approve',
1063 + 'label' => __( 'Approve the client', 'notificationx' ),
1064 + 'hint' => __( 'Add it in Claude, ChatGPT or Cursor and confirm.', 'notificationx' ),
1065 + ),
1066 + );
1067 + }
1068 +
1069 + /**
1070 + * Path to one of the tab's own icon files.
1071 + *
1072 + * The panel HTML is rendered into the settings app through a `message`
1073 + * field, where an inline `<svg>` does not survive: icons are therefore real
1074 + * files referenced with `<img>`, never markup and never a `data:` URI.
1075 + *
1076 + * @param string $name File name, without extension.
870 1077 * @return string
871 1078 */
1079 + protected function icon_url( $name ) {
1080 + return NOTIFICATIONX_ADMIN_URL . 'images/mcp/' . $name . '.svg';
1081 + }
1082 +
1083 + /**
1084 + * Hero header: what the feature is, where the site currently stands, and
1085 + * the three steps between here and a working connection.
1086 + *
1087 + * @return string
1088 + */
872 1089 protected function hero_html() {
873 1090 list( $state, $label ) = $this->status();
1091 + $steps = $this->setup_steps();
874 1092 ob_start();
875 1093 ?>
876 - <div class="nx-mcp-hero">
877 - <div class="nx-mcp-hero-icon">&#128268;</div>
878 - <div class="nx-mcp-hero-body">
879 - <h3 class="nx-mcp-hero-title">
880 - <?php esc_html_e( 'MCP Server', 'notificationx' ); ?>
881 - <span class="nx-mcp-badge nx-mcp-badge-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $label ); ?></span>
882 - </h3>
883 - <p class="nx-mcp-hero-text">
884 - <?php esc_html_e( 'Connect NotificationX to Claude, ChatGPT, Cursor and other AI assistants through a built-in MCP server, so you can manage notifications and read analytics in plain language. It is off by default and only administrators can use it.', 'notificationx' ); ?>
885 - </p>
886 - <a class="nx-mcp-learn" href="<?php echo esc_url( 'https://notificationx.com/docs/mcp-in-notificationx' ); ?>" target="_blank" rel="noopener noreferrer">
887 - <span class="nx-mcp-learn-text"><?php esc_html_e( 'Learn how it works', 'notificationx' ); ?></span>
888 - <span class="nx-mcp-learn-arrow" aria-hidden="true">&rarr;</span>
889 - </a>
1094 + <div class="nx-mcp-hero nx-mcp-hero-<?php echo esc_attr( $state ); ?>">
1095 + <div class="nx-mcp-hero-main">
1096 + <span class="nx-mcp-hero-tile">
1097 + <img class="nx-mcp-hero-tile-ic" width="24" height="24" alt="" src="<?php echo esc_url( $this->icon_url( 'icon-mcp' ) ); ?>" />
1098 + </span>
1099 + <div class="nx-mcp-hero-body">
1100 + <h3 class="nx-mcp-hero-title">
1101 + <?php esc_html_e( 'Run NotificationX from your AI assistant', 'notificationx' ); ?>
1102 + <span class="nx-mcp-badge nx-mcp-badge-<?php echo esc_attr( $state ); ?>">
1103 + <span class="nx-mcp-badge-dot" aria-hidden="true"></span>
1104 + <span class="nx-mcp-badge-text"><?php echo esc_html( $label ); ?></span>
1105 + </span>
1106 + </h3>
1107 + <p class="nx-mcp-hero-text">
1108 + <?php esc_html_e( 'A built-in MCP server lets Claude, ChatGPT, Cursor and other assistants build campaigns, flip notifications on or off and read your analytics — in plain language, without leaving the chat. It stays off until you switch it on, and only administrators can connect.', 'notificationx' ); ?>
1109 + </p>
1110 + <a class="nx-mcp-learn" href="<?php echo esc_url( 'https://notificationx.com/docs/mcp-in-notificationx' ); ?>" target="_blank" rel="noopener noreferrer">
1111 + <span class="nx-mcp-learn-text"><?php esc_html_e( 'Learn how it works', 'notificationx' ); ?></span>
1112 + <span class="nx-mcp-learn-arrow" aria-hidden="true">&rarr;</span>
1113 + </a>
1114 + </div>
890 1115 </div>
1116 + <ol class="nx-mcp-rail">
1117 + <?php foreach ( $steps as $step ) : ?>
1118 + <li class="nx-mcp-rail-step">
1119 + <span class="nx-mcp-rail-mark" aria-hidden="true">
1120 + <img class="nx-mcp-rail-ic" width="16" height="16" alt="" src="<?php echo esc_url( $this->icon_url( $step['icon'] ) ); ?>" />
1121 + </span>
1122 + <span class="nx-mcp-rail-body">
1123 + <strong class="nx-mcp-rail-label"><?php echo esc_html( $step['label'] ); ?></strong>
1124 + <span class="nx-mcp-rail-hint"><?php echo esc_html( $step['hint'] ); ?></span>
1125 + </span>
1126 + </li>
1127 + <?php endforeach; ?>
1128 + </ol>
891 1129 </div>
892 1130 <?php
893 1131 return ob_get_clean();
894 1132 }
@@ -893,15 +1131,115 @@
893 1131 return ob_get_clean();
894 1132 }
895 1133
896 1134 /**
1135 + * The four headline numbers, each one read from state the page already has.
1136 + *
1137 + * There is deliberately no trend line or delta anywhere on this row: the
1138 + * server keeps a single `last_used` stamp and no history at all, so a trend
1139 + * here could only be invented.
1140 + *
1141 + * @return string
1142 + */
1143 + protected function stats_html() {
1144 + list( $state, $status_label ) = $this->status();
1145 +
1146 + $pairing = Pairing::get_instance();
1147 + $pstate = $pairing->state();
1148 + $connected_at = ! empty( $pstate['connected_at'] ) ? (int) $pstate['connected_at'] : 0;
1149 + $last_used = ! empty( $pstate['last_used'] ) ? (int) $pstate['last_used'] : 0;
1150 +
1151 + $rows = $this->ability_rows();
1152 + $tool_count = count( $rows['read'] ) + count( $rows['write'] );
1153 + $pro_count = 0;
1154 + foreach ( array_merge( $rows['read'], $rows['write'] ) as $row ) {
1155 + if ( $row['pro'] ) {
1156 + ++$pro_count;
1157 + }
1158 + }
1159 +
1160 + $apps = count( $this->get_connected_apps() );
1161 +
1162 + $tiles = array(
1163 + array(
1164 + 'key' => 'status',
1165 + 'icon' => 'icon-status',
1166 + 'label' => __( 'Server status', 'notificationx' ),
1167 + 'value' => $status_label,
1168 + 'small' => true,
1169 + 'note' => $connected_at
1170 + /* translators: %s: the date the connection was established. */
1171 + ? sprintf( __( 'since %s', 'notificationx' ), date_i18n( get_option( 'date_format' ), $connected_at ) )
1172 + : '',
1173 + ),
1174 + array(
1175 + 'icon' => 'icon-tools',
1176 + 'label' => __( 'Tools exposed', 'notificationx' ),
1177 + 'value' => number_format_i18n( $tool_count ),
1178 + 'small' => false,
1179 + 'note' => $pro_count
1180 + /* translators: %s: number of Pro-only tools. */
1181 + ? sprintf( _n( '%s from Pro', '%s from Pro', $pro_count, 'notificationx' ), number_format_i18n( $pro_count ) )
1182 + : __( 'more with Pro', 'notificationx' ),
1183 + ),
1184 + array(
1185 + 'icon' => 'icon-apps',
1186 + 'label' => __( 'Connected apps', 'notificationx' ),
1187 + 'value' => number_format_i18n( $apps ),
1188 + 'small' => false,
1189 + 'note' => $apps ? '' : __( 'none yet', 'notificationx' ),
1190 + ),
1191 + array(
1192 + 'icon' => 'icon-activity',
1193 + 'label' => __( 'Last activity', 'notificationx' ),
1194 + 'value' => $last_used
1195 + /* translators: %s: human-readable time difference, e.g. "5 mins". */
1196 + ? sprintf( __( '%s ago', 'notificationx' ), human_time_diff( $last_used ) )
1197 + : __( 'Never', 'notificationx' ),
1198 + 'small' => true,
1199 + 'note' => '',
1200 + ),
1201 + );
1202 +
1203 + ob_start();
1204 + ?>
1205 + <div class="nx-mcp-stats nx-mcp-stats-<?php echo esc_attr( $state ); ?>">
1206 + <?php foreach ( $tiles as $tile ) : ?>
1207 + <div class="nx-mcp-stat">
1208 + <div class="nx-mcp-stat-top">
1209 + <span class="nx-mcp-stat-label"><?php echo esc_html( $tile['label'] ); ?></span>
1210 + <span class="nx-mcp-stat-ic">
1211 + <img width="16" height="16" alt="" src="<?php echo esc_url( $this->icon_url( $tile['icon'] ) ); ?>" />
1212 + </span>
1213 + </div>
1214 + <div class="nx-mcp-stat-row">
1215 + <span class="nx-mcp-stat-value<?php echo $tile['small'] ? ' is-sm' : ''; ?><?php echo isset( $tile['key'] ) ? ' nx-mcp-stat-' . esc_attr( $tile['key'] ) : ''; ?>"><?php echo esc_html( $tile['value'] ); ?></span>
1216 + <?php if ( $tile['note'] ) : ?>
1217 + <span class="nx-mcp-stat-note"><?php echo esc_html( $tile['note'] ); ?></span>
1218 + <?php endif; ?>
1219 + </div>
1220 + </div>
1221 + <?php endforeach; ?>
1222 + </div>
1223 + <?php
1224 + return ob_get_clean();
1225 + }
1226 +
1227 + /**
897 1228 * Connector URL + token cards with copy/reveal controls.
898 1229 *
1230 + * Always rendered, even while MCP is off: the enable toggle saves itself and
1231 + * hands back the token, which nxMcpSetToken() writes into these cards, so
1232 + * the panel works without a reload.
1233 + *
899 1234 * @return string
900 1235 */
901 1236 protected function connection_html() {
902 - $url = $this->connector_url();
903 - $token = Pairing::get_instance()->site_token();
1237 + // The token is deliberately not printed here. This markup is part of the
1238 + // settings schema, which reaches every user who can open the
1239 + // NotificationX admin (and GET /builder), not only administrators.
1240 + // Show/Copy fetch it from GET /mcp/connection, which requires manage_options.
1241 + $url = $this->connector_url();
904 1242 ob_start();
905 1243 ?>
906 1244 <div class="nx-mcp-grid">
907 1245 <div class="nx-mcp-card">
@@ -914,19 +1252,21 @@
914 1252 </div>
915 1253 <div class="nx-mcp-card">
916 1254 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connection token', 'notificationx' ); ?></span>
917 1255 <div class="nx-mcp-copyrow">
918 - <code class="nx-mcp-value nx-mcp-token" data-token="<?php echo esc_attr( $token ); ?>">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
1256 + <code class="nx-mcp-value nx-mcp-token" data-token="">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
919 1257 <button type="button" class="nx-mcp-copy" onclick="nxMcpReveal(this)"><?php esc_html_e( 'Show', 'notificationx' ); ?></button>
920 - <button type="button" class="nx-mcp-copy" onclick="nxMcpCopy(this,'<?php echo esc_js( $token ); ?>')"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
1258 + <?php // Reads the token from the element rather than a value baked in at render time: the panel is built before MCP is switched on, so a literal here would stay empty until a reload. ?>
1259 + <button type="button" class="nx-mcp-copy" onclick="nxMcpCopyToken(this)"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
921 1260 </div>
922 1261 <p class="nx-mcp-hint"><?php esc_html_e( 'For token-based clients (ChatGPT, Cursor): send it as an Authorization: Bearer header. Keep it secret.', 'notificationx' ); ?></p>
923 1262 </div>
924 1263 </div>
925 1264 <div class="nx-mcp-actions">
926 - <button type="button" class="nx-mcp-btn nx-mcp-btn-secondary" onclick="nxMcpAction(this,'test',{success:'<?php echo esc_js( __( 'Connection test passed — the MCP server is reachable and exposing its tools.', 'notificationx' ) ); ?>'})"><?php esc_html_e( 'Test connection', 'notificationx' ); ?></button>
1265 + <button type="button" class="nx-mcp-btn nx-mcp-btn-primary" onclick="nxMcpAction(this,'test',{result:'nx-mcp-testresult',success:'<?php echo esc_js( __( 'Connection test passed — the MCP server is reachable and exposing its tools.', 'notificationx' ) ); ?>'})"><?php esc_html_e( 'Test connection', 'notificationx' ); ?></button>
927 1266 <button type="button" class="nx-mcp-btn nx-mcp-btn-ghost" onclick="nxMcpAction(this,'rotate',{confirm:'<?php echo esc_js( __( 'Reset the connection token? Existing clients will need the new token to reconnect.', 'notificationx' ) ); ?>',reload:true,success:'<?php echo esc_js( __( 'A new connection token was generated.', 'notificationx' ) ); ?>'})"><?php esc_html_e( 'Reset token', 'notificationx' ); ?></button>
928 1267 </div>
1268 + <div class="nx-mcp-result" id="nx-mcp-testresult"></div>
929 1269 <?php
930 1270 return ob_get_clean();
931 1271 }
932 1272
@@ -932,38 +1272,59 @@
932 1272
933 1273 /**
934 1274 * Per-client setup cards.
935 1275 *
1276 + * Each card ends in a copy control that hands over exactly what that client
1277 + * asks for — a URL for the two that take one, and a ready-made server block
1278 + * for the config-file clients. The token is never baked into these strings:
1279 + * the handler reads it from the token field already on the page, so this
1280 + * panel adds no second copy of the secret to the document.
1281 + *
936 1282 * @return string
937 1283 */
938 1284 protected function clients_html() {
939 - $url = esc_html( $this->connector_url() );
1285 + $url = $this->connector_url();
940 1286 ob_start();
941 1287 ?>
942 1288 <div class="nx-mcp-clients">
943 1289 <div class="nx-mcp-client">
944 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/claude.svg' ); ?>" /> <?php esc_html_e( 'Claude', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'OAuth', 'notificationx' ); ?></span></div>
1290 + <div class="nx-mcp-client-name">
1291 + <img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/claude.svg' ); ?>" />
1292 + <span class="nx-mcp-client-title"><?php esc_html_e( 'Claude', 'notificationx' ); ?></span>
1293 + <span class="nx-mcp-pill nx-mcp-pill-oauth"><?php esc_html_e( 'OAuth', 'notificationx' ); ?></span>
1294 + </div>
945 1295 <ol class="nx-mcp-steps">
946 1296 <li><?php esc_html_e( 'In Claude, add a custom connector.', 'notificationx' ); ?></li>
947 1297 <li><?php esc_html_e( 'Paste the Connector URL above.', 'notificationx' ); ?></li>
948 1298 <li><?php esc_html_e( 'Approve the connection when prompted — you sign in here, no token needed.', 'notificationx' ); ?></li>
949 1299 </ol>
1300 + <button type="button" class="nx-mcp-copy nx-mcp-copy-wide" onclick="nxMcpCopy(this,'<?php echo esc_js( $url ); ?>')"><?php esc_html_e( 'Copy connector URL', 'notificationx' ); ?></button>
950 1301 </div>
951 1302 <div class="nx-mcp-client">
952 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/chatgpt.svg' ); ?>" /> <?php esc_html_e( 'ChatGPT', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'Token', 'notificationx' ); ?></span></div>
1303 + <div class="nx-mcp-client-name">
1304 + <img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/chatgpt.svg' ); ?>" />
1305 + <span class="nx-mcp-client-title"><?php esc_html_e( 'ChatGPT', 'notificationx' ); ?></span>
1306 + <span class="nx-mcp-pill nx-mcp-pill-token"><?php esc_html_e( 'Token', 'notificationx' ); ?></span>
1307 + </div>
953 1308 <ol class="nx-mcp-steps">
954 1309 <li><?php esc_html_e( 'Settings → Connectors → Add a custom connector.', 'notificationx' ); ?></li>
955 - <li><?php /* translators: %s: connector URL */ printf( esc_html__( 'Use the URL %s.', 'notificationx' ), '<code>' . $url . '</code>' ); ?></li>
1310 + <li><?php esc_html_e( 'Use the Connector URL above.', 'notificationx' ); ?></li>
956 1311 <li><?php esc_html_e( 'Provide the connection token as a Bearer credential.', 'notificationx' ); ?></li>
957 1312 </ol>
1313 + <button type="button" class="nx-mcp-copy nx-mcp-copy-wide" onclick="nxMcpCopy(this,'<?php echo esc_js( $url ); ?>')"><?php esc_html_e( 'Copy connector URL', 'notificationx' ); ?></button>
958 1314 </div>
959 1315 <div class="nx-mcp-client">
960 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/cursor.svg' ); ?>" /> <?php esc_html_e( 'Cursor &amp; others', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'Token', 'notificationx' ); ?></span></div>
1316 + <div class="nx-mcp-client-name">
1317 + <img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/cursor.svg' ); ?>" />
1318 + <span class="nx-mcp-client-title"><?php esc_html_e( 'Cursor &amp; others', 'notificationx' ); ?></span>
1319 + <span class="nx-mcp-pill nx-mcp-pill-token"><?php esc_html_e( 'Token', 'notificationx' ); ?></span>
1320 + </div>
961 1321 <ol class="nx-mcp-steps">
962 - <li><?php esc_html_e( 'Add an MCP server with the Connector URL above.', 'notificationx' ); ?></li>
963 - <li><?php esc_html_e( 'Set the Authorization header to: Bearer <token>.', 'notificationx' ); ?></li>
1322 + <li><?php esc_html_e( 'Open the client’s MCP configuration file.', 'notificationx' ); ?></li>
1323 + <li><?php esc_html_e( 'Paste the server block below into mcpServers.', 'notificationx' ); ?></li>
964 1324 <li><?php esc_html_e( 'Confirm the install when the client asks.', 'notificationx' ); ?></li>
965 1325 </ol>
1326 + <button type="button" class="nx-mcp-copy nx-mcp-copy-wide nx-mcp-copy-config" data-url="<?php echo esc_attr( $url ); ?>"><?php esc_html_e( 'Copy JSON config', 'notificationx' ); ?></button>
966 1327 </div>
967 1328 </div>
968 1329 <?php
969 1330 return ob_get_clean();
@@ -969,13 +1330,8 @@
969 1330 return ob_get_clean();
970 1331 }
971 1332
972 1333 /**
973 - * The list of currently connected AI apps (pairing token + OAuth clients).
974 - *
975 - * @return string
976 - */
977 - /**
978 1334 * The currently connected apps (pairing token + active OAuth clients). Shared
979 1335 * by the rendered panel and the /mcp/apps endpoint so the two cannot drift.
980 1336 *
981 1337 * @return array[] Each: type, client_id, name, read_only.
@@ -1092,14 +1448,16 @@
1092 1448 // after a client-side navigation. Otherwise the panel shows unstyled until
1093 1449 // a manual reload.
1094 1450 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check.
1095 1451 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
1096 - if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) ) {
1452 + if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) || ! current_user_can( 'manage_options' ) ) {
1097 1453 return;
1098 1454 }
1099 1455 $nonce = wp_create_nonce( 'wp_rest' );
1100 1456 $urls = array(
1101 1457 'test' => esc_url_raw( rest_url( 'notificationx/v1/mcp/self-test' ) ),
1458 + 'enable' => esc_url_raw( rest_url( 'notificationx/v1/mcp/enable' ) ),
1459 + 'connection' => esc_url_raw( rest_url( 'notificationx/v1/mcp/connection' ) ),
1102 1460 'rotate' => esc_url_raw( rest_url( 'notificationx/v1/mcp/rotate' ) ),
1103 1461 'disconnect' => esc_url_raw( rest_url( 'notificationx/v1/mcp/disconnect' ) ),
1104 1462 'revoke' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps/revoke' ) ),
1105 1463 'apps' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps' ) ),
@@ -1108,8 +1466,17 @@
1108 1466 'revoke' => __( 'Revoke', 'notificationx' ),
1109 1467 'empty' => __( 'No AI clients are connected yet.', 'notificationx' ),
1110 1468 'refreshFailed' => __( 'Could not refresh the connected apps.', 'notificationx' ),
1111 1469 'revokeConfirm' => __( 'Revoke this connection? The client will need to reconnect.', 'notificationx' ),
1470 + // Enable toggle outcomes.
1471 + 'enabled' => __( 'MCP access enabled.', 'notificationx' ),
1472 + 'disabled' => __( 'MCP access disabled.', 'notificationx' ),
1473 + 'enableFailed' => __( 'Could not save the MCP setting.', 'notificationx' ),
1474 + // Generic action outcomes.
1475 + 'genericError' => __( 'Something went wrong.', 'notificationx' ),
1476 + 'requestFailed' => __( 'Request failed.', 'notificationx' ),
1477 + 'done' => __( 'Done.', 'notificationx' ),
1478 + 'revoked' => __( 'Connection revoked.', 'notificationx' ),
1112 1479 // Refresh outcomes: say what actually changed, not just a count.
1113 1480 'noneStill' => __( 'No apps connected yet.', 'notificationx' ),
1114 1481 'upToDate' => __( 'Up to date — nothing changed.', 'notificationx' ),
1115 1482 'addedOne' => __( '1 new app connected.', 'notificationx' ),
@@ -1118,90 +1485,209 @@
1118 1485 'removedOne' => __( '1 app disconnected.', 'notificationx' ),
1119 1486 /* translators: %d: number of disconnected apps. */
1120 1487 'removedMany' => __( '%d apps disconnected.', 'notificationx' ),
1121 1488 'changed' => __( 'Connected apps updated.', 'notificationx' ),
1489 + 'statusActive' => __( 'Active', 'notificationx' ),
1490 + 'statusOff' => __( 'Off', 'notificationx' ),
1491 + 'copied' => __( 'Copied', 'notificationx' ),
1492 + 'tokenMissing' => __( 'The token is not on screen yet. Reload the page and try again.', 'notificationx' ),
1493 + 'configCopied' => __( 'Server block copied. Paste it into your client’s MCP config.', 'notificationx' ),
1122 1494 );
1123 1495 ?>
1124 1496 <style id="nx-mcp-panel-css">
1125 - .nx-mcp-hero{display:flex;gap:14px;align-items:flex-start}
1126 - .nx-mcp-hero-icon{font-size:26px;line-height:1}
1127 - .nx-mcp-hero-title{margin:0 0 6px;font-size:18px;display:flex;align-items:center;gap:10px}
1128 - .nx-mcp-hero-text{margin:0;color:#50575e;max-width:640px}
1129 - .nx-mcp-learn{display:inline-flex;align-items:center;gap:5px;margin-top:10px;color:#6a4bff;font-size:13px;font-weight:600}
1497 + /* The settings form renders a message field's HTML inside a <p>, which
1498 + carries the form's own paragraph spacing: reset it on our own fields
1499 + so the panels control their own rhythm. */
1500 + .nx-mcp-field p{margin:0}
1501 + .nx-mcp-field-flush > p{margin:0}
1502 +
1503 + /* NotificationX's own `#notificationx .wprf-message p {font-size:16px}`
1504 + outranks a bare class, so every paragraph and list item inside a panel
1505 + would silently come back at the form's body size — which is what made
1506 + the old hint text read as body copy. These carry the same id plus the
1507 + class, so the panel keeps the type scale it was designed at without
1508 + reaching for !important. The unprefixed rules further down stay as the
1509 + fallback for anywhere the `#notificationx` root is absent. */
1510 + #notificationx .wprf-message p.nx-mcp-hero-text{font-size:13.5px;line-height:1.65}
1511 + #notificationx .wprf-message p.nx-mcp-hint{font-size:12px;line-height:1.55}
1512 + #notificationx .wprf-message p.nx-mcp-empty{font-size:13px}
1513 + #notificationx .wprf-message ol.nx-mcp-steps,#notificationx .wprf-message ol.nx-mcp-steps li{font-size:12.5px;line-height:1.75}
1514 + #notificationx .wprf-message ol.nx-mcp-rail,#notificationx .wprf-message ol.nx-mcp-rail li{font-size:12.5px}
1515 +
1516 + /* ---- Hero -------------------------------------------------------- */
1517 + .nx-mcp-hero{border-radius:14px;overflow:hidden;background:linear-gradient(135deg,#f6f3ff 0%,#fbfaff 55%,#ffffff 100%);color:#1d2327;border:1px solid #e4ddff;box-shadow:0 6px 20px rgba(106,75,255,.08)}
1518 + .nx-mcp-hero-main{display:flex;gap:16px;align-items:flex-start;padding:22px 24px 20px}
1519 + .nx-mcp-hero-tile{width:44px;height:44px;flex:none;border-radius:12px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#6a4bff,#8b6bff);border:0;box-shadow:0 4px 12px rgba(106,75,255,.28)}
1520 + .nx-mcp-hero-tile-ic{width:24px;height:24px;display:block}
1521 + .nx-mcp-hero-body{min-width:0}
1522 + .nx-mcp-hero-title{margin:0 0 8px;font-size:19px;line-height:1.3;font-weight:700;color:#1d2327;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
1523 + .nx-mcp-hero-text{margin:0;color:#50575e;font-size:13.5px;line-height:1.65;max-width:720px}
1524 + .nx-mcp-learn{display:inline-flex;align-items:center;gap:5px;margin-top:12px;color:#5a3ee6;font-size:13px;font-weight:600}
1130 1525 /* The message-field CSS (#notificationx .wprf-message p a) underlines the
1131 1526 whole anchor at rest, which draws a line under the arrow too. Override
1132 1527 it in every state (!important beats that #id rule) and underline only
1133 1528 the text span on hover. */
1134 - .nx-mcp-learn,.nx-mcp-learn:link,.nx-mcp-learn:visited,.nx-mcp-learn:hover,.nx-mcp-learn:focus,.nx-mcp-learn:active{text-decoration:none!important}
1529 + .nx-mcp-learn,.nx-mcp-learn:link,.nx-mcp-learn:visited,.nx-mcp-learn:hover,.nx-mcp-learn:focus,.nx-mcp-learn:active{text-decoration:none!important;color:#5a3ee6!important}
1135 1530 .nx-mcp-learn .nx-mcp-learn-text{text-decoration:none}
1136 1531 .nx-mcp-learn:hover .nx-mcp-learn-text{text-decoration:underline}
1137 1532 .nx-mcp-learn-arrow{display:inline-block;transition:transform .2s}
1138 1533 .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:translateX(3px)}
1139 - .nx-mcp-badge{font-size:11px;font-weight:600;padding:2px 10px;border-radius:999px;text-transform:uppercase;letter-spacing:.02em}
1140 - .nx-mcp-badge-off{background:#e2e4e7;color:#50575e}
1141 - .nx-mcp-badge-active{background:#e5f6ea;color:#1a7f37}
1142 - .nx-mcp-badge-setup{background:#fcf3e3;color:#996800}
1143 - /* Enable toggle: keep label + switch on one row (no fixed 200px label
1144 - column gap) and let the help text span full-width, left-aligned. */
1534 + /* The glyph is a literal right arrow: mirror it, and the nudge, in RTL. */
1535 + [dir="rtl"] .nx-mcp-learn-arrow{transform:scaleX(-1)}
1536 + [dir="rtl"] .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:scaleX(-1) translateX(3px)}
1537 +
1538 + /* ---- Status badge ------------------------------------------------ */
1539 + .nx-mcp-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:700;padding:3px 11px;border-radius:999px;text-transform:uppercase;letter-spacing:.04em;white-space:nowrap}
1540 + .nx-mcp-badge-dot{width:7px;height:7px;border-radius:50%;flex:none;background:currentColor}
1541 + .nx-mcp-badge-off{background:#eef0f3;color:#50575e}
1542 + .nx-mcp-badge-active{background:#d8f7e2;color:#127a35}
1543 + .nx-mcp-badge-setup{background:#ffeccc;color:#8a5a00}
1544 + .nx-mcp-badge-active .nx-mcp-badge-dot{animation:nx-mcp-pulse 1.8s ease-in-out infinite}
1545 + @keyframes nx-mcp-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.72)}}
1546 + @media(prefers-reduced-motion:reduce){.nx-mcp-badge-active .nx-mcp-badge-dot{animation:none}}
1547 +
1548 + /* ---- Setup rail -------------------------------------------------- */
1549 + .nx-mcp-rail{display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin:0;padding:0;list-style:none;background:transparent;border-top:1px solid #ece8ff}
1550 + .nx-mcp-rail-step{display:flex;gap:12px;align-items:center;padding:14px 20px;margin:0;position:relative}
1551 + .nx-mcp-rail-step + .nx-mcp-rail-step{border-inline-start:1px solid #ece8ff}
1552 + .nx-mcp-rail-mark{width:32px;height:32px;flex:none;border-radius:9px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#6a4bff 0%,#8b6bff 100%);box-shadow:0 3px 8px rgba(106,75,255,.25)}
1553 + .nx-mcp-rail-ic{width:16px;height:16px;display:block}
1554 + .nx-mcp-rail-body{display:flex;flex-direction:column;gap:2px;min-width:0}
1555 + .nx-mcp-rail-label{font-size:12.5px;font-weight:700;color:#1d2327}
1556 + .nx-mcp-rail-hint{font-size:11.5px;line-height:1.5;color:#646970}
1557 + @media(max-width:782px){.nx-mcp-rail{grid-template-columns:1fr}.nx-mcp-rail-step + .nx-mcp-rail-step{border-inline-start:0;border-top:1px solid #ece8ff}}
1558 +
1559 + /* ---- Enable toggle row ------------------------------------------- */
1560 + /* Keep label + switch on one row (no fixed 200px label column gap) and
1561 + let the help text span full-width, left-aligned. */
1145 1562 .wprf-name-enable_mcp{display:flex;flex-wrap:wrap;align-items:center}
1146 1563 .wprf-name-enable_mcp .wprf-control-label{width:auto!important;flex:0 0 auto!important;margin:0 12px 0 0!important}
1147 1564 .wprf-name-enable_mcp .wprf-control-field{display:contents}
1148 1565 .wprf-name-enable_mcp .wprf-toggle-wrap{order:2}
1149 1566 .wprf-name-enable_mcp .wprf-help{order:3;flex-basis:100%;width:100%;margin:8px 0 0!important}
1567 +
1568 + /* ---- Stat tiles -------------------------------------------------- */
1569 + .nx-mcp-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
1570 + @media(max-width:960px){.nx-mcp-stats{grid-template-columns:repeat(2,1fr)}}
1571 + @media(max-width:600px){.nx-mcp-stats{grid-template-columns:1fr}}
1572 + .nx-mcp-stat{border:1px solid #e6e6ec;border-radius:12px;padding:13px 15px;background:#fff;position:relative;overflow:hidden}
1573 + .nx-mcp-stat:before{content:"";position:absolute;top:0;bottom:0;inset-inline-start:0;width:3px;background:#6a4bff;opacity:.85}
1574 + .nx-mcp-stat-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}
1575 + .nx-mcp-stat-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#6b7280}
1576 + .nx-mcp-stat-ic{width:26px;height:26px;border-radius:8px;background:#f4f2ff;display:flex;align-items:center;justify-content:center;flex:none}
1577 + .nx-mcp-stat-ic img{width:16px;height:16px;display:block}
1578 + .nx-mcp-stat-row{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
1579 + .nx-mcp-stat-value{font-size:26px;line-height:1.1;font-weight:700;color:#1f2330}
1580 + .nx-mcp-stat-value.is-sm{font-size:16px;line-height:1.4}
1581 + .nx-mcp-stat-note{font-size:11.5px;color:#8a8f9c}
1582 +
1583 +
1584 + /* ---- Pills ------------------------------------------------------- */
1585 + .nx-mcp-pill{font-size:10px;font-weight:700;padding:2px 8px;border-radius:999px;text-transform:uppercase;letter-spacing:.03em;white-space:nowrap}
1586 + .nx-mcp-pill-pro{background:#fff1d6;color:#9a6400}
1587 + .nx-mcp-pill-oauth{background:#f0eefe;color:#6a4bff}
1588 + .nx-mcp-pill-token{background:#e7f1ff;color:#1d4ed8}
1589 +
1590 +
1591 + /* ---- Connection cards -------------------------------------------- */
1150 1592 .nx-mcp-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1151 1593 @media(max-width:782px){.nx-mcp-grid{grid-template-columns:1fr}}
1152 - .nx-mcp-card{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1153 - .nx-mcp-card-label{display:block;font-weight:600;font-size:12px;color:#50575e;text-transform:uppercase;letter-spacing:.03em;margin-bottom:8px}
1594 + .nx-mcp-card{border:1px solid #e6e6ec;border-radius:12px;padding:14px 16px;background:#fff}
1595 + .nx-mcp-card-label{display:block;font-weight:700;font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px}
1154 1596 .nx-mcp-copyrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1155 - .nx-mcp-value{background:#f6f7f7;border:1px solid #e0e0e0;border-radius:6px;padding:6px 10px;font-size:12px;flex:1;min-width:0;overflow:auto;white-space:nowrap}
1156 - .nx-mcp-copy{cursor:pointer;border:1px solid #c3c4c7;background:#f6f7f7;border-radius:6px;padding:6px 12px;font-size:12px;font-weight:600;color:#2c3338}
1157 - .nx-mcp-copy:hover{background:#eef0f1}
1158 - .nx-mcp-hint{margin:8px 0 0;color:#787c82;font-size:12px}
1597 + .nx-mcp-value{background:#f6f7f9;border:1px solid #e6e6ec;border-radius:8px;padding:7px 10px;font-size:12px;flex:1;min-width:0;overflow:auto;white-space:nowrap}
1598 + .nx-mcp-copy{cursor:pointer;border:1px solid #d3d4da;background:#fff;border-radius:8px;padding:7px 13px;font-size:12px;font-weight:600;color:#2c3338;transition:background .15s,border-color .15s}
1599 + .nx-mcp-copy:hover{background:#f4f2ff;border-color:#c3b8ff;color:#4c31d6}
1600 + .nx-mcp-copy-wide{display:block;width:100%;margin-top:12px;text-align:center}
1601 + .nx-mcp-hint{margin:8px 0 0;color:#8a8f9c;font-size:12px}
1602 +
1603 + /* ---- Pending (enabled but unsaved) ------------------------------- */
1604 +
1605 + /* ---- Client cards ------------------------------------------------ */
1159 1606 .nx-mcp-clients{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
1160 1607 @media(max-width:960px){.nx-mcp-clients{grid-template-columns:1fr}}
1161 - .nx-mcp-client{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1162 - .nx-mcp-client-name{font-weight:600;display:flex;align-items:center;gap:8px;margin-bottom:8px}
1163 - .nx-mcp-tag{font-size:10px;font-weight:600;background:#f0eefe;color:#6a4bff;padding:2px 8px;border-radius:999px;text-transform:uppercase}
1164 - .nx-mcp-steps{margin:0;padding-left:18px;color:#50575e;font-size:13px;line-height:1.7}
1608 + .nx-mcp-client{border:1px solid #e6e6ec;border-radius:12px;padding:14px 16px;background:#fff;display:flex;flex-direction:column;transition:border-color .15s,box-shadow .15s}
1609 + .nx-mcp-client:hover{border-color:#c3b8ff;box-shadow:0 6px 18px rgba(106,75,255,.08)}
1610 + .nx-mcp-client-name{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
1611 + .nx-mcp-client-title{font-weight:700;font-size:13.5px;color:#1f2330}
1612 + /* Nothing on this tab is submitted by the settings form: the enable
1613 + toggle saves itself and everything else is an ajax button or
1614 + read-only text, so a Save button here only invites a click that
1615 + does nothing. The control is a single shared quickbuilder
1616 + component every other tab still needs, so it is hidden for this
1617 + tab rather than removed. Selector mirrors the Entries tab, which
1618 + already hides it the same way, and has to out-specify
1619 + `#notificationx .wp-react-form... .wprf-submit{display:flex}`. */
1620 + #notificationx .nx-admin-wrapper .nx-settings-form-wrapper.tab-mcp .wprf-submit.wprf-control{display:none}
1621 + /* Client icons are <img> tags pointing at real SVG files: the card HTML is
1622 + kses-filtered, which strips <svg> and rejects data: URIs in src/style. */
1623 + .nx-mcp-client-ic{width:20px;height:20px;flex:none;display:inline-block;vertical-align:middle}
1624 + .nx-mcp-steps{margin:0;padding-inline-start:18px;color:#50575e;font-size:12.5px;line-height:1.75;flex:1}
1625 +
1626 + /* ---- Connected apps ---------------------------------------------- */
1165 1627 .nx-mcp-apps-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}
1166 - .nx-mcp-apps-hint{color:#787c82;font-size:12px}
1628 + .nx-mcp-apps-hint{color:#8a8f9c;font-size:12px}
1167 1629 .nx-mcp-btn-sm{padding:5px 12px;font-size:12px}
1168 1630 /* Once moved into the section heading bar, sit flush right on that row. */
1169 - .wprf-section-title .nx-mcp-refresh-apps{margin-left:auto}
1631 + .wprf-section-title .nx-mcp-refresh-apps{margin-inline-start:auto}
1170 1632 .nx-mcp-apps-head:empty{display:none;margin:0}
1171 1633 .nx-mcp-apps{display:flex;flex-direction:column;gap:10px}
1172 - .nx-mcp-app{display:flex;justify-content:space-between;align-items:center;border:1px solid #e0e0e0;border-radius:8px;padding:10px 14px;background:#fff}
1173 - .nx-mcp-app-info{display:flex;align-items:center;gap:10px}
1174 - .nx-mcp-scope{font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px}
1175 - .nx-mcp-scope-ro{background:#eef0f1;color:#50575e}
1176 - .nx-mcp-scope-rw{background:#e5f6ea;color:#1a7f37}
1177 - .nx-mcp-revoke{cursor:pointer;border:1px solid #d63638;background:#fff;color:#d63638;border-radius:6px;padding:5px 12px;font-size:12px;font-weight:600}
1178 - .nx-mcp-revoke:hover{background:#d63638;color:#fff}
1179 - .nx-mcp-empty{color:#787c82;font-style:italic}
1180 - .nx-mcp-health{display:flex;flex-direction:column;gap:8px}
1181 - .nx-mcp-health-row{display:flex;align-items:center;gap:8px;color:#2c3338;font-size:13px}
1634 + .nx-mcp-app{display:flex;justify-content:space-between;align-items:center;gap:12px;border:1px solid #e6e6ec;border-radius:10px;padding:11px 14px;background:#fff}
1635 + .nx-mcp-app-info{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0}
1636 + .nx-mcp-scope{font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px}
1637 + .nx-mcp-scope-ro{background:#eef0f3;color:#50575e}
1638 + .nx-mcp-scope-rw{background:#d8f7e2;color:#127a35}
1639 + .nx-mcp-revoke{cursor:pointer;border:1px solid #e2b5b6;background:#fff;color:#d63638;border-radius:8px;padding:6px 13px;font-size:12px;font-weight:600;flex:none;transition:background .15s,color .15s,border-color .15s}
1640 + .nx-mcp-revoke:hover{background:#d63638;color:#fff;border-color:#d63638}
1641 + .nx-mcp-empty{color:#8a8f9c;font-style:italic}
1642 +
1643 + /* ---- Health + danger --------------------------------------------- */
1644 + .nx-mcp-health{display:flex;flex-direction:column;gap:9px}
1645 + .nx-mcp-health-row{display:flex;align-items:center;gap:9px;color:#2c3338;font-size:13px}
1182 1646 .nx-mcp-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1183 - .nx-mcp-dot-good{background:#1a7f37}
1647 + .nx-mcp-dot-good{background:#16a34a}
1184 1648 .nx-mcp-dot-warn{background:#dba617}
1185 - /* Client icons are <img> tags pointing at real SVG files: the card HTML is
1186 - kses-filtered, which strips <svg> and rejects data: URIs in src/style. */
1187 - .nx-mcp-client-ic{width:20px;height:20px;flex:none;display:inline-block;vertical-align:middle}
1188 - .nx-mcp-actions{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
1189 - .nx-mcp-btn{cursor:pointer;border-radius:6px;padding:8px 16px;font-size:13px;font-weight:600;border:1px solid transparent;line-height:1.2}
1649 + .nx-mcp-danger{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-top:16px;padding:14px 16px;border:1px solid #f0c4c4;background:#fdf4f4;border-radius:12px;flex-wrap:wrap}
1650 + .nx-mcp-danger-text{display:flex;flex-direction:column;gap:2px}
1651 + .nx-mcp-danger-text strong{color:#8a1f21}
1652 + .nx-mcp-danger-text span{color:#a15b5b;font-size:12px}
1653 +
1654 + /* ---- Buttons ----------------------------------------------------- */
1655 + .nx-mcp-actions{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap;align-items:center}
1656 + .nx-mcp-btn{cursor:pointer;border-radius:8px;padding:9px 17px;font-size:13px;font-weight:600;border:1px solid transparent;line-height:1.2;text-decoration:none!important;display:inline-flex;align-items:center;justify-content:center;transition:background .15s,border-color .15s,box-shadow .15s}
1190 1657 .nx-mcp-btn[disabled]{opacity:.6;cursor:default}
1658 + .nx-mcp-btn-primary{background:#6a4bff;color:#fff!important;box-shadow:0 4px 12px rgba(106,75,255,.25)}
1659 + .nx-mcp-btn-primary:hover{background:#583fd6}
1660 + /* Kept as an alias: earlier markup used -secondary for the same control. */
1191 1661 .nx-mcp-btn-secondary{background:#6a4bff;color:#fff}
1192 1662 .nx-mcp-btn-secondary:hover{background:#583fd6}
1193 - .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#c3c4c7}
1194 - .nx-mcp-btn-ghost:hover{background:#f6f7f7}
1663 + .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#d3d4da}
1664 + .nx-mcp-btn-ghost:hover{background:#f6f7f9;border-color:#c3c4c7}
1195 1665 .nx-mcp-btn-danger{background:#d63638;color:#fff;border-color:#d63638}
1196 1666 .nx-mcp-btn-danger:hover{background:#b32d2e}
1197 - .nx-mcp-danger{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-top:16px;padding:14px 16px;border:1px solid #f0c4c4;background:#fcf0f0;border-radius:10px;flex-wrap:wrap}
1198 - .nx-mcp-danger-text{display:flex;flex-direction:column;gap:2px}
1199 - .nx-mcp-danger-text strong{color:#8a1f21}
1200 - .nx-mcp-danger-text span{color:#a15b5b;font-size:12px}
1201 - .nx-mcp-toast{position:fixed;bottom:28px;right:28px;z-index:100001;padding:12px 18px;border-radius:8px;color:#fff;font-size:13px;font-weight:600;box-shadow:0 8px 28px rgba(0,0,0,.2);opacity:0;transform:translateY(12px);transition:opacity .28s,transform .28s;max-width:380px}
1667 +
1668 + /* ---- Focus ------------------------------------------------------- */
1669 + /* WP admin sets `a:focus{outline:2px solid transparent}` and leans on a
1670 + box-shadow that never lands here, so the hero link had no visible focus
1671 + state at all. Every control in the panel gets an explicit brand-colour
1672 + ring. Keyboard only —
1673 + :focus-visible keeps mouse clicks from drawing it. */
1674 + .nx-mcp-copy:focus-visible,.nx-mcp-btn:focus-visible,.nx-mcp-revoke:focus-visible{outline:2px solid #4c31d6;outline-offset:2px;border-radius:8px}
1675 + .nx-mcp-learn:focus-visible{outline:2px solid #4c31d6;outline-offset:3px;border-radius:4px}
1676 + /* The controls that are anchors, not buttons — the hero link — is additionally zeroed by NotificationX's own
1677 + `#notificationx a:focus{outline:0}`, which carries an id and outranks a
1678 + class. Same id here so the ring survives; everything else in the panel
1679 + is a <button> and never meets that rule. */
1680 + #notificationx a.nx-mcp-learn:focus-visible{outline:2px solid #4c31d6;outline-offset:3px;border-radius:4px}
1681 +
1682 + /* ---- Inline result + toast --------------------------------------- */
1683 + .nx-mcp-result{display:none;margin-top:12px;padding:11px 14px;border-radius:10px;font-size:12.5px;line-height:1.6;border:1px solid transparent}
1684 + .nx-mcp-result.is-shown{display:block}
1685 + .nx-mcp-result.is-ok{background:#eefaf1;border-color:#bfe6cb;color:#12652c}
1686 + .nx-mcp-result.is-err{background:#fdf1f1;border-color:#f0c4c4;color:#8a1f21}
1687 + .nx-mcp-toast{position:fixed;bottom:28px;inset-inline-end:28px;z-index:100001;padding:12px 18px;border-radius:10px;color:#fff;font-size:13px;font-weight:600;box-shadow:0 8px 28px rgba(0,0,0,.2);opacity:0;transform:translateY(12px);transition:opacity .28s,transform .28s;max-width:380px}
1202 1688 .nx-mcp-toast-in{opacity:1;transform:translateY(0)}
1203 - .nx-mcp-toast-success{background:#1a7f37}
1689 + .nx-mcp-toast-success{background:#16a34a}
1204 1690 .nx-mcp-toast-error{background:#d63638}
1205 1691 </style>
1206 1692 <script id="nx-mcp-panel-js">
1207 1693 window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?>, i18n: <?php echo wp_json_encode( $i18n ); ?> };
@@ -1213,17 +1699,65 @@
1213 1699 requestAnimationFrame(function(){ t.classList.add('nx-mcp-toast-in'); });
1214 1700 setTimeout(function(){ t.classList.remove('nx-mcp-toast-in'); setTimeout(function(){ t.remove(); }, 320); }, 3600);
1215 1701 };
1216 1702 window.nxMcpCopy = function(btn, text){
1217 - var done = function(){ var o = btn.textContent; btn.textContent = '✓'; setTimeout(function(){ btn.textContent = o; }, 1200); };
1703 + var done = function(){ var o = btn.textContent; btn.textContent = '✓ ' + window.nxMcpData.i18n.copied; setTimeout(function(){ btn.textContent = o; }, 1400); };
1218 1704 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, done); }
1219 1705 else { var t=document.createElement('textarea'); t.value=text; document.body.appendChild(t); t.select(); try{document.execCommand('copy');}catch(e){} document.body.removeChild(t); done(); }
1220 1706 };
1221 1707 window.nxMcpReveal = function(btn){
1222 1708 var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return;
1223 - if (code.dataset.shown === '1'){ code.textContent = '••••••••••••'; code.dataset.shown='0'; btn.textContent='Show'; }
1224 - else { code.textContent = code.dataset.token || ''; code.dataset.shown='1'; btn.textContent='Hide'; }
1709 + if (code.dataset.shown === '1'){ code.textContent = '••••••••••••'; code.dataset.shown='0'; btn.textContent='<?php echo esc_js( __( 'Show', 'notificationx' ) ); ?>'; return; }
1710 + // The panel may have been rendered before MCP was switched on, in
1711 + // which case there was no token to print into it. Fetch it rather
1712 + // than revealing an empty box.
1713 + nxMcpWithToken(function(token){
1714 + code.textContent = token || ''; code.dataset.shown='1';
1715 + btn.textContent='<?php echo esc_js( __( 'Hide', 'notificationx' ) ); ?>';
1716 + });
1225 1717 };
1718 + window.nxMcpCopyToken = function(btn){
1719 + var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return;
1720 + nxMcpWithToken(function(token){ nxMcpCopy(btn, token || ''); });
1721 + };
1722 + // Hand the caller the token, fetching it once if the panel does not
1723 + // have one yet.
1724 + window.nxMcpWithToken = function(done){
1725 + var code = document.querySelector('.nx-mcp-token');
1726 + var have = code && code.dataset.token;
1727 + if (have) { done(code.dataset.token); return; }
1728 + nxMcpSyncConnection(function(state){ done(state && state.token ? state.token : ''); });
1729 + };
1730 + // The config-file clients want a server block, not a bare URL. It is
1731 + // assembled here from the token already rendered into the connection
1732 + // card, so the page never carries a second copy of the secret.
1733 + window.nxMcpCopyConfig = function(btn){
1734 + // Same source as the token card's Copy: fetched once if the panel
1735 + // was rendered before MCP was switched on.
1736 + nxMcpWithToken(function(token){
1737 + if (!token){ nxMcpToast('error', window.nxMcpData.i18n.tokenMissing); return; }
1738 + var config = {
1739 + mcpServers: {
1740 + notificationx: {
1741 + url: btn.getAttribute('data-url') || '',
1742 + headers: { Authorization: 'Bearer ' + token }
1743 + }
1744 + }
1745 + };
1746 + nxMcpCopy(btn, JSON.stringify(config, null, 2));
1747 + nxMcpToast('success', window.nxMcpData.i18n.configCopied);
1748 + });
1749 + };
1750 + // Write an action's outcome into the panel next to the button that ran
1751 + // it. The toast still fires: it covers the case where the button has
1752 + // been scrolled out of view, and this covers the case where the reader
1753 + // looks back at the panel after the toast has gone.
1754 + window.nxMcpShowResult = function(id, ok, message){
1755 + var box = document.getElementById(id);
1756 + if (!box) return;
1757 + box.textContent = message || '';
1758 + box.className = 'nx-mcp-result is-shown ' + (ok ? 'is-ok' : 'is-err');
1759 + };
1226 1760 window.nxMcpAction = function(btn, action, opts){
1227 1761 opts = opts || {};
1228 1762 if (opts.confirm && !window.confirm(opts.confirm)) return;
1229 1763 var old = btn.textContent; btn.disabled = true; btn.textContent = '…';
@@ -1232,19 +1766,28 @@
1232 1766 headers:{'Content-Type':'application/json','X-WP-Nonce':window.nxMcpData.nonce},
1233 1767 body: JSON.stringify(opts.body || {})
1234 1768 }).then(function(r){ return r.json().catch(function(){ return {}; }); }).then(function(res){
1235 1769 btn.disabled = false; btn.textContent = old;
1236 - if (res && res.status === 'error'){ nxMcpToast('error', res.message || 'Something went wrong.'); return; }
1237 - nxMcpToast('success', opts.success || (res && res.message) || 'Done.');
1770 + if (res && res.status === 'error'){
1771 + if (opts.result) nxMcpShowResult(opts.result, false, res.message || window.nxMcpData.i18n.genericError);
1772 + nxMcpToast('error', res.message || window.nxMcpData.i18n.genericError); return;
1773 + }
1774 + var msg = opts.success || (res && res.message) || window.nxMcpData.i18n.done;
1775 + if (opts.result) nxMcpShowResult(opts.result, true, (res && res.message) || msg);
1776 + nxMcpToast('success', msg);
1238 1777 if (opts.reload){ setTimeout(function(){ window.location.reload(); }, 900); }
1239 - }).catch(function(){ btn.disabled = false; btn.textContent = old; nxMcpToast('error', 'Request failed.'); });
1778 + }).catch(function(){
1779 + btn.disabled = false; btn.textContent = old;
1780 + if (opts.result) nxMcpShowResult(opts.result, false, window.nxMcpData.i18n.requestFailed);
1781 + nxMcpToast('error', window.nxMcpData.i18n.requestFailed);
1782 + });
1240 1783 };
1241 1784 window.nxMcpRevoke = function(btn, type, clientId){
1242 1785 nxMcpAction(btn, 'revoke', {
1243 - confirm: 'Revoke this connection? The client will need to reconnect.',
1786 + confirm: window.nxMcpData.i18n.revokeConfirm,
1244 1787 body: { type: type, client_id: clientId },
1245 1788 reload: true,
1246 - success: 'Connection revoked.'
1789 + success: window.nxMcpData.i18n.revoked
1247 1790 });
1248 1791 };
1249 1792 // Re-read the connected apps without a full page reload, so a newly
1250 1793 // approved or detached client shows up immediately. Rows are built with
@@ -1252,9 +1795,9 @@
1252 1795 window.nxMcpRefreshApps = function(btn){
1253 1796 var wrap = document.getElementById('nx-mcp-apps-wrap');
1254 1797 if (!wrap) return;
1255 1798 var old = btn ? btn.textContent : '';
1256 - if (btn){ btn.disabled = true; btn.textContent = '\u2026'; }
1799 + if (btn){ btn.disabled = true; btn.textContent = '…'; }
1257 1800 fetch(window.nxMcpData.urls.apps, {
1258 1801 method: 'GET',
1259 1802 credentials: 'same-origin',
1260 1803 headers: { 'X-WP-Nonce': window.nxMcpData.nonce }
@@ -1350,25 +1893,118 @@
1350 1893 }
1351 1894 document.addEventListener('DOMContentLoaded', function(){ nxMcpPlaceRefresh(); });
1352 1895 nxMcpPlaceRefresh();
1353 1896
1354 - // Bound by delegation rather than an inline onclick, so the button keeps
1897 + // Bound by delegation rather than an inline onclick, so the buttons keep
1355 1898 // working even if the panel markup is passed through a sanitiser.
1356 1899 document.addEventListener('click', function(e){
1357 - var btn = e.target && e.target.closest ? e.target.closest('.nx-mcp-refresh-apps') : null;
1358 - if (!btn) return;
1359 - e.preventDefault();
1360 - nxMcpRefreshApps(btn);
1900 + if (!e.target || !e.target.closest) return;
1901 + var refresh = e.target.closest('.nx-mcp-refresh-apps');
1902 + if (refresh){ e.preventDefault(); nxMcpRefreshApps(refresh); return; }
1903 + var config = e.target.closest('.nx-mcp-copy-config');
1904 + if (config){ e.preventDefault(); nxMcpCopyConfig(config); }
1361 1905 });
1362 1906
1363 - // Keep the status badge in sync with the enable toggle, live.
1364 - document.addEventListener('change', function(e){
1365 - if (!e.target || e.target.name !== 'enable_mcp') return;
1907 + // Paint the badge for a given state.
1908 + // Only the label changes, so the status dot inside the badge survives.
1909 + window.nxMcpPaintBadge = function(on){
1366 1910 var badge = document.querySelector('.nx-mcp-badge');
1367 1911 if (!badge) return;
1368 - var on = !!e.target.checked;
1369 - badge.textContent = on ? '<?php echo esc_js( __( 'Active', 'notificationx' ) ); ?>' : '<?php echo esc_js( __( 'Off', 'notificationx' ) ); ?>';
1912 + var text = badge.querySelector('.nx-mcp-badge-text');
1913 + var label = on ? window.nxMcpData.i18n.statusActive : window.nxMcpData.i18n.statusOff;
1914 + if (text) { text.textContent = label; } else { badge.textContent = label; }
1370 1915 badge.className = 'nx-mcp-badge nx-mcp-badge-' + (on ? 'active' : 'off');
1916 + // The stats row is revealed by the toggle too; keep its status tile in step.
1917 + var tile = document.querySelector('.nx-mcp-stat-status');
1918 + if (tile) { tile.textContent = label; }
1919 + };
1920 +
1921 + // The enable toggle saves itself. The settings form's own Save still
1922 + // works, but the toggle gates every panel below it, so leaving it
1923 + // unsaved meant the connector URL, token and connection test all
1924 + // described a state the server was not in.
1925 + var nxMcpEnableInFlight = false;
1926 + var nxMcpToggleSync = false;
1927 + // The toggle is a controlled React input: setting `checked` on the DOM
1928 + // node leaves the settings form holding the old value, and the form's
1929 + // Save would later write it back. Click it instead, so React's own
1930 + // onChange updates the form state, and skip our handler for that click.
1931 + var nxMcpSetToggle = function(input, value){
1932 + if (!!input.checked === value) return;
1933 + nxMcpToggleSync = true;
1934 + try { input.click(); } finally { nxMcpToggleSync = false; }
1935 + };
1936 + document.addEventListener('change', function(e){
1937 + if (!e.target || e.target.name !== 'enable_mcp') return;
1938 + if (nxMcpToggleSync) return;
1939 + var input = e.target;
1940 + var on = !!input.checked;
1941 +
1942 + // Show the intent straight away, then reconcile with the server.
1943 + nxMcpPaintBadge(on);
1944 +
1945 + if (nxMcpEnableInFlight) return;
1946 + nxMcpEnableInFlight = true;
1947 + input.disabled = true;
1948 +
1949 + fetch(window.nxMcpData.urls.enable, {
1950 + method: 'POST',
1951 + headers: { 'Content-Type':'application/json', 'X-WP-Nonce': window.nxMcpData.nonce },
1952 + body: JSON.stringify({ enabled: on })
1953 + }).then(function(r){
1954 + return r.json().catch(function(){ return {}; }).then(function(j){
1955 + if (!r.ok) { throw new Error((j && j.message) || 'http'); }
1956 + return j;
1957 + });
1958 + }).then(function(res){
1959 + nxMcpEnableInFlight = false; input.disabled = false;
1960 + // Server is the truth: repaint from what it reports.
1961 + var saved = !!res.enabled;
1962 + nxMcpSetToggle(input, saved);
1963 + nxMcpPaintBadge(saved);
1964 + if (res.token) { nxMcpSetToken(res.token); }
1965 + nxMcpToast('success', saved ? window.nxMcpData.i18n.enabled : window.nxMcpData.i18n.disabled);
1966 + }).catch(function(){
1967 + nxMcpEnableInFlight = false; input.disabled = false;
1968 + // Put the control back where it was so it cannot claim a
1969 + // state that was never stored.
1970 + nxMcpSetToggle(input, !on);
1971 + nxMcpPaintBadge(!on);
1972 + nxMcpToast('error', window.nxMcpData.i18n.enableFailed);
1973 + });
1974 + });
1975 +
1976 + // Fill in the token the panel was rendered without, so Show/Copy and
1977 + // the connection test work without a reload.
1978 + window.nxMcpSetToken = function(token){
1979 + var code = document.querySelector('.nx-mcp-token');
1980 + if (!code) return;
1981 + code.dataset.token = token;
1982 + if (code.dataset.shown === '1') { code.textContent = token; }
1983 + };
1984 +
1985 + // Re-read the connection from the server and repaint the panel.
1986 + // The panel is server-rendered once; anything that switches MCP on
1987 + // afterwards -- the toggle, or the settings form's own Save -- leaves
1988 + // the markup describing the old state until this runs.
1989 + window.nxMcpSyncConnection = function(done){
1990 + fetch(window.nxMcpData.urls.connection, {
1991 + headers: { 'X-WP-Nonce': window.nxMcpData.nonce }
1992 + }).then(function(r){ return r.json(); }).then(function(state){
1993 + if (state && typeof state.enabled !== 'undefined') { nxMcpPaintBadge(!!state.enabled); }
1994 + if (state && state.token) { nxMcpSetToken(state.token); }
1995 + if (done) { done(state); }
1996 + }).catch(function(){ if (done) { done(null); } });
1997 + };
1998 +
1999 + // The settings form's Save can switch MCP on without going through
2000 + // the toggle handler (a value restored by the browser, or a save
2001 + // triggered from another tab). Pick the new state up either way.
2002 + document.addEventListener('click', function(e){
2003 + var btn = e.target && e.target.closest ? e.target.closest('.wprf-submit-button') : null;
2004 + if (!btn) return;
2005 + if (!document.querySelector('.nx-mcp-token')) return;
2006 + setTimeout(function(){ nxMcpSyncConnection(); }, 1200);
1371 2007 });
1372 2008 </script>
1373 2009 <?php
1374 2010 }