parent_slug, __( 'Connections', 'albert-ai-butler' ), __( 'Connections', 'albert-ai-butler' ), 'manage_options', $this->page_slug, [ $this, 'render_page' ] ); } /** * Handle user actions (GET-based). * * @return void * @since 1.0.0 */ public function handle_actions(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. if ( ! isset( $_GET['action'] ) || ! isset( $_GET['page'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. if ( $this->page_slug !== $_GET['page'] ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $action = sanitize_key( $_GET['action'] ); switch ( $action ) { case 'revoke': $this->handle_revoke_session(); break; case 'revoke_full': $this->handle_revoke_full_session(); break; case 'revoke_all': $this->handle_revoke_all_sessions(); break; case 'remove_allowed_user': $this->handle_remove_allowed_user(); break; case 'revoke_user_session': $this->handle_revoke_user_session(); break; case 'revoke_all_user_sessions': $this->handle_revoke_all_user_sessions(); break; } } /** * Handle revoking a single session. * * @return void * @since 1.0.0 */ private function handle_revoke_session(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $token_id = isset( $_GET['token_id'] ) ? absint( $_GET['token_id'] ) : 0; if ( ! $token_id ) { return; } // Verify nonce. if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'revoke_my_session_' . $token_id ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } // Check permissions. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to revoke sessions.', 'albert-ai-butler' ) ); } global $wpdb; $table = $wpdb->prefix . 'albert_oauth_access_tokens'; // Admins can revoke any connection. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table, [ 'revoked' => 1 ], [ 'id' => $token_id ], [ '%d' ], [ '%d' ] ); add_settings_error( 'albert_connections', 'session_revoked', __( 'Session revoked successfully.', 'albert-ai-butler' ), 'success' ); // Redirect back. wp_safe_redirect( add_query_arg( [ 'page' => $this->page_slug, 'settings-updated' => 'true', ], admin_url( 'admin.php' ) ) ); exit; } /** * Handle revoking a full session (access + refresh tokens). * * @return void * @since 1.0.0 */ private function handle_revoke_full_session(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $token_id = isset( $_GET['token_id'] ) ? absint( $_GET['token_id'] ) : 0; if ( ! $token_id ) { return; } // Verify nonce. if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'revoke_full_session_' . $token_id ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } // Check permissions. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to end sessions.', 'albert-ai-butler' ) ); } global $wpdb; $table = $wpdb->prefix . 'albert_oauth_access_tokens'; // Get the token_id string before revoking. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $token_id_string = $wpdb->get_var( $wpdb->prepare( 'SELECT token_id FROM %i WHERE id = %d', $table, $token_id ) ); // Revoke the access token. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table, [ 'revoked' => 1 ], [ 'id' => $token_id ], [ '%d' ], [ '%d' ] ); // Revoke associated refresh tokens. if ( $token_id_string ) { $refresh_repo = new RefreshTokenRepository(); $refresh_repo->revokeRefreshTokensByAccessToken( $token_id_string ); } add_settings_error( 'albert_connections', 'session_ended', __( 'Session ended successfully.', 'albert-ai-butler' ), 'success' ); // Redirect back. wp_safe_redirect( add_query_arg( [ 'page' => $this->page_slug, 'settings-updated' => 'true', ], admin_url( 'admin.php' ) ) ); exit; } /** * Handle revoking all sessions. * * @return void * @since 1.0.0 */ private function handle_revoke_all_sessions(): void { // Verify nonce. if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'revoke_all_my_sessions' ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } // Check permissions. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to revoke sessions.', 'albert-ai-butler' ) ); } Settings::revoke_user_tokens( get_current_user_id() ); add_settings_error( 'albert_connections', 'all_sessions_revoked', __( 'All sessions revoked successfully.', 'albert-ai-butler' ), 'success' ); // Redirect back. wp_safe_redirect( add_query_arg( [ 'page' => $this->page_slug, 'settings-updated' => 'true', ], admin_url( 'admin.php' ) ) ); exit; } /** * Handle adding a user to the allowed users list. * * @return void * @since 1.0.0 */ public function handle_add_allowed_user(): void { // Verify nonce. if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['albert_add_user_nonce'] ?? '' ) ), 'albert_add_allowed_user' ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } // Check permissions. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to manage MCP access.', 'albert-ai-butler' ) ); } $user_id = isset( $_POST['albert_user_id'] ) ? absint( $_POST['albert_user_id'] ) : 0; if ( ! $user_id ) { wp_safe_redirect( add_query_arg( [ 'page' => $this->page_slug ], admin_url( 'admin.php' ) ) ); exit; } // Verify user exists. $user = get_user_by( 'id', $user_id ); if ( ! $user ) { wp_die( esc_html__( 'Invalid user selected.', 'albert-ai-butler' ) ); } // Get current allowed users and add the new one. $allowed_users = get_option( 'albert_allowed_users', [] ); if ( ! in_array( $user_id, $allowed_users, true ) ) { $allowed_users[] = $user_id; update_option( 'albert_allowed_users', $allowed_users ); } // Redirect back. wp_safe_redirect( add_query_arg( [ 'page' => $this->page_slug ], admin_url( 'admin.php' ) ) ); exit; } /** * Handle removing a user from the allowed users list. * * @return void * @since 1.0.0 */ private function handle_remove_allowed_user(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; if ( ! $user_id ) { return; } // Verify nonce. if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'remove_user_' . $user_id ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } // Check permissions. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to manage MCP access.', 'albert-ai-butler' ) ); } // Remove user from allowed list. $allowed_users = get_option( 'albert_allowed_users', [] ); $allowed_users = array_filter( $allowed_users, fn( $id ) => $id !== $user_id ); update_option( 'albert_allowed_users', array_values( $allowed_users ) ); // Revoke all their sessions. Settings::revoke_user_tokens( $user_id ); add_settings_error( 'albert_connections', 'user_removed', __( 'User removed and all their sessions revoked.', 'albert-ai-butler' ), 'success' ); // Redirect back. wp_safe_redirect( add_query_arg( [ 'page' => $this->page_slug, 'settings-updated' => 'true', ], admin_url( 'admin.php' ) ) ); exit; } /** * Handle revoking a single user session. * * @return void * @since 1.0.0 */ private function handle_revoke_user_session(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $token_id = isset( $_GET['token_id'] ) ? absint( $_GET['token_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; if ( ! $token_id || ! $user_id ) { return; } // Verify nonce. if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'revoke_session_' . $token_id ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } // Check permissions. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to revoke sessions.', 'albert-ai-butler' ) ); } global $wpdb; $table = $wpdb->prefix . 'albert_oauth_access_tokens'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table, [ 'revoked' => 1 ], [ 'id' => $token_id ], [ '%d' ], [ '%d' ] ); add_settings_error( 'albert_connections', 'session_revoked', __( 'Session revoked successfully.', 'albert-ai-butler' ), 'success' ); // Redirect back to user sessions view. wp_safe_redirect( add_query_arg( [ 'page' => $this->page_slug, 'action' => 'view_user_sessions', 'user_id' => $user_id, 'settings-updated' => 'true', ], admin_url( 'admin.php' ) ) ); exit; } /** * Handle revoking all sessions for a user. * * @return void * @since 1.0.0 */ private function handle_revoke_all_user_sessions(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; if ( ! $user_id ) { return; } // Verify nonce. if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'revoke_all_sessions_' . $user_id ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } // Check permissions. if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to revoke sessions.', 'albert-ai-butler' ) ); } Settings::revoke_user_tokens( $user_id ); add_settings_error( 'albert_connections', 'all_sessions_revoked', __( 'All sessions revoked successfully.', 'albert-ai-butler' ), 'success' ); // Redirect back to user sessions view. wp_safe_redirect( add_query_arg( [ 'page' => $this->page_slug, 'action' => 'view_user_sessions', 'user_id' => $user_id, 'settings-updated' => 'true', ], admin_url( 'admin.php' ) ) ); exit; } /** * Render the page. * * @return void * @since 1.0.0 */ public function render_page(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'albert-ai-butler' ) ); } // Check if viewing sessions for a specific user. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking for view action. $view_sessions = isset( $_GET['action'] ) && $_GET['action'] === 'view_user_sessions'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just getting user ID for display. $view_user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; if ( $view_sessions && $view_user_id ) { $this->render_user_sessions_view( $view_user_id ); return; } $mcp_endpoint = McpServer::get_endpoint_url(); $external_url_fs = McpServer::get_external_url_state(); ?>

render_external_url_filter_notice( $external_url_fs ); ?>
render_allowed_users_section(); ?> render_active_connections_section(); ?>

, 2: closing wrapping the filter name. */ esc_html__( 'External override active — MCP endpoint uses the URL returned by the %1$salbert/mcp/external_url%2$s filter.', 'albert-ai-butler' ), '', '' ); ?>

, 2: closing for the filter name, 3: opening , 4: closing for the invalid value. */ esc_html__( 'The %1$salbert/mcp/external_url%2$s filter returned %3$s%5$s%4$s, which is not a valid URL. Albert is ignoring it and using the default endpoint instead.', 'albert-ai-butler' ), '', '', '', '', esc_html( $bad_value ) ); ?>

https://, 2: closing , 3: opening http://, 4: closing . */ esc_html__( 'To fix this, make sure the filter returns a full URL with %1$shttps://%2$s (or %3$shttp://%4$s) and no extra characters, or return an empty string to disable the override.', 'albert-ai-butler' ), '', '', '', '' ); ?>

'display_name', 'order' => 'ASC', ] ); ?>

get_user_session_count( $user_id ); if ( ! $user ) { continue; } $remove_url = wp_nonce_url( add_query_arg( [ 'page' => $this->page_slug, 'action' => 'remove_allowed_user', 'user_id' => $user_id, ], admin_url( 'admin.php' ) ), 'remove_user_' . $user_id ); $sessions_url = add_query_arg( [ 'page' => $this->page_slug, 'action' => 'view_user_sessions', 'user_id' => $user_id, ], admin_url( 'admin.php' ) ); ?>
0 ) { ?>
get_results( $wpdb->prepare( "SELECT t.id, t.client_id, t.user_id, t.token_id, UNIX_TIMESTAMP( COALESCE( c.created_at, t.created_at ) ) as connected_ts, COALESCE(c.name, 'Unknown Client') as client_name FROM %i t LEFT JOIN %i c ON t.client_id = c.client_id WHERE t.revoked = 0 AND t.expires_at > UTC_TIMESTAMP() ORDER BY t.created_at DESC", $tables['access_tokens'], $tables['clients'] ) ); ?>

client_name ) ? $session->client_name : __( 'Unknown Client', 'albert-ai-butler' ); $user = get_userdata( $session->user_id ); $connected_at = (int) $session->connected_ts; $revoke_url = wp_nonce_url( add_query_arg( [ 'page' => $this->page_slug, 'action' => 'revoke', 'token_id' => $session->id, ], admin_url( 'admin.php' ) ), 'revoke_my_session_' . $session->id ); $revoke_full_url = wp_nonce_url( add_query_arg( [ 'page' => $this->page_slug, 'action' => 'revoke_full', 'token_id' => $session->id, ], admin_url( 'admin.php' ) ), 'revoke_full_session_' . $session->id ); ?>
token_id, 0, 12 ) . '...' ); ?> display_name ) : esc_html__( 'Unknown', 'albert-ai-butler' ); ?>
1 ) { ?> $this->page_slug, 'action' => 'revoke_all', ], admin_url( 'admin.php' ) ), 'revoke_all_my_sessions' ); ?>

' . esc_html__( 'User not found.', 'albert-ai-butler' ) . '

'; return; } $tables = Tables::oauth(); // Get active sessions grouped by client, with first connection time. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $sessions = $wpdb->get_results( $wpdb->prepare( "SELECT t.client_id, MAX(t.id) as id, MAX(t.token_id) as token_id, COALESCE(c.name, 'Unknown') as client_name, MIN(CONVERT_TZ(t.created_at, @@session.time_zone, '+00:00')) as first_connected FROM %i t LEFT JOIN %i c ON t.client_id = c.client_id WHERE t.user_id = %d AND t.revoked = 0 GROUP BY t.client_id ORDER BY first_connected DESC", $tables['access_tokens'], $tables['clients'], $user_id ) ); $back_url = add_query_arg( [ 'page' => $this->page_slug ], admin_url( 'admin.php' ) ); ?>

←

display_name ); ?>
user_email ); ?>

client_name ?? __( 'Unknown', 'albert-ai-butler' ) ); ?> token_id, 0, 16 ) . '...' ); ?> first_connected ?? $session->created_at; echo esc_html( sprintf( /* translators: %s: human-readable time difference */ __( '%s ago', 'albert-ai-butler' ), human_time_diff( strtotime( $first_connected ), time() ) ) ); ?> $this->page_slug, 'action' => 'revoke_user_session', 'token_id' => $session->id, 'user_id' => $user_id, ], admin_url( 'admin.php' ) ), 'revoke_session_' . $session->id ); ?>
$this->page_slug, 'action' => 'revoke_all_user_sessions', 'user_id' => $user_id, ], admin_url( 'admin.php' ) ), 'revoke_all_sessions_' . $user_id ); ?>

get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT client_id) FROM %i WHERE user_id = %d AND revoked = 0', $tables['access_tokens'], $user_id ) ); } /** * Enqueue assets. * * @param string $hook Current admin page hook. * * @return void * @since 1.0.0 */ public function enqueue_assets( string $hook ): void { // Hook format for submenu is: {parent_slug}_page_{menu_slug}. if ( 'albert_page_' . $this->page_slug !== $hook ) { return; } wp_enqueue_style( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/css/admin-settings.css', [], ALBERT_VERSION ); wp_enqueue_script( 'albert-admin-utils', ALBERT_PLUGIN_URL . 'assets/js/albert-admin-utils.js', [], ALBERT_VERSION, true ); wp_enqueue_script( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/js/admin-settings.js', [ 'albert-admin-utils' ], ALBERT_VERSION, true ); wp_localize_script( 'albert-admin', 'albertAdmin', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'albert_oauth_nonce' ), 'i18n' => [ 'copied' => __( 'Copied!', 'albert-ai-butler' ), 'copyFailed' => __( 'Copy failed', 'albert-ai-butler' ), ], ] ); wp_enqueue_script( 'albert-admin-popover', ALBERT_PLUGIN_URL . 'assets/js/admin-popover.js', [], ALBERT_VERSION, true ); } }