Connected Projects" admin submenu: GitHub OAuth App * settings, connecting a GitHub-synced repo (Lovable, Bolt.new, or any * Vite/React project), and the list of projects * this site auto-converts on every push. * * @package Esipher */ namespace Esipher\Admin; use Esipher\Connected_Project; use Esipher\Github_Oauth; use Esipher\Github_Sync_Service; use Esipher\Project_Store; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Registers the submenu page and its admin-post handlers. */ class Github_Admin_Page { const MENU_SLUG = 'esipher-github'; const ACTION_SAVE_SETTINGS = 'esipher_github_save_settings'; const ACTION_OAUTH_START = 'esipher_github_oauth_start'; const ACTION_OAUTH_CALLBACK = 'esipher_github_oauth_callback'; const ACTION_DISCONNECT_ACCT = 'esipher_github_disconnect_account'; const ACTION_CONNECT_PROJECT = 'esipher_github_connect_project'; const ACTION_SYNC_NOW = 'esipher_github_sync_now'; const ACTION_APPLY_UPDATE = 'esipher_github_apply_update'; const ACTION_DISCONNECT_PROJ = 'esipher_github_disconnect_project'; public function register() { add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_post_' . self::ACTION_SAVE_SETTINGS, array( $this, 'handle_save_settings' ) ); add_action( 'admin_post_' . self::ACTION_OAUTH_START, array( $this, 'handle_oauth_start' ) ); add_action( 'admin_post_' . self::ACTION_OAUTH_CALLBACK, array( $this, 'handle_oauth_callback' ) ); add_action( 'admin_post_' . self::ACTION_DISCONNECT_ACCT, array( $this, 'handle_disconnect_account' ) ); add_action( 'admin_post_' . self::ACTION_CONNECT_PROJECT, array( $this, 'handle_connect_project' ) ); add_action( 'admin_post_' . self::ACTION_SYNC_NOW, array( $this, 'handle_sync_now' ) ); add_action( 'admin_post_' . self::ACTION_APPLY_UPDATE, array( $this, 'handle_apply_update' ) ); add_action( 'admin_post_' . self::ACTION_DISCONNECT_PROJ, array( $this, 'handle_disconnect_project' ) ); } public function add_menu() { add_submenu_page( Admin_Page::MENU_SLUG, __( 'Connected Projects', 'esipher' ), __( 'Connected Projects', 'esipher' ), Admin_Page::CAPABILITY, self::MENU_SLUG, array( $this, 'render_page' ) ); } /** * @return string This page's own admin_url(), used as the OAuth * callback's eventual redirect-back target. */ private function page_url( array $args = array() ) { return add_query_arg( $args, admin_url( 'admin.php?page=' . self::MENU_SLUG ) ); } /** * @return string The fixed callback URL registered with GitHub — * admin-post.php, not this page's own URL, since it * must exactly match what the OAuth App's settings * say and never change based on query args. */ private function oauth_callback_url() { return admin_url( 'admin-post.php?action=' . self::ACTION_OAUTH_CALLBACK ); } public function render_page() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'esipher' ) ); } $oauth = new Github_Oauth(); $store = new Project_Store(); $notice = $this->render_notice(); Admin_Layout::open( self::MENU_SLUG, __( 'Esipher - Connected Projects', 'esipher' ) ); echo $notice; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_notice() already escapes. echo '

' . esc_html__( 'Connect a GitHub-synced Lovable or Bolt.new repo once. GitHub then rebuilds your site on every push, and Esipher shows an Apply update button here when a new build is ready, one click to update your WordPress theme, no manual export, template, or re-upload needed.', 'esipher' ) . '

'; $this->render_settings_section( $oauth ); if ( $oauth->is_configured() && $oauth->is_connected() ) { $this->render_add_project_section(); $this->render_projects_table( $store->all() ); } Admin_Layout::close(); } private function render_notice() { return Admin_Notice::render(); } /** * Queue a notice for the next page load and go back to this page. * * @param string $message Plain-text message. * @param bool $is_error Whether to style it as an error. */ private function redirect_notice( $message, $is_error = false ) { Admin_Notice::flash( $message, $is_error ); wp_safe_redirect( $this->page_url() ); exit; } private function render_settings_section( Github_Oauth $oauth ) { Admin_Layout::card_open( __( 'GitHub connection', 'esipher' ), $oauth->is_connected() ? '' : __( 'Esipher uses your own GitHub OAuth App, so your account and your server stay in your hands.', 'esipher' ) ); if ( $oauth->is_connected() ) { echo '

' . Admin_Layout::pill( __( 'Connected', 'esipher' ), 'ok' ) . ' '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pill() escapes. printf( /* translators: %s: connected GitHub account login. */ esc_html__( 'Signed in as %s.', 'esipher' ) . ' ', '' . esc_html( $oauth->account_login() ) . '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the dynamic part is escaped. ); echo '' . esc_html__( 'Disconnect', 'esipher' ) . '

'; if ( ! $oauth->has_required_scopes() ) { echo '

' . esc_html__( 'This connection is missing the GitHub "workflow" permission, so Esipher can\'t commit the build workflow into a repo — connecting a project will fail with "Not Found". Click Disconnect above, then Save & Connect again and approve the additional permission.', 'esipher' ) . '

'; } Admin_Layout::card_close(); return; } echo '
'; wp_nonce_field( self::ACTION_SAVE_SETTINGS ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
'; echo '

' . sprintf( /* translators: 1: opening tag, 2: closing tag, 3: callback URL to paste into GitHub. */ esc_html__( 'Register your own free GitHub OAuth App once: %1$sgithub.com → Settings → Developer settings → OAuth Apps → New OAuth App%2$s. Set "Authorization callback URL" to exactly:', 'esipher' ), '', '' ) . '

'; echo '

' . esc_html( $this->oauth_callback_url() ) . '

'; submit_button( __( 'Save & Connect', 'esipher' ) ); echo '
'; Admin_Layout::card_close(); } private function render_add_project_section() { Admin_Layout::card_open( __( 'Connect a project', 'esipher' ), __( 'Point Esipher at the GitHub repo your AI builder syncs to.', 'esipher' ) ); echo '
'; wp_nonce_field( self::ACTION_CONNECT_PROJECT ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '

' . esc_html__( 'The repo your builder\'s GitHub sync pushes to (in Lovable or Bolt.new, connect the project to GitHub from the project menu if not already set up).', 'esipher' ) . '

' . esc_html__( 'Options', 'esipher' ) . ''; echo '
'; echo ''; echo '
'; submit_button( __( 'Connect & sync now', 'esipher' ) ); echo '
'; Admin_Layout::card_close(); } /** * @param Connected_Project[] $projects */ private function render_projects_table( array $projects ) { Admin_Layout::card_open( __( 'Connected projects', 'esipher' ) ); if ( empty( $projects ) ) { echo '

' . esc_html__( 'No projects connected yet.', 'esipher' ) . '

'; Admin_Layout::card_close(); return; } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $projects as $project ) { echo ''; echo ''; echo ''; echo ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- status_badge() already escapes. echo ''; echo ''; echo ''; // Shown for a hard 'error' and also for a 'synced' project // that carries a note (e.g. converted fine, but its theme // isn't the active one). if ( '' !== $project->last_error ) { echo ''; } } echo '
' . esc_html__( 'Repo', 'esipher' ) . '' . esc_html__( 'Branch', 'esipher' ) . '' . esc_html__( 'Theme', 'esipher' ) . '' . esc_html__( 'Status', 'esipher' ) . '' . esc_html__( 'Last synced', 'esipher' ) . '
' . esc_html( $project->full_name() ) . '' . esc_html( $project->branch ) . '' . esc_html( $project->theme_slug ) . '' . $this->status_badge( $project ) . '' . esc_html( $project->last_synced_at ? $project->last_synced_at : __( 'never', 'esipher' ) ) . ''; if ( 'update_ready' === $project->status ) { echo '' . esc_html__( 'Apply update', 'esipher' ) . ' '; } echo '' . esc_html__( 'Sync now', 'esipher' ) . ' '; echo '' . esc_html__( 'Disconnect', 'esipher' ) . ''; echo '
' . esc_html( $project->last_error ) . '
'; Admin_Layout::card_close(); } private function status_badge( Connected_Project $project ) { $labels = array( 'connected' => array( __( 'Connected, waiting for the first build', 'esipher' ), 'info' ), 'synced' => array( __( 'Up to date', 'esipher' ), 'ok' ), 'update_ready' => array( __( 'Update ready, click Apply update', 'esipher' ), 'warn' ), 'error' => array( __( 'Error', 'esipher' ), 'err' ), ); $entry = $labels[ $project->status ] ?? array( $project->status, '' ); return Admin_Layout::pill( $entry[0], $entry[1] ); } public function handle_save_settings() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_SAVE_SETTINGS ); $client_id = isset( $_POST['esipher_gh_client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_gh_client_id'] ) ) : ''; $client_secret = isset( $_POST['esipher_gh_client_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_gh_client_secret'] ) ) : ''; ( new Github_Oauth() )->save_credentials( $client_id, $client_secret ); wp_safe_redirect( admin_url( 'admin-post.php?action=' . self::ACTION_OAUTH_START ) ); exit; } public function handle_oauth_start() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } $oauth = new Github_Oauth(); $url = $oauth->authorize_url( $this->oauth_callback_url() ); if ( null === $url ) { $this->redirect_notice( __( 'Save a Client ID and Secret first.', 'esipher' ), true ); exit; } wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- destination is github.com, deliberately external, not user-controlled input. exit; } public function handle_oauth_callback() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified via the state param below instead, per OAuth convention (GitHub, not our own form, sends this request). $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( '' === $code ) { $this->redirect_notice( __( 'GitHub sign-in was cancelled.', 'esipher' ), true ); exit; } $result = ( new Github_Oauth() )->exchange_code( $code, $state, $this->oauth_callback_url() ); if ( is_wp_error( $result ) ) { $this->redirect_notice( $result->get_error_message(), true ); exit; } $this->redirect_notice( __( 'GitHub connected.', 'esipher' ) ); exit; } public function handle_disconnect_account() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_DISCONNECT_ACCT ); ( new Github_Oauth() )->disconnect(); $this->redirect_notice( __( 'GitHub disconnected. Connected projects are kept but will stop auto-syncing until you reconnect.', 'esipher' ) ); exit; } public function handle_connect_project() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_CONNECT_PROJECT ); $repo_input = isset( $_POST['esipher_gh_repo'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_gh_repo'] ) ) : ''; $branch = isset( $_POST['esipher_gh_branch'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_gh_branch'] ) ) : ''; $slug = isset( $_POST['esipher_gh_slug'] ) ? sanitize_title( wp_unslash( $_POST['esipher_gh_slug'] ) ) : ''; $activate = ! empty( $_POST['esipher_gh_activate'] ); $recover = ! empty( $_POST['esipher_gh_recover_images'] ); $parsed = $this->parse_owner_repo( $repo_input ); if ( null === $parsed ) { $this->redirect_notice( __( 'Could not read an owner/repo from that GitHub URL.', 'esipher' ), true ); exit; } $service = new Github_Sync_Service(); $project = $service->connect_project( $parsed['owner'], $parsed['repo'], $branch, $slug, $activate, $recover ); if ( is_wp_error( $project ) ) { $this->redirect_notice( $project->get_error_message(), true ); exit; } $dispatched = $service->trigger_manual_sync( $project->id ); $message = is_wp_error( $dispatched ) ? sprintf( /* translators: %s: error message from GitHub. */ __( 'Connected, but the first sync could not be started: %s. It will still auto-sync on your next push.', 'esipher' ), $dispatched->get_error_message() ) : __( 'Connected, the first build just started on GitHub. When it finishes (usually a minute or two), click Apply update here.', 'esipher' ); $this->redirect_notice( $message ); exit; } public function handle_apply_update() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_APPLY_UPDATE ); $project_id = isset( $_GET['project'] ) ? sanitize_text_field( wp_unslash( $_GET['project'] ) ) : ''; $result = ( new Github_Sync_Service() )->apply_pending_update( $project_id ); $this->redirect_notice( is_wp_error( $result ) ? $result->get_error_message() : __( 'Update applied. Your theme now matches the latest build.', 'esipher' ), is_wp_error( $result ) ); } public function handle_sync_now() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_SYNC_NOW ); $project_id = isset( $_GET['project'] ) ? sanitize_text_field( wp_unslash( $_GET['project'] ) ) : ''; $result = ( new Github_Sync_Service() )->trigger_manual_sync( $project_id ); $message = is_wp_error( $result ) ? $result->get_error_message() : __( 'Build started on GitHub. When it finishes (usually a minute or two), click Apply update here.', 'esipher' ); $this->redirect_notice( $message, is_wp_error( $result ) ); exit; } public function handle_disconnect_project() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_DISCONNECT_PROJ ); $project_id = isset( $_GET['project'] ) ? sanitize_text_field( wp_unslash( $_GET['project'] ) ) : ''; $result = ( new Github_Sync_Service() )->disconnect_project( $project_id ); $message = is_wp_error( $result ) ? $result->get_error_message() : __( 'Project disconnected.', 'esipher' ); $this->redirect_notice( $message, is_wp_error( $result ) ); exit; } /** * Accepts a bare "owner/repo" or a full https://github.com/owner/repo(.git) URL. * * @param string $input * * @return array{owner:string,repo:string}|null */ private function parse_owner_repo( $input ) { $input = trim( $input ); if ( preg_match( '#github\.com[/:]([^/]+)/([^/.\s]+)#i', $input, $matches ) ) { return array( 'owner' => $matches[1], 'repo' => $matches[2] ); } if ( preg_match( '#^([\w.-]+)/([\w.-]+)$#', $input, $matches ) ) { return array( 'owner' => $matches[1], 'repo' => $matches[2] ); } return null; } }