' . esc_html__( 'Connect AI Agent', 'aicom' ) . '';
array_unshift( $links, $setup_link );
return $links;
}
// ── Menu Registration ─────────────────────────────────────────────────
public function register_menus(): void {
$icon_svg = @file_get_contents( AICOM_DIR . 'assets/branding/aicom-icon-mono.svg' );
$menu_icon = $icon_svg
? 'data:image/svg+xml;base64,' . base64_encode( $icon_svg )
: 'dashicons-rest-api';
add_menu_page(
__( 'aicom', 'aicom' ),
__( 'aicom', 'aicom' ),
self::CAPABILITY,
self::MENU_SLUG,
[ $this, 'page_dashboard' ],
$menu_icon,
80
);
add_submenu_page( self::MENU_SLUG, __( 'Dashboard', 'aicom' ), __( 'Dashboard', 'aicom' ), self::CAPABILITY, self::MENU_SLUG, [ $this, 'page_dashboard' ] );
add_submenu_page( self::MENU_SLUG, __( 'Manage API Keys', 'aicom' ), __( 'Manage API Keys', 'aicom' ), self::CAPABILITY, 'aicom-api-keys', [ $this, 'page_api_keys' ] );
add_submenu_page( self::MENU_SLUG, __( 'Activity', 'aicom' ), __( 'Activity', 'aicom' ), self::CAPABILITY, 'aicom-audit-logs', [ $this, 'page_audit_logs' ] );
add_submenu_page( self::MENU_SLUG, __( 'Safety', 'aicom' ), __( 'Safety', 'aicom' ), self::CAPABILITY, 'aicom-safety', [ $this, 'page_safety' ] );
add_submenu_page( self::MENU_SLUG, __( 'Capabilities', 'aicom' ), __( 'Capabilities', 'aicom' ), self::CAPABILITY, 'aicom-modules', [ $this, 'page_modules' ] );
add_submenu_page( self::MENU_SLUG, __( 'Snapshots', 'aicom' ), __( 'Snapshots', 'aicom' ), self::CAPABILITY, 'aicom-backups', [ $this, 'page_backups' ] );
add_submenu_page( self::MENU_SLUG, __( 'Saved Workflows', 'aicom' ), __( 'Saved Workflows', 'aicom' ), self::CAPABILITY, 'aicom-skills', [ $this, 'page_skills' ] );
add_submenu_page( self::MENU_SLUG, __( 'Help', 'aicom' ), __( 'Help', 'aicom' ), self::CAPABILITY, 'aicom-help', [ $this, 'page_help' ] );
// Hidden onboarding wizard — reachable only by direct URL or by maybe_redirect_to_onboarding().
add_submenu_page( null, __( 'Welcome to AICOM', 'aicom' ), '', self::CAPABILITY, 'aicom-onboarding', [ $this, 'page_onboarding' ] );
}
// ── Asset Enqueuing ───────────────────────────────────────────────────
public function enqueue_assets( string $hook ): void {
$plugin_url = AICOM_URL;
$version = AICOM_VERSION;
$on_aicom = strpos( $hook, 'aicom' ) !== false;
// CSS enqueued on every admin page (admin bar styles needed everywhere)
wp_enqueue_style(
'aicom-admin',
$plugin_url . 'assets/admin.css',
[],
$version
);
// JS only needed on AICOM pages OR when admin bar is showing (toolbar toggle)
wp_enqueue_script(
'aicom-admin',
$plugin_url . 'assets/admin.js',
[ 'jquery' ],
$version,
true
);
wp_localize_script( 'aicom-admin', 'AICOM_MCP', [
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
] );
}
// ── Page Renderers ────────────────────────────────────────────────────
public function page_dashboard(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/dashboard.php';
}
public function page_api_keys(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/api-keys.php';
}
public function page_audit_logs(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/audit-logs.php';
}
public function page_safety(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/safety.php';
}
public function page_modules(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/modules.php';
}
public function page_backups(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/backups.php';
}
public function page_skills(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/skills.php';
}
public function page_help(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/help.php';
}
public function page_onboarding(): void {
$this->require_cap();
require AICOM_DIR . 'admin/pages/onboarding.php';
}
// ── Admin notices (key created / rotated) ─────────────────────────────
public function display_key_notice(): void {
$screen = get_current_screen();
if ( ! $screen || strpos( $screen->id ?? '', 'aicom' ) === false ) {
return;
}
$created = isset( $_GET['created'] ) ? absint( $_GET['created'] ) : 0;
$rotated = isset( $_GET['rotated'] ) ? absint( $_GET['rotated'] ) : 0;
$new_key_id = $created ?: $rotated;
if ( ! $new_key_id ) {
return;
}
$plain_key = get_transient( 'aicom_new_key_' . $new_key_id );
if ( ! $plain_key ) {
return;
}
delete_transient( 'aicom_new_key_' . $new_key_id );
$mcp_url = get_site_url() . '/wp-json/aicom/v1/mcp';
$fallback_url = get_site_url() . '/?aicom=1';
$schema_url = get_site_url() . '/wp-json/aicom/v1/schema';
// Per-client paste-ready snippets + plain-English instructions.
// The user picks their client and we swap both the instruction line
// and the terminal snippet in place. No need to wade through the
// generic "Connect to my WordPress site via MCP…" wall of text.
$client_recipes = [
'general' => [
'name' => __( 'General', 'aicom' ),
'intro' => __( 'Paste this into any MCP-aware AI agent. Tells it where the site is, how to authenticate, and a fallback if the primary endpoint is refused.', 'aicom' ),
'snippet' => sprintf(
/* translators: 1: MCP endpoint URL, 2: fallback URL, 3: API key */
__( "Connect to my WordPress site via MCP:\n\nEndpoint: %1\$s\nFallback: %2\$s\nAuthorization: Bearer %3\$s\n\nUse the endpoint and the Authorization header for every request. If the connection is refused or returns 401, retry the request with real browser headers (User-Agent, Accept, Accept-Language).", 'aicom' ),
$mcp_url,
$fallback_url,
$plain_key
),
],
'claude-desktop' => [
'name' => __( 'Claude Desktop', 'aicom' ),
'intro' => __( 'Open your config file (on macOS: ~/Library/Application Support/Claude/claude_desktop_config.json). Paste this into it, save, then restart Claude Desktop.', 'aicom' ),
'snippet' => "{\n \"mcpServers\": {\n \"aicom\": {\n \"url\": \"" . $mcp_url . "\",\n \"headers\": {\n \"Authorization\": \"Bearer " . $plain_key . "\"\n }\n }\n }\n}",
],
'claude-code' => [
'name' => __( 'Claude Code', 'aicom' ),
'intro' => __( 'Open a terminal in any project folder and paste this single command. Claude Code remembers it across sessions.', 'aicom' ),
'snippet' => "claude mcp add aicom " . $mcp_url . " \\\n --transport http \\\n --header \"Authorization: Bearer " . $plain_key . "\"",
],
'cursor' => [
'name' => __( 'Cursor IDE', 'aicom' ),
'intro' => __( 'Create .cursor/mcp.json in your project (or ~/.cursor/mcp.json to share across projects). Paste this, then reload Cursor.', 'aicom' ),
'snippet' => "{\n \"mcpServers\": {\n \"aicom\": {\n \"url\": \"" . $mcp_url . "\",\n \"type\": \"http\",\n \"headers\": {\n \"Authorization\": \"Bearer " . $plain_key . "\"\n }\n }\n }\n}",
],
'chatgpt' => [
'name' => __( 'ChatGPT (Custom GPT)', 'aicom' ),
'intro' => __( 'Open the Custom GPT builder, add an Action, paste this URL. Set Authentication to API Key → Bearer, then paste the key above.', 'aicom' ),
'snippet' => $schema_url,
],
'generic' => [
'name' => __( 'Something else', 'aicom' ),
'intro' => __( 'Any MCP-aware client. Endpoint + Bearer header, sample request body below.', 'aicom' ),
'snippet' => "POST " . $mcp_url . "\nContent-Type: application/json\nAuthorization: Bearer " . $plain_key . "\n\n{\n \"jsonrpc\": \"2.0\",\n \"method\": \"wp.posts.list\",\n \"params\": { \"per_page\": 5 },\n \"id\": 1\n}",
],
];
$client_recipes_json = wp_json_encode( $client_recipes );
$label = $created
? __( 'Key created', 'aicom' )
: __( 'Key rotated', 'aicom' );
?>
handle_set_lock(
! empty( $_POST['soft_lock'] ),
! empty( $_POST['hard_lock'] )
);
break;
case 'set_lockdown':
AICOM_Lockdown::set_enabled( ! empty( $_POST['lockdown_mode'] ) );
wp_safe_redirect( add_query_arg( 'updated', 'lockdown', admin_url( 'admin.php?page=aicom-safety' ) ) );
exit;
case 'clear_rate_limit':
$ip = sanitize_text_field( wp_unslash( $_POST['ip'] ?? '' ) );
if ( $ip ) {
AICOM_Tool_Router::clear_rate_limit( $ip );
}
wp_safe_redirect( add_query_arg( 'ip_unbanned', $ip ? '1' : '0', admin_url( 'admin.php?page=aicom-safety' ) ) );
exit;
case 'generate_pairing_token':
// One-time bootstrap token for AICOM Hub pairing (PRD §16.2).
// Plaintext is stashed in a short-lived transient so the next
// pageview can reveal it exactly once.
$token = AICOM_Hub_Pairing::create_token();
set_transient( 'aicom_new_pairing_token', $token, 600 );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-safety&pairing_generated=1' ) );
exit;
case 'create_key':
$raw_scopes = filter_input( INPUT_POST, 'scopes', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?? [];
$scopes = array_map( 'sanitize_text_field', wp_unslash( $raw_scopes ) );
$this->handle_create_key(
sanitize_text_field( wp_unslash( $_POST['label'] ?? '' ) ),
$scopes,
! empty( $_POST['dry_run_only'] ),
sanitize_textarea_field( wp_unslash( $_POST['ip_allowlist'] ?? '' ) ),
sanitize_text_field( wp_unslash( $_POST['expires_at'] ?? '' ) ),
! empty( $_POST['ip_lock'] ),
self::read_working_hours_from_post(),
! empty( $_POST['compact_tools'] )
);
break;
case 'quick_create_key':
$preset_slug = sanitize_key( wp_unslash( $_POST['preset'] ?? '' ) );
$this->handle_quick_create_key( $preset_slug );
break;
case 'create_from_task':
$task_slug = sanitize_key( wp_unslash( $_POST['task'] ?? '' ) );
$this->handle_create_from_task( $task_slug );
break;
case 'complete_onboarding':
$preset_slug = sanitize_key( wp_unslash( $_POST['preset'] ?? '' ) );
if ( ! empty( $_POST['enable_lockdown'] ) ) {
AICOM_Lockdown::set_enabled( true );
}
$this->handle_complete_onboarding( $preset_slug );
break;
case 'skip_onboarding':
update_option( 'aicom_onboarding_done', true );
wp_safe_redirect( admin_url( 'admin.php?page=aicom' ) );
exit;
case 'rotate_key':
$this->handle_rotate_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
break;
case 'revoke_key':
$this->handle_revoke_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
break;
case 'suspend_key':
$this->handle_suspend_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
break;
case 'unsuspend_key':
$this->handle_unsuspend_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
break;
case 'edit_key':
$raw_scopes = filter_input( INPUT_POST, 'scopes', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?? [];
$scopes = array_map( 'sanitize_text_field', wp_unslash( $raw_scopes ) );
$this->handle_edit_key(
absint( wp_unslash( $_POST['key_id'] ?? 0 ) ),
$scopes,
! empty( $_POST['dry_run_only'] ),
sanitize_textarea_field( wp_unslash( $_POST['ip_allowlist'] ?? '' ) ),
sanitize_text_field( wp_unslash( $_POST['expires_at'] ?? '' ) ),
! empty( $_POST['rotate_secret'] ),
! empty( $_POST['ip_lock'] ),
self::read_working_hours_from_post(),
! empty( $_POST['compact_tools'] )
);
break;
case 'reset_ip_lock':
$key_id = absint( wp_unslash( $_POST['key_id'] ?? 0 ) );
AICOM_Auth::reset_ip_lock( $key_id );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&ip_lock_reset=' . $key_id ) );
exit;
case 'archive_key':
AICOM_Auth::archive_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&archived=' . absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) ) );
exit;
case 'unarchive_key':
AICOM_Auth::unarchive_key( absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&unarchived=' . absint( wp_unslash( $_POST['key_id'] ?? 0 ) ) ) );
exit;
case 'delete_backup':
$this->handle_delete_backup( absint( wp_unslash( $_POST['backup_id'] ?? 0 ) ) );
break;
case 'close_session':
AICOM_Sessions::close_by_id( absint( wp_unslash( $_POST['session_id'] ?? 0 ) ), 'cancelled' );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-audit-logs&tab=sessions&closed=1' ) );
exit;
case 'restore_session':
$this->handle_restore_session( absint( wp_unslash( $_POST['session_id'] ?? 0 ) ) );
break;
case 'save_backup_settings':
update_option( 'aicom_backup_max_age_days', absint( wp_unslash( $_POST['max_age_days'] ?? 0 ) ) );
update_option( 'aicom_backup_max_size_mb', absint( wp_unslash( $_POST['max_size_mb'] ?? 0 ) ) );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-backups&settings_saved=1' ) );
exit;
case 'save_schedule':
$this->handle_save_schedule();
break;
case 'skills_activate':
$skill_id = absint( wp_unslash( $_POST['skill_id'] ?? 0 ) );
AICOM_Skills::set_status( $skill_id, 'active' );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&tab=skills&updated=activated' ) );
exit;
case 'skills_save_draft':
$skill_id = absint( wp_unslash( $_POST['skill_id'] ?? 0 ) );
AICOM_Skills::set_status( $skill_id, 'draft' );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&tab=skills&updated=draft' ) );
exit;
case 'skills_archive':
$skill_id = absint( wp_unslash( $_POST['skill_id'] ?? 0 ) );
AICOM_Skills::set_status( $skill_id, 'archived' );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&tab=skills&updated=archived' ) );
exit;
case 'skills_delete':
$skill_id = absint( wp_unslash( $_POST['skill_id'] ?? 0 ) );
if ( ! empty( $_POST['confirm'] ) ) {
AICOM_Skills::delete( $skill_id );
}
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&tab=suggested&updated=rejected' ) );
exit;
case 'skills_accept_proposal':
$proposal_id = absint( wp_unslash( $_POST['proposal_id'] ?? 0 ) );
$original_id = absint( wp_unslash( $_POST['original_id'] ?? 0 ) );
$proposal = AICOM_Skills::get( $proposal_id );
if ( $proposal && $original_id ) {
$patch = array_intersect_key( $proposal, array_flip( [ 'name', 'description', 'steps', 'rules', 'tags', 'input_schema', 'permissions', 'type' ] ) );
AICOM_Skills::update( $original_id, $patch );
AICOM_Skills::delete( $proposal_id );
}
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&tab=proposals&updated=accepted' ) );
exit;
case 'skills_toggle_suggestions':
$enabled = ! empty( $_POST['suggestions_enabled'] );
update_option( 'aicom_skill_suggestions', $enabled ? '1' : '0' );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&settings_saved=1' ) );
exit;
case 'skills_export':
$skill_id = absint( wp_unslash( $_POST['skill_id'] ?? 0 ) );
$skill = AICOM_Skills::get( $skill_id );
if ( ! $skill ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&error=not_found' ) );
exit;
}
$export = array_filter( [
'name' => $skill['name'],
'slug' => $skill['slug'],
'description' => $skill['description'] ?: null,
'type' => $skill['type'],
'input_schema' => $skill['input_schema'] ?: null,
'rules' => $skill['rules'] ?: null,
'steps' => $skill['steps'] ?: null,
'permissions' => $skill['permissions'] ?: null,
'tags' => $skill['tags'] ?: null,
], fn( $v ) => $v !== null );
nocache_headers();
header( 'Content-Type: application/json; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $skill['slug'] ) . '.json"' );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wp_json_encode( $export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
exit;
case 'skills_import_json':
$json_raw = sanitize_textarea_field( wp_unslash( $_POST['skill_json'] ?? '' ) );
$data = json_decode( $json_raw, true );
if ( ! is_array( $data ) || empty( $data['name'] ) ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&tab=skills&import=1&import_error=invalid_json' ) );
exit;
}
if ( empty( $data['slug'] ) ) {
$data['slug'] = sanitize_title( $data['name'] );
}
$data['status'] = 'draft';
$result = AICOM_Skills::create( $data );
if ( is_wp_error( $result ) ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&tab=skills&import=1&import_error=' . urlencode( $result->get_error_code() ) ) );
exit;
}
wp_safe_redirect( admin_url( 'admin.php?page=aicom-skills&tab=skills&imported=' . (int) $result['id'] ) );
exit;
default:
wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '&error=unknown_action' ) );
exit;
}
}
// ── Action Handlers ───────────────────────────────────────────────────
private function handle_set_lock( bool $soft_lock, bool $hard_lock ): void {
AICOM_Lock_Manager::set_soft_lock( $soft_lock );
AICOM_Lock_Manager::set_hard_lock( $hard_lock );
AICOM_Audit_Logger::log( [
'remote_ip' => AICOM_Tool_Router::remote_ip(),
'tool_name' => 'admin.set_lock',
'module' => 'admin',
'status' => 'success',
'http_status' => 200,
'api_key_label' => wp_get_current_user()->user_login,
'result_summary_json' => wp_json_encode( [
'soft_lock' => $soft_lock,
'hard_lock' => $hard_lock,
] ),
] );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-safety&updated=lock' ) );
exit;
}
private function handle_save_schedule(): void {
$enabled = ! empty( $_POST['schedule_enabled'] );
$raw_days = filter_input( INPUT_POST, 'schedule_days', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?? [];
$days = array_values( array_map( 'intval', (array) $raw_days ) );
$start = sanitize_text_field( wp_unslash( $_POST['schedule_start'] ?? '09:00' ) );
$end = sanitize_text_field( wp_unslash( $_POST['schedule_end'] ?? '18:00' ) );
$lock_type = in_array( wp_unslash( $_POST['schedule_lock_type'] ?? '' ), [ 'soft_locked', 'hard_locked' ], true )
? sanitize_key( wp_unslash( $_POST['schedule_lock_type'] ) )
: 'soft_locked';
if ( ! preg_match( '/^\d{2}:\d{2}$/', $start ) ) { $start = '09:00'; }
if ( ! preg_match( '/^\d{2}:\d{2}$/', $end ) ) { $end = '18:00'; }
AICOM_Lock_Manager::set_schedule( [
'enabled' => $enabled,
'days' => $days,
'start' => $start,
'end' => $end,
'lock_type' => $lock_type,
] );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-safety&schedule_saved=1' ) );
exit;
}
private function parse_lines( string $raw ): array {
return array_values( array_filter( array_map( 'trim', preg_split( '/[\r\n]+/', $raw ) ) ) );
}
private function apply_resource_restrictions( array &$restrictions ): void {
$fields = [
'res_post_types' => 'post_types',
'res_taxonomies' => 'taxonomies',
'res_meta_keys' => 'meta_keys',
'res_options' => 'options',
'res_file_paths' => 'file_paths',
];
foreach ( $fields as $post_key => $restriction_key ) {
$raw = sanitize_textarea_field( wp_unslash( $_POST[ $post_key ] ?? '' ) );
if ( $raw !== '' ) {
$restrictions[ $restriction_key ] = $this->parse_lines( $raw );
}
}
if ( AICOM_Module_Detector::is_polylang_active() ) {
$raw = sanitize_textarea_field( wp_unslash( $_POST['res_languages'] ?? '' ) );
if ( $raw !== '' ) {
$restrictions['languages'] = $this->parse_lines( $raw );
}
}
}
private function handle_create_key( string $label, array $scopes, bool $dry_run_only, string $ip_allowlist_raw, string $expires_raw = '', bool $ip_lock = false, array $working_hours = [], bool $compact_tools = false ): void {
if ( ! $label ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&error=missing_label' ) );
exit;
}
$restrictions = [];
if ( $dry_run_only ) {
$restrictions['dry_run_only'] = true;
}
if ( $ip_allowlist_raw ) {
$restrictions['ip_allowlist'] = array_filter( array_map( 'trim', explode( "\n", $ip_allowlist_raw ) ) );
}
if ( $ip_lock ) {
$restrictions['ip_lock'] = true;
}
if ( ! empty( $working_hours['enabled'] ) ) {
$restrictions['working_hours'] = $working_hours;
}
if ( $compact_tools ) {
$restrictions['compact_tools'] = true;
}
$this->apply_resource_restrictions( $restrictions );
$expires_ts = $expires_raw ? strtotime( $expires_raw ) : false;
$expires_at = ( $expires_ts !== false ) ? gmdate( 'Y-m-d 23:59:59', $expires_ts ) : null;
if ( $expires_raw && $expires_ts === false ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&error=invalid_date' ) );
exit;
}
$result = AICOM_Auth::create_key( $label, $scopes, $restrictions, $expires_at );
// Store the plain key in a short-lived transient for one-time display
set_transient( 'aicom_new_key_' . $result['id'], $result['plain_key'], 60 );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&created=' . $result['id'] ) );
exit;
}
private function handle_complete_onboarding( string $preset_slug ): void {
$presets = self::system_presets();
if ( ! isset( $presets[ $preset_slug ] ) ) {
// Bad preset — bounce back to step 2 with an error.
wp_safe_redirect( admin_url( 'admin.php?page=aicom-onboarding&step=2&error=invalid_preset' ) );
exit;
}
$preset = $presets[ $preset_slug ];
$label = self::next_available_label( 'First Key — ' . $preset['name'] );
$result = AICOM_Auth::create_key( $label, $preset['scopes'], [], null );
set_transient( 'aicom_new_key_' . $result['id'], $result['plain_key'], 120 );
// Mark onboarding complete so the welcome wizard never reappears.
update_option( 'aicom_onboarding_done', true );
// Land on the AICOM dashboard with the key-created modal open.
wp_safe_redirect( admin_url( 'admin.php?page=aicom&created=' . $result['id'] . '&onboarded=1' ) );
exit;
}
private function handle_create_from_task( string $task_slug ): void {
$tasks = self::task_presets();
if ( ! isset( $tasks[ $task_slug ] ) ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom&error=invalid_task' ) );
exit;
}
$task = $tasks[ $task_slug ];
$label = self::next_available_label( $task['name'] );
$restrictions = [];
if ( ! empty( $task['dry_run'] ) ) {
$restrictions['dry_run_only'] = true;
}
$result = AICOM_Auth::create_key( $label, $task['scopes'], $restrictions, null );
set_transient( 'aicom_new_key_' . $result['id'], $result['plain_key'], 60 );
wp_safe_redirect( admin_url( 'admin.php?page=aicom&created=' . $result['id'] ) );
exit;
}
private function handle_quick_create_key( string $preset_slug ): void {
$presets = self::system_presets();
if ( ! isset( $presets[ $preset_slug ] ) ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom&error=invalid_preset' ) );
exit;
}
$preset = $presets[ $preset_slug ];
$label = self::next_available_label( 'Quick ' . $preset['name'] );
$result = AICOM_Auth::create_key( $label, $preset['scopes'], [], null );
set_transient( 'aicom_new_key_' . $result['id'], $result['plain_key'], 60 );
wp_safe_redirect( admin_url( 'admin.php?page=aicom&created=' . $result['id'] ) );
exit;
}
private function handle_rotate_key( int $key_id ): void {
$new_key = AICOM_Auth::rotate_key( $key_id );
if ( $new_key ) {
set_transient( 'aicom_new_key_' . $key_id, $new_key, 60 );
}
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&rotated=' . $key_id ) );
exit;
}
private function handle_revoke_key( int $key_id ): void {
AICOM_Auth::revoke_key( $key_id );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&revoked=' . $key_id ) );
exit;
}
private function handle_suspend_key( int $key_id ): void {
AICOM_Auth::suspend_key( $key_id );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&suspended=' . $key_id ) );
exit;
}
private function handle_unsuspend_key( int $key_id ): void {
AICOM_Auth::unsuspend_key( $key_id );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&unsuspended=' . $key_id ) );
exit;
}
private function handle_edit_key( int $id, array $scopes, bool $dry_run_only, string $ip_raw, string $expires_raw, bool $rotate, bool $ip_lock = false, array $working_hours = [], bool $compact_tools = false ): void {
if ( ! $id ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&error=invalid_key' ) );
exit;
}
global $wpdb;
$existing_row = $wpdb->get_row( $wpdb->prepare( "SELECT restrictions_json FROM {$wpdb->prefix}aicom_api_keys WHERE id = %d", $id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$existing_restrictions = json_decode( $existing_row['restrictions_json'] ?? '{}', true ) ?: [];
$restrictions = [];
if ( $dry_run_only ) {
$restrictions['dry_run_only'] = true;
}
if ( $ip_raw ) {
$restrictions['ip_allowlist'] = array_filter( array_map( 'trim', explode( "\n", $ip_raw ) ) );
}
if ( $ip_lock ) {
$restrictions['ip_lock'] = true;
// Preserve auto-bound IP if ip_lock remains on and the admin left the allowlist field blank.
if ( empty( $restrictions['ip_allowlist'] ) && ! empty( $existing_restrictions['ip_lock_bound_at'] ) ) {
$restrictions['ip_allowlist'] = $existing_restrictions['ip_allowlist'] ?? [];
$restrictions['ip_lock_bound_at'] = $existing_restrictions['ip_lock_bound_at'];
}
}
if ( ! empty( $working_hours['enabled'] ) ) {
$restrictions['working_hours'] = $working_hours;
}
if ( $compact_tools ) {
$restrictions['compact_tools'] = true;
}
$this->apply_resource_restrictions( $restrictions );
$expires_ts = $expires_raw ? strtotime( $expires_raw ) : false;
$expires_at = ( $expires_ts !== false ) ? gmdate( 'Y-m-d 23:59:59', $expires_ts ) : null;
if ( $expires_raw && $expires_ts === false ) {
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&error=invalid_date' ) );
exit;
}
$diff = AICOM_Auth::update_key( $id, $scopes, $restrictions, $expires_at );
$rotated = false;
if ( $rotate ) {
$new_key = AICOM_Auth::rotate_key( $id );
if ( $new_key ) {
set_transient( 'aicom_new_key_' . $id, $new_key, 60 );
$rotated = true;
}
}
set_transient( 'aicom_edit_result_' . $id, wp_json_encode( [
'added' => $diff['added'],
'removed' => $diff['removed'],
'rotated' => $rotated,
] ), 30 );
wp_safe_redirect( admin_url( 'admin.php?page=aicom-api-keys&edited=' . $id . ( $rotated ? '&rotated=' . $id : '' ) ) );
exit;
}
private function handle_delete_backup( int $id ): void {
global $wpdb;
if ( $id ) {
$wpdb->delete( $wpdb->prefix . 'aicom_backups', [ 'id' => $id ] );
}
wp_safe_redirect( admin_url( 'admin.php?page=aicom-backups&deleted=' . $id ) );
exit;
}
private function handle_restore_session( int $session_id ): void {
$back_to = sanitize_key( wp_unslash( $_POST['_redirect_to'] ?? '' ) ) === 'backups'
? 'admin.php?page=aicom-backups&tab=snapshots'
: 'admin.php?page=aicom-audit-logs&tab=sessions';
if ( ! $session_id ) {
wp_safe_redirect( admin_url( $back_to . '&restore_error=1' ) );
exit;
}
$restored = self::do_restore_session( $session_id );
wp_safe_redirect( admin_url( $back_to . '&restored=' . $restored ) );
exit;
}
/**
* Shared system preset definitions — used by both the API Keys page (full list)
* and the dashboard Quick API key card (first four). Keeping a single source
* means the dashboard never drifts from the API Keys page.
*/
public static function system_presets(): array {
$all_scopes = AICOM_Auth::scope_slugs();
$presets = [
'read-only' => [
'name' => __( 'Read-only', 'aicom' ),
// Includes manage.skills (create/update a saved Skill) at the
// user's explicit request, applied to every preset below — and
// read.skills with it (2026-10-15): without it a key could save
// a Skill but never find or run it, nor run skills.match first —
// note this preset is no longer strictly read-only on the
// site itself, only on WordPress content; risk bumped
// accordingly so the badge doesn't undersell it.
'scopes' => [ 'read.wp', 'read.users', 'read.skills', 'manage.skills' ],
'risk' => 'med',
'desc' => __( 'Safe browsing only, plus saving Skills', 'aicom' ),
],
'content-assistant' => [
'name' => __( 'Content Assistant', 'aicom' ),
'scopes' => [ 'read.wp', 'write.wp.posts', 'manage.meta', 'manage.taxonomies', 'read.skills', 'manage.skills' ],
'risk' => 'med',
'desc' => __( 'Write & publish content', 'aicom' ),
],
'elementor-editor' => [
'name' => __( 'Elementor Editor', 'aicom' ),
'scopes' => [ 'read.wp', 'write.wp.posts', 'manage.meta', 'manage.elementor', 'manage.media', 'read.skills', 'manage.skills' ],
'risk' => 'med',
'desc' => __( 'Build Elementor pages', 'aicom' ),
],
'woocommerce-catalog' => [
'name' => __( 'WooCommerce Catalog', 'aicom' ),
'scopes' => [ 'read.wp', 'write.wp.posts', 'manage.meta', 'manage.woocommerce.products', 'read.skills', 'manage.skills' ],
'risk' => 'med',
'desc' => __( 'Manage products', 'aicom' ),
],
'site-maintenance' => [
'name' => __( 'Site Maintenance', 'aicom' ),
'scopes' => [ 'read.wp', 'manage.plugins', 'manage.backups', 'manage.wordpress.settings', 'read.skills', 'manage.skills' ],
'risk' => 'high',
'desc' => __( 'Updates, backups, settings', 'aicom' ),
],
];
// Translator and SEO presets adapt their scopes to whichever
// translation/SEO plugin is actually active, instead of assuming
// Polylang or Yoast specifically — switching a site from Polylang to
// WPML (or Yoast to SEOPress) shouldn't require hand-editing these
// presets, and a key issued from either only requests scopes for a
// plugin that's actually installed. Deliberately excludes
// language-creation scopes for the same reason as before: no
// AICOM tool exposes that capability for either Polylang or WPML,
// so there's nothing to additionally restrict.
$translation_scopes = [];
if ( AICOM_Module_Detector::is_polylang_active() ) {
$translation_scopes = array_merge( $translation_scopes, [ 'read.polylang', 'manage.polylang', 'manage.polylang.settings' ] );
}
if ( AICOM_Module_Detector::is_wpml_active() ) {
$translation_scopes = array_merge( $translation_scopes, [ 'read.wpml', 'manage.wpml' ] );
}
if ( $translation_scopes ) {
$presets['translator'] = [
'name' => __( 'Translator', 'aicom' ),
'scopes' => array_merge(
[ 'read.wp', 'write.wp.posts', 'manage.meta', 'manage.taxonomies', 'manage.media', 'read.skills', 'manage.skills' ],
$translation_scopes
),
'risk' => 'high',
'desc' => __( 'Translate posts & terms with the active translation plugin', 'aicom' ),
];
}
$seo_scopes = [];
if ( AICOM_Module_Detector::is_yoast_active() ) {
$seo_scopes[] = 'manage.yoast';
}
if ( AICOM_Module_Detector::is_seopress_active() ) {
$seo_scopes[] = 'manage.seopress';
}
if ( $seo_scopes ) {
$presets['seo-editor'] = [
'name' => __( 'SEO Editor', 'aicom' ),
// manage.yoast/manage.seopress cover writes; reading is
// already covered by read.wp (see each scope's own
// description) so no extra read.* scope is needed here.
'scopes' => array_merge( [ 'read.wp', 'read.skills', 'manage.skills' ], $seo_scopes ),
'risk' => 'med',
'desc' => __( 'Edit SEO meta with the active SEO plugin', 'aicom' ),
];
}
$presets['full-admin'] = [
'name' => __( 'Full Admin', 'aicom' ),
'scopes' => $all_scopes,
'risk' => 'critical',
'desc' => __( 'All permissions', 'aicom' ),
];
return $presets;
}
/**
* Read working_hours form fields out of $_POST into the shape stored in
* restrictions_json. Returns an empty array when the user didn't enable it.
* Caller is responsible for nonce/cap; $_POST is already verified by handle_post().
*/
public static function read_working_hours_from_post(): array {
if ( empty( $_POST['working_hours_enabled'] ) ) {
return [];
}
$raw_days = filter_input( INPUT_POST, 'working_hours_days', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?? [];
$days = array_values( array_unique( array_filter( array_map( 'intval', $raw_days ), function ( $d ) {
return $d >= 0 && $d <= 6;
} ) ) );
$start = sanitize_text_field( wp_unslash( $_POST['working_hours_start'] ?? '09:00' ) );
$end = sanitize_text_field( wp_unslash( $_POST['working_hours_end'] ?? '18:00' ) );
// Validate HH:MM format; fall back to defaults if malformed.
if ( ! preg_match( '/^[0-2]\d:[0-5]\d$/', $start ) ) { $start = '09:00'; }
if ( ! preg_match( '/^[0-2]\d:[0-5]\d$/', $end ) ) { $end = '18:00'; }
return [
'enabled' => true,
'days' => $days ?: [ 1, 2, 3, 4, 5 ],
'start' => $start,
'end' => $end,
];
}
/**
* Task-oriented "quick start" recipes (mirrors AICOM Hub keys → Quick Start).
* Each card represents a real-world job the user wants done; the recipe
* pre-fills sensible scopes (and dry-run where it's safer) so the user
* doesn't have to think in terms of permissions.
*/
public static function task_presets(): array {
return [
'drafts' => [
'icon' => 'pencil',
'name' => __( 'Drafts new content', 'aicom' ),
'desc' => __( 'Writes posts and pages, sets categories, saves as drafts. Nothing publishes without your nod.', 'aicom' ),
'scopes' => [ 'read.wp', 'write.wp.posts', 'manage.meta', 'manage.taxonomies' ],
'dry_run' => false,
],
'seo' => [
'icon' => 'search',
'name' => __( 'Polishes SEO across the site', 'aicom' ),
'desc' => __( 'Reads and improves Yoast meta titles, descriptions, and Open Graph fields.', 'aicom' ),
'scopes' => [ 'read.wp', 'manage.yoast' ],
'dry_run' => false,
],
'a11y' => [
'icon' => 'image',
'name' => __( 'Keeps the media library tidy', 'aicom' ),
'desc' => __( 'Adds alt text to your images, runs accessibility audits, helps describe what each picture shows.', 'aicom' ),
'scopes' => [ 'read.wp', 'manage.media', 'manage.a11y' ],
'dry_run' => false,
],
'pages' => [
'icon' => 'layout',
'name' => __( 'Designs pages in Elementor', 'aicom' ),
'desc' => __( 'Edits existing Elementor pages, duplicates templates, swaps copy across widgets in one go.', 'aicom' ),
'scopes' => [ 'read.wp', 'write.wp.posts', 'manage.meta', 'manage.elementor', 'manage.media' ],
'dry_run' => false,
],
'shop' => [
'icon' => 'cart',
'name' => __( 'Manages your shop', 'aicom' ),
'desc' => __( 'WooCommerce products, prices, stock, attributes. Tells you what looks off and offers fixes.', 'aicom' ),
'scopes' => [ 'read.wp', 'manage.woocommerce.products' ],
'dry_run' => true,
],
'observer' => [
'icon' => 'eye',
'name' => __( 'Just watches', 'aicom' ),
'desc' => __( 'Read-only access to everything — content, users, settings. Cannot change a thing. Safest mode.', 'aicom' ),
'scopes' => [ 'read.wp', 'read.users' ],
'dry_run' => false,
],
];
}
/**
* Pick a label not already in use, with macOS-style " (N)" suffix.
* "Quick Content Assistant" → "Quick Content Assistant (2)" if base is taken.
*/
public static function next_available_label( string $base ): string {
global $wpdb;
$table = $wpdb->prefix . 'aicom_api_keys';
$rows = $wpdb->get_col( $wpdb->prepare(
"SELECT label FROM {$table} WHERE label = %s OR label LIKE %s",
$base,
$wpdb->esc_like( $base . ' (' ) . '%)'
) );
if ( empty( $rows ) ) return $base;
$base_taken = false;
$max = 1;
foreach ( $rows as $label ) {
if ( $label === $base ) {
$base_taken = true;
} elseif ( preg_match( '/^' . preg_quote( $base, '/' ) . ' \((\d+)\)$/', $label, $m ) ) {
$n = (int) $m[1];
if ( $n > $max ) $max = $n;
}
}
if ( ! $base_taken ) return $base;
return $base . ' (' . ( $max + 1 ) . ')';
}
/**
* Core restore logic — replays all backups for a session in reverse order.
* Returns the number of objects restored.
* Public so tests can call it directly without going through the POST handler.
*/
public static function do_restore_session( int $session_id ): int {
global $wpdb;
$backups = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}aicom_backups WHERE session_id = %d ORDER BY created_at DESC",
$session_id
),
ARRAY_A
);
$restored = 0;
foreach ( $backups as $backup ) {
if ( $backup['target_type'] === 'elementor_page' ) {
$target_post_id = (int) $backup['target_id'];
if ( $target_post_id && get_post( $target_post_id ) && $backup['payload_json'] ) {
// wp_slash() counteracts update_post_meta's internal wp_unslash() — mirrors
// AICOM_Module_Elementor::handle_restore().
update_post_meta( $target_post_id, '_elementor_data', wp_slash( $backup['payload_json'] ) );
update_post_meta( $target_post_id, '_elementor_edit_mode', 'builder' );
$restored++;
}
continue;
}
$payload = json_decode( $backup['payload_json'], true );
if ( ! $payload ) {
continue;
}
if ( $backup['target_type'] === 'post' && ! empty( $payload['post']['ID'] ) ) {
$post_data = $payload['post'];
$original_id = (int) $post_data['ID'];
unset( $post_data['post_modified'], $post_data['post_modified_gmt'] );
// Check at DB level — get_post() uses object cache which may be stale.
$exists = (bool) $wpdb->get_var( $wpdb->prepare(
"SELECT 1 FROM {$wpdb->posts} WHERE ID = %d LIMIT 1", $original_id
) );
if ( $exists ) {
$post_data['ID'] = $original_id;
wp_update_post( $post_data );
} else {
// Re-insert with the original ID via import_id (wp_insert_post INSERT branch).
unset( $post_data['ID'] );
$post_data['import_id'] = $original_id;
wp_insert_post( $post_data );
$post_data['ID'] = $original_id; // restore for meta/terms below
}
foreach ( $payload['meta'] ?? [] as $key => $values ) {
delete_post_meta( $original_id, $key );
foreach ( (array) $values as $val ) {
// Same gadget-chain guard as AICOM_Module_Backup::handle_post_restore().
if ( is_string( $val ) && preg_match( '/^[OC]:\d+:/', $val ) ) {
continue;
}
add_post_meta( $original_id, $key, maybe_unserialize( $val ) );
}
}
// Meta the session created (absent at backup time) is removed — e.g. an alt text added to an image that had none.
foreach ( (array) ( $payload['absent_meta'] ?? [] ) as $key ) {
delete_post_meta( $original_id, (string) $key );
}
foreach ( $payload['terms'] ?? [] as $tax => $term_ids ) {
wp_set_post_terms( $original_id, $term_ids, $tax );
}
// Elementor Theme Builder templates cache their display conditions in a
// separate global option — restoring _elementor_conditions postmeta alone
// leaves that option stale, so push it through the same sync path
// elementor.template.set_conditions uses.
if ( ( $post_data['post_type'] ?? '' ) === 'elementor_library'
&& array_key_exists( '_elementor_conditions', $payload['meta'] ?? [] )
&& class_exists( 'AICOM_Module_Elementor' )
) {
$restored_conditions = get_post_meta( $original_id, '_elementor_conditions', true );
$restored_type = (string) get_post_meta( $original_id, '_elementor_template_type', true );
if ( $restored_type ) {
( new AICOM_Module_Elementor() )->sync_conditions_cache(
$original_id,
$restored_type,
is_array( $restored_conditions ) ? $restored_conditions : []
);
}
}
clean_post_cache( $original_id );
$restored++;
} elseif ( $backup['target_type'] === 'term' && ! empty( $payload['term']['term_id'] ) ) {
$t = $payload['term'];
wp_update_term( (int) $t['term_id'], $t['taxonomy'] ?? '', [
'name' => $t['name'] ?? '',
'slug' => $t['slug'] ?? '',
'description' => $t['description'] ?? '',
'parent' => $t['parent'] ?? 0,
] );
foreach ( $payload['meta'] ?? [] as $key => $values ) {
delete_term_meta( (int) $t['term_id'], $key );
foreach ( (array) $values as $val ) {
add_term_meta( (int) $t['term_id'], $key, maybe_unserialize( $val ) );
}
}
$restored++;
}
}
return $restored;
}
/**
* Delete old backups per configured age/size caps. Called by daily cron.
*/
public static function run_backup_cleanup(): void {
global $wpdb;
$max_age = (int) get_option( 'aicom_backup_max_age_days', 0 );
$max_mb = (int) get_option( 'aicom_backup_max_size_mb', 0 );
if ( $max_age > 0 ) {
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->prefix}aicom_backups WHERE created_at < DATE_SUB(%s, INTERVAL %d DAY)",
current_time( 'mysql', true ),
$max_age
)
);
}
if ( $max_mb > 0 ) {
$max_bytes = $max_mb * 1024 * 1024;
for ( $i = 0; $i < 10000; $i++ ) { // hard limit to avoid infinite loop
$total = (int) $wpdb->get_var( "SELECT SUM(LENGTH(payload_json)) FROM {$wpdb->prefix}aicom_backups" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if ( $total <= $max_bytes ) {
break;
}
$oldest = (int) $wpdb->get_var( "SELECT id FROM {$wpdb->prefix}aicom_backups ORDER BY created_at ASC LIMIT 1" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if ( ! $oldest ) {
break;
}
$wpdb->delete( $wpdb->prefix . 'aicom_backups', [ 'id' => $oldest ] );
}
}
}
// ── Admin Bar ─────────────────────────────────────────────────────────
public static function register_admin_bar( WP_Admin_Bar $bar ): void {
if ( ! current_user_can( self::CAPABILITY ) ) {
return;
}
global $wpdb;
$keys = $wpdb->get_results(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
"SELECT id, label, status FROM {$wpdb->prefix}aicom_api_keys
WHERE status IN ('active','suspended')
ORDER BY created_at DESC LIMIT 20",
ARRAY_A
);
$manage_url = admin_url( 'admin.php?page=aicom-api-keys' );
$active_count = count( array_filter( $keys, fn( $k ) => $k['status'] === 'active' ) );
$count_html = $active_count > 0
? ' ' . $active_count . ''
: '';
$lock_status = AICOM_Lock_Manager::get_effective_lock();
$tb_class = 'aicom-tb-parent';
if ( $lock_status === 'hard_locked' ) {
$tb_class .= ' aicom-tb-hardlocked';
} elseif ( $lock_status === 'soft_locked' ) {
$tb_class .= ' aicom-tb-softlocked';
}
$bar->add_node( [
'id' => 'aicom-toolbar',
'title' => ''
. 'aicom keys'
. $count_html,
'href' => $manage_url,
'meta' => [ 'class' => $tb_class ],
] );
// ── Lock controls (first child) ────────────────────────────────────
$lock_nonce = wp_create_nonce( 'aicom_toolbar_lock' );
$bar->add_node( [
'id' => 'aicom-toolbar-lock',
'parent' => 'aicom-toolbar',
'title' => ''
. ''
. '',
'href' => false,
'meta' => [ 'class' => 'aicom-tb-lock-row' ],
] );
foreach ( $keys as $key ) {
$is_active = $key['status'] === 'active';
$dot = $is_active ? '🟢' : '🟡';
$action = $is_active ? 'suspend_key' : 'unsuspend_key';
$btn_label = $is_active ? 'Suspend' : 'Unsuspend';
$btn_class = $is_active ? 'aicom-tb-btn-suspend' : 'aicom-tb-btn-unsuspend';
$bar->add_node( [
'id' => 'aicom-key-' . (int) $key['id'],
'parent' => 'aicom-toolbar',
'title' => '' . $dot . ''
. '' . esc_html( $key['label'] ) . ''
. '',
'href' => false,
'meta' => [ 'class' => 'aicom-tb-key' ],
] );
}
$bar->add_node( [
'id' => 'aicom-toolbar-manage',
'parent' => 'aicom-toolbar',
'title' => 'Manage API Keys',
'href' => $manage_url,
'meta' => [ 'class' => 'aicom-tb-manage' ],
] );
}
public static function ajax_save_preset(): void {
if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
if ( ! $name ) {
wp_send_json_error( 'missing_name', 400 );
}
$raw_scopes = filter_input( INPUT_POST, 'scopes', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?? [];
$scopes = array_values( array_map( 'sanitize_text_field', wp_unslash( $raw_scopes ) ) );
if ( empty( $scopes ) ) {
wp_send_json_error( 'no_scopes', 400 );
}
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'aicom_presets',
[
'name' => $name,
'scopes_json' => wp_json_encode( $scopes ),
'created_at' => current_time( 'mysql' ),
'created_by_user_id' => get_current_user_id(),
]
);
wp_send_json_success( [
'id' => (int) $wpdb->insert_id,
'name' => $name,
'scopes' => $scopes,
] );
}
public static function ajax_delete_preset(): void {
if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$id = absint( $_POST['preset_id'] ?? 0 );
if ( ! $id ) {
wp_send_json_error( 'invalid_id', 400 );
}
global $wpdb;
$deleted = $wpdb->delete( $wpdb->prefix . 'aicom_presets', [ 'id' => $id ] );
if ( ! $deleted ) {
wp_send_json_error( 'not_found', 404 );
}
wp_send_json_success( [ 'deleted' => $id ] );
}
public static function ajax_rename_preset(): void {
if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$id = absint( wp_unslash( $_POST['id'] ?? 0 ) );
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
if ( ! $id || ! $name ) {
wp_send_json_error( 'invalid_data', 400 );
}
global $wpdb;
$updated = $wpdb->update( $wpdb->prefix . 'aicom_presets', [ 'name' => $name ], [ 'id' => $id ] );
if ( $updated === false ) {
wp_send_json_error( 'db_error', 500 );
}
wp_send_json_success( [ 'id' => $id, 'name' => $name ] );
}
public static function ajax_duplicate_preset(): void {
if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$id = absint( wp_unslash( $_POST['id'] ?? 0 ) );
if ( ! $id ) {
wp_send_json_error( 'invalid_id', 400 );
}
global $wpdb;
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aicom_presets WHERE id = %d", $id ),
ARRAY_A
);
if ( ! $row ) {
wp_send_json_error( 'not_found', 404 );
}
$new_name = 'Copy of ' . $row['name'];
$wpdb->insert( $wpdb->prefix . 'aicom_presets', [
'name' => $new_name,
'scopes_json' => $row['scopes_json'],
'created_at' => current_time( 'mysql', true ),
'created_by_user_id' => get_current_user_id(),
] );
$new_id = (int) $wpdb->insert_id;
$scopes = json_decode( $row['scopes_json'], true ) ?: [];
wp_send_json_success( [
'id' => $new_id,
'name' => $new_name,
'scopes' => $scopes,
'count' => count( $scopes ),
] );
}
public static function ajax_toolbar_toggle(): void {
$key_id = absint( $_POST['key_id'] ?? 0 );
$action = sanitize_key( wp_unslash( $_POST['aicom_action'] ?? '' ) );
if ( ! $key_id || ! in_array( $action, [ 'suspend_key', 'unsuspend_key' ], true ) ) {
wp_send_json_error( 'invalid_params', 400 );
}
if ( ! check_ajax_referer( 'aicom_toolbar_' . $key_id, 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$ok = $action === 'suspend_key'
? AICOM_Auth::suspend_key( $key_id )
: AICOM_Auth::unsuspend_key( $key_id );
if ( ! $ok ) {
wp_send_json_error( 'no_change', 409 );
}
$new_status = $action === 'suspend_key' ? 'suspended' : 'active';
$new_dot = $new_status === 'active' ? '🟢' : '🟡';
$new_action = $new_status === 'active' ? 'suspend_key' : 'unsuspend_key';
$new_btn_label = $new_status === 'active' ? 'Suspend' : 'Unsuspend';
$new_btn_class = $new_status === 'active' ? 'aicom-tb-btn-suspend' : 'aicom-tb-btn-unsuspend';
$new_nonce = wp_create_nonce( 'aicom_toolbar_' . $key_id );
wp_send_json_success( [
'new_status' => $new_status,
'new_dot' => $new_dot,
'new_action' => $new_action,
'new_btn_label' => $new_btn_label,
'new_btn_class' => $new_btn_class,
'new_nonce' => $new_nonce,
] );
}
public static function ajax_toolbar_lock(): void {
if ( ! check_ajax_referer( 'aicom_toolbar_lock', 'nonce', false ) ) {
wp_send_json_error( 'bad_nonce', 403 );
}
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_send_json_error( 'unauthorized', 403 );
}
$mode = sanitize_key( wp_unslash( $_POST['lock_mode'] ?? '' ) );
if ( ! in_array( $mode, [ 'unlock', 'soft', 'hard' ], true ) ) {
wp_send_json_error( 'invalid_mode', 400 );
}
AICOM_Lock_Manager::set_hard_lock( $mode === 'hard' );
AICOM_Lock_Manager::set_soft_lock( $mode === 'soft' );
if ( $mode === 'unlock' ) {
// If schedule would lock us, override it until the next working period
$next = AICOM_Lock_Manager::get_schedule_next_start();
if ( $next > 0 ) {
AICOM_Lock_Manager::set_schedule_override_until( $next );
} else {
AICOM_Lock_Manager::clear_schedule_override();
}
} else {
// Manual lock applied — clear any schedule override
AICOM_Lock_Manager::clear_schedule_override();
}
$new_status = AICOM_Lock_Manager::get_effective_lock();
wp_send_json_success( [
'lock_status' => $new_status,
'new_nonce' => wp_create_nonce( 'aicom_toolbar_lock' ),
] );
}
// ── Private Helpers ───────────────────────────────────────────────────
private function require_cap(): void {
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'aicom' ) );
}
}
}