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 ( $added > 0 && ! empty( $_POST['albert_email_setup'] ) ) { $message .= ' ' . $this->email_setup_links( $added_ids ); } 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(); } /** * Email the people just added their setup link, and say how that went. * * @param array $user_ids The people added. * * @return string A sentence to add to the confirmation. * @since 1.5.0 */ private function email_setup_links( array $user_ids ): string { $from = wp_get_current_user(); $sent = 0; $failed = 0; foreach ( $user_ids as $user_id ) { $user = get_user_by( 'id', $user_id ); if ( ! $user || ! SetupInvite::can_send_to( $user ) ) { continue; } if ( SetupInvite::send( $user, $from ) ) { ++$sent; } else { ++$failed; } } if ( $failed > 0 ) { return sprintf( /* translators: 1: number of emails sent, 2: number that failed. */ __( 'Setup emails sent: %1$d. Not sent: %2$d; copy their link from Send setup link instead.', 'albert-ai-butler' ), $sent, $failed ); } if ( $sent === 0 ) { return __( 'No setup email was sent: nobody you added has an email address.', 'albert-ai-butler' ); } return sprintf( /* translators: %d: number of emails sent. */ _n( 'A setup email went to %d person.', 'Setup emails went to %d people.', $sent, 'albert-ai-butler' ), $sent ); } /** * Email one person on the list their setup link. * * @return void * @since 1.5.0 */ public function handle_email_setup_link(): void { $is_ajax = $this->is_ajax_request(); $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0; if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ?? '' ) ), self::EMAIL_SETUP_ACTION . '_' . $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 ); $user = get_user_by( 'id', $user_id ); // Renewed first, so the email states the new expiry. $renewed = $user instanceof \WP_User && AllowedUsers::renew_invitation( $user_id ); $sent = $user instanceof \WP_User && SetupInvite::send( $user, wp_get_current_user() ); $message = $sent /* translators: %s: the person's email address. */ ? sprintf( __( 'Setup link sent to %s.', 'albert-ai-butler' ), $user->user_email ) : __( 'The email couldn’t be sent. Copy the link and send it another way.', 'albert-ai-butler' ); if ( $is_ajax ) { $data = [ 'message' => $message, 'expiry' => $renewed ? $this->format_allowed_user_expiry( AllowedUsers::all()[ $user_id ] ) : '', ]; if ( $sent ) { wp_send_json_success( $data ); } wp_send_json_error( $data ); } $this->notify( $sent ? 'setup_emailed' : 'setup_email_failed', $message, $sent ? 'success' : 'error' ); $this->redirect_to_page(); } /** * Restart someone's invitation because their setup link menu was opened. * * So a link sent on the last day of the window, or after it, still works * when opened, whichever way it is sent. * * @return void * @since 1.5.0 */ public function handle_renew_setup_link(): void { $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0; if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ?? '' ) ), self::RENEW_SETUP_ACTION . '_' . $user_id ) ) { wp_send_json_error( [ 'message' => __( 'Security check failed.', 'albert-ai-butler' ) ], 403 ); } $this->require_capability( __( 'You do not have permission to manage MCP access.', 'albert-ai-butler' ), true ); if ( ! AllowedUsers::renew_invitation( $user_id ) ) { wp_send_json_success( [ 'expiry' => '' ] ); } wp_send_json_success( [ 'expiry' => $this->format_allowed_user_expiry( AllowedUsers::all()[ $user_id ] ) ] ); } /** * 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(); ?>


render_connect_card( $endpoint, $external_state ); ?>
render_connections_card(); ?> render_allowed_users_card(); ?>
render_disconnect_dialog(); UserPickerModal::render( 'connections' ); ConnectDialog::render(); } /** * The connect card: where the site runs, the button that walks through * connecting, the MCP URL for people who know what to do with it, and a * shortcut to each app's steps. * * The same in every state. Moving it below the lists once something is * connected made the screen rearrange itself between visits. * * @param string $endpoint The MCP endpoint URL. * @param array{state: string, value: string} $external_state External-URL filter state. * * @return void * @since 1.5.0 */ private function render_connect_card( string $endpoint, array $external_state ): void { $guides = ClientSetupGuides::for_location( $endpoint, SiteLocation::current() ); ?>

render_endpoint_hint( $external_state ); ?>
render_instruction_row( __( 'Instructions for', 'albert-ai-butler' ), array_filter( $guides, static fn( array $guide ): bool => ! $guide['needs_public'] ) ); ?> render_instruction_row( __( 'Needs a public site:', 'albert-ai-butler' ), array_filter( $guides, static fn( array $guide ): bool => (bool) $guide['needs_public'] ) ); ?>
> $guides The apps. * * @return void * @since 1.5.0 */ private function render_instruction_row( string $label, array $guides ): void { if ( $guides === [] ) { return; } ?>
render_external_url_invalid_hint( $external_state ); return; } if ( $external_state['state'] !== 'active' ) { return; } ?>

, 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'] ) ); ?>

`: reused as-is * so a caller that only needs to refresh the count (removing an allowed * user can revoke a connection's only token elsewhere on the page) sends * back the exact same escaped, pluralised markup a full page load would. * * @param int $count How many connections there are. * * @return void * @since 1.4.0 */ private function render_connections_count_badge( int $count ): void { echo esc_html( number_format_i18n( $count ) ); ?> > $connections Rows from {@see self::get_connections()}. * * @return void * @since 1.4.0 */ private function render_connections_body( array $connections ): void { if ( empty( $connections ) ) { ?>

get_connections(); $count = count( $connections ); ?>

0 ) { ?>

0 ? '' : ' hidden'; ?>> render_connections_count_badge( $count ); ?>
render_connections_toolbar( $count ); ?>
render_connections_body( $connections ); ?>
0 ? '' : ' hidden'; ?>>
$connection A row from {@see self::get_connections()}. * @param string $scope A per-view suffix keeping element ids unique. * * @return void * @since 1.4.0 */ private function render_connection_row( array $connection, string $scope ): void { $client_id = (string) $connection['client_id']; $name = (string) $connection['name']; $label = (string) $connection['label']; $title = $label !== '' ? $label : $name; $never_used = (int) $connection['last_used_ts'] === 0; $people = $this->people_for( $connection ); $checkbox_id = 'albert-conn-' . $scope; $label_id = 'albert-conn-label-' . $scope; $meta = $this->format_connection_meta( $connection, $people ); $revoke_url = wp_nonce_url( add_query_arg( [ 'page' => $this->page_slug, 'action' => 'revoke_client', 'client_id' => $client_id, ], admin_url( 'admin.php' ) ), 'albert_revoke_client_' . $client_id ); $revoke_full_url = wp_nonce_url( add_query_arg( [ 'page' => $this->page_slug, 'action' => 'revoke_client_full', 'client_id' => $client_id, ], admin_url( 'admin.php' ) ), 'albert_revoke_client_full_' . $client_id ); $classes = 'albert-conn' . ( $never_used ? ' albert-conn--quiet' : '' ); // Just connected through the dialog, which reloads with its id. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only highlight of one row. if ( isset( $_GET['albert_new'] ) && sanitize_text_field( wp_unslash( $_GET['albert_new'] ) ) === $client_id ) { $classes .= ' albert-conn--new'; } $select_label = sprintf( /* translators: 1: connection name, 2: the row's own meta line. */ __( 'Select %1$s. %2$s', 'albert-ai-butler' ), $title, $meta ); ?>
  • format_label_attribution( $connection ); ?>

  • $allowed_users The allowed users, from {@see \Albert\OAuth\AllowedUsers::all()}. * * @return void * @since 1.4.0 */ private function render_allowed_users_body( array $allowed_users ): void { if ( empty( $allowed_users ) ) { ?>

    render_allowed_users_body( $allowed_users ); ?>
    get_user_session_count( $user_id ); $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' ) ); $meta = $user->user_email . ' · ' . $this->role_label_for( $user ); $expiry = $this->format_allowed_user_expiry( $entry ); ?>
  • display_name ); ?>
    >

    0 ) { ?>
    1 ? __( 'Remove this user? Every assistant they have connected is signed out straight away.', 'albert-ai-butler' ) : __( 'This is the only person on the list. After this nobody can connect an assistant until someone is added again. Every assistant they have connected is signed out straight away.', 'albert-ai-butler' ); ?>
  • > * @since 1.4.0 */ private function get_connections(): array { return ( new ClientRepository() )->getLiveConnections(); } /** * The display names of the people who authorised a connection. * * @param array $connection A connection row. * * @return array * @since 1.4.0 */ private function people_for( array $connection ): array { $names = []; foreach ( (array) $connection['user_ids'] as $user_id ) { $user = get_userdata( (int) $user_id ); if ( $user ) { $names[] = $user->display_name; } } return $names; } /** * The one muted line under a connection's name. * * "Never used" is spelled out in words rather than left to the row's quieter * colours: colour is the reinforcement, never the only signal. * * @param array $connection A connection row. * @param array $people Display names of the authorising users. * * @return string A translated sentence. * @since 1.4.0 */ private function format_connection_meta( array $connection, array $people ): string { $authorised = empty( $people ) ? __( 'Authorised by an account that no longer exists', 'albert-ai-butler' ) : sprintf( /* translators: %s: comma-separated list of user names. */ __( 'Authorised by %s', 'albert-ai-butler' ), implode( ', ', $people ) ); $connected = sprintf( /* translators: %s: human-readable time difference, e.g. "2 hours ago". */ __( 'connected %s', 'albert-ai-butler' ), $this->format_ago( $connection['created_ts'] ) ); $used = (int) $connection['last_used_ts'] > 0 ? sprintf( /* translators: %s: human-readable time difference, e.g. "2 hours ago". */ __( 'used %s', 'albert-ai-butler' ), $this->format_ago( $connection['last_used_ts'] ) ) : __( 'never used', 'albert-ai-butler' ); return implode( ' · ', [ $authorised, $connected, $used ] ); } /** * "Labelled by Mark Jansen on 3 August 2026", or '' when there is nothing to say. * * A label is the one thing on this row that a person wrote about somebody * else's connection, so it carries its own byline. Nothing is rendered for an * unlabelled connection, or for a label written before the attribution * columns existed: an invented byline would be worse than none. * * @param array $connection A connection row. * * @return string A translated sentence, or ''. * @since 1.4.0 */ private function format_label_attribution( array $connection ): string { if ( (string) $connection['label'] === '' ) { return ''; } $author_id = (int) $connection['label_set_by']; $moment = (int) $connection['label_set_at']; if ( $author_id === 0 || $moment === 0 ) { return ''; } $author = get_userdata( $author_id ); return sprintf( /* translators: 1: the person who wrote the label, 2: the date they wrote it. */ __( 'Labelled by %1$s on %2$s', 'albert-ai-butler' ), $author ? $author->display_name : __( 'a deleted account', 'albert-ai-butler' ), date_i18n( (string) get_option( 'date_format' ), $moment ) ); } /** * Everything the client-side filter should match a row on. * * @param string $title The row's visible name. * @param string $name The client's own name. * @param array $people Display names of the authorising users. * * @return string A lowercase haystack. * @since 1.4.0 */ private function search_index( string $title, string $name, array $people ): string { return $this->normalise( $title . ' ' . $name . ' ' . implode( ' ', $people ) ); } /** * Lowercase, collapsed whitespace: the shape both filters compare against. * * @param string $value The raw value. * * @return string * @since 1.4.0 */ private function normalise( string $value ): string { /* * `mb_strtolower`, not `strtolower`. The value this produces is matched * in the browser against `term.toLowerCase()`, which is Unicode-aware, * while PHP's `strtolower` is byte-based and leaves anything outside * ASCII alone. "Émile" indexed as "Émile" never matches a typed * "émile", so the person disappears from a list they are plainly on. */ $lower = function_exists( 'mb_strtolower' ) ? mb_strtolower( $value, 'UTF-8' ) : strtolower( $value ); return trim( (string) preg_replace( '/\s+/', ' ', $lower ) ); } /** * "3 hours ago", or "never" for an empty timestamp. * * @param mixed $timestamp A UNIX timestamp, or 0/null when there is none. * * @return string A translated phrase. * @since 1.4.0 */ private function format_ago( $timestamp ): string { $timestamp = (int) $timestamp; if ( $timestamp <= 0 ) { return __( 'never', 'albert-ai-butler' ); } return sprintf( /* translators: %s: human-readable time difference, e.g. "2 hours". */ __( '%s ago', 'albert-ai-butler' ), human_time_diff( $timestamp, time() ) ); } /** * "Added 3 days ago", "Expires at 21 August 2026, 14:32", or "Invitation * expired 20 August 2026, 09:14". * * An exercised invitation (an `authorised_at`) or one with no stored * `expires_at` (expiry was off when it was granted) just says when the * person was added: neither is ever refused, so there is nothing to show * a deadline for. Otherwise it is always the exact date and time, not a * relative countdown: a fixed deadline is simpler to act on at a glance * than "in 11 hours" is, and it does not go stale the way a relative * phrase would between page loads. * * @param array{added_at: int|null, authorised_at: int|null, expires_at: int|null} $entry The user's entry. * * @return string A translated phrase, or '' when there is no added_at to show. * @since 1.4.0 */ private function format_allowed_user_expiry( array $entry ): string { $added_at = $entry['added_at']; if ( $added_at === null ) { return ''; } if ( $entry['authorised_at'] !== null || $entry['expires_at'] === null ) { return sprintf( /* translators: %s: human-readable time since they were added, e.g. "3 days ago". */ __( 'Added %s', 'albert-ai-butler' ), $this->format_ago( $added_at ) ); } $expires_at = $entry['expires_at']; $when = $this->format_datetime( $expires_at ); if ( $expires_at <= time() ) { return sprintf( /* translators: %s: the date and time it expired. */ __( 'Invitation expired %s', 'albert-ai-butler' ), $when ); } return sprintf( /* translators: %s: the exact date and time it expires. */ __( 'Expires at %s', 'albert-ai-butler' ), $when ); } /** * A UTC timestamp, converted to the site's timezone and formatted for * the viewing admin's own language, e.g. "Aug 21, 2026, 2:32 PM" for an * English admin, "21 aug. 2026, 14:32" for a Dutch one on the same site. * * Two things a naive `date_i18n()` call gets wrong, both fixed here: * * 1. Timezone: passed a raw timestamp, `date_i18n()` formats it as-is * without applying the site's UTC offset, which is correct for a * `current_time()`-derived value but silently wrong for a true UTC * one like the timestamps stored here. On any site not actually set * to UTC, that reads as the right time in the wrong zone. * 2. Convention, not just translation: the site's `time_format` option * is one fixed pattern for the whole site (typically whatever the * installer's own language defaulted to), so relying on it always * shows the same 12- or 24-hour convention to every admin regardless * of which language *they* use. `IntlDateFormatter` derives the * correct convention (and date ordering) from * {@see get_user_locale()} instead, using ICU's own locale data * rather than a hand-maintained guess: no assumption here about * which languages prefer which. * * Falls back to the site's configured format (still timezone-corrected * via `wp_date()`) when the `intl` extension is not loaded, so this * degrades rather than fatals on a host without it. * * @param int $timestamp A UNIX (UTC) timestamp. * * @return string * @since 1.4.0 */ private function format_datetime( int $timestamp ): string { if ( class_exists( '\IntlDateFormatter' ) ) { $formatter = new \IntlDateFormatter( get_user_locale(), \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, wp_timezone() ); $formatted = $formatter->format( $timestamp ); if ( $formatted !== false ) { return $formatted; } } $format = trim( (string) get_option( 'date_format' ) . ' ' . (string) get_option( 'time_format' ) ); $formatted = wp_date( $format, $timestamp ); return $formatted !== false ? $formatted : ''; } /** * A user's role, in the language of the admin. * * @param \WP_User $user The user. * * @return string The translated role name, or a dash when the user has none. * @since 1.4.0 */ private function role_label_for( \WP_User $user ): string { $names = wp_roles()->get_names(); $role = $user->roles[0] ?? ''; if ( $role === '' || ! isset( $names[ $role ] ) ) { return __( 'No role on this site', 'albert-ai-butler' ); } return translate_user_role( $names[ $role ] ); } /** * Get the number of active sessions for a user. * * @param int $user_id The user ID. * * @return int The number of active sessions. * @since 1.0.0 */ private function get_user_session_count( int $user_id ): int { global $wpdb; $tables = Tables::oauth(); // The same definition of "connected" the connections card uses, so the // pill beside a user and the list below it cannot disagree. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT( DISTINCT t.client_id ) FROM %i t LEFT JOIN %i r ON r.access_token_id = t.token_id AND r.revoked = 0 AND r.expires_at > UTC_TIMESTAMP() WHERE t.user_id = %d AND ( ( t.revoked = 0 AND t.expires_at > UTC_TIMESTAMP() ) OR r.id IS NOT NULL )', $tables['access_tokens'], $tables['refresh_tokens'], $user_id ) ); } /** * Render sessions view for a specific user. * * @param int $user_id The user ID. * * @return void * @since 1.0.0 */ private function render_user_sessions_view( int $user_id ): void { global $wpdb; $user = get_user_by( 'id', $user_id ); if ( ! $user ) { echo '

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

    '; return; } $tables = Tables::oauth(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $sessions = $wpdb->get_results( $wpdb->prepare( "SELECT t.client_id, MAX(t.id) as id, COALESCE(c.label, c.name, 'Unknown') as client_name, MIN( UNIX_TIMESTAMP( t.created_at ) ) as first_connected_ts 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, c.label, c.name ORDER BY first_connected_ts DESC", $tables['access_tokens'], $tables['clients'], $user_id ) ); $back_url = add_query_arg( [ 'page' => $this->page_slug ], admin_url( 'admin.php' ) ); ?>

    display_name ), esc_html( $user->user_email ) ); ?>


      $this->page_slug, 'action' => 'revoke_user_session', 'token_id' => $session->id, 'user_id' => $user_id, ], admin_url( 'admin.php' ) ), 'revoke_session_' . $session->id ); ?>
    • client_name ); ?> format_ago( $session->first_connected_ts ) ) ); ?>
    $this->page_slug, 'action' => 'revoke_all_user_sessions', 'user_id' => $user_id, ], admin_url( 'admin.php' ) ), 'revoke_all_sessions_' . $user_id ); ?>
    page_slug !== $hook ) { return; } wp_enqueue_style( 'albert-connections', ALBERT_PLUGIN_URL . 'assets/css/admin-connections.css', [ Assets::PRIMITIVES_HANDLE ], Assets::version( 'assets/css/admin-connections.css' ) ); ConnectDialog::enqueue(); wp_enqueue_script( 'albert-admin', ALBERT_PLUGIN_URL . 'assets/js/admin-settings.js', [ 'albert-admin-utils' ], Assets::version( 'assets/js/admin-settings.js' ), 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' ), /* translators: %s: the connection's name. */ 'disconnectTitle' => __( 'Disconnect %s?', 'albert-ai-butler' ), ], ] ); UserPickerModal::enqueue( 'connections' ); wp_enqueue_script( 'albert-admin-popover', ALBERT_PLUGIN_URL . 'assets/js/admin-popover.js', [], Assets::version( 'assets/js/admin-popover.js' ), true ); } }