page_slug, [ $this, 'render_page' ] ); } /* --------------------------------------------------------------------- * Action handling * ------------------------------------------------------------------ */ /** * Handle user actions (GET-based). * * @return void * @since 1.0.0 */ public function handle_actions(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in each handler. if ( ! isset( $_GET['action'], $_GET['page'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in each handler. if ( $this->page_slug !== $_GET['page'] ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in each handler. $action = sanitize_key( $_GET['action'] ); switch ( $action ) { case 'revoke_client': $this->handle_revoke_client( false ); break; case 'revoke_client_full': $this->handle_revoke_client( true ); break; case 'revoke_all': $this->handle_revoke_all_connections(); 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; } } /** * Revoke one client's connection. * * Two depths, because they mean different things to the person clicking. * Revoking the access token alone drops the current session; the client * still holds a refresh token and comes back within the hour, which is what * you want when an assistant is misbehaving rather than unwelcome. Revoking * the refresh tokens too ends the authorisation: the client has to be * approved again by an allowed user. * * @param bool $full Whether to revoke refresh tokens as well. * * @return void * @since 1.4.0 */ private function handle_revoke_client( bool $full ): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $client_id = isset( $_GET['client_id'] ) ? sanitize_text_field( wp_unslash( $_GET['client_id'] ) ) : ''; if ( $client_id === '' ) { return; } $nonce_action = ( $full ? 'albert_revoke_client_full_' : 'albert_revoke_client_' ) . $client_id; if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), $nonce_action ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } $this->require_capability(); $this->revoke_client_tokens( $client_id, $full ); $this->notify( 'connection_revoked', $full ? __( 'Connection ended. The assistant has to be approved again before it can reconnect.', 'albert-ai-butler' ) : __( 'Connection signed out. The assistant reconnects on its own within the hour.', 'albert-ai-butler' ) ); $this->redirect_to_page(); } /** * Revoke every currently active connection. * * @return void * @since 1.4.0 */ private function handle_revoke_all_connections(): void { if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'albert_revoke_all_connections' ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } $this->require_capability(); foreach ( $this->get_connections() as $connection ) { $this->revoke_client_tokens( (string) $connection['client_id'], true ); } $this->notify( 'all_connections_revoked', __( 'Every assistant was disconnected. Each one has to be approved again before it can reconnect.', 'albert-ai-butler' ) ); $this->redirect_to_page(); } /** * Revoke the connections ticked in selection mode. * * A bulk revoke ends the authorisation outright rather than only signing the * assistants out: somebody who has entered selection mode, ticked several * rows and pressed a danger-coloured button is clearing them out, not asking * them all to sign back in within the hour. * * @return void * @since 1.4.0 */ public function handle_revoke_selected(): void { if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['albert_bulk_nonce'] ?? '' ) ), 'albert_revoke_selected' ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } $this->require_capability(); $posted = isset( $_POST['client_ids'] ) ? (array) wp_unslash( $_POST['client_ids'] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized per element below. // Both the flat and the grouped list render a checkbox for the same // connection, and a connection authorised by two people appears twice in // the grouped list as well, so the same id can legitimately arrive more // than once. $client_ids = array_values( array_unique( array_filter( array_map( 'sanitize_text_field', $posted ) ) ) ); if ( empty( $client_ids ) ) { $this->notify( 'nothing_selected', __( 'Nothing was selected, so nothing was revoked.', 'albert-ai-butler' ), 'warning' ); $this->redirect_to_page(); } foreach ( $client_ids as $client_id ) { $this->revoke_client_tokens( $client_id, true ); } $count = count( $client_ids ); $this->notify( 'selection_revoked', sprintf( /* translators: %d: number of connections revoked. */ _n( '%d connection ended. It has to be approved again before it can reconnect.', '%d connections ended. Each has to be approved again before it can reconnect.', $count, 'albert-ai-butler' ), $count ) ); $this->redirect_to_page(); } /** * Revoke a client's tokens. * * @param string $client_id The OAuth client identifier. * @param bool $include_refresh Whether to revoke refresh tokens too. * * @return void * @since 1.4.0 */ private function revoke_client_tokens( string $client_id, bool $include_refresh ): void { /* * The full disconnect is ClientRepository::revokeAllTokens(), not a * copy of it. These two were byte-identical until now, which is exactly * why the refresh-token bug had to be fixed in both and why a review * caught it in only one of them. */ if ( $include_refresh ) { ( new ClientRepository() )->revokeAllTokens( $client_id ); return; } global $wpdb; // Sign-out only: the access tokens go, the refresh tokens stay, and the // assistant reconnects on its own. That is the difference this branch // exists to express. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->update( Tables::oauth()['access_tokens'], [ 'revoked' => 1 ], [ 'client_id' => $client_id ], [ '%d' ], [ '%s' ] ); } /** * Add one or more users to the allowed list. * * The picker is a multi-select because setup is a real argument: an agency * onboarding a site adds three editors at once, and doing that one modal at * a time is three round trips for one decision. * * Responds two ways depending on how it was called. The picker's own JS * calls this via `fetch()` when it is opened from the Connections screen * (there is an allowed-users list on the page to update in place) and * sends back JSON: a message, the ids actually added, and the freshly * rendered list body. A native form submission, which is what the * Dashboard's onboarding checklist still does, gets the original * queue-a-notice-and-redirect behaviour. Both paths run the exact same * validation and write; only the response differs. * * @return void * @since 1.4.0 */ public function handle_add_allowed_users(): void { $is_ajax = $this->is_ajax_request(); if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['albert_add_users_nonce'] ?? '' ) ), UserPickerModal::ACTION ) ) { if ( $is_ajax ) { wp_send_json_error( [ 'message' => __( 'Security check failed.', 'albert-ai-butler' ) ], 403 ); } wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } $this->require_capability( __( 'You do not have permission to manage MCP access.', 'albert-ai-butler' ), $is_ajax ); $raw = isset( $_POST['albert_user_ids'] ) ? sanitize_text_field( wp_unslash( $_POST['albert_user_ids'] ) ) : ''; $ids = array_values( array_unique( array_filter( array_map( 'absint', explode( ',', $raw ) ) ) ) ); $return_to = isset( $_POST['albert_return'] ) ? sanitize_key( wp_unslash( $_POST['albert_return'] ) ) : 'connections'; if ( empty( $ids ) ) { $message = __( 'Nobody was chosen, so nothing changed. Search for someone and tick their name.', 'albert-ai-butler' ); if ( $is_ajax ) { wp_send_json_error( [ 'message' => $message ] ); } $this->notify( 'nobody_chosen', $message, 'warning' ); $this->redirect_after_add( $return_to ); } $added_ids = []; foreach ( $ids as $user_id ) { if ( ! get_user_by( 'id', $user_id ) ) { continue; } if ( AllowedUsers::add( $user_id ) ) { $added_ids[] = $user_id; } } $added = count( $added_ids ); $message = $added === 0 ? __( 'Everyone you chose could already approve an assistant, so nothing changed.', 'albert-ai-butler' ) : sprintf( /* translators: %d: number of users added. */ _n( '%d person can now approve an assistant.', '%d people can now approve an assistant.', $added, 'albert-ai-butler' ), $added ); if ( $is_ajax ) { if ( $added === 0 ) { wp_send_json_error( [ 'message' => $message ] ); } ob_start(); $this->render_allowed_users_body( AllowedUsers::all() ); $body_html = (string) ob_get_clean(); wp_send_json_success( [ 'message' => $message, 'addedIds' => $added_ids, 'bodyHtml' => $body_html, ] ); } $this->notify( 'users_added', $message ); $this->redirect_after_add( $return_to ); } /** * Handle removing a user from the allowed users list. * * Removing revokes. The allowed list is checked when an authorisation * *starts*, so without this "Remove" would only mean "cannot connect a new * assistant" while every token they already hold keeps working, which is * not what the word says. * * Unlike adding a user, removing one can change the *other* card too: it * revokes every token this person holds, on every client, so a connection * they were the only authoriser of disappears, and one they co-authorised * loses their name from its "Authorised by" line. So the AJAX response (the * same detection and JSON-vs-redirect split as * {@see self::handle_add_allowed_users()}) sends back a fresh copy of both * cards, not just the one the link lives on; anything narrower would leave * "Connected assistants" silently wrong until the next reload. * * @return void * @since 1.0.0 */ private function handle_remove_allowed_user(): void { $is_ajax = $this->is_ajax_request(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below. $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; if ( ! $user_id ) { if ( $is_ajax ) { wp_send_json_error( [ 'message' => __( 'Something went wrong. Reload the page and try again.', 'albert-ai-butler' ) ] ); } return; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'remove_user_' . $user_id ) ) { if ( $is_ajax ) { wp_send_json_error( [ 'message' => __( 'Security check failed.', 'albert-ai-butler' ) ], 403 ); } wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } $this->require_capability( __( 'You do not have permission to manage MCP access.', 'albert-ai-butler' ), $is_ajax ); AllowedUsers::remove( $user_id ); Settings::revoke_user_tokens( $user_id ); $message = __( 'User removed. Every assistant they had connected was signed out.', 'albert-ai-butler' ); if ( $is_ajax ) { ob_start(); $this->render_allowed_users_body( AllowedUsers::all() ); $users_html = (string) ob_get_clean(); $connections = $this->get_connections(); ob_start(); $this->render_connections_body( $connections ); $connections_html = (string) ob_get_clean(); ob_start(); $this->render_connections_count_badge( count( $connections ) ); $count_html = (string) ob_get_clean(); wp_send_json_success( [ 'message' => $message, 'removedId' => $user_id, 'usersBodyHtml' => $users_html, 'connectionsHtml' => $connections_html, 'connectionsCountHtml' => $count_html, 'hasConnections' => ! empty( $connections ), ] ); } $this->notify( 'user_removed', $message ); $this->redirect_to_page(); } /** * 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; } 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' ) ); } $this->require_capability( __( 'You do not have permission to revoke sessions.', 'albert-ai-butler' ) ); global $wpdb; $tables = Tables::oauth(); /* * Revoke what can revive it, before revoking it. * * Revoking the access token alone left the refresh token live, so the * assistant minted a replacement within the hour while the screen said * "Session revoked." That made revoking one session quietly weaker than * revoking all of them, which has always gone through * Settings::revoke_user_tokens() and does revoke both. * * The refresh row names the access token by its `token_id` string, not * by the row id the link carries, so it has to be read first. */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $access_token_id = $wpdb->get_var( $wpdb->prepare( 'SELECT token_id FROM %i WHERE id = %d', $tables['access_tokens'], $token_id ) ); if ( is_string( $access_token_id ) && $access_token_id !== '' ) { ( new RefreshTokenRepository() )->revokeForAccessTokens( [ $access_token_id ] ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->update( $tables['access_tokens'], [ 'revoked' => 1 ], [ 'id' => $token_id ], [ '%d' ], [ '%d' ] ); $this->notify( 'session_revoked', __( 'Session revoked.', 'albert-ai-butler' ) ); $this->redirect_to_user_sessions( $user_id ); } /** * 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; } 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' ) ); } $this->require_capability( __( 'You do not have permission to revoke sessions.', 'albert-ai-butler' ) ); Settings::revoke_user_tokens( $user_id ); $this->notify( 'all_sessions_revoked', __( 'All sessions revoked.', 'albert-ai-butler' ) ); $this->redirect_to_user_sessions( $user_id ); } /** * Save (or clear) the owner's own name for a connection. * * Who wrote the label and when are stored with it, and rendered on the row. * A label is a display name one administrator can write onto somebody else's * connection, on the screen where an owner decides what looks trustworthy; * the attribution is what keeps a misleading rename visible on sight. * * @return void * @since 1.4.0 */ public function handle_set_connection_label(): void { $client_id = isset( $_POST['albert_client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['albert_client_id'] ) ) : ''; if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['albert_label_nonce'] ?? '' ) ), 'albert_set_connection_label_' . $client_id ) ) { wp_die( esc_html__( 'Security check failed.', 'albert-ai-butler' ) ); } $this->require_capability( __( 'You do not have permission to manage connections.', 'albert-ai-butler' ) ); $label = isset( $_POST['albert_connection_label'] ) ? sanitize_text_field( wp_unslash( $_POST['albert_connection_label'] ) ) : ''; $saved = ( new ClientRepository() )->updateClientLabel( $client_id, $label, get_current_user_id() ); if ( ! $saved ) { $this->notify( 'label_save_failed', __( 'Could not save the label. Please try again.', 'albert-ai-butler' ), 'error' ); $this->redirect_to_page(); return; } $this->notify( 'label_saved', $label === '' ? __( 'Label removed.', 'albert-ai-butler' ) : __( 'Label saved.', 'albert-ai-butler' ) ); $this->redirect_to_page(); } /** * Stop anyone without the capability, with a message that says which action failed. * * @param string $message The message to die with. Defaults to the revoke wording. * @param bool $is_ajax Send a JSON error instead of dying, for a handler the * picker's own `fetch()` call may have made. * * @return void * @since 1.4.0 */ private function require_capability( string $message = '', bool $is_ajax = false ): void { if ( current_user_can( 'manage_options' ) ) { return; } $message = $message !== '' ? $message : __( 'You do not have permission to manage connections.', 'albert-ai-butler' ); if ( $is_ajax ) { wp_send_json_error( [ 'message' => $message ], 403 ); } wp_die( esc_html( $message ) ); } /** * Whether this request is the picker's own `fetch()` call rather than a * native form submission. * * Only the Connections screen has an allowed-users list on the page to * update in place; the Dashboard's onboarding checklist opens the same * dialog but has nowhere to insert a new row, so it keeps the plain * submit-and-redirect behaviour. The header is set explicitly by the * picker's JS, this is not `admin-ajax.php` so `wp_doing_ajax()` would * always be false here regardless of transport. * * @return bool * @since 1.4.0 */ private function is_ajax_request(): bool { return isset( $_SERVER['HTTP_X_REQUESTED_WITH'] ) && strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REQUESTED_WITH'] ) ) ) === 'xmlhttprequest'; } /** * Queue a notice that survives the redirect back to the screen. * * `add_settings_error()` alone does not: the message lives in a global that * the redirect throws away. WordPress reads the transient back only when the * URL carries `settings-updated`, which {@see self::redirect_to_page()} adds. * * @param string $code Notice identifier. * @param string $message The message to show. * @param string $type `success`, `error`, `warning` or `info`. * * @return void * @since 1.4.0 */ private function notify( string $code, string $message, string $type = 'success' ): void { add_settings_error( 'albert_connections', $code, $message, $type ); set_transient( 'settings_errors', get_settings_errors(), 30 ); } /** * Redirect back to the Connections screen. * * @param bool $updated Whether to flag the redirect as a completed update. * * @return void * @since 1.4.0 */ private function redirect_to_page( bool $updated = true ): void { $args = [ 'page' => $this->page_slug ]; if ( $updated ) { $args['settings-updated'] = 'true'; } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } /** * Send the picker back to whichever screen opened it. * * The value is matched against a fixed set rather than trusted: it arrives * in the request, and an open redirect built out of a "which screen was I * on" field is a classic way to get one. * * @param string $return_to The posted screen key. * * @return void * @since 1.4.0 */ private function redirect_after_add( string $return_to ): void { if ( $return_to === 'dashboard' ) { wp_safe_redirect( add_query_arg( [ 'page' => 'albert', 'settings-updated' => 'true', ], admin_url( 'admin.php' ) ) ); exit; } $this->redirect_to_page(); } /** * Redirect back to a user's session drill-down. * * @param int $user_id The user whose sessions were being viewed. * * @return void * @since 1.4.0 */ private function redirect_to_user_sessions( int $user_id ): void { 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; } /* --------------------------------------------------------------------- * Rendering * ------------------------------------------------------------------ */ /** * 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' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view selection. $view_sessions = isset( $_GET['action'] ) && $_GET['action'] === 'view_user_sessions'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view selection. $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; } $endpoint = McpServer::get_endpoint_url(); $external_state = McpServer::get_external_url_state(); ?>
, 2: closing wrapping the filter name. */
esc_html__( 'External override active: the address above comes from the %1$salbert/mcp/external_url%2$s filter, not from this site\'s own web address.', 'albert-ai-butler' ),
'',
''
);
?>
, 2: closing for the filter name, 3: opening , 4: closing for the invalid value, 5: 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 web address. Albert is ignoring it and using this site\'s own address instead.', 'albert-ai-butler' ),
'',
'',
'',
'',
esc_html( (string) $state['value'] )
);
?>
' . esc_html__( 'User not found.', 'albert-ai-butler' ) . '