$links * @return array */ function coverground_plugin_action_links( array $links ): array { // The settings page is registered at manage_options and both handlers // wp_die() below it. Offering the link to someone who cannot follow it is // a dead end that costs a page load to discover. if ( ! current_user_can( 'manage_options' ) ) { return $links; } $visible = __( 'Settings', 'coverground' ); $settings = sprintf( '%3$s', esc_url( coverground_settings_url() ), esc_attr( sprintf( /* translators: %s: the visible link text, "Settings". Keep %s verbatim, and first where the language allows — speech-input users say the word they can see. */ __( '%s for Coverground', 'coverground' ), $visible ) ), esc_html( $visible ) ); // A string key, not array_unshift: WP_List_Table::row_actions() wraps each // entry in , and renumbering would emit class="0". return array_merge( array( 'settings' => $settings ), $links ); } add_filter( 'plugin_action_links_' . plugin_basename( COVERGROUND_PLUGIN_FILE ), 'coverground_plugin_action_links' ); function coverground_render_settings_page() { $subscriber = coverground_subscriber(); ?>


array( 'success', sprintf( /* translators: %s: the Coverground account name. */ __( 'Connected to Coverground as %s.', 'coverground' ), $subscriber['name'] ?? 'Coverground' ), ), 'denied' => array( 'info', __( 'You chose not to allow access on Coverground. Nothing was connected.', 'coverground' ) ), 'invalid_state' => array( 'error', __( 'This connection attempt expired or did not match this site. Nothing was changed. Start again with Connect.', 'coverground' ) ), 'exchange_failed' => array( 'error', __( 'Connecting failed while confirming with Coverground. Nothing was changed. Try Connect again in a moment.', 'coverground' ) ), 'disconnected' => array( 'success', __( 'Disconnected. Existing route cards keep working.', 'coverground' ) ), ); if ( isset( $notices[ $result ] ) ) { // tabindex -1 + the id lets notices.js focus this outcome on load // (login pattern per plan C8) — a full-page redirect fires no live // region, so without focus the result would go unnoticed by AT. wp_admin_notice( $notices[ $result ][1], array( 'type' => $notices[ $result ][0], 'additional_classes' => array( 'inline' ), 'attributes' => array( 'id' => 'cg-result-notice', 'tabindex' => '-1', ), ) ); } } /** * Connect: generate the state AT CLICK TIME (a render-time state would churn * on every view and re-arm on prefetch), park it with its expiry, and send * the admin to the consent screen. */ function coverground_handle_connect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this site\'s Coverground connection.', 'coverground' ) ); } check_admin_referer( 'coverground_connect' ); $state = wp_generate_password( 32, false ); update_option( 'coverground_state', array( 'value' => $state, 'expires' => time() + COVERGROUND_STATE_TTL ), false ); // Retires the setup notice. Set HERE, at the click, rather than in the // callback: someone who reaches the consent screen and declines, or closes // the tab, has still been shown where this lives, and the callback may // never run to record it. Never cleared on disconnect — a site that has // been connected once does not need the introduction again. update_option( 'coverground_connect_attempted', true, false ); $url = add_query_arg( array( 'callback' => rawurlencode( admin_url( 'admin-post.php?action=coverground_callback' ) ), 'state' => $state, ), coverground_app_origin() . '/connect' ); // Off-site by design; the destination is the fixed, filterable app origin, // never user input — hence wp_redirect, not wp_safe_redirect. wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } add_action( 'admin_post_coverground_connect', 'coverground_handle_connect' ); /** * The return leg: validate state, exchange the one-time code server-to-server * (automatic — never add a manual "finish connecting" step, the 5-minute code * TTL depends on this), then redirect to the clean settings URL. The state * option is the CSRF guard here; a WP nonce cannot ride through Coverground. */ function coverground_handle_callback() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this site\'s Coverground connection.', 'coverground' ) ); } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the single-use state parameter is the CSRF token for this cross-site leg. $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $code = isset( $_GET['cg_code'] ) ? sanitize_text_field( wp_unslash( $_GET['cg_code'] ) ) : ''; $error = isset( $_GET['cg_error'] ) ? sanitize_key( wp_unslash( $_GET['cg_error'] ) ) : ''; // phpcs:enable $stored = get_option( 'coverground_state' ); delete_option( 'coverground_state' ); // Single use, whatever happens next. $valid = is_array( $stored ) && is_string( $stored['value'] ?? null ) && hash_equals( $stored['value'], $state ) && time() < (int) ( $stored['expires'] ?? 0 ); if ( ! $valid ) { coverground_redirect_result( 'invalid_state' ); } if ( 'denied' === $error || '' === $code ) { coverground_redirect_result( 'denied' ); } $response = wp_remote_post( coverground_app_origin() . '/connect/exchange', array( 'timeout' => 10, 'headers' => array( 'Accept' => 'application/json' ), 'body' => array( 'code' => $code ), ) ); $body = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || empty( $body['token'] ) || empty( $body['subscriber'] ) ) { coverground_redirect_result( 'exchange_failed' ); } coverground_store_connection( (string) $body['token'], (array) $body['subscriber'] ); coverground_schedule_check(); coverground_run_check(); // First report right away, not tomorrow. coverground_redirect_result( 'connected' ); } add_action( 'admin_post_coverground_callback', 'coverground_handle_callback' ); /** * Disconnect: best-effort remote revoke, then ALWAYS clear local state — a * dead token must never wedge the site in "connected". */ function coverground_handle_disconnect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage this site\'s Coverground connection.', 'coverground' ) ); } check_admin_referer( 'coverground_disconnect' ); $token = (string) get_option( 'coverground_token', '' ); if ( '' !== $token ) { wp_remote_request( coverground_api_origin() . '/api/site/connection', array( 'method' => 'DELETE', 'timeout' => 5, 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json' ), ) ); } coverground_clear_connection(); coverground_unschedule_check(); coverground_redirect_result( 'disconnected' ); } add_action( 'admin_post_coverground_disconnect', 'coverground_handle_disconnect' ); /** PRG tail shared by every settings flow. */ function coverground_redirect_result( string $result ): void { wp_safe_redirect( coverground_settings_url( array( 'cg_result' => $result ) ) ); exit; }