diff --git a/src/components/access/GroupsTab.test.tsx b/src/components/access/GroupsTab.test.tsx
new file mode 100644
index 00000000..2b53520f
--- /dev/null
+++ b/src/components/access/GroupsTab.test.tsx
@@ -0,0 +1,123 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+
+const allGroups = [
+ { _id: 'grp-a', name: 'Group A', memberIds: [], source: 'entra' },
+ { _id: 'grp-b', name: 'Group B', memberIds: [], source: 'entra' },
+];
+
+vi.mock('@tanstack/react-start', () => ({
+ createServerFn: () => ({
+ inputValidator: () => ({
+ handler: (fn: (...args: unknown[]) => unknown) => fn,
+ }),
+ handler: (fn: (...args: unknown[]) => unknown) => fn,
+ }),
+ createServerOnlyFn: (fn: (...args: unknown[]) => unknown) => fn,
+}));
+
+vi.mock('@/server/utils/api', () => ({
+ apiFetch: vi.fn(async (url: string) => {
+ if (url.startsWith('/api/admin/groups?')) {
+ const params = new URLSearchParams(url.split('?')[1]);
+ const search = params.get('search')?.toLowerCase() ?? '';
+ const matches = search
+ ? allGroups.filter((group) => group.name.toLowerCase().includes(search))
+ : allGroups;
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ groups: matches, total: matches.length }),
+ };
+ }
+ return { ok: false, status: 404, json: async () => ({ error: 'Not found' }) };
+ }),
+ extractApiError: vi.fn(async (_res: unknown, msg: string) => {
+ throw new Error(msg);
+ }),
+}));
+
+vi.mock('@/hooks/useLocalize', () => ({
+ useLocalize: () => (key: string) => key,
+}));
+
+vi.mock('@/hooks/useCapabilities', () => ({
+ useCapabilities: () => ({ hasCapability: () => true }),
+}));
+
+vi.mock('./EditGroupDialog', () => ({ EditGroupDialog: () => null }));
+vi.mock('./ConfirmDialog', () => ({ ConfirmDialog: () => null }));
+
+interface MockButtonProps {
+ label: string;
+ onClick?: () => void;
+}
+interface MockSearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder: string;
+}
+
+vi.mock('@clickhouse/click-ui', () => ({
+ createToast: vi.fn(),
+ Button: ({ label, onClick }: MockButtonProps) => (
+
+ ),
+}));
+
+vi.mock('@/components/shared', () => ({
+ LoadingState: () =>
loading
,
+ SearchInput: ({ value, onChange, placeholder }: MockSearchInputProps) => (
+ onChange(e.target.value)}
+ />
+ ),
+ EmptyState: ({ message }: { message: string }) => {message}
,
+ Pagination: () => null,
+ TrashButton: () => null,
+}));
+
+import { GroupsTab } from './GroupsTab';
+import { apiFetch } from '@/server/utils/api';
+
+const mockedApiFetch = vi.mocked(apiFetch);
+
+function renderTab() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ return render( {}} />, { wrapper });
+}
+
+describe('GroupsTab search clamping', () => {
+ it('clamps the search input to the backend limit so displayed and queried text agree', async () => {
+ renderTab();
+ await screen.findByText('Group A');
+
+ fireEvent.change(screen.getByLabelText('group-search'), {
+ target: { value: 'x'.repeat(250) },
+ });
+
+ expect(screen.getByLabelText('group-search')).toHaveValue('x'.repeat(200));
+
+ await waitFor(() => {
+ const searchUrls = mockedApiFetch.mock.calls
+ .map(([url]) => url)
+ .filter((url) => url.includes('search='));
+ expect(searchUrls).toHaveLength(1);
+ });
+ const url = mockedApiFetch.mock.calls.map(([u]) => u).find((u) => u.includes('search='));
+ const sent = new URLSearchParams(url?.split('?')[1] ?? '').get('search') ?? '';
+ expect(sent).toHaveLength(200);
+ });
+});
diff --git a/src/components/access/GroupsTab.tsx b/src/components/access/GroupsTab.tsx
index 2819f3c5..ef8e0a1d 100644
--- a/src/components/access/GroupsTab.tsx
+++ b/src/components/access/GroupsTab.tsx
@@ -10,7 +10,7 @@ import {
Pagination,
TrashButton,
} from '@/components/shared';
-import { deleteGroupFn, groupsQueryOptions, GROUPS_PAGE_SIZE } from '@/server';
+import { deleteGroupFn, groupsQueryOptions, GROUPS_PAGE_SIZE, MAX_SEARCH_LENGTH } from '@/server';
import { cn, notifySuccess, notifyError } from '@/utils';
import { useCapabilities, useLocalize } from '@/hooks';
import { EditGroupDialog } from './EditGroupDialog';
@@ -34,10 +34,11 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) {
}, []);
const handleSearchChange = (value: string) => {
- setSearch(value);
+ const clamped = value.slice(0, MAX_SEARCH_LENGTH);
+ setSearch(clamped);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
- setDebouncedSearch(value);
+ setDebouncedSearch(clamped);
setPage(1);
}, 300);
};
diff --git a/src/components/configuration/ScopeSelector.test.tsx b/src/components/configuration/ScopeSelector.test.tsx
new file mode 100644
index 00000000..6a937093
--- /dev/null
+++ b/src/components/configuration/ScopeSelector.test.tsx
@@ -0,0 +1,322 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { PrincipalType } from 'librechat-data-provider';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+
+const PAGE_SIZE = 50;
+const TOTAL_GROUPS = 60;
+
+/** When set, group list requests block until the promise resolves. */
+const mockListGate: { promise: Promise | null } = { promise: null };
+
+/** When set, group list requests fail with a 500. */
+const mockListFail = { fail: false };
+
+const allGroups = Array.from({ length: TOTAL_GROUPS }, (_, i) => ({
+ _id: `grp-${i + 1}`,
+ name: `Group ${String(i + 1).padStart(2, '0')}`,
+ memberIds: [],
+ source: 'entra',
+}));
+
+/** Every group on the first page already has a configuration; page two has eligible groups. */
+const configuredGroups = allGroups.slice(0, PAGE_SIZE);
+
+const configs = configuredGroups.map((group) => ({
+ _id: `cfg-${group._id}`,
+ principalType: PrincipalType.GROUP,
+ principalId: group._id,
+ priority: 20,
+ isActive: true,
+ overrides: {},
+}));
+
+vi.mock('@tanstack/react-start', () => ({
+ createServerFn: () => ({
+ inputValidator: () => ({
+ handler: (fn: (...args: unknown[]) => unknown) => fn,
+ }),
+ handler: (fn: (...args: unknown[]) => unknown) => fn,
+ }),
+ createServerOnlyFn: (fn: (...args: unknown[]) => unknown) => fn,
+}));
+
+vi.mock('@/server/utils/api', () => ({
+ apiFetch: vi.fn(async (url: string) => {
+ const json = (body: object) => ({ ok: true, status: 200, json: async () => body });
+ if (url === '/api/admin/config') return json({ configs });
+ if (url.startsWith('/api/admin/roles')) return json({ roles: [], total: 0 });
+ if (url.startsWith('/api/admin/groups?')) {
+ if (mockListGate.promise) await mockListGate.promise;
+ if (mockListFail.fail) {
+ return { ok: false, status: 500, json: async () => ({ error: 'boom' }) };
+ }
+ const params = new URLSearchParams(url.split('?')[1]);
+ const search = params.get('search')?.toLowerCase() ?? '';
+ const limit = Number(params.get('limit') ?? 200);
+ const offset = Number(params.get('offset') ?? 0);
+ const matches = search
+ ? allGroups.filter((group) => group.name.toLowerCase().includes(search))
+ : allGroups;
+ return json({ groups: matches.slice(offset, offset + limit), total: matches.length });
+ }
+ const byId = allGroups.find((group) => url === `/api/admin/groups/${group._id}`);
+ if (byId) return json({ group: byId });
+ return { ok: false, status: 404, json: async () => ({ error: 'Not found' }) };
+ }),
+ extractApiError: vi.fn(async (_res: unknown, msg: string) => {
+ throw new Error(msg);
+ }),
+}));
+
+vi.mock('@/hooks/useLocalize', () => ({
+ useLocalize: () => (key: string) => key,
+}));
+
+interface ChildrenProps {
+ children?: ReactNode;
+}
+interface CommandDialogProps extends ChildrenProps {
+ open: boolean;
+}
+interface CommandItemProps extends ChildrenProps {
+ onSelect?: () => void;
+}
+interface MockButtonProps {
+ label: string;
+ onClick?: () => void;
+}
+interface MockIconProps {
+ name: string;
+}
+interface MockSearchFieldProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ 'aria-label'?: string;
+}
+interface MockPaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onChange: (page: number) => void;
+}
+
+vi.mock('cmdk', () => {
+ const Passthrough = ({ children }: ChildrenProps) => {children}
;
+ return {
+ Command: Object.assign(Passthrough, {
+ Dialog: ({ open, children }: CommandDialogProps) =>
+ open ? {children}
: null,
+ Input: () => ,
+ List: Passthrough,
+ Group: Passthrough,
+ Item: ({ children, onSelect }: CommandItemProps) => (
+ onSelect?.()}>{children}
+ ),
+ Empty: Passthrough,
+ }),
+ };
+});
+
+vi.mock('@clickhouse/click-ui', () => ({
+ Button: ({ label, onClick }: MockButtonProps) => (
+
+ ),
+ Icon: ({ name }: MockIconProps) => ,
+ SearchField: (props: MockSearchFieldProps) => (
+ props.onChange(e.target.value)}
+ />
+ ),
+ Pagination: ({ currentPage, totalPages, onChange }: MockPaginationProps) => (
+
+ ),
+}));
+
+vi.mock('@radix-ui/react-visually-hidden', () => ({
+ VisuallyHidden: ({ children }: ChildrenProps) => {children},
+}));
+
+vi.mock('@radix-ui/react-dialog', () => ({
+ Title: ({ children }: ChildrenProps) => {children},
+ Description: ({ children }: ChildrenProps) => {children},
+}));
+
+import { ScopeSelector } from './ScopeSelector';
+import { apiFetch } from '@/server/utils/api';
+
+const mockedApiFetch = vi.mocked(apiFetch);
+
+async function openCreateView() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ render(
+
+ {}}
+ currentSelection={{ type: 'BASE' }}
+ onSelect={() => {}}
+ permissions={{ canView: true, canEdit: true, canAssign: true }}
+ />
+ ,
+ );
+ fireEvent.click(await screen.findByText('com_scope_create'));
+}
+
+async function renderCreateView() {
+ await openCreateView();
+ await screen.findByText('Group 01');
+}
+
+beforeEach(() => {
+ mockListGate.promise = null;
+ mockListFail.fail = false;
+ mockedApiFetch.mockClear();
+});
+
+describe('ScopeSelector create view group pagination', () => {
+ it('renders a fully configured page as disabled entries instead of a global empty state', async () => {
+ await renderCreateView();
+
+ expect(screen.queryByText('com_access_groups_empty')).toBeNull();
+ expect(screen.queryByText('com_scope_no_matching_groups')).toBeNull();
+
+ const configuredButton = screen.getByText('Group 01').closest('button');
+ expect(configuredButton).toBeDisabled();
+ expect(screen.getAllByText('com_scope_already_configured')).toHaveLength(PAGE_SIZE);
+ });
+
+ it('keeps pagination truthful so eligible groups on later pages stay reachable', async () => {
+ await renderCreateView();
+
+ const pagination = screen.getByTestId('pagination');
+ expect(pagination.dataset.total).toBe('2');
+
+ fireEvent.click(screen.getByText('next-page'));
+
+ const eligibleButton = (await screen.findByText('Group 51')).closest('button');
+ expect(eligibleButton).toBeEnabled();
+ expect(screen.queryByText('com_scope_already_configured')).toBeNull();
+ });
+
+ it('disables stale rows while a new page or search is being fetched', async () => {
+ await renderCreateView();
+
+ fireEvent.click(screen.getByText('next-page'));
+ const eligibleButton = (await screen.findByText('Group 51')).closest('button');
+ expect(eligibleButton).toBeEnabled();
+
+ let release = () => {};
+ mockListGate.promise = new Promise((resolve) => {
+ release = resolve;
+ });
+ fireEvent.change(screen.getByLabelText('com_access_search_groups'), {
+ target: { value: 'Group 01' },
+ });
+
+ await waitFor(() => expect(screen.getByText('Group 51').closest('button')).toBeDisabled());
+
+ release();
+ mockListGate.promise = null;
+ await screen.findByText('Group 01');
+ expect(screen.queryByText('Group 51')).toBeNull();
+ expect(screen.getByText('Group 01').closest('button')).toBeDisabled();
+ expect(screen.getByText('com_scope_already_configured')).toBeInTheDocument();
+ });
+
+ it('disables rows during the debounce window before the search request starts', async () => {
+ await renderCreateView();
+
+ fireEvent.click(screen.getByText('next-page'));
+ const eligibleButton = (await screen.findByText('Group 51')).closest('button');
+ expect(eligibleButton).toBeEnabled();
+
+ fireEvent.change(screen.getByLabelText('com_access_search_groups'), {
+ target: { value: 'Needle' },
+ });
+
+ expect(screen.getByText('Group 51').closest('button')).toBeDisabled();
+ });
+
+ it('ignores pagination clicks while a search request is in flight', async () => {
+ await renderCreateView();
+ expect(screen.getByTestId('pagination')).toBeInTheDocument();
+
+ let release = () => {};
+ mockListGate.promise = new Promise((resolve) => {
+ release = resolve;
+ });
+ fireEvent.change(screen.getByLabelText('com_access_search_groups'), {
+ target: { value: 'Group 01' },
+ });
+ await waitFor(() =>
+ expect(mockedApiFetch.mock.calls.some(([url]) => url.includes('search='))).toBe(true),
+ );
+
+ fireEvent.click(screen.getByText('next-page'));
+
+ release();
+ mockListGate.promise = null;
+ await screen.findByText('com_scope_already_configured');
+ expect(screen.getByText('Group 01').closest('button')).toBeInTheDocument();
+ expect(screen.queryByText('com_scope_no_matching_groups')).toBeNull();
+ expect(screen.queryByTestId('pagination')).toBeNull();
+
+ const strandedUrls = mockedApiFetch.mock.calls
+ .map(([url]) => url)
+ .filter((url) => url.includes('search=') && url.includes('offset=50'));
+ expect(strandedUrls).toEqual([]);
+ });
+
+ it('renders a retryable error state instead of an empty list when the fetch fails', async () => {
+ mockListFail.fail = true;
+ await openCreateView();
+
+ await screen.findByText('com_error_load_groups');
+ expect(screen.queryByText('com_access_groups_empty')).toBeNull();
+ expect(screen.queryByText('com_scope_no_matching_groups')).toBeNull();
+ expect(screen.queryByTestId('pagination')).toBeNull();
+
+ mockListFail.fail = false;
+ fireEvent.click(screen.getByText('com_ui_retry'));
+
+ await screen.findByText('Group 01');
+ expect(screen.queryByText('com_error_load_groups')).toBeNull();
+ });
+
+ it('reopening the create view after a search starts from page 1 unfiltered', async () => {
+ await renderCreateView();
+
+ fireEvent.change(screen.getByLabelText('com_access_search_groups'), {
+ target: { value: 'Group 51' },
+ });
+ await screen.findByText('Group 51');
+ expect(screen.queryByText('Group 01')).toBeNull();
+
+ fireEvent.click(screen.getByLabelText('com_scope_create_back'));
+ const callsAfterBack = mockedApiFetch.mock.calls.length;
+ fireEvent.click(screen.getByText('com_scope_create'));
+
+ await screen.findByText('Group 01');
+ expect(screen.getByLabelText('com_access_search_groups')).toHaveValue('');
+ expect(screen.queryByText('Group 51')).toBeNull();
+
+ const staleUrls = mockedApiFetch.mock.calls
+ .slice(callsAfterBack)
+ .map(([url]) => url)
+ .filter((url) => url.includes('search='));
+ expect(staleUrls).toEqual([]);
+ });
+});
diff --git a/src/components/configuration/ScopeSelector.tsx b/src/components/configuration/ScopeSelector.tsx
index 9af2d913..f8214816 100644
--- a/src/components/configuration/ScopeSelector.tsx
+++ b/src/components/configuration/ScopeSelector.tsx
@@ -10,12 +10,12 @@ import type * as t from '@/types';
import {
availableScopesOptions,
allRolesQueryOptions,
- allGroupsQueryOptions,
createScopeFn,
deleteScopeFn,
} from '@/server';
+import { SearchInput, Pagination } from '@/components/shared';
+import { useGroupSearch, useLocalize } from '@/hooks';
import { getScopeTypeConfig } from '@/constants';
-import { useLocalize } from '@/hooks';
import { cn } from '@/utils';
// ── Main selector ───────────────────────────────────────────────────
@@ -47,22 +47,25 @@ export function ScopeSelector({
enabled: open && showCreate,
});
- const { data: allGroups = [] } = useQuery({
- ...allGroupsQueryOptions,
- enabled: open && showCreate,
- });
+ const groupSearch = useGroupSearch(open && showCreate);
+ const { reset: resetGroupSearch } = groupSearch;
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
if (listRef.current) listRef.current.scrollTop = 0;
}, []);
- const resetState = useCallback(() => {
+ const closeCreate = useCallback(() => {
setShowCreate(false);
+ resetGroupSearch();
+ }, [resetGroupSearch]);
+
+ const resetState = useCallback(() => {
setCreating(false);
setDeleteTarget(null);
setDeleting(false);
- }, []);
+ closeCreate();
+ }, [closeCreate]);
const close = useCallback(() => {
onOpenChange(false);
@@ -103,9 +106,9 @@ export function ScopeSelector({
[allRoles, existingScopeKeys],
);
- const availableGroups = useMemo(
- () => allGroups.filter((g) => !existingScopeKeys.has(`${PrincipalType.GROUP}:${g.id}`)),
- [allGroups, existingScopeKeys],
+ const isGroupConfigured = useCallback(
+ (group: AdminGroup) => existingScopeKeys.has(`${PrincipalType.GROUP}:${group.id}`),
+ [existingScopeKeys],
);
const handleCreateForRole = useCallback(
@@ -259,7 +262,93 @@ export function ScopeSelector({
if (showCreate) {
const noRoles = availableRoles.length === 0;
- const noGroups = availableGroups.length === 0;
+ const noGroups = groupSearch.groups.length === 0;
+ const groupsBusy = groupSearch.isFetching || groupSearch.isSearchPending;
+ const groupsEmptyKey = groupSearch.search
+ ? 'com_scope_no_matching_groups'
+ : 'com_access_groups_empty';
+
+ const handleGroupPageChange = (page: number) => {
+ if (!groupsBusy) groupSearch.setPage(page);
+ };
+
+ const renderGroupsContent = () => {
+ if (groupSearch.isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+ if (groupSearch.isError) {
+ return (
+
+
+ {localize('com_error_load_groups')}
+
+
+
+ );
+ }
+ if (noGroups) {
+ return (
+
+ {localize(groupsEmptyKey)}
+
+ );
+ }
+ return (
+
+ {groupSearch.groups.map((group) => {
+ const configured = isGroupConfigured(group);
+ return (
+
+ );
+ })}
+
+ );
+ };
return (