diff --git a/changes/47430-specific-API-endpoints-search-improvements b/changes/47430-specific-API-endpoints-search-improvements
new file mode 100644
index 0000000000..8f0f14ca50
--- /dev/null
+++ b/changes/47430-specific-API-endpoints-search-improvements
@@ -0,0 +1,2 @@
+- Removed pagination from the "Select API endpoints" search results table (Settings > Users > Add API-only user > Specific API endpoints), relying on the results dropdown's existing scrollbar instead.
+- Ranked "Select API endpoints" search results by relevance (exact match, then prefix, then whole-word, then substring, matched against both name and path) instead of leaving them in an unranked catalog order, and fixed a bug where the table's default sort silently discarded that ranking.
diff --git a/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tests.tsx b/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tests.tsx
new file mode 100644
index 0000000000..e87bbf657e
--- /dev/null
+++ b/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tests.tsx
@@ -0,0 +1,226 @@
+import React from "react";
+
+import { screen, waitFor, within } from "@testing-library/react";
+import { createCustomRenderer } from "test/test-utils";
+
+import apiEndpointsAPI from "services/entities/api_endpoints";
+import { IApiEndpoint } from "interfaces/api_endpoint";
+
+import ApiEndpointSelectorTable from "./ApiEndpointSelectorTable";
+
+jest.mock("services/entities/api_endpoints");
+
+const LIST_HOSTS: IApiEndpoint = {
+ method: "GET",
+ path: "/api/v1/fleet/hosts",
+ display_name: "List hosts",
+ deprecated: false,
+};
+
+const UNINSTALL_SOFTWARE: IApiEndpoint = {
+ method: "POST",
+ path: "/api/v1/fleet/hosts/:id/software/:software_title_id/uninstall",
+ display_name: "Uninstall software",
+ deprecated: false,
+};
+
+const GET_HOST_SOFTWARE: IApiEndpoint = {
+ method: "GET",
+ path: "/api/v1/fleet/hosts/:id/software",
+ display_name: "List host's software",
+ deprecated: false,
+};
+
+const DEPRECATED_ENDPOINT: IApiEndpoint = {
+ method: "GET",
+ path: "/api/v1/fleet/packs",
+ display_name: "List packs",
+ deprecated: true,
+};
+
+const MOCK_ENDPOINTS: IApiEndpoint[] = [
+ UNINSTALL_SOFTWARE,
+ GET_HOST_SOFTWARE,
+ DEPRECATED_ENDPOINT,
+ LIST_HOSTS,
+];
+
+describe("ApiEndpointSelectorTable", () => {
+ const render = createCustomRenderer({ withBackendMock: true });
+
+ beforeEach(() => {
+ (apiEndpointsAPI.loadAll as jest.Mock).mockResolvedValue(MOCK_ENDPOINTS);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it("does not show a results dropdown when the search box is empty", async () => {
+ render(
+
+ );
+
+ await waitFor(() => expect(apiEndpointsAPI.loadAll).toHaveBeenCalled());
+ expect(screen.queryByText("List hosts")).not.toBeInTheDocument();
+ });
+
+ it("ranks a broad, single-word search by relevance instead of catalog order", async () => {
+ const { user } = render(
+
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("Search by name or path"),
+ "hosts"
+ );
+
+ const names = await screen.findAllByText(
+ /^(List hosts|List host's software|Uninstall software)$/
+ );
+ // "List hosts" is a whole-word match on a shallower path than the other
+ // two "hosts"-containing endpoints, so it should rank first.
+ expect(names[0]).toHaveTextContent("List hosts");
+ });
+
+ it("ranks an exact name match first even when it isn't first in catalog order", async () => {
+ const { user } = render(
+
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("Search by name or path"),
+ "list hosts"
+ );
+
+ const results = await screen.findAllByText(
+ /^(List hosts|List host's software)$/
+ );
+ expect(results).toHaveLength(1);
+ expect(results[0]).toHaveTextContent("List hosts");
+ });
+
+ it("matches on path as well as name", async () => {
+ const { user } = render(
+
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("Search by name or path"),
+ "uninstall"
+ );
+
+ await screen.findByText("Uninstall software");
+ expect(screen.queryByText("List hosts")).not.toBeInTheDocument();
+ });
+
+ it("excludes already-selected endpoints from the search results", async () => {
+ const { user } = render(
+
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("Search by name or path"),
+ "hosts"
+ );
+
+ await screen.findByText("Uninstall software");
+ // "List hosts" should only appear once now, in the selected-endpoints
+ // table, not also in the search results dropdown.
+ expect(screen.getAllByText("List hosts")).toHaveLength(1);
+ });
+
+ it("shows an empty state when nothing matches", async () => {
+ const { user } = render(
+
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("Search by name or path"),
+ "nonexistent-endpoint"
+ );
+
+ await screen.findByText("No matching API endpoints.");
+ });
+
+ it("shows a deprecated badge for deprecated endpoints", async () => {
+ const { user } = render(
+
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("Search by name or path"),
+ "packs"
+ );
+
+ await screen.findByText("List packs");
+ expect(screen.getByText("Deprecated")).toBeInTheDocument();
+ });
+
+ it("adds the clicked endpoint to the selection and clears the search text", async () => {
+ const onSelectionChange = jest.fn();
+ const { user } = render(
+
+ );
+
+ const searchInput = screen.getByPlaceholderText("Search by name or path");
+ await user.type(searchInput, "list hosts");
+
+ const result = await screen.findByText("List hosts");
+ await user.click(result);
+
+ await waitFor(() => {
+ expect(onSelectionChange).toHaveBeenCalledWith([
+ { method: LIST_HOSTS.method, path: LIST_HOSTS.path },
+ ]);
+ });
+ expect(searchInput).toHaveValue("");
+ });
+
+ it("removes an endpoint from the selected-endpoints table", async () => {
+ const onSelectionChange = jest.fn();
+ const { user } = render(
+
+ );
+
+ const selectedRow = (await screen.findByText("List hosts")).closest("tr");
+ if (!selectedRow) {
+ throw new Error("Expected to find the selected endpoint's table row");
+ }
+
+ await user.click(within(selectedRow).getByRole("button"));
+
+ expect(onSelectionChange).toHaveBeenCalledWith([]);
+ });
+});
diff --git a/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tsx b/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tsx
index 02d063ac4e..fac1388a3c 100644
--- a/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tsx
+++ b/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tsx
@@ -35,6 +35,37 @@ interface IApiEndpointRow extends IApiEndpoint {
const normalizePath = (s: string) =>
s.toLowerCase().replace(/:[a-z0-9_]+/g, ":_");
+/** Split on path separators, whitespace, and word-boundary punctuation so
+ * both names ("List hosts") and paths ("/api/v1/fleet/hosts") can be
+ * compared word-by-word. */
+const WORD_SPLIT_RE = /[\s/_-]+/;
+
+/** Score how well a single field matches the query: exact match ranks
+ * highest, then prefix match, then whole-word match, then any substring
+ * match. Returns 0 when there's no match at all. */
+const scoreField = (field: string, query: string): number => {
+ if (!field || !query) return 0;
+ if (field === query) return 100;
+ if (field.startsWith(query)) return 90;
+ if (field.split(WORD_SPLIT_RE).filter(Boolean).includes(query)) return 70;
+ if (field.includes(query)) return 50;
+ return 0;
+};
+
+/** An endpoint's relevance is the strongest match across its name and path —
+ * a strong hit on one field can outrank a weak hit on the other. Method
+ * matches (e.g. searching "post") are ranked below any name/path match. */
+const scoreEndpoint = (ep: IApiEndpointRow, query: string): number =>
+ Math.max(
+ scoreField(ep.display_name.toLowerCase(), query),
+ scoreField(normalizePath(ep.path), query),
+ ep.method.toLowerCase().includes(query) ? 10 : 0
+ );
+
+/** Fewer path segments = a broader, higher-level endpoint. Used to break
+ * score ties so e.g. `/hosts` sorts before `/hosts/:id/software`. */
+const pathDepth = (path: string) => path.split("/").filter(Boolean).length;
+
interface IApiEndpointSelectorTableProps {
selectedEndpoints: IApiEndpointRef[];
onSelectionChange: (endpoints: IApiEndpointRef[]) => void;
@@ -122,20 +153,23 @@ const ApiEndpointSelectorTable = ({
[apiEndpoints]
);
- // Filter search results: match search text and exclude already-selected.
+ // Filter search results: match search text and exclude already-selected,
+ // then rank by relevance (best match across name/path first, broader
+ // paths breaking ties) rather than leaving them in catalog order.
// Path parameter names (e.g. `:id`, `:host_id`) are normalized so searching
// "/hosts/:id/report" matches "/hosts/:host_id/report".
const searchResults: IApiEndpointRow[] = useMemo(() => {
if (isEmpty(searchText)) return [];
const query = normalizePath(searchText);
- return allRows.filter((ep) => {
- if (selectedEndpoints.some((s) => endpointKey(s) === ep.id)) return false;
- return (
- ep.display_name.toLowerCase().includes(query) ||
- normalizePath(ep.path).includes(query) ||
- ep.method.toLowerCase().includes(query)
- );
- });
+ return allRows
+ .filter((ep) => !selectedEndpoints.some((s) => endpointKey(s) === ep.id))
+ .map((ep) => ({ ep, score: scoreEndpoint(ep, query) }))
+ .filter(({ score }) => score > 0)
+ .sort(
+ (a, b) =>
+ b.score - a.score || pathDepth(a.ep.path) - pathDepth(b.ep.path)
+ )
+ .map(({ ep }) => ep);
}, [allRows, searchText, selectedEndpoints]);
const selectedRows: IApiEndpointRow[] = useMemo(
@@ -240,8 +274,12 @@ const ApiEndpointSelectorTable = ({
isAllPagesSelected={false}
disableCount
disableMultiRowSelect
- isClientSidePagination
- pageSize={10}
+ disablePagination
+ // Without this, TableContainer's default sort (by a "name"
+ // column that doesn't exist here) silently re-shuffles rows via
+ // react-table's built-in sorting, discarding the relevance
+ // order computed above.
+ manualSortBy
onClickRow={handleRowSelect}
/>