connection = $connection; $this->backend_client = $backend_client; $this->logger = $logger; } /** * Register WPPilot as a top-level wp-admin menu item. */ public function register_menu(): void { $this->page_hook = add_menu_page( Wppilot_Strings::page_title(), Wppilot_Strings::menu_label(), 'manage_options', 'wppilot', array( $this, 'render_page' ), 'dashicons-superhero', 58 ); // WordPress points the top-level item at the first submenu entry. This // entry, registered before Leads (see the admin_menu priority in // wingpilot.php), keeps that the WPPilot page. add_submenu_page( 'wppilot', Wppilot_Strings::page_title(), __( 'Dashboard', 'wingpilot' ), 'manage_options', 'wppilot', array( $this, 'render_page' ) ); } /** * Enqueue admin CSS/JS only on the plugin's own settings page. */ public function enqueue_assets( string $hook ): void { if ( $hook !== $this->page_hook ) { return; } wp_enqueue_style( 'wppilot-admin', WPPILOT_URL . 'admin/css/wppilot-admin.css', array(), WPPILOT_VERSION ); wp_enqueue_script( 'wppilot-admin', WPPILOT_URL . 'admin/js/wppilot-admin.js', array(), WPPILOT_VERSION, true ); wp_localize_script( 'wppilot-admin', 'wppilotStrings', Wppilot_Strings::all_js() ); wp_localize_script( 'wppilot-admin', 'wppilotAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'wppilot_handoff' ), ) ); } /** * Render the product dashboard: one connection state, one panel. * * The state is derived once, by Connection_State, before anything is * drawn. The page used to pick its layout from the local `connected` * option and let each card ask the backend for itself, so a rejection * discovered half-way down sat beside "Active" and "Setup incomplete" * (wptech.group, 2026-10-08). The backend is asked only when local facts * cannot decide, and only once. */ public function render_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $summary = null; $state = $this->connection_state( $summary ); $notice = $this->take_notice(); ?>
render_brandbar(); ?> render_header_end(); } if ( is_array( $notice ) ) { $this->render_result( $notice ); } switch ( $state ) { case Connection_State::NOT_CONNECTED: $this->render_empty_state(); $this->render_header_end(); break; case Connection_State::NEEDS_RECONNECT: $this->render_needs_reconnect(); $this->render_advanced_section( $this->connection->get_site_id(), $this->connection->get_secret(), false ); break; default: $this->render_connected( $state, $summary ); } ?>

|null, stale: bool}|null $summary * Receives the summary when it was fetched, so the page renders the * same answer the state was derived from. * @return string A Connection_State constant. */ private function connection_state( ?array &$summary ): string { $connected = $this->connection->is_connected(); $has_secret = '' !== $this->connection->get_secret(); $needs_reconnect = $this->connection->needs_reconnect(); $local = Connection_State::local( $connected, $has_secret, $needs_reconnect ); if ( null !== $local ) { return $local; } $summary = ( new Site_Summary( $this->backend_client ) )->get(); $account_linked = is_array( $summary['data'] ) && ! empty( $summary['data']['account_linked'] ); return Connection_State::derive( $connected, $has_secret, $needs_reconnect, $summary['state'], $account_linked ); } /** * The result of the last connect / reconnect / disconnect / open action. * * Drawn as the page's own banner rather than a core `.notice`, which core * relocates and notice-hiding plugins remove: an owner who clicks * Reconnect must always be told what happened. * * A failure carries its technical cause in a collapsed line beneath the * owner's sentence: there for support, out of the owner's way. * * @param array{type: string, message: string, technical?: string} $notice */ private function render_result( array $notice ): void { $technical = (string) ( $notice['technical'] ?? '' ); ?>

render_reconnect_form( 'button button-primary button-hero wppilot-cta' ); ?>

|null, stale: bool} $summary */ private function render_connected( string $state, array $summary ): void { $site_name = get_bloginfo( 'name' ); $site_url = get_site_url(); $data = is_array( $summary['data'] ) ? $summary['data'] : array(); // Last activity timestamp, from the summary's authoritative maximum // rather than from whatever happened to be first in a display page. $last_activity = Site_Summary::ago( $data['last_activity_at'] ?? null ) ?? ''; // Site host for display. $site_host = wp_parse_url( $site_url, PHP_URL_HOST ); if ( empty( $site_host ) ) { $site_host = $site_url; } $this->render_connection_card( $site_name, $site_url, $last_activity, $state ); if ( Connection_State::CONNECTED_UNLINKED === $state ) { // "Finish connecting" leads the first run; see render_page(). $this->render_header_end(); } $this->render_activity_status_card( $summary ); // Unreachable: everything below would be more requests to a backend // that just did not answer, each ending in an empty card. if ( Connection_State::UNREACHABLE !== $state ) { // "Active" is claimed only when the backend says monitoring runs. if ( ! empty( $data['monitoring'] ) ) { $this->render_watching_card( $site_host, $last_activity ); } $this->render_briefing_section( $this->backend_client->get_briefing_summary() ); $this->render_activity_section( $this->backend_client->get_recent_activity( 5 ) ); $this->render_upgrade_section(); } $this->render_advanced_section( $this->connection->get_site_id(), $this->connection->get_secret(), true ); } /** * Connection hero card. * * Both destinations go through the same single-use handoff, as forms * posting to admin-post.php: the server mints the handoff and redirects, * so they work with JavaScript or pop-ups blocked. They used to be links * to the bare app URL whose only route to the handoff was JavaScript, so * "Finish connecting" without it landed on the app with nothing linked. * * @param string $state A Connection_State constant (connected, * connected_unlinked or unreachable). */ private function render_connection_card( string $site_name, string $site_url, string $last_activity, string $state ): void { if ( Connection_State::CONNECTED === $state ) { $pill = array( 'wppilot-pill--connected', Wppilot_Strings::status_connected() ); } elseif ( Connection_State::CONNECTED_UNLINKED === $state ) { $pill = array( 'wppilot-pill--pending', Wppilot_Strings::status_setup_incomplete() ); } else { $pill = array( 'wppilot-pill--muted', Wppilot_Strings::status_unavailable() ); } $primary = Connection_State::CONNECTED_UNLINKED === $state ? __( 'Finish connecting to WPPilot', 'wingpilot' ) : __( 'Open WPPilot Cockpit', 'wingpilot' ); ?>

render_open_form( '/app', $primary, 'button button-primary button-hero wppilot-cta' ); if ( Connection_State::UNREACHABLE !== $state ) { $this->render_open_form( '/briefing', __( 'View Site Briefing', 'wingpilot' ), 'button button-hero' ); } ?>

|null, stale: bool} $summary */ private function render_activity_status_card( array $summary ): void { $data = is_array( $summary['data'] ) ? $summary['data'] : array(); $state = $summary['state']; if ( 'unavailable' === $state ) { ?>

0 ) || ( null !== $findings && $findings > 0 ) ) : ?>
0 ) : ?> 0 ) : ?>

|null}|null $briefing */ private function render_briefing_section( $briefing ): void { $data = is_array( $briefing ) ? ( $briefing['briefing'] ?? null ) : null; $status = is_array( $briefing ) ? ( $briefing['status'] ?? 'none' ) : 'none'; ?>

→

> $events Newest-first activity events. */ private function render_activity_section( array $events ): void { ?>

→

render_reconnect_form( 'button' ); ?>

__( 'Applied meta description update', 'wingpilot' ), '/seo\s*rewrite/i' => __( 'Previewed SEO rewrite', 'wingpilot' ), '/initial.*review|connected.*look/i' => __( 'Initial site review completed', 'wingpilot' ), ); foreach ( $own as $pattern => $replacement ) { if ( preg_match( $pattern, $title ) ) { return $replacement; } } } // Neutral descriptions, true of an event whoever caused it. $rules = array( '/^unknown\s+plugin$/i' => __( 'New plugin detected', 'wingpilot' ), '/^Plugin installed:/i' => __( 'New plugin detected', 'wingpilot' ), '/login[_\s]attempt/i' => __( 'Failed login attempt detected', 'wingpilot' ), '/wp-content\/.*$/i' => __( 'File system change detected', 'wingpilot' ), '/\/wp-json\//i' => __( 'API request processed', 'wingpilot' ), ); foreach ( $rules as $pattern => $replacement ) { if ( preg_match( $pattern, $title ) ) { return $replacement; } } if ( preg_match( '/\/|\\\\|\.php|\.zip|\.js/', $title ) ) { return 'sitepilot' === $source ? __( 'System update processed', 'wingpilot' ) : __( 'File change detected', 'wingpilot' ); } return $title; } /** * Generate a one-sentence detail for an activity event. * * @param array $event */ private function humanize_activity_detail( array $event ): string { $title = $event['title'] ?? ''; $source = $event['source'] ?? ''; if ( 'watchtower' === $source ) { if ( preg_match( '/^Vulnerability in (.+?): .+ \[(.+?)\]/i', $title, $m ) ) { /* translators: %s: plugin name */ return sprintf( __( '%s contains a known vulnerability.', 'wingpilot' ), $m[1] ); } if ( preg_match( '/scan complete.*no issues/i', $title ) ) { return __( 'No threats or vulnerabilities were found.', 'wingpilot' ); } if ( preg_match( '/scan complete/i', $title ) ) { return __( 'Scan finished with findings to review.', 'wingpilot' ); } if ( preg_match( '/malicious|shell|backdoor|obfuscated|remote code|known malware/i', $title ) ) { return __( 'Watchtower flagged this for review.', 'wingpilot' ); } } if ( preg_match( '/^Plugin installed:/i', $title ) ) { return __( 'A new plugin was installed on your site.', 'wingpilot' ); } return ''; } /** * Format ISO timestamp as relative time. */ private function relative_time( string $iso ): string { $ts = strtotime( $iso ); if ( false === $ts ) { return ''; } /* translators: %s: human-readable time difference, e.g. "3 minutes". */ return sprintf( __( '%s ago', 'wingpilot' ), human_time_diff( $ts, time() ) ); } /** * Handle the "Connect to WPPilot" and "Reconnect WPPilot" form submissions. * * Always ends on the WPPilot page with a result the owner can read. On * wptech.group (2026-10-08) a Reconnect click cleared the pairing and came * back with no word of what had happened, so it looked as if nothing had, * and a second click was needed. The result now travels in the redirect * itself (see report()), so it cannot be lost on the way. */ public function handle_connect(): void { check_admin_referer( 'wppilot_connect', 'wppilot_nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html( Wppilot_Strings::error_no_permission() ) ); } // Recovery, in one click. When WPPilot no longer recognises this site, // the stored pairing is the problem, and `ensure_connected()` would // short-circuit on a `connected` flag that is no longer true. Clearing // it first sends the same first-connect path — secret, handshake, // callback token — through again, in this same request, rather than // inventing a second way to pair. // // The backend keys a site on its URL, so reconnecting rejoins the // existing site and keeps its history; it does not create a new one. $reconnect = isset( $_POST['wppilot_reconnect'] ); if ( $reconnect ) { $this->connection->clear(); Site_Summary::forget(); $this->clear_dashboard_cache(); } $this->backend_client->limit_total_time( self::ACTION_BUDGET ); $result = $this->ensure_connected(); if ( is_wp_error( $result ) ) { $this->logger->warning( $reconnect ? 'Reconnect did not complete' : 'Connect did not complete', array( 'error' => $result->get_error_message() ) ); $this->report( $reconnect ? 'reconnect_failed' : 'connect_failed', $result->get_error_message(), Connect_Failure::technical_of( $result ) ); return; } if ( $reconnect ) { $this->logger->info( 'Site reconnected to WPPilot', array( 'site_id' => $this->connection->get_site_id() ) ); } $this->report( $reconnect ? 'reconnected' : 'connected' ); } /** * Open WPPilot through the single-use handoff, without JavaScript. * * The server-side twin of handle_handoff_ajax(): the open forms post here * when the script is blocked or did not run, and the owner is redirected * to the authorize URL — so "Finish connecting" links the account instead * of landing on the app with nothing linked. A failure comes back to the * WPPilot page with the reason. */ public function handle_open(): void { check_admin_referer( 'wppilot_open', 'wppilot_nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html( Wppilot_Strings::error_no_permission() ) ); } $url = $this->authorize_url( self::requested_dest() ); if ( is_wp_error( $url ) ) { $this->logger->warning( 'Open WPPilot did not complete', array( 'error' => $url->get_error_message() ) ); $this->report( 'open_failed', $url->get_error_message(), Connect_Failure::technical_of( $url ) ); return; } $this->redirect_to_app( $url ); } /** * One-click authorize handoff (AJAX). */ public function handle_handoff_ajax(): void { check_ajax_referer( 'wppilot_handoff' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => Wppilot_Strings::error_no_permission() ), 403 ); } $url = $this->authorize_url( self::requested_dest() ); if ( is_wp_error( $url ) ) { wp_send_json_error( array( 'message' => $url->get_error_message(), 'technical' => Connect_Failure::technical_of( $url ), ) ); } wp_send_json_success( array( 'authorize_url' => $url ) ); } /** * Where in WPPilot this click was meant to land. Allowlisted, so a crafted * request cannot turn our own admin screen into an open redirector, and * relative-only so it can never leave the app. Callers verify the nonce. */ private static function requested_dest(): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- both callers verify a nonce first. $requested = isset( $_POST['dest'] ) ? sanitize_text_field( wp_unslash( $_POST['dest'] ) ) : ''; return in_array( $requested, array( '/app', '/briefing' ), true ) ? $requested : '/app'; } /** * Mint a single-use handoff and build the WPPilot authorize URL for it, * pairing the site first if it is not paired. * * @param string $dest Allowlisted destination inside WPPilot. * @return string|\WP_Error */ private function authorize_url( string $dest ) { $this->backend_client->limit_total_time( self::ACTION_BUDGET ); $connected = $this->ensure_connected(); if ( is_wp_error( $connected ) ) { return $connected; } $secret = $this->connection->get_secret(); if ( empty( $secret ) ) { return new \WP_Error( 'wppilot_no_secret', Wppilot_Strings::handoff_error() ); } $handoff = $this->backend_client->create_handoff( $secret, get_current_user_id() ); if ( is_wp_error( $handoff ) ) { return $handoff; } // No email in this URL. The admin email travels in the body of the // handoff mint above (see Backend_Client::create_handoff()), and the app // reads it back from the backend only while no account owns the site. // A URL is kept in browser history, server logs and Referer headers. return WPPILOT_APP_URL . '/authorize?site=' . rawurlencode( get_site_url() ) . '&handoff=' . rawurlencode( $handoff['handoff_token'] ) . '&dest=' . rawurlencode( $dest ); } /** * Ensure this site is connected to the backend. * * @return true|\WP_Error */ private function ensure_connected() { /* * Our own memory of being connected is not evidence that we are. * * This used to be the whole test, and a backend that had forgotten this * site could not be told to look again: the option said `1`, so the * handshake below was skipped for ever while every authenticated call * 401'd. The owner had no route back — recovering this state needed a * database edit. `needs_reconnect()` carries the backend's own verdict * (a refused pairing, or deliveries refused until sending stopped), and * it overrules the local belief in exactly one direction: it can send * us back to the handshake, never the reverse. */ if ( $this->connection->is_connected() && ! $this->connection->needs_reconnect() && ! empty( $this->connection->get_secret() ) ) { return true; } $secret = $this->connection->generate_secret(); $connect_nonce = wp_generate_password( 32, false ); set_transient( 'wppilot_connect_window', $connect_nonce, 60 ); $result = $this->backend_client->connect( $secret, $connect_nonce ); if ( is_wp_error( $result ) ) { delete_transient( 'wppilot_connect_window' ); return $result; } $this->connection->set_site_id( $result['site_id'] ); $this->connection->set_callback_token_hash( hash( 'sha256', $result['callback_token'] ) ); $this->connection->set_connected_user_id( get_current_user_id() ); delete_transient( 'wppilot_connect_window' ); // A fresh pairing ends every earlier verdict, so activity delivery // resumes. Withdrawn before the token exchange below, which records a // new verdict of its own if the backend refuses even this pairing. $this->connection->set_pairing_rejected( false ); $this->connection->reset_delivery(); $token_result = $this->backend_client->get_token( $secret ); if ( ! is_wp_error( $token_result ) ) { $this->connection->set_jwt( $token_result['token'], $token_result['expires_in'] ); } $this->connection->set_connected( true ); $this->logger->info( 'Site connected to WPPilot', array( 'site_id' => $result['site_id'] ) ); return true; } /** * Handle the "Emergency Disconnect" form submission. */ public function handle_disconnect(): void { check_admin_referer( 'wppilot_disconnect', 'wppilot_nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html( Wppilot_Strings::error_no_permission() ) ); } $disconnect_result = $this->backend_client->disconnect(); if ( is_wp_error( $disconnect_result ) ) { $this->logger->warning( 'Backend disconnect call failed — clearing local state anyway', array( 'error' => $disconnect_result->get_error_message() ) ); } $this->connection->clear(); $this->clear_dashboard_cache(); $this->logger->info( 'Site disconnected from WPPilot' ); $this->report( 'disconnected' ); } /** * Seconds an owner's click (Connect, Reconnect, Open) may keep the page * waiting in total, across connect, token exchange and handoff. */ private const ACTION_BUDGET = 25; /** Query argument that carries an action's result code to the page. */ public const RESULT_ARG = 'wppilot_result'; /** Transient holding the detail (a failure reason) for that result. */ private const RESULT_TRANSIENT = 'wppilot_admin_notice'; /** * Add the result argument to the ones WordPress strips from the address * bar after load, so a refresh does not repeat the banner. * * @param string[] $args Removable query arguments. * @return string[] */ public static function removable_query_args( array $args ): array { $args[] = self::RESULT_ARG; return $args; } /** * The banner for a result code, or null for an unknown one. * * The code is what the redirect carries, so the outcome is shown even if * the detail transient is gone (object-cache eviction, a second tab); only * the reason is then missing, never the result. * * @param string $code A result code from report(). * @param string $detail The owner-facing failure reason, possibly ''. * @param string $technical A short technical line for support, possibly ''. * @return array{type: string, message: string, technical: string}|null */ public static function result_notice( string $code, string $detail, string $technical = '' ): ?array { $notice = self::result_message( $code, $detail ); if ( null === $notice ) { return null; } $notice['technical'] = 'error' === $notice['type'] ? $technical : ''; return $notice; } /** * The banner type and owner-facing sentence for a result code. * * @param string $code A result code from report(). * @param string $detail The owner-facing failure reason, possibly ''. * @return array{type: string, message: string}|null */ private static function result_message( string $code, string $detail ): ?array { $reason = '' !== $detail ? $detail : Wppilot_Strings::no_reason_given(); switch ( $code ) { case 'connected': return array( 'type' => 'success', 'message' => Wppilot_Strings::notice_connected() ); case 'reconnected': return array( 'type' => 'success', 'message' => Wppilot_Strings::notice_reconnected() ); case 'disconnected': return array( 'type' => 'success', 'message' => Wppilot_Strings::notice_disconnected() ); case 'connect_failed': return array( 'type' => 'error', 'message' => Wppilot_Strings::notice_connect_failed( $reason ) ); case 'reconnect_failed': return array( 'type' => 'error', 'message' => Wppilot_Strings::notice_reconnect_failed( $reason ) ); case 'open_failed': return array( 'type' => 'error', 'message' => Wppilot_Strings::notice_open_failed( $reason ) ); } return null; } /** * Read, and consume, the result to show on this page load. * * @return array{type: string, message: string, technical: string}|null */ private function take_notice(): ?array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only: selects one of a fixed set of messages. $code = isset( $_GET[ self::RESULT_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::RESULT_ARG ] ) ) : ''; $stored = get_transient( self::RESULT_TRANSIENT ); if ( false !== $stored ) { delete_transient( self::RESULT_TRANSIENT ); } if ( '' === $code ) { return null; } $mine = is_array( $stored ) && ( $stored['code'] ?? '' ) === $code; $detail = $mine ? (string) ( $stored['detail'] ?? '' ) : ''; $technical = $mine ? (string) ( $stored['technical'] ?? '' ) : ''; return self::result_notice( $code, $detail, $technical ); } /** * End an action on the WPPilot page with its result. * * @param string $code A result code result_notice() knows. * @param string $detail The owner-facing failure reason, if any. * @param string $technical A short technical line for support, if any. */ private function report( string $code, string $detail = '', string $technical = '' ): void { set_transient( self::RESULT_TRANSIENT, array( 'code' => $code, 'detail' => sanitize_text_field( $detail ), 'technical' => sanitize_text_field( $technical ), ), 120 ); $this->redirect_to_settings( $code ); } private function clear_dashboard_cache(): void { delete_transient( 'wppilot_briefing_summary' ); delete_transient( 'wppilot_recent_activity_5' ); } /** * Redirect to the WPPilot page, carrying a result code. * * @param string $result A result code, or '' for none. */ protected function redirect_to_settings( string $result = '' ): void { $url = admin_url( 'admin.php?page=wppilot' ); if ( '' !== $result ) { $url = add_query_arg( self::RESULT_ARG, $result, $url ); } wp_safe_redirect( $url ); exit; } /** * Redirect to the WPPilot app (the authorize URL), the one external host * this page sends an owner to. * * @param string $url An authorize URL built from WPPILOT_APP_URL. */ protected function redirect_to_app( string $url ): void { $host = (string) wp_parse_url( WPPILOT_APP_URL, PHP_URL_HOST ); add_filter( 'allowed_redirect_hosts', static function ( array $hosts ) use ( $host ): array { $hosts[] = $host; return $hosts; } ); wp_safe_redirect( $url, 303 ); exit; } }