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(); ?>

render_endpoint_card( $endpoint, $external_state ); ?>
render_connections_card(); ?> render_allowed_users_card(); ?>
render_setup_card( $endpoint ); ?>
render_disconnect_dialog(); UserPickerModal::render( 'connections' ); } /** * The endpoint card: what this URL is, and whether it can be reached. * * Reachability comes with the URL rather than after it. A `.test` site shows * a perfectly valid endpoint that no cloud assistant can ever open, and * finding that out an hour later is the single most expensive way to learn * it. At most one hint shows at a time: stacking two boxes under one field * is how a calm card turns into a wall. * * @param string $endpoint The MCP endpoint URL. * @param array{state: string, value: string} $external_state External-URL filter state. * * @return void * @since 1.4.0 */ private function render_endpoint_card( string $endpoint, array $external_state ): void { ?>

render_endpoint_hint( $external_state ); ?>
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 ); ?>

render_connections_count_badge( $count ); ?>
render_connections_toolbar( $count ); ?>
render_connections_body( $connections ); ?>
$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' : '' ); $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 ) { ?>
  • render_setup_guide( $guide ); ?>
    ` rather than a scripted accordion: it opens with the keyboard, * it is announced correctly, and it works before any JavaScript loads. * * @param array $guide One entry from {@see ClientSetupGuides}. * * @return void * @since 1.4.0 */ private function render_setup_guide( array $guide ): void { $id = (string) $guide['id']; $snippet_id = 'albert-snippet-' . $id; $config_path = (string) $guide['config_path']; $deeplink = (string) $guide['deeplink']; ?>
    > * @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' ) ); 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 ); } }