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 '';
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 '';
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 '| ' . esc_html__( 'Repo', 'esipher' ) . ' | ';
echo '' . esc_html__( 'Branch', 'esipher' ) . ' | ';
echo '' . esc_html__( 'Theme', 'esipher' ) . ' | ';
echo '' . esc_html__( 'Status', 'esipher' ) . ' | ';
echo '' . esc_html__( 'Last synced', 'esipher' ) . ' | ';
echo ' | ';
echo '
';
foreach ( $projects as $project ) {
echo '';
echo '| ' . esc_html( $project->full_name() ) . ' | ';
echo '' . esc_html( $project->branch ) . ' | ';
echo '' . esc_html( $project->theme_slug ) . ' | ';
echo '' . $this->status_badge( $project ) . ' | '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- status_badge() already escapes.
echo '' . esc_html( $project->last_synced_at ? $project->last_synced_at : __( 'never', 'esipher' ) ) . ' | ';
echo '';
if ( 'update_ready' === $project->status ) {
echo '' . esc_html__( 'Apply update', 'esipher' ) . ' ';
}
echo '' . esc_html__( 'Sync now', 'esipher' ) . ' ';
echo '' . esc_html__( 'Disconnect', 'esipher' ) . '';
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 '| ' . esc_html( $project->last_error ) . ' |
';
}
}
echo '
';
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;
}
}