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;
}
}