| @@ -26,8 +26,17 @@ | ||
| 26 | 26 | * @method static Manager get_instance( $args = null ) |
| 27 | 27 | */ |
| 28 | 28 | class Manager { |
| 29 | 29 | |
| 30 | + /** | |
| 31 | + * Path of the pretty MCP endpoint, relative to home. | |
| 32 | + * | |
| 33 | + * Also the suffix of the RFC 9728 discovery URLs we answer for. | |
| 34 | + * | |
| 35 | + * @var string | |
| 36 | + */ | |
| 37 | + const ENDPOINT_PATH = 'notificationx/mcp'; | |
| 38 | + | |
| 30 | 39 | use GetInstance; |
| 31 | 40 | |
| 32 | 41 | /** |
| 33 | 42 | * Boot the module. Called from the MCP bootstrap only when the runtime is |
| @@ -44,14 +53,36 @@ | ||
| 44 | 53 | add_action( 'parse_request', array( $this, 'handle_front_requests' ), 0 ); |
| 45 | 54 | |
| 46 | 55 | // Admin settings tab (pure PHP field schema; no JS rebuild needed). |
| 47 | 56 | add_filter( 'nx_settings_tab', array( $this, 'register_settings_tab' ), 20 ); |
| 57 | + add_filter( 'nx_protected_settings', array( $this, 'protect_enable_setting' ) ); | |
| 48 | 58 | |
| 49 | 59 | // CSS + JS for the MCP panel (copy / reveal / revoke controls). |
| 50 | 60 | add_action( 'admin_print_footer_scripts', array( $this, 'print_panel_assets' ) ); |
| 61 | + add_action( 'admin_init', array( $this, 'redirect_hidden_tab' ) ); | |
| 51 | 62 | } |
| 52 | 63 | |
| 53 | 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 | + /** | |
| 54 | 85 | * Whether MCP access is switched on. |
| 55 | 86 | * |
| 56 | 87 | * @return bool |
| 57 | 88 | */ |
| @@ -87,8 +118,25 @@ | ||
| 87 | 118 | 'permission_callback' => '__return_true', |
| 88 | 119 | ) ); |
| 89 | 120 | |
| 90 | 121 | // OAuth: dynamic client registration + token endpoint (public). |
| 122 | + // Discovery over REST as well as `/.well-known/`. The well-known path is | |
| 123 | + // a single namespace the whole site shares: another plugin that hooks | |
| 124 | + // `parse_request` earlier, or a host that answers `/.well-known/` itself | |
| 125 | + // (ACME), takes it and our clients then read someone else's metadata. | |
| 126 | + // A route inside our own REST namespace cannot be taken, so that is what | |
| 127 | + // Server::with_challenge() advertises. Public, like the documents | |
| 128 | + // themselves. | |
| 129 | + register_rest_route( $ns, '/mcp/oauth/protected-resource', array( | |
| 130 | + 'methods' => 'GET', | |
| 131 | + 'callback' => array( $this, 'rest_protected_resource' ), | |
| 132 | + 'permission_callback' => '__return_true', | |
| 133 | + ) ); | |
| 134 | + register_rest_route( $ns, '/mcp/oauth/authorization-server', array( | |
| 135 | + 'methods' => 'GET', | |
| 136 | + 'callback' => array( $this, 'rest_authorization_server' ), | |
| 137 | + 'permission_callback' => '__return_true', | |
| 138 | + ) ); | |
| 91 | 139 | register_rest_route( $ns, '/mcp/oauth/register', array( |
| 92 | 140 | 'methods' => 'POST', |
| 93 | 141 | 'callback' => array( $this, 'rest_oauth_register' ), |
| 94 | 142 | 'permission_callback' => '__return_true', |
| @@ -125,16 +173,66 @@ | ||
| 125 | 173 | 'methods' => 'POST', |
| 126 | 174 | 'callback' => array( $this, 'rest_self_test' ), |
| 127 | 175 | 'permission_callback' => $admin, |
| 128 | 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 | + ) ); | |
| 129 | 193 | register_rest_route( $ns, '/mcp/apps/revoke', array( |
| 130 | 194 | 'methods' => 'POST', |
| 131 | 195 | 'callback' => array( $this, 'rest_revoke_app' ), |
| 132 | 196 | 'permission_callback' => $admin, |
| 133 | 197 | ) ); |
| 198 | + register_rest_route( $ns, '/mcp/apps', array( | |
| 199 | + 'methods' => 'GET', | |
| 200 | + 'callback' => array( $this, 'rest_list_apps' ), | |
| 201 | + 'permission_callback' => $admin, | |
| 202 | + ) ); | |
| 134 | 203 | } |
| 135 | 204 | |
| 136 | 205 | /** |
| 206 | + * List the currently connected apps as JSON, so the Connected apps panel can | |
| 207 | + * refresh itself without a full page reload (an app may have been approved or | |
| 208 | + * detached since the page was rendered). | |
| 209 | + * | |
| 210 | + * @return \WP_REST_Response | |
| 211 | + */ | |
| 212 | + public function rest_list_apps() { | |
| 213 | + $apps = array(); | |
| 214 | + foreach ( $this->get_connected_apps() as $app ) { | |
| 215 | + $apps[] = array( | |
| 216 | + 'type' => $app['type'], | |
| 217 | + 'client_id' => $app['client_id'], | |
| 218 | + 'name' => $app['name'], | |
| 219 | + 'read_only' => (bool) $app['read_only'], | |
| 220 | + 'scope_label' => $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' ), | |
| 221 | + ); | |
| 222 | + } | |
| 223 | + | |
| 224 | + return new \WP_REST_Response( | |
| 225 | + array( | |
| 226 | + 'status' => 'success', | |
| 227 | + 'count' => count( $apps ), | |
| 228 | + 'apps' => $apps, | |
| 229 | + ), | |
| 230 | + 200 | |
| 231 | + ); | |
| 232 | + } | |
| 233 | + | |
| 234 | + /** | |
| 137 | 235 | * Revoke a single connected app (pairing token or one OAuth client). |
| 138 | 236 | * |
| 139 | 237 | * @param \WP_REST_Request $request Request. |
| 140 | 238 | * @return \WP_REST_Response |
| @@ -221,8 +319,13 @@ | ||
| 221 | 319 | * |
| 222 | 320 | * @return \WP_REST_Response |
| 223 | 321 | */ |
| 224 | 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 | + | |
| 225 | 328 | return new \WP_REST_Response( $this->connection_state(), 200 ); |
| 226 | 329 | } |
| 227 | 330 | |
| 228 | 331 | /** |
| @@ -261,13 +364,66 @@ | ||
| 261 | 364 | * |
| 262 | 365 | * @return \WP_REST_Response |
| 263 | 366 | */ |
| 264 | 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 | + | |
| 265 | 375 | $result = SelfTest::get_instance()->run(); |
| 266 | 376 | return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 ); |
| 267 | 377 | } |
| 268 | 378 | |
| 269 | 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 | + /** | |
| 270 | 426 | * Summarise the connection for the admin UI. |
| 271 | 427 | * |
| 272 | 428 | * @return array |
| 273 | 429 | */ |
| @@ -298,18 +454,25 @@ | ||
| 298 | 454 | if ( '' === $path ) { |
| 299 | 455 | return; |
| 300 | 456 | } |
| 301 | 457 | |
| 302 | - // OAuth discovery (also accept the path-suffixed RFC form). | |
| 303 | - if ( 0 === strpos( $path, '.well-known/oauth-authorization-server' ) ) { | |
| 304 | - $this->emit_json( OAuth::get_instance()->authorization_server_metadata() ); | |
| 458 | + // OAuth discovery (also accept the path-suffixed RFC form). Only our | |
| 459 | + // own documents are served, and only while MCP is switched on: another | |
| 460 | + // MCP plugin on the same site owns `.well-known/...`/<its-endpoint>, | |
| 461 | + // and answering that with our metadata would point its clients at our | |
| 462 | + // authorization server. With MCP off we own no resource to describe, so | |
| 463 | + // the request falls through to WordPress instead. | |
| 464 | + if ( $this->is_enabled() ) { | |
| 465 | + if ( $this->owns_discovery_path( $path, 'oauth-authorization-server' ) ) { | |
| 466 | + $this->emit_json( OAuth::get_instance()->authorization_server_metadata() ); | |
| 467 | + } | |
| 468 | + if ( $this->owns_discovery_path( $path, 'oauth-protected-resource' ) ) { | |
| 469 | + $this->emit_json( OAuth::get_instance()->protected_resource_metadata() ); | |
| 470 | + } | |
| 305 | 471 | } |
| 306 | - if ( 0 === strpos( $path, '.well-known/oauth-protected-resource' ) ) { | |
| 307 | - $this->emit_json( OAuth::get_instance()->protected_resource_metadata() ); | |
| 308 | - } | |
| 309 | 472 | |
| 310 | 473 | // Pretty MCP endpoint. |
| 311 | - if ( 'notificationx/mcp' === $path ) { | |
| 474 | + if ( self::ENDPOINT_PATH === $path ) { | |
| 312 | 475 | $this->handle_pretty_mcp(); |
| 313 | 476 | } |
| 314 | 477 | |
| 315 | 478 | // OAuth authorize consent page. |
| @@ -403,8 +566,51 @@ | ||
| 403 | 566 | $this->render_authorize_page( $request ); |
| 404 | 567 | } |
| 405 | 568 | |
| 406 | 569 | /** |
| 570 | + * The brand mark for a connecting client. | |
| 571 | + * | |
| 572 | + * Clients arrive through open dynamic registration, so the name is whatever | |
| 573 | + * the app sent and anyone can call themselves "Claude". A vendor mark is | |
| 574 | + * therefore only shown when the name matches AND the code is being sent | |
| 575 | + * back to a host that vendor controls; everything else (including loopback | |
| 576 | + * redirects used by desktop apps) falls back to the initial. The files are | |
| 577 | + * the same ones the Connect a client panel uses, so the consent screen and | |
| 578 | + * the admin panel can never show different marks for the same app. | |
| 579 | + * | |
| 580 | + * @param string $name Registered client name. | |
| 581 | + * @param string $redirect_uri Validated redirect URI of this authorize request. | |
| 582 | + * @return array{file:string,tint:string}|array Empty when unrecognised. | |
| 583 | + */ | |
| 584 | + protected static function client_brand( $name, $redirect_uri ) { | |
| 585 | + $brands = array( | |
| 586 | + 'claude' => array( 'file' => 'claude.svg', 'tint' => '#fdf1ec', 'hosts' => array( 'claude.ai', 'claude.com', 'anthropic.com' ) ), | |
| 587 | + 'chatgpt' => array( 'file' => 'chatgpt.svg', 'tint' => '#eaf6f2', 'hosts' => array( 'chatgpt.com', 'openai.com' ) ), | |
| 588 | + 'openai' => array( 'file' => 'chatgpt.svg', 'tint' => '#eaf6f2', 'hosts' => array( 'chatgpt.com', 'openai.com' ) ), | |
| 589 | + 'cursor' => array( 'file' => 'cursor.svg', 'tint' => '#eceaf6', 'hosts' => array( 'cursor.com', 'cursor.sh' ) ), | |
| 590 | + ); | |
| 591 | + $host = strtolower( (string) wp_parse_url( (string) $redirect_uri, PHP_URL_HOST ) ); | |
| 592 | + $scheme = strtolower( (string) wp_parse_url( (string) $redirect_uri, PHP_URL_SCHEME ) ); | |
| 593 | + if ( '' === $host || 'https' !== $scheme ) { | |
| 594 | + return array(); | |
| 595 | + } | |
| 596 | + foreach ( $brands as $needle => $brand ) { | |
| 597 | + if ( false === stripos( (string) $name, $needle ) ) { | |
| 598 | + continue; | |
| 599 | + } | |
| 600 | + foreach ( $brand['hosts'] as $vendor_host ) { | |
| 601 | + if ( $host === $vendor_host || substr( $host, -strlen( '.' . $vendor_host ) ) === '.' . $vendor_host ) { | |
| 602 | + return array( | |
| 603 | + 'file' => $brand['file'], | |
| 604 | + 'tint' => $brand['tint'], | |
| 605 | + ); | |
| 606 | + } | |
| 607 | + } | |
| 608 | + } | |
| 609 | + return array(); | |
| 610 | + } | |
| 611 | + | |
| 612 | + /** | |
| 407 | 613 | * Output the consent form. |
| 408 | 614 | * |
| 409 | 615 | * @param array $request Validated authorize request. |
| 410 | 616 | * @return void |
| @@ -438,10 +644,10 @@ | ||
| 438 | 644 | } |
| 439 | 645 | $substr = function_exists( 'mb_substr' ) ? 'mb_substr' : 'substr'; |
| 440 | 646 | $who_initial = strtoupper( $substr( $who_name, 0, 1 ) ); |
| 441 | 647 | $client_initial = strtoupper( $substr( $name, 0, 1 ) ); |
| 442 | - // Show the connecting app's own mark when we recognise it; otherwise the initial. | |
| 443 | - $client_is_claude = ( false !== stripos( $name, 'claude' ) ); | |
| 648 | + // Show the connecting app's own mark only when we can vouch for it; otherwise the initial. | |
| 649 | + $client_brand = self::client_brand( $name, $request['redirect_uri'] ); | |
| 444 | 650 | |
| 445 | 651 | // The exact tools this grant unlocks, straight from the ability |
| 446 | 652 | // registry so the list can never drift from what the server exposes. |
| 447 | 653 | Registrar::get_instance()->boot(); |
| @@ -480,10 +686,9 @@ | ||
| 480 | 686 | .apps{display:flex;align-items:flex-start;justify-content:center;gap:8px;margin:4px 0 22px} |
| 481 | 687 | .app{width:132px;text-align:center} |
| 482 | 688 | .tile{width:64px;height:64px;margin:0 auto 10px;border-radius:16px;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px rgba(30,20,80,.10)} |
| 483 | 689 | .tile.client{background:#eef0f6;color:#3a4056;font-size:26px;font-weight:700} |
| 484 | - .tile.client.has-mark{background:#fdf1ec} | |
| 485 | - .tile.client svg{width:38px;height:38px;display:block} | |
| 690 | + .tile.client svg,.tile.client img{width:38px;height:38px;display:block} | |
| 486 | 691 | .tile.nx{background:#fff;border:1px solid var(--line)} |
| 487 | 692 | .tile.nx svg{width:42px;height:42px;display:block} |
| 488 | 693 | .app-name{font-size:14px;font-weight:600;line-height:1.3} |
| 489 | 694 | .app-host{font-size:12px;color:var(--muted);word-break:break-word;margin-top:2px} |
| @@ -522,11 +727,11 @@ | ||
| 522 | 727 | <body> |
| 523 | 728 | <div class="card"> |
| 524 | 729 | <div class="apps"> |
| 525 | 730 | <div class="app"> |
| 526 | - <div class="tile client<?php echo $client_is_claude ? ' has-mark' : ''; ?>"> | |
| 527 | - <?php if ( $client_is_claude ) : ?> | |
| 528 | - <svg viewBox="0 0 24 24" fill="none" stroke="#d97757" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><line x1="12" y1="3" x2="12" y2="21"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(30 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(60 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(90 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(120 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(150 12 12)"/></svg> | |
| 731 | + <div class="tile client<?php echo $client_brand ? ' has-mark' : ''; ?>"<?php echo $client_brand ? ' style="background:' . esc_attr( $client_brand['tint'] ) . '"' : ''; ?>> | |
| 732 | + <?php if ( $client_brand ) : ?> | |
| 733 | + <img src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/' . $client_brand['file'] ); ?>" alt="" width="38" height="38" /> | |
| 529 | 734 | <?php else : ?> |
| 530 | 735 | <?php echo esc_html( $client_initial ); ?> |
| 531 | 736 | <?php endif; ?> |
| 532 | 737 | </div> |
| @@ -628,11 +833,19 @@ | ||
| 628 | 833 | * @param array $tabs Existing tabs. |
| 629 | 834 | * @return array |
| 630 | 835 | */ |
| 631 | 836 | public function register_settings_tab( $tabs ) { |
| 632 | - // If MCP is on, make sure a pairing token exists so the UI has one to show. | |
| 633 | - if ( $this->is_enabled() && ! Pairing::get_instance()->is_connected() ) { | |
| 634 | - 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; | |
| 635 | 848 | } |
| 636 | 849 | |
| 637 | 850 | $tabs['tab-mcp'] = array( |
| 638 | 851 | 'id' => 'tab-mcp', |
| @@ -644,12 +857,38 @@ | ||
| 644 | 857 | return $tabs; |
| 645 | 858 | } |
| 646 | 859 | |
| 647 | 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 | + /** | |
| 648 | 879 | * Build the MCP settings field schema. The rich panels are server-rendered |
| 649 | 880 | * HTML delivered through quickbuilder `message` fields (html => true); the |
| 650 | - * 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()}. | |
| 651 | 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 | + * | |
| 652 | 891 | * @return array |
| 653 | 892 | */ |
| 654 | 893 | protected function settings_fields() { |
| 655 | 894 | $enabled_rule = Rules::is( 'enable_mcp', true ); |
| @@ -663,8 +902,9 @@ | ||
| 663 | 902 | 'mcp_hero' => array( |
| 664 | 903 | 'name' => 'mcp_hero', |
| 665 | 904 | 'type' => 'message', |
| 666 | 905 | 'html' => true, |
| 906 | + 'classes' => 'nx-mcp-field nx-mcp-field-flush', | |
| 667 | 907 | 'message' => $this->hero_html(), |
| 668 | 908 | ), |
| 669 | 909 | 'enable_mcp' => array( |
| 670 | 910 | 'name' => 'enable_mcp', |
| @@ -670,10 +910,18 @@ | ||
| 670 | 910 | 'name' => 'enable_mcp', |
| 671 | 911 | 'type' => 'toggle', |
| 672 | 912 | 'default' => false, |
| 673 | 913 | 'label' => __( 'Enable MCP access', 'notificationx' ), |
| 674 | - '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' ), | |
| 675 | 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 | + ), | |
| 676 | 924 | ), |
| 677 | 925 | ), |
| 678 | 926 | |
| 679 | 927 | 'mcp_connection_section' => array( |
| @@ -685,8 +933,9 @@ | ||
| 685 | 933 | 'mcp_connection_html' => array( |
| 686 | 934 | 'name' => 'mcp_connection_html', |
| 687 | 935 | 'type' => 'message', |
| 688 | 936 | 'html' => true, |
| 937 | + 'classes' => 'nx-mcp-field', | |
| 689 | 938 | 'message' => $this->connection_html(), |
| 690 | 939 | ), |
| 691 | 940 | ), |
| 692 | 941 | ), |
| @@ -700,8 +949,9 @@ | ||
| 700 | 949 | 'mcp_clients_html' => array( |
| 701 | 950 | 'name' => 'mcp_clients_html', |
| 702 | 951 | 'type' => 'message', |
| 703 | 952 | 'html' => true, |
| 953 | + 'classes' => 'nx-mcp-field', | |
| 704 | 954 | 'message' => $this->clients_html(), |
| 705 | 955 | ), |
| 706 | 956 | ), |
| 707 | 957 | ), |
| @@ -715,8 +965,9 @@ | ||
| 715 | 965 | 'mcp_apps_html' => array( |
| 716 | 966 | 'name' => 'mcp_apps_html', |
| 717 | 967 | 'type' => 'message', |
| 718 | 968 | 'html' => true, |
| 969 | + 'classes' => 'nx-mcp-field', | |
| 719 | 970 | 'message' => $this->connected_apps_html(), |
| 720 | 971 | ), |
| 721 | 972 | ), |
| 722 | 973 | ), |
| @@ -730,8 +981,9 @@ | ||
| 730 | 981 | 'mcp_health_html' => array( |
| 731 | 982 | 'name' => 'mcp_health_html', |
| 732 | 983 | 'type' => 'message', |
| 733 | 984 | 'html' => true, |
| 985 | + 'classes' => 'nx-mcp-field', | |
| 734 | 986 | 'message' => $this->health_html(), |
| 735 | 987 | ), |
| 736 | 988 | ), |
| 737 | 989 | ), |
| @@ -755,31 +1007,126 @@ | ||
| 755 | 1007 | return array( 'setup', __( 'Setup needed', 'notificationx' ) ); |
| 756 | 1008 | } |
| 757 | 1009 | |
| 758 | 1010 | /** |
| 759 | - * Hero header with the status badge. | |
| 1011 | + * The abilities currently registered, split the way the panel reads them. | |
| 760 | 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. | |
| 761 | 1077 | * @return string |
| 762 | 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 | + */ | |
| 763 | 1089 | protected function hero_html() { |
| 764 | 1090 | list( $state, $label ) = $this->status(); |
| 1091 | + $steps = $this->setup_steps(); | |
| 765 | 1092 | ob_start(); |
| 766 | 1093 | ?> |
| 767 | - <div class="nx-mcp-hero"> | |
| 768 | - <div class="nx-mcp-hero-icon">🔌</div> | |
| 769 | - <div class="nx-mcp-hero-body"> | |
| 770 | - <h3 class="nx-mcp-hero-title"> | |
| 771 | - <?php esc_html_e( 'MCP Server', 'notificationx' ); ?> | |
| 772 | - <span class="nx-mcp-badge nx-mcp-badge-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $label ); ?></span> | |
| 773 | - </h3> | |
| 774 | - <p class="nx-mcp-hero-text"> | |
| 775 | - <?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' ); ?> | |
| 776 | - </p> | |
| 777 | - <a class="nx-mcp-learn" href="<?php echo esc_url( 'https://notificationx.com/docs/mcp-in-notificationx' ); ?>" target="_blank" rel="noopener noreferrer"> | |
| 778 | - <span class="nx-mcp-learn-text"><?php esc_html_e( 'Learn how it works', 'notificationx' ); ?></span> | |
| 779 | - <span class="nx-mcp-learn-arrow" aria-hidden="true">→</span> | |
| 780 | - </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">→</span> | |
| 1113 | + </a> | |
| 1114 | + </div> | |
| 781 | 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> | |
| 782 | 1129 | </div> |
| 783 | 1130 | <?php |
| 784 | 1131 | return ob_get_clean(); |
| 785 | 1132 | } |
| @@ -784,15 +1131,115 @@ | ||
| 784 | 1131 | return ob_get_clean(); |
| 785 | 1132 | } |
| 786 | 1133 | |
| 787 | 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 | + /** | |
| 788 | 1228 | * Connector URL + token cards with copy/reveal controls. |
| 789 | 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 | + * | |
| 790 | 1234 | * @return string |
| 791 | 1235 | */ |
| 792 | 1236 | protected function connection_html() { |
| 793 | - $url = $this->connector_url(); | |
| 794 | - $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(); | |
| 795 | 1242 | ob_start(); |
| 796 | 1243 | ?> |
| 797 | 1244 | <div class="nx-mcp-grid"> |
| 798 | 1245 | <div class="nx-mcp-card"> |
| @@ -805,19 +1252,21 @@ | ||
| 805 | 1252 | </div> |
| 806 | 1253 | <div class="nx-mcp-card"> |
| 807 | 1254 | <span class="nx-mcp-card-label"><?php esc_html_e( 'Connection token', 'notificationx' ); ?></span> |
| 808 | 1255 | <div class="nx-mcp-copyrow"> |
| 809 | - <code class="nx-mcp-value nx-mcp-token" data-token="<?php echo esc_attr( $token ); ?>">••••••••••••</code> | |
| 1256 | + <code class="nx-mcp-value nx-mcp-token" data-token="">••••••••••••</code> | |
| 810 | 1257 | <button type="button" class="nx-mcp-copy" onclick="nxMcpReveal(this)"><?php esc_html_e( 'Show', 'notificationx' ); ?></button> |
| 811 | - <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> | |
| 812 | 1260 | </div> |
| 813 | 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> |
| 814 | 1262 | </div> |
| 815 | 1263 | </div> |
| 816 | 1264 | <div class="nx-mcp-actions"> |
| 817 | - <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> | |
| 818 | 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> |
| 819 | 1267 | </div> |
| 1268 | + <div class="nx-mcp-result" id="nx-mcp-testresult"></div> | |
| 820 | 1269 | <?php |
| 821 | 1270 | return ob_get_clean(); |
| 822 | 1271 | } |
| 823 | 1272 | |
| @@ -823,38 +1272,59 @@ | ||
| 823 | 1272 | |
| 824 | 1273 | /** |
| 825 | 1274 | * Per-client setup cards. |
| 826 | 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 | + * | |
| 827 | 1282 | * @return string |
| 828 | 1283 | */ |
| 829 | 1284 | protected function clients_html() { |
| 830 | - $url = esc_html( $this->connector_url() ); | |
| 1285 | + $url = $this->connector_url(); | |
| 831 | 1286 | ob_start(); |
| 832 | 1287 | ?> |
| 833 | 1288 | <div class="nx-mcp-clients"> |
| 834 | 1289 | <div class="nx-mcp-client"> |
| 835 | - <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> | |
| 836 | 1295 | <ol class="nx-mcp-steps"> |
| 837 | 1296 | <li><?php esc_html_e( 'In Claude, add a custom connector.', 'notificationx' ); ?></li> |
| 838 | 1297 | <li><?php esc_html_e( 'Paste the Connector URL above.', 'notificationx' ); ?></li> |
| 839 | 1298 | <li><?php esc_html_e( 'Approve the connection when prompted — you sign in here, no token needed.', 'notificationx' ); ?></li> |
| 840 | 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> | |
| 841 | 1301 | </div> |
| 842 | 1302 | <div class="nx-mcp-client"> |
| 843 | - <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> | |
| 844 | 1308 | <ol class="nx-mcp-steps"> |
| 845 | 1309 | <li><?php esc_html_e( 'Settings → Connectors → Add a custom connector.', 'notificationx' ); ?></li> |
| 846 | - <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> | |
| 847 | 1311 | <li><?php esc_html_e( 'Provide the connection token as a Bearer credential.', 'notificationx' ); ?></li> |
| 848 | 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> | |
| 849 | 1314 | </div> |
| 850 | 1315 | <div class="nx-mcp-client"> |
| 851 | - <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 & 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 & others', 'notificationx' ); ?></span> | |
| 1319 | + <span class="nx-mcp-pill nx-mcp-pill-token"><?php esc_html_e( 'Token', 'notificationx' ); ?></span> | |
| 1320 | + </div> | |
| 852 | 1321 | <ol class="nx-mcp-steps"> |
| 853 | - <li><?php esc_html_e( 'Add an MCP server with the Connector URL above.', 'notificationx' ); ?></li> | |
| 854 | - <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> | |
| 855 | 1324 | <li><?php esc_html_e( 'Confirm the install when the client asks.', 'notificationx' ); ?></li> |
| 856 | 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> | |
| 857 | 1327 | </div> |
| 858 | 1328 | </div> |
| 859 | 1329 | <?php |
| 860 | 1330 | return ob_get_clean(); |
| @@ -860,13 +1330,14 @@ | ||
| 860 | 1330 | return ob_get_clean(); |
| 861 | 1331 | } |
| 862 | 1332 | |
| 863 | 1333 | /** |
| 864 | - * The list of currently connected AI apps (pairing token + OAuth clients). | |
| 1334 | + * The currently connected apps (pairing token + active OAuth clients). Shared | |
| 1335 | + * by the rendered panel and the /mcp/apps endpoint so the two cannot drift. | |
| 865 | 1336 | * |
| 866 | - * @return string | |
| 1337 | + * @return array[] Each: type, client_id, name, read_only. | |
| 867 | 1338 | */ |
| 868 | - protected function connected_apps_html() { | |
| 1339 | + protected function get_connected_apps() { | |
| 869 | 1340 | $apps = array(); |
| 870 | 1341 | |
| 871 | 1342 | // Only list the token connection once a client has actually used it — |
| 872 | 1343 | // the token existing on its own is not a "connected app". |
| @@ -888,9 +1359,22 @@ | ||
| 888 | 1359 | 'read_only' => ! empty( $client['read_only'] ), |
| 889 | 1360 | ); |
| 890 | 1361 | } |
| 891 | 1362 | |
| 1363 | + return $apps; | |
| 1364 | + } | |
| 1365 | + | |
| 1366 | + protected function connected_apps_html() { | |
| 1367 | + $apps = $this->get_connected_apps(); | |
| 1368 | + | |
| 892 | 1369 | ob_start(); |
| 1370 | + ?> | |
| 1371 | + <div class="nx-mcp-apps-head"> | |
| 1372 | + <span class="nx-mcp-apps-hint"><?php esc_html_e( 'Apps you have approved. Refresh to pick up a new or detached connection.', 'notificationx' ); ?></span> | |
| 1373 | + <button type="button" class="nx-mcp-btn nx-mcp-btn-ghost nx-mcp-btn-sm nx-mcp-refresh-apps"><?php esc_html_e( 'Refresh', 'notificationx' ); ?></button> | |
| 1374 | + </div> | |
| 1375 | + <div id="nx-mcp-apps-wrap"> | |
| 1376 | + <?php | |
| 893 | 1377 | if ( empty( $apps ) ) { |
| 894 | 1378 | echo '<p class="nx-mcp-empty">' . esc_html__( 'No AI clients are connected yet.', 'notificationx' ) . '</p>'; |
| 895 | 1379 | } else { |
| 896 | 1380 | echo '<div class="nx-mcp-apps">'; |
| @@ -897,9 +1381,9 @@ | ||
| 897 | 1381 | foreach ( $apps as $app ) { |
| 898 | 1382 | $scope_class = $app['read_only'] ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw'; |
| 899 | 1383 | $scope_label = $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' ); |
| 900 | 1384 | ?> |
| 901 | - <div class="nx-mcp-app"> | |
| 1385 | + <div class="nx-mcp-app" data-nx-key="<?php echo esc_attr( $app['type'] . ':' . $app['client_id'] ); ?>"> | |
| 902 | 1386 | <div class="nx-mcp-app-info"> |
| 903 | 1387 | <strong><?php echo esc_html( $app['name'] ); ?></strong> |
| 904 | 1388 | <span class="nx-mcp-scope <?php echo esc_attr( $scope_class ); ?>"><?php echo esc_html( $scope_label ); ?></span> |
| 905 | 1389 | </div> |
| @@ -908,8 +1392,11 @@ | ||
| 908 | 1392 | <?php |
| 909 | 1393 | } |
| 910 | 1394 | echo '</div>'; |
| 911 | 1395 | } |
| 1396 | + ?> | |
| 1397 | + </div> | |
| 1398 | + <?php | |
| 912 | 1399 | return ob_get_clean(); |
| 913 | 1400 | } |
| 914 | 1401 | |
| 915 | 1402 | /** |
| @@ -961,97 +1448,250 @@ | ||
| 961 | 1448 | // after a client-side navigation. Otherwise the panel shows unstyled until |
| 962 | 1449 | // a manual reload. |
| 963 | 1450 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check. |
| 964 | 1451 | $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; |
| 965 | - if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) ) { | |
| 1452 | + if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) || ! current_user_can( 'manage_options' ) ) { | |
| 966 | 1453 | return; |
| 967 | 1454 | } |
| 968 | 1455 | $nonce = wp_create_nonce( 'wp_rest' ); |
| 969 | 1456 | $urls = array( |
| 970 | 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' ) ), | |
| 971 | 1460 | 'rotate' => esc_url_raw( rest_url( 'notificationx/v1/mcp/rotate' ) ), |
| 972 | 1461 | 'disconnect' => esc_url_raw( rest_url( 'notificationx/v1/mcp/disconnect' ) ), |
| 973 | 1462 | 'revoke' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps/revoke' ) ), |
| 1463 | + 'apps' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps' ) ), | |
| 974 | 1464 | ); |
| 1465 | + $i18n = array( | |
| 1466 | + 'revoke' => __( 'Revoke', 'notificationx' ), | |
| 1467 | + 'empty' => __( 'No AI clients are connected yet.', 'notificationx' ), | |
| 1468 | + 'refreshFailed' => __( 'Could not refresh the connected apps.', 'notificationx' ), | |
| 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' ), | |
| 1479 | + // Refresh outcomes: say what actually changed, not just a count. | |
| 1480 | + 'noneStill' => __( 'No apps connected yet.', 'notificationx' ), | |
| 1481 | + 'upToDate' => __( 'Up to date — nothing changed.', 'notificationx' ), | |
| 1482 | + 'addedOne' => __( '1 new app connected.', 'notificationx' ), | |
| 1483 | + /* translators: %d: number of newly connected apps. */ | |
| 1484 | + 'addedMany' => __( '%d new apps connected.', 'notificationx' ), | |
| 1485 | + 'removedOne' => __( '1 app disconnected.', 'notificationx' ), | |
| 1486 | + /* translators: %d: number of disconnected apps. */ | |
| 1487 | + 'removedMany' => __( '%d apps disconnected.', 'notificationx' ), | |
| 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' ), | |
| 1494 | + ); | |
| 975 | 1495 | ?> |
| 976 | 1496 | <style id="nx-mcp-panel-css"> |
| 977 | - .nx-mcp-hero{display:flex;gap:14px;align-items:flex-start} | |
| 978 | - .nx-mcp-hero-icon{font-size:26px;line-height:1} | |
| 979 | - .nx-mcp-hero-title{margin:0 0 6px;font-size:18px;display:flex;align-items:center;gap:10px} | |
| 980 | - .nx-mcp-hero-text{margin:0;color:#50575e;max-width:640px} | |
| 981 | - .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} | |
| 982 | 1525 | /* The message-field CSS (#notificationx .wprf-message p a) underlines the |
| 983 | 1526 | whole anchor at rest, which draws a line under the arrow too. Override |
| 984 | 1527 | it in every state (!important beats that #id rule) and underline only |
| 985 | 1528 | the text span on hover. */ |
| 986 | - .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} | |
| 987 | 1530 | .nx-mcp-learn .nx-mcp-learn-text{text-decoration:none} |
| 988 | 1531 | .nx-mcp-learn:hover .nx-mcp-learn-text{text-decoration:underline} |
| 989 | 1532 | .nx-mcp-learn-arrow{display:inline-block;transition:transform .2s} |
| 990 | 1533 | .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:translateX(3px)} |
| 991 | - .nx-mcp-badge{font-size:11px;font-weight:600;padding:2px 10px;border-radius:999px;text-transform:uppercase;letter-spacing:.02em} | |
| 992 | - .nx-mcp-badge-off{background:#e2e4e7;color:#50575e} | |
| 993 | - .nx-mcp-badge-active{background:#e5f6ea;color:#1a7f37} | |
| 994 | - .nx-mcp-badge-setup{background:#fcf3e3;color:#996800} | |
| 995 | - /* Enable toggle: keep label + switch on one row (no fixed 200px label | |
| 996 | - 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. */ | |
| 997 | 1562 | .wprf-name-enable_mcp{display:flex;flex-wrap:wrap;align-items:center} |
| 998 | 1563 | .wprf-name-enable_mcp .wprf-control-label{width:auto!important;flex:0 0 auto!important;margin:0 12px 0 0!important} |
| 999 | 1564 | .wprf-name-enable_mcp .wprf-control-field{display:contents} |
| 1000 | 1565 | .wprf-name-enable_mcp .wprf-toggle-wrap{order:2} |
| 1001 | 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 -------------------------------------------- */ | |
| 1002 | 1592 | .nx-mcp-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px} |
| 1003 | 1593 | @media(max-width:782px){.nx-mcp-grid{grid-template-columns:1fr}} |
| 1004 | - .nx-mcp-card{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff} | |
| 1005 | - .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} | |
| 1006 | 1596 | .nx-mcp-copyrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap} |
| 1007 | - .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} | |
| 1008 | - .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} | |
| 1009 | - .nx-mcp-copy:hover{background:#eef0f1} | |
| 1010 | - .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 ------------------------------------------------ */ | |
| 1011 | 1606 | .nx-mcp-clients{display:grid;grid-template-columns:repeat(3,1fr);gap:16px} |
| 1012 | 1607 | @media(max-width:960px){.nx-mcp-clients{grid-template-columns:1fr}} |
| 1013 | - .nx-mcp-client{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff} | |
| 1014 | - .nx-mcp-client-name{font-weight:600;display:flex;align-items:center;gap:8px;margin-bottom:8px} | |
| 1015 | - .nx-mcp-tag{font-size:10px;font-weight:600;background:#f0eefe;color:#6a4bff;padding:2px 8px;border-radius:999px;text-transform:uppercase} | |
| 1016 | - .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 ---------------------------------------------- */ | |
| 1627 | + .nx-mcp-apps-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap} | |
| 1628 | + .nx-mcp-apps-hint{color:#8a8f9c;font-size:12px} | |
| 1629 | + .nx-mcp-btn-sm{padding:5px 12px;font-size:12px} | |
| 1630 | + /* Once moved into the section heading bar, sit flush right on that row. */ | |
| 1631 | + .wprf-section-title .nx-mcp-refresh-apps{margin-inline-start:auto} | |
| 1632 | + .nx-mcp-apps-head:empty{display:none;margin:0} | |
| 1017 | 1633 | .nx-mcp-apps{display:flex;flex-direction:column;gap:10px} |
| 1018 | - .nx-mcp-app{display:flex;justify-content:space-between;align-items:center;border:1px solid #e0e0e0;border-radius:8px;padding:10px 14px;background:#fff} | |
| 1019 | - .nx-mcp-app-info{display:flex;align-items:center;gap:10px} | |
| 1020 | - .nx-mcp-scope{font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px} | |
| 1021 | - .nx-mcp-scope-ro{background:#eef0f1;color:#50575e} | |
| 1022 | - .nx-mcp-scope-rw{background:#e5f6ea;color:#1a7f37} | |
| 1023 | - .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} | |
| 1024 | - .nx-mcp-revoke:hover{background:#d63638;color:#fff} | |
| 1025 | - .nx-mcp-empty{color:#787c82;font-style:italic} | |
| 1026 | - .nx-mcp-health{display:flex;flex-direction:column;gap:8px} | |
| 1027 | - .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} | |
| 1028 | 1646 | .nx-mcp-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none} |
| 1029 | - .nx-mcp-dot-good{background:#1a7f37} | |
| 1647 | + .nx-mcp-dot-good{background:#16a34a} | |
| 1030 | 1648 | .nx-mcp-dot-warn{background:#dba617} |
| 1031 | - /* Client icons are <img> tags pointing at real SVG files: the card HTML is | |
| 1032 | - kses-filtered, which strips <svg> and rejects data: URIs in src/style. */ | |
| 1033 | - .nx-mcp-client-ic{width:20px;height:20px;flex:none;display:inline-block;vertical-align:middle} | |
| 1034 | - .nx-mcp-actions{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap} | |
| 1035 | - .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} | |
| 1036 | 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. */ | |
| 1037 | 1661 | .nx-mcp-btn-secondary{background:#6a4bff;color:#fff} |
| 1038 | 1662 | .nx-mcp-btn-secondary:hover{background:#583fd6} |
| 1039 | - .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#c3c4c7} | |
| 1040 | - .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} | |
| 1041 | 1665 | .nx-mcp-btn-danger{background:#d63638;color:#fff;border-color:#d63638} |
| 1042 | 1666 | .nx-mcp-btn-danger:hover{background:#b32d2e} |
| 1043 | - .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} | |
| 1044 | - .nx-mcp-danger-text{display:flex;flex-direction:column;gap:2px} | |
| 1045 | - .nx-mcp-danger-text strong{color:#8a1f21} | |
| 1046 | - .nx-mcp-danger-text span{color:#a15b5b;font-size:12px} | |
| 1047 | - .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} | |
| 1048 | 1688 | .nx-mcp-toast-in{opacity:1;transform:translateY(0)} |
| 1049 | - .nx-mcp-toast-success{background:#1a7f37} | |
| 1689 | + .nx-mcp-toast-success{background:#16a34a} | |
| 1050 | 1690 | .nx-mcp-toast-error{background:#d63638} |
| 1051 | 1691 | </style> |
| 1052 | 1692 | <script id="nx-mcp-panel-js"> |
| 1053 | - window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?> }; | |
| 1693 | + window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?>, i18n: <?php echo wp_json_encode( $i18n ); ?> }; | |
| 1054 | 1694 | window.nxMcpToast = function(type, msg){ |
| 1055 | 1695 | var t = document.createElement('div'); |
| 1056 | 1696 | t.className = 'nx-mcp-toast nx-mcp-toast-' + (type === 'error' ? 'error' : 'success'); |
| 1057 | 1697 | t.textContent = msg; |
| @@ -1059,17 +1699,65 @@ | ||
| 1059 | 1699 | requestAnimationFrame(function(){ t.classList.add('nx-mcp-toast-in'); }); |
| 1060 | 1700 | setTimeout(function(){ t.classList.remove('nx-mcp-toast-in'); setTimeout(function(){ t.remove(); }, 320); }, 3600); |
| 1061 | 1701 | }; |
| 1062 | 1702 | window.nxMcpCopy = function(btn, text){ |
| 1063 | - 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); }; | |
| 1064 | 1704 | if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, done); } |
| 1065 | 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(); } |
| 1066 | 1706 | }; |
| 1067 | 1707 | window.nxMcpReveal = function(btn){ |
| 1068 | 1708 | var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return; |
| 1069 | - if (code.dataset.shown === '1'){ code.textContent = '••••••••••••'; code.dataset.shown='0'; btn.textContent='Show'; } | |
| 1070 | - 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 | + }); | |
| 1071 | 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 | + }; | |
| 1072 | 1760 | window.nxMcpAction = function(btn, action, opts){ |
| 1073 | 1761 | opts = opts || {}; |
| 1074 | 1762 | if (opts.confirm && !window.confirm(opts.confirm)) return; |
| 1075 | 1763 | var old = btn.textContent; btn.disabled = true; btn.textContent = '…'; |
| @@ -1078,30 +1766,246 @@ | ||
| 1078 | 1766 | headers:{'Content-Type':'application/json','X-WP-Nonce':window.nxMcpData.nonce}, |
| 1079 | 1767 | body: JSON.stringify(opts.body || {}) |
| 1080 | 1768 | }).then(function(r){ return r.json().catch(function(){ return {}; }); }).then(function(res){ |
| 1081 | 1769 | btn.disabled = false; btn.textContent = old; |
| 1082 | - if (res && res.status === 'error'){ nxMcpToast('error', res.message || 'Something went wrong.'); return; } | |
| 1083 | - 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); | |
| 1084 | 1777 | if (opts.reload){ setTimeout(function(){ window.location.reload(); }, 900); } |
| 1085 | - }).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 | + }); | |
| 1086 | 1783 | }; |
| 1087 | 1784 | window.nxMcpRevoke = function(btn, type, clientId){ |
| 1088 | 1785 | nxMcpAction(btn, 'revoke', { |
| 1089 | - confirm: 'Revoke this connection? The client will need to reconnect.', | |
| 1786 | + confirm: window.nxMcpData.i18n.revokeConfirm, | |
| 1090 | 1787 | body: { type: type, client_id: clientId }, |
| 1091 | 1788 | reload: true, |
| 1092 | - success: 'Connection revoked.' | |
| 1789 | + success: window.nxMcpData.i18n.revoked | |
| 1093 | 1790 | }); |
| 1094 | 1791 | }; |
| 1095 | - // Keep the status badge in sync with the enable toggle, live. | |
| 1096 | - document.addEventListener('change', function(e){ | |
| 1097 | - if (!e.target || e.target.name !== 'enable_mcp') return; | |
| 1792 | + // Re-read the connected apps without a full page reload, so a newly | |
| 1793 | + // approved or detached client shows up immediately. Rows are built with | |
| 1794 | + // textContent because a client's name comes from dynamic registration. | |
| 1795 | + window.nxMcpRefreshApps = function(btn){ | |
| 1796 | + var wrap = document.getElementById('nx-mcp-apps-wrap'); | |
| 1797 | + if (!wrap) return; | |
| 1798 | + var old = btn ? btn.textContent : ''; | |
| 1799 | + if (btn){ btn.disabled = true; btn.textContent = '…'; } | |
| 1800 | + fetch(window.nxMcpData.urls.apps, { | |
| 1801 | + method: 'GET', | |
| 1802 | + credentials: 'same-origin', | |
| 1803 | + headers: { 'X-WP-Nonce': window.nxMcpData.nonce } | |
| 1804 | + }).then(function(r){ return r.json(); }).then(function(res){ | |
| 1805 | + if (btn){ btn.disabled = false; btn.textContent = old; } | |
| 1806 | + if (!res || res.status !== 'success' || !Array.isArray(res.apps)){ | |
| 1807 | + nxMcpToast('error', window.nxMcpData.i18n.refreshFailed); return; | |
| 1808 | + } | |
| 1809 | + // What was on screen before this refresh, so the toast can report | |
| 1810 | + // the actual delta rather than just restating a count. | |
| 1811 | + var prev = []; | |
| 1812 | + wrap.querySelectorAll('.nx-mcp-app').forEach(function(el){ | |
| 1813 | + prev.push(el.getAttribute('data-nx-key') || ''); | |
| 1814 | + }); | |
| 1815 | + var next = res.apps.map(function(a){ return a.type + ':' + (a.client_id || ''); }); | |
| 1816 | + var added = next.filter(function(k){ return prev.indexOf(k) === -1; }).length; | |
| 1817 | + var removed = prev.filter(function(k){ return next.indexOf(k) === -1; }).length; | |
| 1818 | + var i18n = window.nxMcpData.i18n; | |
| 1819 | + var toast; | |
| 1820 | + if (!added && !removed) { | |
| 1821 | + toast = next.length ? i18n.upToDate : i18n.noneStill; | |
| 1822 | + } else if (added && !removed) { | |
| 1823 | + toast = added === 1 ? i18n.addedOne : i18n.addedMany.replace('%d', added); | |
| 1824 | + } else if (removed && !added) { | |
| 1825 | + toast = removed === 1 ? i18n.removedOne : i18n.removedMany.replace('%d', removed); | |
| 1826 | + } else { | |
| 1827 | + toast = i18n.changed; | |
| 1828 | + } | |
| 1829 | + | |
| 1830 | + while (wrap.firstChild) { wrap.removeChild(wrap.firstChild); } | |
| 1831 | + if (!res.apps.length){ | |
| 1832 | + var p = document.createElement('p'); | |
| 1833 | + p.className = 'nx-mcp-empty'; | |
| 1834 | + p.textContent = i18n.empty; | |
| 1835 | + wrap.appendChild(p); | |
| 1836 | + nxMcpToast('success', toast); | |
| 1837 | + return; | |
| 1838 | + } | |
| 1839 | + var list = document.createElement('div'); | |
| 1840 | + list.className = 'nx-mcp-apps'; | |
| 1841 | + res.apps.forEach(function(app){ | |
| 1842 | + var row = document.createElement('div'); row.className = 'nx-mcp-app'; | |
| 1843 | + row.setAttribute('data-nx-key', app.type + ':' + (app.client_id || '')); | |
| 1844 | + var info = document.createElement('div'); info.className = 'nx-mcp-app-info'; | |
| 1845 | + var name = document.createElement('strong'); name.textContent = app.name || ''; | |
| 1846 | + var scope = document.createElement('span'); | |
| 1847 | + scope.className = 'nx-mcp-scope ' + (app.read_only ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw'); | |
| 1848 | + scope.textContent = app.scope_label || ''; | |
| 1849 | + info.appendChild(name); info.appendChild(scope); | |
| 1850 | + var rev = document.createElement('button'); | |
| 1851 | + rev.type = 'button'; rev.className = 'nx-mcp-revoke'; | |
| 1852 | + rev.textContent = window.nxMcpData.i18n.revoke; | |
| 1853 | + rev.addEventListener('click', function(){ nxMcpRevoke(rev, app.type, app.client_id || ''); }); | |
| 1854 | + row.appendChild(info); row.appendChild(rev); | |
| 1855 | + list.appendChild(row); | |
| 1856 | + }); | |
| 1857 | + wrap.appendChild(list); | |
| 1858 | + nxMcpToast('success', toast); | |
| 1859 | + }).catch(function(){ | |
| 1860 | + if (btn){ btn.disabled = false; btn.textContent = old; } | |
| 1861 | + nxMcpToast('error', window.nxMcpData.i18n.refreshFailed); | |
| 1862 | + }); | |
| 1863 | + }; | |
| 1864 | + // The section heading ("Connected apps") is rendered by the settings form, | |
| 1865 | + // outside this message field, so the button starts inside the content box. | |
| 1866 | + // Move it onto that heading row once it exists — flex handles the exact | |
| 1867 | + // alignment, so no hard-coded offsets that break at the responsive padding | |
| 1868 | + // change. If this never runs the button simply stays in the box and works. | |
| 1869 | + window.nxMcpPlaceRefresh = function(){ | |
| 1870 | + var btns = document.querySelectorAll('.nx-mcp-refresh-apps'); | |
| 1871 | + if (!btns.length) return; | |
| 1872 | + var fresh = null, section = null, i; | |
| 1873 | + for (i = 0; i < btns.length; i++){ | |
| 1874 | + var sec = btns[i].closest ? btns[i].closest('.wprf-control-section') : null; | |
| 1875 | + if (!sec) continue; | |
| 1876 | + var t = sec.querySelector('.wprf-section-title'); | |
| 1877 | + if (!t) continue; | |
| 1878 | + section = sec; | |
| 1879 | + // A button still sitting in the content box is a freshly rendered one. | |
| 1880 | + if (!t.contains(btns[i])) { fresh = btns[i]; break; } | |
| 1881 | + } | |
| 1882 | + if (!section || !fresh) return; | |
| 1883 | + var title = section.querySelector('.wprf-section-title'); | |
| 1884 | + if (!title) return; | |
| 1885 | + // Drop any previously moved button first, so a re-render cannot leave two. | |
| 1886 | + var stale = title.querySelectorAll('.nx-mcp-refresh-apps'); | |
| 1887 | + for (i = 0; i < stale.length; i++){ stale[i].parentNode.removeChild(stale[i]); } | |
| 1888 | + title.appendChild(fresh); | |
| 1889 | + }; | |
| 1890 | + if (window.MutationObserver){ | |
| 1891 | + new MutationObserver(function(){ nxMcpPlaceRefresh(); }) | |
| 1892 | + .observe(document.body, { childList: true, subtree: true }); | |
| 1893 | + } | |
| 1894 | + document.addEventListener('DOMContentLoaded', function(){ nxMcpPlaceRefresh(); }); | |
| 1895 | + nxMcpPlaceRefresh(); | |
| 1896 | + | |
| 1897 | + // Bound by delegation rather than an inline onclick, so the buttons keep | |
| 1898 | + // working even if the panel markup is passed through a sanitiser. | |
| 1899 | + document.addEventListener('click', function(e){ | |
| 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); } | |
| 1905 | + }); | |
| 1906 | + | |
| 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){ | |
| 1098 | 1910 | var badge = document.querySelector('.nx-mcp-badge'); |
| 1099 | 1911 | if (!badge) return; |
| 1100 | - var on = !!e.target.checked; | |
| 1101 | - 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; } | |
| 1102 | 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 | + }); | |
| 1103 | 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); | |
| 2007 | + }); | |
| 1104 | 2008 | </script> |
| 1105 | 2009 | <?php |
| 1106 | 2010 | } |
| 1107 | 2011 | |
| @@ -1107,8 +2011,80 @@ | ||
| 1107 | 2011 | |
| 1108 | 2012 | /* --------------------------------------------------------------------- */ |
| 1109 | 2013 | /* Helpers */ |
| 1110 | 2014 | /* --------------------------------------------------------------------- */ |
| 2015 | + | |
| 2016 | + /** | |
| 2017 | + * RFC 9728 protected-resource metadata, served from our own REST namespace. | |
| 2018 | + * | |
| 2019 | + * @return \WP_REST_Response | |
| 2020 | + */ | |
| 2021 | + public function rest_protected_resource() { | |
| 2022 | + if ( ! $this->is_enabled() ) { | |
| 2023 | + return $this->discovery_disabled(); | |
| 2024 | + } | |
| 2025 | + | |
| 2026 | + return new \WP_REST_Response( OAuth::get_instance()->protected_resource_metadata(), 200 ); | |
| 2027 | + } | |
| 2028 | + | |
| 2029 | + /** | |
| 2030 | + * RFC 8414 authorization-server metadata, served from our own REST namespace. | |
| 2031 | + * | |
| 2032 | + * @return \WP_REST_Response | |
| 2033 | + */ | |
| 2034 | + public function rest_authorization_server() { | |
| 2035 | + if ( ! $this->is_enabled() ) { | |
| 2036 | + return $this->discovery_disabled(); | |
| 2037 | + } | |
| 2038 | + | |
| 2039 | + return new \WP_REST_Response( OAuth::get_instance()->authorization_server_metadata(), 200 ); | |
| 2040 | + } | |
| 2041 | + | |
| 2042 | + /** | |
| 2043 | + * The response for a discovery request made while MCP is switched off. | |
| 2044 | + * A 404 keeps us indistinguishable from a site that never shipped MCP, so | |
| 2045 | + * a client cannot read our settings state from the discovery surface. | |
| 2046 | + * | |
| 2047 | + * @return \WP_Error | |
| 2048 | + */ | |
| 2049 | + protected function discovery_disabled() { | |
| 2050 | + return new \WP_Error( | |
| 2051 | + 'rest_no_route', | |
| 2052 | + __( 'No route was found matching the URL and request method.', 'notificationx' ), | |
| 2053 | + array( 'status' => 404 ) | |
| 2054 | + ); | |
| 2055 | + } | |
| 2056 | + | |
| 2057 | + /** | |
| 2058 | + * Whether an OAuth discovery path belongs to this plugin. | |
| 2059 | + * | |
| 2060 | + * The handler runs on `parse_request` at priority 0 and `emit_json()` | |
| 2061 | + * exits, so whatever it answers is final -- nothing later in the request | |
| 2062 | + * gets a say. A prefix match would therefore serve our metadata for | |
| 2063 | + * *any* suffix, including another MCP plugin's | |
| 2064 | + * `.well-known/oauth-protected-resource/<their-plugin>/mcp`, sending | |
| 2065 | + * their clients to our authorization server (RFC 9728 requires the | |
| 2066 | + * resource to match exactly, so their handshake then fails). | |
| 2067 | + * | |
| 2068 | + * Two forms are ours, and only those two: | |
| 2069 | + * | |
| 2070 | + * - the bare document, which our own `WWW-Authenticate` challenge | |
| 2071 | + * advertises (see Server::with_challenge()); | |
| 2072 | + * - the RFC 9728 path-suffixed form for our endpoint. | |
| 2073 | + * | |
| 2074 | + * Anything else is declined by returning false, so the request falls | |
| 2075 | + * through to whichever plugin does own it -- deliberately not a 404, | |
| 2076 | + * which would break that neighbour just as effectively. | |
| 2077 | + * | |
| 2078 | + * @param string $path Request path, relative to home and unslashed. | |
| 2079 | + * @param string $doc Discovery document name. | |
| 2080 | + * @return bool | |
| 2081 | + */ | |
| 2082 | + protected function owns_discovery_path( $path, $doc ) { | |
| 2083 | + $base = '.well-known/' . $doc; | |
| 2084 | + | |
| 2085 | + return $path === $base || $path === $base . '/' . self::ENDPOINT_PATH; | |
| 2086 | + } | |
| 1111 | 2087 | |
| 1112 | 2088 | /** |
| 1113 | 2089 | * The request path relative to the WordPress home path, without query string. |
| 1114 | 2090 | * |