diff --git a/e2e/access.spec.ts b/e2e/access.spec.ts
index 74ee7fb0..a46218da 100644
--- a/e2e/access.spec.ts
+++ b/e2e/access.spec.ts
@@ -12,7 +12,7 @@ import {
searchField,
uniqueName,
} from './access';
-import { AUDITED_WRITES_SKIP_REASON, LIVE, MOCK_FIXTURES_SKIP_REASON } from './live';
+import { AUDITED_WRITES_SKIP_REASON, LIVE, MOCK_FIXTURES_SKIP_REASON, THEMES } from './live';
const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
@@ -136,6 +136,167 @@ test.describe('Access page accessibility', () => {
});
});
+/** The open dialog (Edit role, Edit group, Delete role). */
+const dialogOf = (page: Page) => page.getByRole('dialog');
+
+/** axe on the open dialog only: the page behind it is inert while it is open. */
+async function scanDialog(page: Page) {
+ return new AxeBuilder({ page }).withTags(WCAG_TAGS).include('[role="dialog"]').analyze();
+}
+
+/** Opens a role's Edit role dialog from its row. */
+async function openEditRole(page: Page, role: string) {
+ await page.goto('/access?tab=roles');
+ await page.waitForLoadState('networkidle');
+ await searchField(page).fill(role);
+ await page.getByText(role, { exact: true }).click();
+ await expect(dialogOf(page)).toBeVisible();
+ return dialogOf(page);
+}
+
+for (const theme of THEMES) {
+ test.describe(`Access page Edit role dialog accessibility: ${theme}`, () => {
+ test.use({ colorScheme: theme });
+
+ for (const tab of ['Details', 'Permissions', 'Members']) {
+ test(`has zero WCAG 2.1 AA violations on the ${tab} tab`, async ({ page }) => {
+ const dialog = await openEditRole(page, 'ADMIN');
+ await dialog.getByRole('tab', { name: tab }).click();
+ await expect(dialog.getByRole('tab', { name: tab })).toHaveAttribute(
+ 'data-state',
+ 'active',
+ );
+ await expect(dialog.getByText('Loading…').filter({ visible: true })).toHaveCount(0);
+
+ expect((await scanDialog(page)).violations).toEqual([]);
+ });
+ }
+ });
+}
+
+test.describe('Access page Edit role dialog reflow', () => {
+ for (const width of [390, 320]) {
+ test(`keeps the Permissions tab's feature grid inside the dialog at ${width}px`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 844 });
+ const dialog = await openEditRole(page, 'ADMIN');
+ await dialog.getByRole('tab', { name: 'Permissions' }).click();
+ const features = dialog.getByText('Allowed features', { exact: true });
+ await expect(features).toBeVisible();
+ const grid = features.locator('xpath=following-sibling::div[1]');
+ const [gridBox, dialogBox] = await Promise.all([grid.boundingBox(), dialog.boundingBox()]);
+ expect(gridBox && dialogBox).toBeTruthy();
+ expect(gridBox!.x + gridBox!.width).toBeLessThanOrEqual(dialogBox!.x + dialogBox!.width);
+ });
+ }
+});
+
+/**
+ * These open the mock's non-system Support role and its Engineering group, and
+ * search the mock's users; a fresh LibreChat has none of them.
+ */
+test.describe('Access page dialog keyboard and contrast', () => {
+ test.skip(LIVE, MOCK_FIXTURES_SKIP_REASON);
+ test.use({ colorScheme: 'light' });
+
+ test('Delete role gives focus back to its Delete button on Escape and on Cancel', async ({
+ page,
+ }) => {
+ await page.goto('/access?tab=roles');
+ await page.waitForLoadState('networkidle');
+ const opener = page.getByRole('button', { name: 'Delete Support', exact: true });
+
+ for (const close of ['Escape', 'Cancel'] as const) {
+ await opener.focus();
+ await page.keyboard.press('Enter');
+ const dialog = dialogOf(page);
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
+ if (close === 'Escape') await page.keyboard.press('Escape');
+ else await dialog.getByRole('button', { name: 'Cancel' }).press('Enter');
+ await expect(dialog).not.toBeVisible();
+ await expect(opener).toBeFocused();
+ }
+ });
+
+ test('Delete role has zero WCAG 2.1 AA violations with Delete focused', async ({ page }) => {
+ await page.goto('/access?tab=roles');
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('button', { name: 'Delete Support', exact: true }).focus();
+ await page.keyboard.press('Enter');
+ const confirm = dialogOf(page).getByRole('button', { name: 'Delete', exact: true });
+ await confirm.focus();
+ await expect(confirm).toBeFocused();
+
+ expect((await scanDialog(page)).violations).toEqual([]);
+ });
+
+ /** Opens Support's Members tab and highlights the first user the search suggests. */
+ async function highlightFirstSuggestion(page: Page) {
+ const dialog = await openEditRole(page, 'Support');
+ await dialog.getByRole('tab', { name: 'Members' }).click();
+ const search = dialog.getByRole('combobox', { name: /search users to add/i });
+ await search.fill('al');
+ const first = page.getByRole('listbox').getByRole('option').first();
+ await expect(first).toBeVisible();
+ await search.press('ArrowDown');
+ await expect(first).toHaveAttribute('aria-selected', 'true');
+ return { dialog, search, first };
+ }
+
+ test('member search: the highlighted suggestion passes axe', async ({ page }) => {
+ await highlightFirstSuggestion(page);
+
+ const results = await new AxeBuilder({ page })
+ .withTags(WCAG_TAGS)
+ .include('[role="listbox"]')
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+
+ test('member search: the field points at the highlighted suggestion', async ({ page }) => {
+ const { search, first } = await highlightFirstSuggestion(page);
+
+ const id = await first.getAttribute('id');
+ expect(id).toBeTruthy();
+ await expect(search).toHaveAttribute('aria-activedescendant', id ?? '');
+ });
+
+ test('member search: Escape closes the suggestions first, then the dialog', async ({ page }) => {
+ const { dialog, search } = await highlightFirstSuggestion(page);
+
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('listbox')).toHaveCount(0);
+ await expect(dialog).toBeVisible();
+ await expect(search).toBeFocused();
+
+ await page.keyboard.press('Escape');
+ await expect(dialog).not.toBeVisible();
+ });
+
+ test('a member staged for removal stays legible, says so, and offers a focused Undo', async ({
+ page,
+ }) => {
+ await page.goto('/access?tab=groups');
+ await page.waitForLoadState('networkidle');
+ await page.getByText('Engineering', { exact: true }).click();
+ const dialog = dialogOf(page);
+ await dialog.getByRole('tab', { name: 'Members' }).click();
+ await dialog.getByRole('button', { name: 'Remove Alice Chen' }).click();
+ await expect(dialog.getByText(/^Removing 1 members?$/)).toBeVisible();
+
+ expect((await scanDialog(page)).violations).toEqual([]);
+ await expect(dialog.getByRole('button', { name: 'Undo removing Alice Chen' })).toBeFocused();
+ await expect(dialog.getByText('Removing 1 member', { exact: true })).toBeVisible();
+ await expect(dialog.getByText('Will be removed', { exact: true })).toBeVisible();
+
+ await page.keyboard.press('Enter');
+ await expect(dialog.getByRole('button', { name: 'Remove Alice Chen' })).toBeFocused();
+ await expect(dialog.getByText('Will be removed', { exact: true })).toHaveCount(0);
+ });
+});
+
test.describe('Access page tabs', () => {
test('defaults to Roles tab', async ({ page }) => {
await page.goto('/access');
@@ -236,6 +397,24 @@ test.describe('Access page Create Group dialog', () => {
await expect(page.getByText(name, { exact: true })).toBeVisible({ timeout: 5000 });
});
+ test('placeholders use the muted text color, not the disabled one', async ({ page }) => {
+ await page.goto('/access?tab=groups');
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('button', { name: /create group/i }).click();
+ const name = page.locator('#create-group-name');
+ await expect(name).toBeVisible();
+
+ const colors = await name.evaluate((input) => {
+ const probe = document.createElement('span');
+ probe.style.color = 'var(--cui-color-text-muted)';
+ document.body.append(probe);
+ const muted = getComputedStyle(probe).color;
+ probe.remove();
+ return { placeholder: getComputedStyle(input, '::placeholder').color, muted };
+ });
+ expect(colors.placeholder).toBe(colors.muted);
+ });
+
test('Create Group button is disabled when name is empty', async ({ page }) => {
await page.goto('/access?tab=groups');
await page.waitForLoadState('networkidle');
@@ -383,6 +562,51 @@ test.describe('Access page delete', () => {
});
});
+/**
+ * A delete removes its row only when the list refetches, after the dialog has
+ * closed and given focus back, so focus left on the row's Delete would be lost
+ * with the row. These delete a role and a group from the keyboard alone. They
+ * focus the dialog's Delete directly: which button it opens on is checked above.
+ */
+test.describe('Access page delete from the keyboard', () => {
+ test.skip(LIVE, AUDITED_WRITES_SKIP_REASON);
+ const tabs = [
+ { tab: 'roles', kind: 'role', create: createRole },
+ { tab: 'groups', kind: 'group', create: createGroup },
+ ] as const;
+ let created: { tab: (typeof tabs)[number]['tab']; name: string } | undefined;
+
+ test.afterEach(async ({ page }) => {
+ if (!created) return;
+ await deleteFromList(page, created.tab, created.name);
+ created = undefined;
+ });
+
+ for (const { tab, kind, create } of tabs) {
+ test(`gives focus to the search field once a ${kind} is deleted`, async ({ page }) => {
+ const name = uniqueName(`Keyboard ${kind}`);
+ created = { tab, name };
+ await create(page, name);
+ await searchField(page).fill(name);
+ const opener = page.getByRole('button', { name: `Delete ${name}`, exact: true });
+ await expect(opener).toBeVisible();
+
+ await opener.focus();
+ await page.keyboard.press('Enter');
+ const dialog = dialogOf(page);
+ const confirm = dialog.getByRole('button', { name: 'Delete', exact: true });
+ await confirm.focus();
+ await expect(confirm).toBeFocused();
+ await page.keyboard.press('Enter');
+
+ await expect(dialog).not.toBeVisible();
+ await expect(opener).toHaveCount(0);
+ created = undefined;
+ await expect(searchField(page)).toBeFocused();
+ });
+ }
+});
+
/**
* LibreChat keys system grants by role name and its role rename (`renameRole`
* in `packages/api/src/admin/roles.ts`) moves only the role's users, so the
diff --git a/e2e/grants.spec.ts b/e2e/grants.spec.ts
index 3b311c2d..8c6ec2b0 100644
--- a/e2e/grants.spec.ts
+++ b/e2e/grants.spec.ts
@@ -133,7 +133,9 @@ test.describe('Grants page - Management tab', () => {
const adminRow = page.locator('tbody tr').filter({ hasText: 'Admin' }).first();
await adminRow.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
- await expect(page.getByRole('dialog').locator('.badge-role')).toBeVisible();
+ const badge = page.getByRole('dialog').locator('.badge-role');
+ await expect(badge).toBeVisible();
+ await expect(badge.getByRole('img')).toHaveCount(0);
});
test('dialog cancel discards changes', async ({ page }) => {
@@ -166,6 +168,41 @@ test.describe('Grants page - Audit Log tab', () => {
await expect(page.getByRole('table')).toBeVisible();
});
+ test('Clear dates gives focus to the From date picker', async ({ page }) => {
+ await page.getByRole('button', { name: 'From', exact: true }).click();
+ await page.getByRole('gridcell').filter({ hasText: /^1$/ }).first().click();
+ const clear = page.getByRole('button', { name: 'Clear date filters' });
+ await clear.focus();
+ await page.keyboard.press('Enter');
+
+ await expect(clear).toHaveCount(0);
+ await expect(page.locator('#audit-date-from')).toBeFocused();
+ await expect(page.locator('#audit-date-from')).toHaveAccessibleName('From');
+ });
+
+ for (const width of [390, 320]) {
+ test(`the filters wrap at ${width}px: no sideways scroll, Export on screen`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 844 });
+ const filters = page.getByRole('group', { name: 'Filters' });
+ await expect(filters).toBeVisible();
+
+ const overflow = await filters.evaluate((el) => {
+ let box = el.parentElement;
+ while (box && getComputedStyle(box).overflowY !== 'auto') box = box.parentElement;
+ return box ? box.scrollWidth - box.clientWidth : 0;
+ });
+ expect(overflow).toBeLessThanOrEqual(0);
+ const exportBox = await page
+ .getByRole('button', { name: /export all matching/i })
+ .boundingBox();
+ expect(exportBox).not.toBeNull();
+ expect(exportBox!.x).toBeGreaterThanOrEqual(0);
+ expect(exportBox!.x + exportBox!.width).toBeLessThanOrEqual(width);
+ });
+ }
+
test('empty state when no entries match filters', async ({ page }) => {
await searchField(page).fill('zzzznonexistent');
await page.waitForTimeout(500);
@@ -220,6 +257,17 @@ test.describe('Grants page - Audit Log tab', () => {
}
});
+ test('Export keeps focus on its button while it runs', async ({ page }) => {
+ const exportButton = page.getByRole('button', { name: /export all matching/i });
+ await exportButton.focus();
+ const download = page.waitForEvent('download');
+ await page.keyboard.press('Enter');
+ await download;
+
+ await expect(exportButton).toBeFocused();
+ await expect(exportButton).not.toHaveAttribute('aria-busy');
+ });
+
test('Export writes the seeded entry the filters match', async ({ page }) => {
await searchField(page).fill('Alice');
await page.getByRole('button', { name: /^Revoked$/i }).click();
@@ -262,6 +310,37 @@ test.describe('Grants page - Audit Log tab', () => {
await expect(drawer.getByText('Alice Chen').first()).toBeVisible();
await expect(drawer.getByText('read:agents').first()).toBeVisible();
await expect(drawer.getByText(/entry not found/i)).toHaveCount(0);
+
+ const results = await new AxeBuilder({ page })
+ .withTags(WCAG_TAGS)
+ .include('[role="dialog"]')
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+
+ test('copying a value from the drawer is announced', async ({ page }) => {
+ await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
+ await page.getByRole('button', { name: 'Open audit log entry for USER' }).click();
+ const copy = page.getByRole('dialog').locator('button', { hasText: 'read:agents' });
+ await copy.focus();
+ await page.keyboard.press('Enter');
+
+ await expect(page.getByRole('status').filter({ hasText: 'Copied' })).toHaveText(
+ 'Copied capability',
+ );
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('read:agents');
+ });
+
+ test('closing a drawer opened from a permalink gives focus to its row', async ({ page }) => {
+ const row = page.getByRole('button', { name: 'Open audit log entry for USER' });
+ await row.click();
+ const entryId = new URL(page.url()).searchParams.get('entryId');
+ await page.goto(`/grants?tab=audit-log&entryId=${entryId}`);
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).not.toBeVisible();
+ await expect(row).toBeFocused();
});
test('the mock serves one entry by id, as LibreChat does', async ({ request }) => {
diff --git a/src/__tests__/buttons.test.ts b/src/__tests__/buttons.test.ts
new file mode 100644
index 00000000..e6b44faa
--- /dev/null
+++ b/src/__tests__/buttons.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import { themes } from '@clickhouse/click-ui';
+import { contrast, over, parseColor, resolveVar } from '@/test/contrast';
+import { customProperties } from '@/test/styles';
+
+/** A danger button (Delete, Clear dates) at rest, hovered, and focused or pressed (`active`). */
+const STATES = ['default', 'hover', 'active'] as const;
+/** WCAG 1.4.3 for normal text. */
+const AA = 4.5;
+
+/** The app's click-ui overrides for both themes, then for one theme only (more specific, so it wins). */
+const SHARED = ':root[data-cui-theme]';
+
+const THEMES = [
+ ['light', ':root', [SHARED, ":root[data-cui-theme='light']"], themes.light],
+ ['dark', '.dark', [SHARED, ":root[data-cui-theme='dark']"], themes.dark],
+] as const;
+
+describe('Danger button text contrast', () => {
+ it.each(THEMES)(
+ 'meets AA in every state on the page and in a dialog in %s',
+ (_, selector, blocks, cui) => {
+ const app = customProperties(selector);
+ const overrides = new Map(blocks.flatMap((block) => [...customProperties(block)]));
+ const { text, background } = cui.click.button.basic.color.danger;
+ const surfaces = {
+ page: parseColor(resolveVar('var(--cui-color-background-default)', app)),
+ dialog: parseColor(cui.click.dialog.color.background.default),
+ };
+
+ const failing = STATES.flatMap((state) => {
+ const fg = parseColor(
+ overrides.get(`--click-button-basic-color-danger-text-${state}`) ?? text[state],
+ );
+ const tint = parseColor(
+ overrides.get(`--click-button-basic-color-danger-background-${state}`) ??
+ background[state],
+ );
+ return Object.entries(surfaces).flatMap(([surface, color]) => {
+ const ratio = contrast(fg, over(tint, color));
+ return ratio >= AA ? [] : [`${state} on ${surface}: ${ratio.toFixed(2)}:1`];
+ });
+ });
+
+ expect(failing).toEqual([]);
+ },
+ );
+});
diff --git a/src/components/access/ConfirmDialog.tsx b/src/components/access/ConfirmDialog.tsx
index 36d1bdcf..5668ec39 100644
--- a/src/components/access/ConfirmDialog.tsx
+++ b/src/components/access/ConfirmDialog.tsx
@@ -1,7 +1,21 @@
-import { ConfirmationDialog } from '@clickhouse/click-ui';
+import { useRef } from 'react';
+import { Button, Dialog } from '@clickhouse/click-ui';
import type * as t from '@/types';
-import { useLocalize } from '@/hooks';
+import { useLocalize, useReturnFocus } from '@/hooks';
+import { BusyButton } from '@/components/shared';
+import { isFocusLost } from '@/utils';
+/**
+ * Asks before a destructive action. It opens on Cancel, so Enter never
+ * confirms by accident, and the message describes the dialog. Confirm keeps
+ * it open while `saving` until the caller closes it on success.
+ *
+ * Cancel, Escape and the close button give focus back to the control that
+ * opened it. Closing after Confirm gives it to `returnFocusTo()` instead: the
+ * action removes the opener's row, but only once the list refetches, after
+ * the dialog has closed. A close while the action is still saving counts as
+ * confirmed; one after it failed (the dialog stays open) does not.
+ */
export function ConfirmDialog({
open,
title,
@@ -12,28 +26,67 @@ export function ConfirmDialog({
error,
onConfirm,
onCancel,
+ returnFocusTo,
}: t.ConfirmDialogProps) {
const localize = useLocalize();
+ const cancelRef = useRef(null);
+ const confirmedRef = useRef(false);
+ const returnFocus = useReturnFocus(open, returnFocusTo);
+
+ const cancel = () => {
+ if (!saving) confirmedRef.current = false;
+ onCancel();
+ };
return (
- {
+ if (!isOpen) cancel();
+ }}
>
- {error && (
-
- {error}
-
- )}
-
+ {
+ event.preventDefault();
+ cancelRef.current?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ if (confirmedRef.current && isFocusLost()) returnFocusTo?.()?.focus();
+ confirmedRef.current = false;
+ returnFocus(event);
+ }}
+ className="modal-frost"
+ >
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {
+ confirmedRef.current = true;
+ onConfirm();
+ }}
+ />
+
+
+
+
);
}
diff --git a/src/components/access/CreateGroupDialog.tsx b/src/components/access/CreateGroupDialog.tsx
index 45506329..8ee45a67 100644
--- a/src/components/access/CreateGroupDialog.tsx
+++ b/src/components/access/CreateGroupDialog.tsx
@@ -122,7 +122,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
onChange={(e) => setName(e.target.value)}
placeholder={localize('com_access_group_name_placeholder')}
autoFocus
- className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled)"
+ className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-muted)"
/>
@@ -138,7 +138,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={localize('com_access_group_desc_placeholder')}
- className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled)"
+ className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-muted)"
/>
diff --git a/src/components/access/CreateRoleDialog.tsx b/src/components/access/CreateRoleDialog.tsx
index 3b96ab92..985a74d2 100644
--- a/src/components/access/CreateRoleDialog.tsx
+++ b/src/components/access/CreateRoleDialog.tsx
@@ -128,7 +128,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
onChange={(e) => setName(e.target.value)}
placeholder={localize('com_access_role_name_placeholder')}
autoFocus
- className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled)"
+ className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-muted)"
/>
@@ -144,7 +144,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={localize('com_access_role_desc_placeholder')}
- className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled)"
+ className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-muted)"
/>
diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx
index dc44e7eb..88f59e41 100644
--- a/src/components/access/EditGroupDialog.tsx
+++ b/src/components/access/EditGroupDialog.tsx
@@ -10,16 +10,10 @@ import {
updateGroupFn,
MEMBERS_PAGE_SIZE,
} from '@/server';
-import {
- Avatar,
- LoadingState,
- Pagination,
- SelectedMemberList,
- TrashButton,
- UserSearchInline,
-} from '@/components/shared';
+import { SelectedMemberList, UserSearchInline } from '@/components/shared';
import { useLocalize, useReturnFocus, useRefreshScopes } from '@/hooks';
import { cn, notifySuccess, notifyError } from '@/utils';
+import { MemberList } from './MemberList';
type EditGroupTab = 'details' | 'members';
@@ -175,7 +169,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
disabled={!canManage}
readOnly={!canManage}
autoFocus
- className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled) disabled:cursor-not-allowed disabled:opacity-50"
+ className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-muted) disabled:cursor-not-allowed disabled:opacity-50"
/>
@@ -193,7 +187,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
placeholder={localize('com_access_group_desc_placeholder')}
disabled={!canManage}
readOnly={!canManage}
- className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled) disabled:cursor-not-allowed disabled:opacity-50"
+ className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-muted) disabled:cursor-not-allowed disabled:opacity-50"
/>
@@ -249,6 +243,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
fetching={membersQuery.isFetching}
removalIds={removalIds}
onRemove={stageRemoval}
+ onUndoRemove={unstageRemoval}
canManage={canManage}
total={total}
currentPage={page}
@@ -286,105 +281,3 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
);
}
-
-interface MemberListProps {
- members: AdminMember[];
- loading: boolean;
- error: boolean;
- fetching: boolean;
- removalIds: Set;
- onRemove: (member: AdminMember) => void;
- canManage: boolean;
- total: number;
- currentPage: number;
- totalPages: number;
- onPageChange: (page: number) => void;
-}
-
-function MemberList({
- members,
- loading,
- error,
- fetching,
- removalIds,
- onRemove,
- canManage,
- total,
- currentPage,
- totalPages,
- onPageChange,
-}: MemberListProps) {
- const localize = useLocalize();
-
- if (loading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
- {localize('com_error_load_members')}
-
- );
- }
-
- if (members.length === 0 && total === 0) {
- return (
-
- {localize('com_access_no_members')}
-
- );
- }
-
- return (
-
-
- {localize('com_access_member_count', { count: total })}
-
-
- {members.map((member, i) => {
- const staged = removalIds.has(member.userId);
- return (
-
-
-
-
-
- {member.name}
-
- {member.email}
-
-
- {canManage && !staged && (
-
onRemove(member)}
- ariaLabel={`${localize('com_access_remove_member')} ${member.name}`}
- />
- )}
-
- );
- })}
-
-
-
- );
-}
diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx
index 2573485f..59842012 100644
--- a/src/components/access/EditRoleDialog.tsx
+++ b/src/components/access/EditRoleDialog.tsx
@@ -15,17 +15,15 @@ import {
MEMBERS_PAGE_SIZE,
} from '@/server';
import {
- Avatar,
LoadingState,
- Pagination,
DecorativeIcon,
SelectedMemberList,
- TrashButton,
UserSearchInline,
} from '@/components/shared';
import { useLocalize, useReturnFocus, useRefreshScopes, useConfigFeatures } from '@/hooks';
import { RolePermissionsPanel } from './RolePermissionsPanel';
import { cn, notifySuccess, notifyError } from '@/utils';
+import { MemberList } from './MemberList';
type EditRoleTab = 'details' | 'permissions' | 'members';
@@ -238,7 +236,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
aria-describedby={
isRenaming && hasProfile ? 'edit-role-rename-note' : undefined
}
- className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled) disabled:cursor-not-allowed disabled:opacity-50"
+ className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-muted) disabled:cursor-not-allowed disabled:opacity-50"
/>
{isRenaming && hasProfile && (
@@ -355,6 +353,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
fetching={membersQuery.isFetching}
removalIds={removalIds}
onRemove={stageRemoval}
+ onUndoRemove={unstageRemoval}
canManage={canManage}
total={total}
currentPage={page}
@@ -423,105 +422,3 @@ function RenameProfileNote({ id, name, moves }: t.RenameProfileNoteProps) {
);
}
-
-interface MemberListProps {
- members: AdminMember[];
- loading: boolean;
- error: boolean;
- fetching: boolean;
- removalIds: Set;
- onRemove: (member: AdminMember) => void;
- canManage: boolean;
- total: number;
- currentPage: number;
- totalPages: number;
- onPageChange: (page: number) => void;
-}
-
-function MemberList({
- members,
- loading,
- error,
- fetching,
- removalIds,
- onRemove,
- canManage,
- total,
- currentPage,
- totalPages,
- onPageChange,
-}: MemberListProps) {
- const localize = useLocalize();
-
- if (loading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
- {localize('com_error_load_members')}
-
- );
- }
-
- if (members.length === 0 && total === 0) {
- return (
-
- {localize('com_access_no_members')}
-
- );
- }
-
- return (
-
-
- {localize('com_access_member_count', { count: total })}
-
-
- {members.map((member, i) => {
- const staged = removalIds.has(member.userId);
- return (
-
-
-
-
-
- {member.name}
-
- {member.email}
-
-
- {canManage && !staged && (
-
onRemove(member)}
- ariaLabel={`${localize('com_access_remove_member')} ${member.name}`}
- />
- )}
-
- );
- })}
-
-
-
- );
-}
diff --git a/src/components/access/GroupsTab.tsx b/src/components/access/GroupsTab.tsx
index 7a987c23..f4cd6169 100644
--- a/src/components/access/GroupsTab.tsx
+++ b/src/components/access/GroupsTab.tsx
@@ -1,5 +1,5 @@
import { Button } from '@clickhouse/click-ui';
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import type { AdminGroup } from '@librechat/data-schemas';
import type * as t from '@/types';
@@ -8,10 +8,11 @@ import {
SearchInput,
EmptyState,
Pagination,
+ ScreenReaderAnnouncer,
TrashButton,
} from '@/components/shared';
import { deleteGroupFn, groupsQueryOptions, GROUPS_PAGE_SIZE, MAX_SEARCH_LENGTH } from '@/server';
-import { useLocalize, useCapabilities, useRefreshScopes } from '@/hooks';
+import { useLocalize, useAnnouncement, useCapabilities, useRefreshScopes } from '@/hooks';
import { cn, notifySuccess, notifyError } from '@/utils';
import { EditGroupDialog } from './EditGroupDialog';
import { SystemCapabilities } from '@/constants';
@@ -29,6 +30,10 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) {
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(1);
const debounceRef = useRef>(undefined);
+ const { message: announcement, announce } = useAnnouncement();
+ const announcedSearch = useRef(debouncedSearch);
+ const searchRef = useRef(null);
+ const searchField = useCallback(() => searchRef.current, []);
useEffect(() => {
return () => clearTimeout(debounceRef.current);
@@ -53,6 +58,13 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) {
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / GROUPS_PAGE_SIZE);
+ /** Announces the match count once each new search has loaded; the first load stays quiet. */
+ useEffect(() => {
+ if (isFetching || isError || announcedSearch.current === debouncedSearch) return;
+ announcedSearch.current = debouncedSearch;
+ announce(localize('com_a11y_groups_found', { count: total }));
+ }, [debouncedSearch, isFetching, isError, total, announce, localize]);
+
const deleteMutation = useMutation({
mutationFn: (group: AdminGroup) => deleteGroupFn({ data: { id: group.id } }),
onSuccess: (_data, group) => {
@@ -75,7 +87,7 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) {
if (isError && !data) {
return (
-
+
{localize('com_error_load_groups')}
);
@@ -85,6 +97,7 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) {
setDeleteTarget(null)}
+ returnFocusTo={searchField}
/>
+
+
);
}
diff --git a/src/components/access/MemberList.tsx b/src/components/access/MemberList.tsx
new file mode 100644
index 00000000..6d83e1d2
--- /dev/null
+++ b/src/components/access/MemberList.tsx
@@ -0,0 +1,138 @@
+import { useRef } from 'react';
+import { Badge, Button } from '@clickhouse/click-ui';
+import type * as t from '@/types';
+import { Avatar, LoadingState, Pagination, TrashButton } from '@/components/shared';
+import { useLocalize } from '@/hooks';
+import { cn } from '@/utils';
+
+/**
+ * The members of a role or group in its Edit dialog. A member staged for
+ * removal stays at full contrast, struck through and tagged "Will be removed",
+ * and its Remove button turns into Undo in place. Focus follows that swap,
+ * so it never falls out of the dialog.
+ */
+export function MemberList({
+ members,
+ loading,
+ error,
+ fetching,
+ removalIds,
+ onRemove,
+ onUndoRemove,
+ canManage,
+ total,
+ currentPage,
+ totalPages,
+ onPageChange,
+}: t.MemberListProps) {
+ const localize = useLocalize();
+ const focusUserId = useRef
(null);
+
+ const focusIfSwapped = (userId: string) => (button: HTMLButtonElement | null) => {
+ if (!button || focusUserId.current !== userId) return;
+ focusUserId.current = null;
+ button.focus();
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {localize('com_error_load_members')}
+
+ );
+ }
+
+ if (members.length === 0 && total === 0) {
+ return (
+
+ {localize('com_access_no_members')}
+
+ );
+ }
+
+ return (
+
+
+ {localize('com_access_member_count', { count: total })}
+
+
+ {members.map((member, i) => {
+ const staged = removalIds.has(member.userId);
+ return (
+
+
+
+
+
+
+ {member.name}
+
+ {staged && (
+
+ )}
+
+
+ {member.email}
+
+
+
+ {canManage && staged && (
+
{
+ focusUserId.current = member.userId;
+ onUndoRemove(member.userId);
+ }}
+ />
+ )}
+ {canManage && !staged && (
+ {
+ focusUserId.current = member.userId;
+ onRemove(member);
+ }}
+ ariaLabel={`${localize('com_access_remove_member')} ${member.name}`}
+ />
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/access/RolePermissionsPanel.tsx b/src/components/access/RolePermissionsPanel.tsx
index 41325255..4be5eb75 100644
--- a/src/components/access/RolePermissionsPanel.tsx
+++ b/src/components/access/RolePermissionsPanel.tsx
@@ -143,7 +143,7 @@ export function RolePermissionsPanel({
6
- ? 'grid w-fit grid-cols-[auto_auto_auto] gap-x-4 gap-y-1.5'
+ ? 'grid min-w-0 grid-cols-1 gap-x-4 gap-y-1.5 sm:grid-cols-2 md:grid-cols-3'
: 'flex flex-wrap gap-x-6 gap-y-1.5'
}
>
diff --git a/src/components/access/RolesTab.tsx b/src/components/access/RolesTab.tsx
index c267103b..f6fa8142 100644
--- a/src/components/access/RolesTab.tsx
+++ b/src/components/access/RolesTab.tsx
@@ -1,16 +1,17 @@
-import { useMemo, useState } from 'react';
import { Button } from '@clickhouse/click-ui';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type * as t from '@/types';
import {
LoadingState,
Pagination,
+ ScreenReaderAnnouncer,
SearchInput,
EmptyState,
TrashButton,
} from '@/components/shared';
+import { useLocalize, useAnnouncement, useCapabilities, useRefreshScopes } from '@/hooks';
import { deleteRoleFn, allRolesQueryOptions, ROLES_PAGE_SIZE } from '@/server';
-import { useLocalize, useCapabilities, useRefreshScopes } from '@/hooks';
import { notifySuccess, notifyError } from '@/utils';
import { EditRoleDialog } from './EditRoleDialog';
import { SystemCapabilities } from '@/constants';
@@ -18,6 +19,10 @@ import { ConfirmDialog } from './ConfirmDialog';
export function RolesTab({ onCreateRole }: t.RolesTabProps) {
const localize = useLocalize();
+ const { message: announcement, announce } = useAnnouncement();
+ const announcedSearch = useRef('');
+ const searchRef = useRef
(null);
+ const searchField = useCallback(() => searchRef.current, []);
const queryClient = useQueryClient();
const refreshScopes = useRefreshScopes();
const { hasCapability } = useCapabilities();
@@ -37,6 +42,13 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) {
);
}, [allRoles, search]);
+ /** Announces the match count after each change to the search; the first render stays quiet. */
+ useEffect(() => {
+ if (announcedSearch.current === search) return;
+ announcedSearch.current = search;
+ announce(localize('com_a11y_roles_found', { count: filtered.length }));
+ }, [search, filtered.length, announce, localize]);
+
const totalPages = Math.ceil(filtered.length / ROLES_PAGE_SIZE);
const paged = useMemo(
() => filtered.slice((page - 1) * ROLES_PAGE_SIZE, page * ROLES_PAGE_SIZE),
@@ -76,6 +88,7 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) {
setDeleteTarget(null)}
+ returnFocusTo={searchField}
/>
+
+
);
}
diff --git a/src/components/access/__tests__/ConfirmDialog.test.tsx b/src/components/access/__tests__/ConfirmDialog.test.tsx
new file mode 100644
index 00000000..eb3b8bb7
--- /dev/null
+++ b/src/components/access/__tests__/ConfirmDialog.test.tsx
@@ -0,0 +1,173 @@
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import type * as t from '@/types';
+import {
+ DIALOG_CLOSERS,
+ expectFocusBack,
+ findFocusedDialog,
+ pressEscape,
+ renderWithOpener,
+} from '@/test/focus';
+import { ConfirmDialog } from '../ConfirmDialog';
+import '@/locales/i18n';
+
+const MESSAGE = 'Are you sure you want to delete "Support"?';
+
+const PROPS: t.ConfirmDialogProps = {
+ open: true,
+ title: 'Delete role',
+ description: MESSAGE,
+ confirmLabel: 'Delete',
+ saving: false,
+ onConfirm: () => {},
+ onCancel: () => {},
+};
+
+/** How the delete that Confirm starts goes. */
+type DeleteOutcome = 'success' | 'failure' | 'saving';
+
+/**
+ * A row whose Delete opens the dialog, as on the Roles tab. Confirm runs the
+ * delete: it succeeds and closes the dialog, fails and leaves it open, or is
+ * still saving. The row goes once `removed` (the list's refetch, which lands
+ * after the dialog has closed).
+ */
+function DeletableRow({ removed, outcome }: { removed: boolean; outcome: DeleteOutcome }) {
+ const [open, setOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [search, setSearch] = useState
(null);
+ const confirm = () => {
+ if (outcome === 'success') setOpen(false);
+ if (outcome === 'saving') setSaving(true);
+ };
+ return (
+ <>
+
+ {!removed && (
+ setOpen(true)}>
+ Delete Support
+
+ )}
+ setOpen(false)}
+ returnFocusTo={() => search}
+ />
+ >
+ );
+}
+
+/** Opens the dialog from the row's Delete and presses its Delete. */
+async function confirmDelete(): Promise {
+ const opener = screen.getByRole('button', { name: 'Delete Support' });
+ opener.focus();
+ fireEvent.click(opener);
+ const dialog = await findFocusedDialog();
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
+ return opener;
+}
+
+const searchBox = () => screen.getByRole('textbox', { name: 'Search roles' });
+
+describe('ConfirmDialog', () => {
+ it('is described by its message and opens on Cancel', async () => {
+ render( );
+ const dialog = await findFocusedDialog();
+ expect(dialog).toHaveAccessibleName('Delete role');
+ expect(dialog).toHaveAccessibleDescription(MESSAGE);
+ expect(within(dialog).getByRole('button', { name: 'Cancel' })).toHaveFocus();
+ });
+
+ it('stays open while the action saves, busy but still focusable, until the caller closes it', async () => {
+ const onConfirm = vi.fn();
+ const onCancel = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ const dialog = await findFocusedDialog();
+ const confirm = within(dialog).getByRole('button', { name: 'Delete' });
+ confirm.focus();
+ fireEvent.click(confirm);
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(onCancel).not.toHaveBeenCalled();
+
+ rerender( );
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(confirm).toHaveAttribute('aria-busy', 'true');
+ expect(confirm).toHaveAttribute('aria-disabled', 'true');
+ expect(confirm).toBeEnabled();
+ expect(confirm).toHaveFocus();
+ fireEvent.click(confirm);
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows an error as an alert below its message', async () => {
+ render( );
+ const dialog = await findFocusedDialog();
+ expect(within(dialog).getByRole('alert')).toHaveTextContent('Role is in use');
+ expect(dialog).toHaveAccessibleDescription(MESSAGE);
+ });
+
+ it.each([
+ ...DIALOG_CLOSERS,
+ [
+ 'Cancel',
+ (dialog: HTMLElement) =>
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })),
+ ] as const,
+ ])('gives focus back to the Delete button that opened it on %s', async (_, close) => {
+ const opener = renderWithOpener((open, onClose) => (
+
+ ));
+ opener.focus();
+ fireEvent.click(opener);
+ close(await findFocusedDialog());
+ await expectFocusBack(opener);
+ });
+
+ it('gives focus to the fallback after a confirmed delete, before the refetch removes its opener', async () => {
+ const { rerender } = render( );
+ await confirmDelete();
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ await waitFor(() => expect(document.body).not.toHaveFocus());
+
+ rerender( );
+ expect(screen.queryByRole('button', { name: 'Delete Support' })).toBeNull();
+ expect(searchBox()).toHaveFocus();
+ });
+
+ it('gives focus to the fallback when the confirmed delete removed its opener first', async () => {
+ const { rerender } = render( );
+ await confirmDelete();
+ rerender( );
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ await waitFor(() => expect(searchBox()).toHaveFocus());
+ });
+
+ it('gives focus to the fallback when Escape closes it while the delete still saves', async () => {
+ render( );
+ await confirmDelete();
+ const dialog = screen.getByRole('dialog');
+ await waitFor(() =>
+ expect(within(dialog).getByRole('button', { name: 'Delete' })).toHaveAttribute(
+ 'aria-busy',
+ 'true',
+ ),
+ );
+ pressEscape(dialog);
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ await waitFor(() => expect(searchBox()).toHaveFocus());
+ });
+
+ it('gives focus back to its opener on Cancel after a confirmed delete failed', async () => {
+ render( );
+ const opener = await confirmDelete();
+ const dialog = screen.getByRole('dialog');
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
+ await expectFocusBack(opener);
+ });
+});
diff --git a/src/components/access/__tests__/GroupsTab.test.tsx b/src/components/access/__tests__/GroupsTab.test.tsx
index d49ca30c..8194ce66 100644
--- a/src/components/access/__tests__/GroupsTab.test.tsx
+++ b/src/components/access/__tests__/GroupsTab.test.tsx
@@ -30,11 +30,15 @@ vi.mock('@/server/utils/api', () => ({
vi.mock('@/server', () => vi.importActual('@/server/groups'));
vi.mock('@/hooks', async () => ({
- useLocalize: () => (key: string) => key,
+ useLocalize: () => (key: string, options?: Record) =>
+ options ? `${key}(${Object.values(options).join(',')})` : key,
useCapabilities: () => ({ hasCapability: () => true }),
useRefreshScopes: () => vi.fn(),
useReturnFocus: (await vi.importActual('@/hooks/dialogs'))
.useReturnFocus,
+ useAnnouncement: (
+ await vi.importActual('@/hooks/useAnnouncement')
+ ).useAnnouncement,
}));
/** The `search` of every groups list request sent so far. */
@@ -62,3 +66,19 @@ describe('GroupsTab search', () => {
expect(clamped).toBeUndefined();
});
});
+
+describe('GroupsTab search announcement', () => {
+ it('announces the match count once a search has loaded, and not on the first load', async () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+ const field = await screen.findByPlaceholderText('com_access_search_groups');
+ const status = screen.getByRole('status');
+ expect(status).toBeEmptyDOMElement();
+ fireEvent.change(field, { target: { value: 'eng' } });
+ await waitFor(() => expect(status).toHaveTextContent('com_a11y_groups_found(1)'));
+ });
+});
diff --git a/src/components/access/__tests__/MemberList.test.tsx b/src/components/access/__tests__/MemberList.test.tsx
new file mode 100644
index 00000000..8df7fc73
--- /dev/null
+++ b/src/components/access/__tests__/MemberList.test.tsx
@@ -0,0 +1,86 @@
+import { useState } from 'react';
+import { describe, expect, it } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { AdminMember } from '@librechat/data-schemas';
+import type * as t from '@/types';
+import { MemberList } from '../MemberList';
+import '@/locales/i18n';
+
+const MEMBERS: AdminMember[] = [
+ { userId: 'u1', name: 'Alice Chen', email: 'alice@example.com' },
+ { userId: 'u2', name: 'Bob Martinez', email: 'bob@example.com' },
+];
+
+const BASE: t.MemberListProps = {
+ members: MEMBERS,
+ loading: false,
+ error: false,
+ fetching: false,
+ removalIds: new Set(),
+ onRemove: () => {},
+ onUndoRemove: () => {},
+ canManage: true,
+ total: MEMBERS.length,
+ currentPage: 1,
+ totalPages: 1,
+ onPageChange: () => {},
+};
+
+/** The list as an Edit dialog drives it: removals are staged in the dialog's state. */
+function StagedMemberList() {
+ const [removalIds, setRemovalIds] = useState>(new Set());
+ return (
+ setRemovalIds((prev) => new Set(prev).add(member.userId))}
+ onUndoRemove={(userId) =>
+ setRemovalIds((prev) => new Set([...prev].filter((id) => id !== userId)))
+ }
+ />
+ );
+}
+
+describe('MemberList', () => {
+ it('counts members in the singular and the plural', () => {
+ const { rerender } = render( );
+ expect(screen.getByText('2 members')).toBeInTheDocument();
+ rerender( );
+ expect(screen.getByText('1 member')).toBeInTheDocument();
+ });
+
+ it('marks a staged member "Will be removed" at full contrast and swaps Remove for a focused Undo', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Remove Alice Chen' }));
+
+ const undo = screen.getByRole('button', { name: 'Undo removing Alice Chen' });
+ expect(undo).toHaveTextContent('Undo');
+ expect(undo).toHaveFocus();
+ expect(screen.queryByRole('button', { name: 'Remove Alice Chen' })).toBeNull();
+ expect(screen.getByText('Will be removed')).toBeVisible();
+ expect(screen.getByText('Alice Chen')).toHaveClass('line-through');
+ expect(screen.getByText('Alice Chen').closest('.opacity-40')).toBeNull();
+
+ fireEvent.click(undo);
+ expect(screen.getByRole('button', { name: 'Remove Alice Chen' })).toHaveFocus();
+ expect(screen.queryByText('Will be removed')).toBeNull();
+ });
+
+ it('leaves the other rows alone', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Remove Alice Chen' }));
+ expect(screen.getByRole('button', { name: 'Remove Bob Martinez' })).toBeEnabled();
+ expect(screen.getByText('Bob Martinez')).not.toHaveClass('line-through');
+ });
+
+ it('reports a failed load as an alert', () => {
+ render( );
+ expect(screen.getByRole('alert')).toHaveTextContent(/members/i);
+ });
+
+ it('offers no Remove or Undo without the manage capability', () => {
+ render( );
+ expect(screen.queryByRole('button')).toBeNull();
+ expect(screen.getByText('Will be removed')).toBeVisible();
+ });
+});
diff --git a/src/components/access/__tests__/RolesTab.test.tsx b/src/components/access/__tests__/RolesTab.test.tsx
new file mode 100644
index 00000000..dbb4c832
--- /dev/null
+++ b/src/components/access/__tests__/RolesTab.test.tsx
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from 'vitest';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import type * as t from '@/types';
+import { RolesTab } from '../RolesTab';
+import '@/locales/i18n';
+
+const role = (name: string, description: string, isSystemRole = false): t.Role => ({
+ id: name,
+ name,
+ description,
+ isSystemRole,
+ isActive: true,
+ userCount: 0,
+ permissions: {} as t.RolePermissions,
+});
+
+const ROLES = [
+ role('ADMIN', '', true),
+ role('Billing', 'Invoices and usage reports'),
+ role('Support', 'Answers user tickets'),
+];
+
+vi.mock('@/hooks', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useCapabilities: () => ({ hasCapability: () => true }),
+ useRefreshScopes: () => vi.fn(),
+}));
+
+vi.mock('@/server', () => ({
+ ROLES_PAGE_SIZE: 50,
+ allRolesQueryOptions: { queryKey: ['roles'], queryFn: async () => ROLES },
+ deleteRoleFn: vi.fn(),
+}));
+
+vi.mock('../EditRoleDialog', () => ({ EditRoleDialog: () => null }));
+
+function renderTab() {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+}
+
+describe('RolesTab search', () => {
+ it('announces how many roles match each search', async () => {
+ renderTab();
+ const field = await screen.findByPlaceholderText('Search roles…');
+ const status = () =>
+ screen.getAllByRole('status').find((el) => el.textContent?.includes('found'));
+
+ fireEvent.change(field, { target: { value: 'support' } });
+ await waitFor(() => expect(status()).toHaveTextContent('1 role found'));
+
+ fireEvent.change(field, { target: { value: 'us' } });
+ await waitFor(() => expect(status()).toHaveTextContent('2 roles found'));
+
+ fireEvent.change(field, { target: { value: 'zzz' } });
+ await waitFor(() => expect(status()).toHaveTextContent('0 roles found'));
+ });
+});
diff --git a/src/components/grants/AuditLogDetailDrawer.tsx b/src/components/grants/AuditLogDetailDrawer.tsx
index 81251b28..6af9a9e4 100644
--- a/src/components/grants/AuditLogDetailDrawer.tsx
+++ b/src/components/grants/AuditLogDetailDrawer.tsx
@@ -1,5 +1,5 @@
import * as Dialog from '@radix-ui/react-dialog';
-import { Badge, Button, Icon } from '@clickhouse/click-ui';
+import { Badge, Button } from '@clickhouse/click-ui';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ReactElement } from 'react';
import type * as t from '@/types';
@@ -17,42 +17,13 @@ import { CloseButton, DecorativeIcon, LoadingState } from '@/components/shared';
import { useLocalize, useReturnFocus } from '@/hooks';
import { cn } from '@/utils';
-interface AuditLogDetailDrawerProps {
- entry: t.AuditLogEntryWithDiff | null;
- open: boolean;
- onClose: () => void;
- /** Resolves to `true` when the clipboard write succeeded so the drawer only
- * flips to its "Copied!" affordance after a real success. */
- onCopyPermalink: (entryId: string) => Promise;
- /** Invoked when any inline `CopyableMono` (timestamp, actor/target/entry IDs,
- * capability) fails to write to the clipboard. The caller is responsible for
- * surfacing the failure — typically via the same screen-reader announcement
- * used for the permalink-copy failure path so the drawer's copy controls are
- * not silent on clipboard error. */
- onCopyFailed?: () => void;
- /** Render a "no entry found" message instead of the detail body when the
- * deep-linked id couldn't be located (e.g. the entry was purged). */
- notFound?: boolean;
- /** Render a loading shell while the single-entry deep-link query is in flight
- * for an entry that is not on the current page. Without this, the drawer
- * would either flicker shut during the fetch or never appear on cold load. */
- loading?: boolean;
- /** Render a load-error message + close affordance when the single-entry
- * fetch failed for reasons other than not-found (network failure, 5xx). The
- * caller is responsible for distinguishing this from `notFound` (which is
- * the 404 case where the request itself succeeded but the entry is gone). */
- loadError?: boolean;
-}
-
-function CopyableMono({
- value,
- ariaLabel,
- onCopyFailed,
-}: {
- value: string;
- ariaLabel: string;
- onCopyFailed?: () => void;
-}): ReactElement {
+/**
+ * A value that copies itself. Its name starts with the value it shows and then
+ * says what pressing it does ("…, copy actor ID"); a copy is announced through
+ * `onCopied`, since only the button's hidden icon changes.
+ */
+function CopyableMono({ value, field, onCopied, onCopyFailed }: t.CopyableMonoProps): ReactElement {
+ const localize = useLocalize();
const [copied, setCopied] = useState(false);
const timerRef = useRef | undefined>(undefined);
useEffect(() => {
@@ -73,16 +44,16 @@ function CopyableMono({
return;
}
setCopied(true);
+ onCopied?.(field);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
- }, [value, onCopyFailed]);
+ }, [value, field, onCopied, onCopyFailed]);
return (
void handleCopy()}
- aria-label={ariaLabel}
- aria-live="polite"
+ aria-label={localize('com_audit_detail_copy_value', { value, field })}
className={cn(
'inline-flex w-fit items-center gap-1 self-start rounded px-1 py-0.5 font-mono text-[11px]',
'text-(--cui-color-text-muted) hover:bg-(--cui-color-background-hover)',
@@ -130,13 +101,15 @@ export function AuditLogDetailDrawer({
open,
onClose,
onCopyPermalink,
+ onCopied,
onCopyFailed,
notFound = false,
loading = false,
loadError = false,
-}: AuditLogDetailDrawerProps): ReactElement | null {
+ returnFocusTo,
+}: t.AuditLogDetailDrawerProps): ReactElement | null {
const localize = useLocalize();
- const returnFocus = useReturnFocus(open);
+ const returnFocus = useReturnFocus(open, returnFocusTo);
// Keep the last non-null entry so the close animation has content to render
// while Radix Dialog slides the panel out. Without this, unmounting on
@@ -425,7 +398,8 @@ export function AuditLogDetailDrawer({
@@ -438,7 +412,8 @@ export function AuditLogDetailDrawer({
@@ -452,7 +427,7 @@ export function AuditLogDetailDrawer({
state="neutral"
text={
-
+
{localize(targetConfig.labelKey)}
}
@@ -463,7 +438,8 @@ export function AuditLogDetailDrawer({
@@ -477,7 +453,8 @@ export function AuditLogDetailDrawer({
@@ -489,7 +466,8 @@ export function AuditLogDetailDrawer({
{subject.label}
@@ -498,7 +476,8 @@ export function AuditLogDetailDrawer({
diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx
index 48f5c5d9..2b0dc7b5 100644
--- a/src/components/grants/AuditLogTab.tsx
+++ b/src/components/grants/AuditLogTab.tsx
@@ -14,18 +14,19 @@ import {
localDayBoundaryIso,
} from './auditLogUtils';
import {
- AUDIT_LOG_PAGE_SIZE,
- auditLogEntryQueryOptions,
- auditLogQueryOptions,
- exportAuditLogServerFn,
-} from '@/server';
-import {
+ BusyButton,
EmptyState,
LoadingState,
Pagination,
ScreenReaderAnnouncer,
SearchInput,
} from '@/components/shared';
+import {
+ AUDIT_LOG_PAGE_SIZE,
+ auditLogEntryQueryOptions,
+ auditLogQueryOptions,
+ exportAuditLogServerFn,
+} from '@/server';
import { useAnnouncement, useDebouncedFilter, useLocalize } from '@/hooks';
import { AuditLogDetailDrawer } from './AuditLogDetailDrawer';
import { AUDIT_ACTIONS, isAuditEntryId } from '@/constants';
@@ -41,6 +42,7 @@ const TARGET_TYPE_OPTIONS: readonly t.AuditTargetType[] = [
/** Radix `Select.Item` cannot use `value=""` (Radix reserves empty string for
* "no selection"). Use a non-empty sentinel and translate to `''` in state. */
const TARGET_TYPE_ALL = '__all__';
+const DATE_FROM_ID = 'audit-date-from';
function downloadBlob(blob: Blob): void {
const url = URL.createObjectURL(blob);
@@ -67,6 +69,10 @@ export function AuditLogTab() {
const [currentPage, setCurrentPage] = useState(1);
const { message: announcement, announce } = useAnnouncement();
+ const searchRef = useRef(null);
+ const rowButtons = useRef>(new Map());
+ const lastEntryId = useRef(entryId);
+ const focusDateFrom = useRef(false);
const resetToFirstPage = useCallback(() => setCurrentPage(1), []);
const searchFilter = useDebouncedFilter('', resetToFirstPage);
@@ -241,6 +247,26 @@ export function AuditLogTab() {
!entryOnPage &&
(!isAuditEntryId(entryId) || (entryFetch.isSuccess && entryFetch.data?.entry === null));
+ useEffect(() => {
+ if (entryId) lastEntryId.current = entryId;
+ }, [entryId]);
+
+ /**
+ * Where focus goes when the drawer closes and nothing opened it (a permalink):
+ * the entry's row when it is on this page, else the search field.
+ */
+ const drawerFallback = useCallback(() => {
+ const id = lastEntryId.current;
+ return (id ? rowButtons.current.get(id) : undefined) ?? searchRef.current;
+ }, []);
+
+ /** Clear dates removes itself, so focus moves to the remounted From picker. */
+ useEffect(() => {
+ if (!focusDateFrom.current) return;
+ focusDateFrom.current = false;
+ document.getElementById(DATE_FROM_ID)?.focus();
+ }, [dateResetNonce]);
+
const openEntry = useCallback(
(id: string) => {
void navigate({ search: (prev: Record) => ({ ...prev, entryId: id }) });
@@ -275,6 +301,9 @@ export function AuditLogTab() {
);
try {
await navigator.clipboard.writeText(url);
+ announce(
+ localize('com_audit_detail_copied_field', { field: localize('com_audit_field_link') }),
+ );
return true;
} catch {
announce(localize('com_a11y_copy_failed'));
@@ -334,24 +363,25 @@ export function AuditLogTab() {
return (
-
+
{AUDIT_ACTIONS.map((act) => {
const selected = actionFilter.includes(act);
@@ -374,7 +404,7 @@ export function AuditLogTab() {
{
setDateFrom('');
setDateTo('');
+ focusDateFrom.current = true;
setDateResetNonce((n) => n + 1);
resetToFirstPage();
}}
@@ -410,14 +441,14 @@ export function AuditLogTab() {
)}
-
-
+ void handleExport()}
- disabled={displayTotal === 0 || exporting}
- loading={exporting}
+ disabled={displayTotal === 0}
+ busy={exporting}
label={exportLabel}
/>
@@ -517,6 +548,10 @@ export function AuditLogTab() {
entry={entry}
isLast={i === pageEntries.length - 1}
onOpen={() => openEntry(entry.id)}
+ buttonRef={(button) => {
+ if (button) rowButtons.current.set(entry.id, button);
+ else rowButtons.current.delete(entry.id);
+ }}
/>
))}
{!showLoading && !isError && pageEntries.length === 0 && (
@@ -580,7 +615,9 @@ export function AuditLogTab() {
*/
loadError={!!entryId && !entryOnPage && entryFetch.isError}
onClose={closeEntry}
+ returnFocusTo={drawerFallback}
onCopyPermalink={handleCopyPermalink}
+ onCopied={(field) => announce(localize('com_audit_detail_copied_field', { field }))}
onCopyFailed={() => announce(localize('com_a11y_copy_failed'))}
/>
diff --git a/src/components/grants/AuditLogTableRow.tsx b/src/components/grants/AuditLogTableRow.tsx
index fad89b69..17b0ed80 100644
--- a/src/components/grants/AuditLogTableRow.tsx
+++ b/src/components/grants/AuditLogTableRow.tsx
@@ -13,7 +13,7 @@ import { useLocalize } from '@/hooks';
import { cn } from '@/utils';
/** One audit entry; its target's name is the button that opens the entry's drawer. */
-export function AuditLogTableRow({ entry, isLast, onOpen }: t.AuditLogTableRowProps) {
+export function AuditLogTableRow({ entry, isLast, onOpen, buttonRef }: t.AuditLogTableRowProps) {
const localize = useLocalize();
const targetConfig = auditTargetConfig(entry.target.type);
const subject = auditSubject(entry, localize);
@@ -40,6 +40,7 @@ export function AuditLogTableRow({ entry, isLast, onOpen }: t.AuditLogTableRowPr
}
/>
{isImplied && (
-
+
{localize('com_cap_implied_by', {
cap: localize(
`com_cap_${
diff --git a/src/components/grants/DateFilter.tsx b/src/components/grants/DateFilter.tsx
index f31f653d..8c2a60f2 100644
--- a/src/components/grants/DateFilter.tsx
+++ b/src/components/grants/DateFilter.tsx
@@ -29,7 +29,7 @@ export function DateFilter({ id, label, date, resetKey, onSelectDate }: t.DateFi
}, [resetKey, id, labelId, valueId, hasDate]);
return (
-
+
{label}
diff --git a/src/components/grants/EditCapabilitiesDialog.tsx b/src/components/grants/EditCapabilitiesDialog.tsx
index 9022e771..835a59fd 100644
--- a/src/components/grants/EditCapabilitiesDialog.tsx
+++ b/src/components/grants/EditCapabilitiesDialog.tsx
@@ -1,14 +1,14 @@
+import { Button, Dialog } from '@clickhouse/click-ui';
import { PrincipalType } from 'librechat-data-provider';
import { useCallback, useEffect, useState } from 'react';
-import { Button, Dialog, Icon } from '@clickhouse/click-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { AdminSystemGrant } from '@librechat/data-schemas';
import type * as t from '@/types';
import { grantCapabilityFn, principalGrantsQueryOptions, revokeCapabilityFn } from '@/server';
import { getScopeTypeConfig, SystemCapabilities } from '@/constants';
+import { DecorativeIcon, LoadingState } from '@/components/shared';
import { cn, notifySuccess, notifyError } from '@/utils';
import { CapabilityPanel } from './CapabilityPanel';
-import { LoadingState } from '@/components/shared';
import { useLocalize } from '@/hooks';
function grantsToRecord(grants: AdminSystemGrant[]): Record
{
@@ -118,7 +118,7 @@ export function EditCapabilitiesDialog({
principalConfig.badgeClass,
)}
>
-
+
{localize(principalConfig.labelKey)}
diff --git a/src/components/grants/__tests__/AuditLogDetailDrawer.test.tsx b/src/components/grants/__tests__/AuditLogDetailDrawer.test.tsx
index 5bd84e62..fec69752 100644
--- a/src/components/grants/__tests__/AuditLogDetailDrawer.test.tsx
+++ b/src/components/grants/__tests__/AuditLogDetailDrawer.test.tsx
@@ -1,6 +1,6 @@
-import { describe, it, vi } from 'vitest';
import { PrincipalType } from 'librechat-data-provider';
-import { fireEvent, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import type { ComponentProps } from 'react';
import type * as t from '@/types';
import { expectFocusBack, openFrom, pressEscape, renderWithOpener } from '@/test/focus';
@@ -59,3 +59,69 @@ describe('AuditLogDetailDrawer focus', () => {
});
});
});
+
+/** Each copy button's value and the field it names. */
+const COPYABLE: ReadonlyArray<[string, string]> = [
+ [ENTRY.timestamp, 'timestamp'],
+ ['u-admin', 'actor ID'],
+ ['EDITORS', 'target ID'],
+ ['read:configs', 'capability'],
+ [ENTRY.id, 'entry ID'],
+];
+
+function renderEntry(props: Partial = {}) {
+ render(
+ ,
+ );
+ return screen.getByRole('dialog');
+}
+
+describe('AuditLogDetailDrawer copy buttons', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it.each(COPYABLE)(
+ 'names the %s button by its value, then what pressing it does',
+ (value, field) => {
+ const drawer = renderEntry();
+ const button = within(drawer).getByRole('button', { name: `${value}, copy ${field}` });
+ expect(button).toHaveTextContent(value);
+ expect(button).not.toHaveAttribute('aria-live');
+ },
+ );
+
+ it('reports each copy with the field it copied', async () => {
+ const writeText = vi.fn(async () => {});
+ vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
+ const onCopied = vi.fn();
+ const drawer = renderEntry({ onCopied });
+ fireEvent.click(within(drawer).getByRole('button', { name: 'u-admin, copy actor ID' }));
+ await waitFor(() => expect(onCopied).toHaveBeenCalledWith('actor ID'));
+ expect(writeText).toHaveBeenCalledWith('u-admin');
+ });
+
+ it('reports a failed copy instead', async () => {
+ vi.stubGlobal('navigator', {
+ ...navigator,
+ clipboard: { writeText: vi.fn(async () => Promise.reject(new Error('denied'))) },
+ });
+ const onCopied = vi.fn();
+ const onCopyFailed = vi.fn();
+ const drawer = renderEntry({ onCopied, onCopyFailed });
+ fireEvent.click(within(drawer).getByRole('button', { name: 'EDITORS, copy target ID' }));
+ await waitFor(() => expect(onCopyFailed).toHaveBeenCalled());
+ expect(onCopied).not.toHaveBeenCalled();
+ });
+
+ it("keeps the target badge's icon out of the drawer's text", () => {
+ const drawer = renderEntry();
+ expect(within(drawer).queryAllByRole('img')).toEqual([]);
+ });
+});
diff --git a/src/components/grants/__tests__/CapabilityPanel.test.tsx b/src/components/grants/__tests__/CapabilityPanel.test.tsx
index 30626386..c438c846 100644
--- a/src/components/grants/__tests__/CapabilityPanel.test.tsx
+++ b/src/components/grants/__tests__/CapabilityPanel.test.tsx
@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
+import { CAPABILITY_CATEGORIES, CapabilityImplications, SystemCapabilities } from '@/constants';
import { CapabilityPanel } from '../CapabilityPanel';
-import { CAPABILITY_CATEGORIES } from '@/constants';
import { nestedControls } from '@/test/a11y';
import { ruleBody } from '@/test/styles';
@@ -76,3 +76,21 @@ describe('CapabilityPanel category headers', () => {
expect(usersToggle()).toHaveAttribute('aria-expanded', 'false');
});
});
+
+describe('CapabilityPanel implied capabilities', () => {
+ it("notes a capability another one implies without reading out its icon's name", async () => {
+ const user = userEvent.setup();
+ const implied = CapabilityImplications[SystemCapabilities.MANAGE_USERS] ?? [];
+ expect(implied.length).toBeGreaterThan(0);
+ render(
+ ,
+ );
+ await user.click(usersToggle());
+
+ expect(screen.getAllByText('com_cap_implied_by')).toHaveLength(implied.length);
+ expect(screen.queryAllByRole('img')).toEqual([]);
+ });
+});
diff --git a/src/components/shared/BusyButton.tsx b/src/components/shared/BusyButton.tsx
new file mode 100644
index 00000000..900657ae
--- /dev/null
+++ b/src/components/shared/BusyButton.tsx
@@ -0,0 +1,32 @@
+import { Button } from '@clickhouse/click-ui';
+import type * as t from '@/types';
+import { cn } from '@/utils';
+
+/**
+ * A click-ui Button that keeps focus while its action runs. click-ui's
+ * `loading` disables the button, and a focused button that turns disabled
+ * drops keyboard focus on ``. Busy, this one shows a spinner, is
+ * `aria-busy` and `aria-disabled`, and ignores presses instead.
+ */
+export function BusyButton({
+ busy,
+ disabled,
+ iconLeft,
+ onClick,
+ className,
+ ...props
+}: t.BusyButtonProps) {
+ return (
+ {
+ if (!busy) onClick?.(event);
+ }}
+ className={cn(busy && 'cursor-progress', className)}
+ />
+ );
+}
diff --git a/src/components/shared/LoadingState.tsx b/src/components/shared/LoadingState.tsx
index 149743c2..898d5162 100644
--- a/src/components/shared/LoadingState.tsx
+++ b/src/components/shared/LoadingState.tsx
@@ -1,5 +1,5 @@
-import { Icon } from '@clickhouse/click-ui';
import type * as t from '@/types';
+import { DecorativeIcon } from './DecorativeIcon';
import { useLocalize } from '@/hooks';
export function LoadingState({ className }: t.LoadingStateProps) {
@@ -7,12 +7,13 @@ export function LoadingState({ className }: t.LoadingStateProps) {
return (
-
+
{localize('com_ui_loading')}
);
diff --git a/src/components/shared/Pagination.test.tsx b/src/components/shared/Pagination.test.tsx
index f6164267..ed62255e 100644
--- a/src/components/shared/Pagination.test.tsx
+++ b/src/components/shared/Pagination.test.tsx
@@ -1,43 +1,14 @@
-import { render } from '@testing-library/react';
+import { useState } from 'react';
import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
import { getPageNumbers, Pagination } from './Pagination';
+import '@/locales/i18n';
-vi.mock('@clickhouse/click-ui', () => ({
- Pagination: ({
- currentPage,
- totalPages,
- onChange,
- }: {
- currentPage: number;
- totalPages: number;
- onChange: (page: number) => void;
- }) => (
-
- onChange(currentPage - 1)}>prev
- onChange(currentPage + 1)}>next
-
- ),
-}));
-
-const TRANSLATIONS: Record = {
- com_a11y_pagination: 'Pagination',
- com_a11y_previous_page: 'Previous page',
- com_a11y_next_page: 'Next page',
- com_a11y_page_n: 'Page {{page}}',
-};
-
-vi.mock('@/hooks/useLocalize', () => ({
- default: () => (key: string, opts?: Record) => {
- let str = TRANSLATIONS[key] ?? key;
- if (opts) for (const [k, v] of Object.entries(opts)) str = str.replace(`{{${k}}}`, v);
- return str;
- },
- useLocalize: () => (key: string, opts?: Record) => {
- let str = TRANSLATIONS[key] ?? key;
- if (opts) for (const [k, v] of Object.entries(opts)) str = str.replace(`{{${k}}}`, v);
- return str;
- },
-}));
+/** A pager that turns pages the way its callers do: the page lives in their state. */
+function Pager({ initialPage, totalPages }: { initialPage: number; totalPages: number }) {
+ const [page, setPage] = useState(initialPage);
+ return ;
+}
describe('getPageNumbers', () => {
it('returns all pages when total <= 7', () => {
@@ -81,12 +52,65 @@ describe('Pagination', () => {
expect(container.innerHTML).toBe('');
});
- it('renders CUI Pagination when totalPages > 1', () => {
- const { getByTestId } = render(
- {}} />,
+ it('names its controls in words, not by their icons or the page number', () => {
+ render( {}} />);
+ const nav = screen.getByRole('navigation', { name: 'Pagination' });
+ expect(nav).toContainElement(screen.getByRole('button', { name: 'Previous page' }));
+ expect(nav).toContainElement(screen.getByRole('button', { name: 'Next page' }));
+ expect(screen.getByRole('spinbutton', { name: 'Page 2 of 5' })).toHaveValue(2);
+ expect(nav).toHaveTextContent('of 5');
+ expect(screen.queryByRole('button', { name: /chevron/ })).toBeNull();
+ });
+
+ it('turns pages with Previous, Next and the page field', () => {
+ const onPageChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Previous page' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ const field = screen.getByRole('spinbutton', { name: 'Page 2 of 5' });
+ fireEvent.change(field, { target: { value: '4' } });
+ fireEvent.change(field, { target: { value: '9' } });
+ fireEvent.change(field, { target: { value: '0' } });
+ expect(onPageChange.mock.calls).toEqual([[1], [3], [4]]);
+ });
+
+ it('disables Previous on the first page and Next on the last', () => {
+ const { rerender } = render(
+ {}} />,
);
- const nav = getByTestId('cui-pagination');
- expect(nav).toHaveAttribute('data-current', '2');
- expect(nav).toHaveAttribute('data-total', '5');
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled();
+ rerender( {}} />);
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
+ });
+
+ it('keeps focus on an enabled control when Next reaches the last page', () => {
+ render( );
+ const next = screen.getByRole('button', { name: 'Next page' });
+ next.focus();
+ fireEvent.click(next);
+ expect(screen.getByRole('spinbutton')).toHaveValue(3);
+ expect(next).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Previous page' })).toHaveFocus();
+ });
+
+ it('keeps focus on an enabled control when Previous reaches the first page', () => {
+ render( );
+ const previous = screen.getByRole('button', { name: 'Previous page' });
+ previous.focus();
+ fireEvent.click(previous);
+ expect(screen.getByRole('spinbutton')).toHaveValue(1);
+ expect(previous).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next page' })).toHaveFocus();
+ });
+
+ it('leaves focus on Next while there are pages after the new one', () => {
+ render( );
+ const next = screen.getByRole('button', { name: 'Next page' });
+ next.focus();
+ fireEvent.click(next);
+ expect(next).toBeEnabled();
+ expect(next).toHaveFocus();
});
});
diff --git a/src/components/shared/Pagination.tsx b/src/components/shared/Pagination.tsx
index 09c2bd31..4ef215a0 100644
--- a/src/components/shared/Pagination.tsx
+++ b/src/components/shared/Pagination.tsx
@@ -1,5 +1,7 @@
-import { Pagination as CUIPagination } from '@clickhouse/click-ui';
+import { useLayoutEffect, useRef } from 'react';
+import { IconButton, NumberField } from '@clickhouse/click-ui';
import type * as t from '@/types';
+import { useLocalize } from '@/hooks';
export function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
if (total <= 7) {
@@ -15,9 +17,79 @@ export function getPageNumbers(current: number, total: number): (number | 'ellip
return pages;
}
+/**
+ * Previous, page number and Next, as click-ui's Pagination lays them out, with
+ * localized names (click-ui names its buttons by their icons and the field by
+ * its value). Pressing Previous on the second page or Next on the one before
+ * last disables that button, which would drop focus on ``, so focus
+ * moves to the other button.
+ */
export function Pagination({ currentPage, totalPages, onPageChange }: t.PaginationProps) {
+ const localize = useLocalize();
+ const previousRef = useRef(null);
+ const nextRef = useRef(null);
+ const pressedRef = useRef(null);
+ const atFirst = currentPage <= 1;
+ const atLast = currentPage >= totalPages;
+
+ useLayoutEffect(() => {
+ const pressed = pressedRef.current;
+ pressedRef.current = null;
+ if (!pressed?.disabled) return;
+ const other = pressed === previousRef.current ? nextRef.current : previousRef.current;
+ other?.focus();
+ }, [currentPage, totalPages]);
+
if (totalPages <= 1) return null;
+
+ const goTo = (page: number, pressed: HTMLButtonElement | null) => {
+ pressedRef.current = pressed;
+ onPageChange(page);
+ };
+
+ const handlePageInput = (value: string) => {
+ const page = parseInt(value, 10);
+ if (Number.isNaN(page) || page < 1 || page > totalPages) return;
+ onPageChange(page);
+ };
+
return (
-
+
+ goTo(currentPage - 1, previousRef.current)}
+ />
+
+
+
+
+ {localize('com_ui_page_of_total', { total: totalPages })}
+
+ goTo(currentPage + 1, nextRef.current)}
+ />
+
);
}
diff --git a/src/components/shared/SearchInput.tsx b/src/components/shared/SearchInput.tsx
index 1b133faa..d8640cfa 100644
--- a/src/components/shared/SearchInput.tsx
+++ b/src/components/shared/SearchInput.tsx
@@ -7,10 +7,12 @@ export function SearchInput({
placeholder,
className,
ariaLabel,
+ inputRef,
}: t.SearchInputProps) {
return (
) => {
event.preventDefault();
onClick();
@@ -16,6 +22,7 @@ export function TrashButton({ onClick, ariaLabel, size = 'sm', disabled }: t.Tra
return (
0;
+ const listOpen = hasResults && results.length > 0;
+ const noUsersFound = hasResults && results.length === 0 && !searchQuery.isLoading;
+ const popupShown = listOpen || noUsersFound;
+ const optionId = (index: number) => `${listboxId}-option-${index}`;
+
+ /**
+ * Escape with the suggestions shown closes only them, and a second one closes
+ * the dialog around the field. Radix dialogs close on Escape from a capture
+ * listener on `document`, before any React handler runs; this one is on
+ * `window`, which the event reaches first.
+ */
+ useEffect(() => {
+ if (!popupShown) return;
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape' || event.target !== inputRef.current) return;
+ event.stopPropagation();
+ event.preventDefault();
+ setShowResults(false);
+ setActiveIndex(-1);
+ };
+ window.addEventListener('keydown', closeOnEscape, true);
+ return () => window.removeEventListener('keydown', closeOnEscape, true);
+ }, [popupShown]);
const handleInputChange = (e: React.ChangeEvent) => {
const value = e.target.value;
@@ -93,13 +117,9 @@ export function UserSearchInline({
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
handleSelect(results[activeIndex]);
- } else if (e.key === 'Escape') {
- setShowResults(false);
}
};
- const hasResults = showResults && debouncedQuery.trim().length > 0;
-
const renderDropdownContent = (rect: { top: number; left: number; width: number }) => {
const positionStyle = {
position: 'fixed' as const,
@@ -120,6 +140,7 @@ export function UserSearchInline({
{results.map((user, i) => (
e.preventDefault()}
@@ -134,16 +155,26 @@ export function UserSearchInline({
{user.name}
- {user.email}
+
+ {user.email}
+
))}
);
}
- if (!searchQuery.isLoading) {
+ if (noUsersFound) {
return (
@@ -205,13 +236,17 @@ export function UserSearchInline({
}}
placeholder={localize('com_access_add_members_placeholder')}
aria-label={localize('com_access_add_members_placeholder')}
- aria-expanded={hasResults && results.length > 0}
+ aria-expanded={listOpen}
aria-autocomplete="list"
aria-controls={listboxId}
+ aria-activedescendant={listOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined}
role="combobox"
disabled={disabled}
- className="w-full rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) py-2 pr-3 pl-9 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-disabled)"
+ className="w-full rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) py-2 pr-3 pl-9 text-sm text-(--cui-color-text-default) placeholder:text-(--cui-color-text-muted)"
/>
+
+ {noUsersFound ? localize('com_access_no_users_found') : ''}
+
{hasResults &&
dropdownRect &&
createPortal(renderDropdownContent(dropdownRect), document.body)}
diff --git a/src/components/shared/__tests__/LoadingState.test.tsx b/src/components/shared/__tests__/LoadingState.test.tsx
new file mode 100644
index 00000000..718d005f
--- /dev/null
+++ b/src/components/shared/__tests__/LoadingState.test.tsx
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { LoadingState } from '../LoadingState';
+import '@/locales/i18n';
+
+describe('LoadingState', () => {
+ it('is a status that reads "Loading…", without the spinner icon\'s name', () => {
+ render( );
+ expect(screen.getByRole('status')).toHaveTextContent(/^Loading…$/);
+ expect(screen.getByRole('status')).toHaveAccessibleName('');
+ expect(screen.queryByRole('img')).toBeNull();
+ });
+
+ it('keeps the status role with a caller-styled layout', () => {
+ render( );
+ expect(screen.getByRole('status')).toHaveClass('py-6');
+ });
+});
diff --git a/src/components/shared/__tests__/UserSearchInline.test.tsx b/src/components/shared/__tests__/UserSearchInline.test.tsx
new file mode 100644
index 00000000..c98b5f8d
--- /dev/null
+++ b/src/components/shared/__tests__/UserSearchInline.test.tsx
@@ -0,0 +1,82 @@
+import '@/locales/i18n';
+import { Dialog } from '@clickhouse/click-ui';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import type { AdminUserSearchResult } from '@librechat/data-schemas';
+import { UserSearchInline } from '../UserSearchInline';
+import { searchUsersFn } from '@/server';
+
+const USERS: AdminUserSearchResult[] = [
+ { id: 'u1', name: 'Alice Chen', email: 'alice@example.com' },
+ { id: 'u2', name: 'Alan Kay', email: 'alan@example.com' },
+];
+
+vi.mock('@/server', () => ({
+ searchUsersFn: vi.fn(async ({ data }: { data: { query: string } }) => ({
+ users: USERS.filter((user) => user.name.toLowerCase().includes(data.query.toLowerCase())),
+ })),
+}));
+
+/** The field in a dialog, as the Edit role, Edit group and Create group dialogs hold it. */
+function renderInDialog() {
+ const onOpenChange = vi.fn();
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+
+
+
+
+ ,
+ );
+ return { input: screen.getByRole('combobox'), onOpenChange };
+}
+
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+});
+
+describe('UserSearchInline', () => {
+ it('points the field at the highlighted suggestion', async () => {
+ const { input } = renderInDialog();
+ fireEvent.change(input, { target: { value: 'al' } });
+ const options = await screen.findAllByRole('option');
+ expect(input).not.toHaveAttribute('aria-activedescendant');
+
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ expect(input).toHaveAttribute('aria-activedescendant', options[1].id);
+ expect(new Set(options.map((option) => option.id)).size).toBe(USERS.length);
+ });
+
+ it('closes only the suggestions on Escape, then the dialog on a second Escape', async () => {
+ const { input, onOpenChange } = renderInDialog();
+ fireEvent.change(input, { target: { value: 'al' } });
+ await screen.findByRole('listbox');
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
+ expect(onOpenChange).not.toHaveBeenCalled();
+ expect(input).toHaveAttribute('aria-expanded', 'false');
+ expect(input).not.toHaveAttribute('aria-activedescendant');
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('announces when no user matches, and Escape closes that message first', async () => {
+ const { input, onOpenChange } = renderInDialog();
+ fireEvent.change(input, { target: { value: 'zz' } });
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('No users found'));
+ expect(vi.mocked(searchUsersFn)).toHaveBeenCalled();
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ await waitFor(() => expect(screen.getByRole('status')).toBeEmptyDOMElement());
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts
index 6748d3b9..37052caa 100644
--- a/src/components/shared/index.ts
+++ b/src/components/shared/index.ts
@@ -1,6 +1,7 @@
export { AccessDenied } from './AccessDenied';
export { AddItemButton } from './AddItemButton';
export { Avatar } from './Avatar';
+export { BusyButton } from './BusyButton';
export { CloseButton } from './CloseButton';
export { CommandDialog } from './CommandDialog';
export { CountBadge, OverrideCount } from './CountBadge';
diff --git a/src/components/users/UserDetailDialog.tsx b/src/components/users/UserDetailDialog.tsx
index bb01dfdb..bae75151 100644
--- a/src/components/users/UserDetailDialog.tsx
+++ b/src/components/users/UserDetailDialog.tsx
@@ -1,5 +1,5 @@
-import { useState, useMemo } from 'react';
import { Button, Dialog, Icon } from '@clickhouse/click-ui';
+import { useCallback, useMemo, useRef, useState } from 'react';
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { TUser } from 'librechat-data-provider';
@@ -47,6 +47,8 @@ export function UserDetailDialog({
const queryClient = useQueryClient();
const refreshScopes = useRefreshScopes();
const returnFocus = useReturnFocus(!!user);
+ const addRef = useRef(null);
+ const addButton = useCallback(() => addRef.current, []);
const [view, setView] = useState<'main' | 'add'>('main');
const [removeTarget, setRemoveTarget] = useState(null);
@@ -243,6 +245,7 @@ export function UserDetailDialog({
{(canManageRoles || canManageGroups || canAssignConfigs) && (
setRemoveTarget(null)}
+ returnFocusTo={addButton}
/>
>
);
diff --git a/src/components/users/__tests__/UserDetailDialog.test.tsx b/src/components/users/__tests__/UserDetailDialog.test.tsx
index 74886550..f7d638f5 100644
--- a/src/components/users/__tests__/UserDetailDialog.test.tsx
+++ b/src/components/users/__tests__/UserDetailDialog.test.tsx
@@ -1,12 +1,31 @@
-import { describe, it, vi } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
import type { TUser } from 'librechat-data-provider';
+import type * as t from '@/types';
import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus';
import { UserDetailDialog } from '../UserDetailDialog';
+import '@/locales/i18n';
+
+/**
+ * Jane's roles as the server holds them. A removal's refetch waits for
+ * `release()`, so the test decides when the removed row leaves the list.
+ */
+const server = vi.hoisted(() => ({
+ roles: [] as t.AssignmentRef[],
+ refetched: Promise.resolve(),
+ release: () => {},
+}));
vi.mock('@/server', () => {
const empty = (key: string) => ({ queryKey: [key], queryFn: () => Promise.resolve([]) });
return {
- roleAssignmentsQueryOptions: { queryKey: ['roleAssignments'], queryFn: () => ({}) },
+ roleAssignmentsQueryOptions: {
+ queryKey: ['roleAssignments'],
+ queryFn: async () => {
+ await server.refetched;
+ return { 'u-jane': server.roles };
+ },
+ },
groupAssignmentsQueryOptions: { queryKey: ['groupAssignments'], queryFn: () => ({}) },
allRolesQueryOptions: empty('allRoles'),
allGroupsQueryOptions: empty('allGroups'),
@@ -16,7 +35,12 @@ vi.mock('@/server', () => {
createScopeFn: vi.fn(),
deleteScopeFn: vi.fn(),
removeGroupMemberFn: vi.fn(),
- removeRoleMemberFn: vi.fn(),
+ removeRoleMemberFn: vi.fn(async () => {
+ server.roles = [];
+ server.refetched = new Promise((resolve) => {
+ server.release = resolve;
+ });
+ }),
};
});
@@ -44,3 +68,24 @@ describe('UserDetailDialog focus', () => {
},
);
});
+
+describe('UserDetailDialog removal', () => {
+ it('gives focus to Add profiles once a confirmed removal takes away the row it started from', async () => {
+ server.roles = [{ id: 'r-support', name: 'Support' }];
+ const opener = renderWithOpener((open, onClose) => (
+
+ ));
+ const details = await openFrom(opener);
+ const remove = await within(details).findByRole('button', { name: 'Remove role Support' });
+ remove.focus();
+ fireEvent.click(remove);
+ const confirm = await screen.findByRole('dialog', { name: 'Remove from role' });
+ fireEvent.click(within(confirm).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(confirm).not.toBeInTheDocument());
+ await waitFor(() => expect(document.body).not.toHaveFocus());
+
+ act(() => server.release());
+ await waitFor(() => expect(remove).not.toBeInTheDocument());
+ expect(within(details).getByRole('button', { name: 'Add profiles' })).toHaveFocus();
+ });
+});
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 19e1a5af..4431a3bf 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -11,6 +11,7 @@
"com_ui_save": "Save",
"com_ui_add": "Add",
"com_ui_cancel": "Cancel",
+ "com_ui_undo": "Undo",
"com_ui_close": "Close",
"com_ui_create": "Create",
"com_ui_retry": "Retry",
@@ -3321,6 +3322,10 @@
"com_access_roles_error": "Failed to load roles",
"com_access_permissions_load_error": "Unable to load permissions for this role.",
"com_access_no_results": "No matching results",
+ "com_a11y_roles_found_one": "{{count}} role found",
+ "com_a11y_roles_found_other": "{{count}} roles found",
+ "com_a11y_groups_found_one": "{{count}} group found",
+ "com_a11y_groups_found_other": "{{count}} groups found",
"com_access_manage_members": "Manage members",
"com_access_delete_group_title": "Delete group",
"com_access_delete_group_desc": "Are you sure you want to delete \"{{name}}\"? All members will be removed and profile configuration will be lost.",
@@ -3345,9 +3350,14 @@
"com_access_add_members_placeholder": "Search users to add…",
"com_access_no_users_found": "No users found",
"com_access_no_members": "No members yet",
- "com_access_member_count": "{{count}} members",
- "com_access_pending_additions": "Adding {{count}} members",
- "com_access_pending_removals": "Removing {{count}} members",
+ "com_access_member_count_one": "{{count}} member",
+ "com_access_member_count_other": "{{count}} members",
+ "com_access_pending_additions_one": "Adding {{count}} member",
+ "com_access_pending_additions_other": "Adding {{count}} members",
+ "com_access_pending_removals_one": "Removing {{count}} member",
+ "com_access_pending_removals_other": "Removing {{count}} members",
+ "com_access_will_be_removed": "Will be removed",
+ "com_access_undo_remove_member": "Undo removing {{name}}",
"com_access_details_saved": "Details saved",
"com_access_permissions_saved": "Permissions saved",
"com_access_member_ops_failed_one": "{{count}} member operation failed",
@@ -3439,6 +3449,8 @@
"com_a11y_previous_page": "Previous page",
"com_a11y_next_page": "Next page",
"com_a11y_page_n": "Page {{page}}",
+ "com_a11y_page_of": "Page {{page}} of {{total}}",
+ "com_ui_page_of_total": "of {{total}}",
"com_a11y_rename_entry": "Rename {{name}}",
"com_a11y_remove_override": "Remove override for {{name}}",
"com_a11y_reset_to_default": "Reset {{name}} to default",
@@ -3562,6 +3574,15 @@
"com_audit_detail_after": "After",
"com_audit_detail_no_changes": "No detailed changes recorded",
"com_audit_detail_copy_permalink": "Copy link",
+ "com_audit_detail_copy_value": "{{value}}, copy {{field}}",
+ "com_audit_detail_copied_field": "Copied {{field}}",
+ "com_audit_field_timestamp": "timestamp",
+ "com_audit_field_actor_id": "actor ID",
+ "com_audit_field_target_id": "target ID",
+ "com_audit_field_grantee_id": "grantee ID",
+ "com_audit_field_capability": "capability",
+ "com_audit_field_entry_id": "entry ID",
+ "com_audit_field_link": "link",
"com_audit_detail_not_found": "Audit log entry not found. It may have been purged or the link is incorrect.",
"com_audit_detail_load_error": "Couldn't load this audit log entry. The request failed — try again in a moment.",
"com_audit_detail_close": "Close",
diff --git a/src/styles.css b/src/styles.css
index b07d3625..3ac2212c 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -389,9 +389,19 @@ button.row-link:focus-visible {
}
/* click-ui DatePicker: round the PopoverTrigger button so the global
- focus-visible outline matches the input wrapper's border-radius. */
+ focus-visible outline matches the input wrapper's border-radius. Its
+ input wrapper is a fixed 250px; capped at the width there is, the audit
+ log's date filters fit a 320px screen instead of scrolling sideways. */
.audit-date-cell button {
border-radius: 4px;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.audit-date-cell button div,
+.audit-date-cell button input {
+ min-width: 0;
+ max-width: 100%;
}
input:not([cmdk-input]):focus-visible,
@@ -1169,6 +1179,15 @@ body > div:has(+ .modal-frost) {
--click-badge-opaque-color-text-success: var(--settings-success-fg);
}
+/* A focused or pressed danger button (Delete, Clear) keeps AA contrast: on
+ click-ui's light 0.3-alpha red, its #c10000 text is 4.06:1. The hover
+ tint, 0.2 alpha, gives 4.75:1, and the focus ring still marks focus. The
+ dark text passes on click-ui's dark tint. Unlayered so it wins over
+ click-ui's sheet. */
+:root[data-cui-theme='light'] {
+ --click-button-basic-color-danger-background-active: rgb(100% 13.725% 13.725% / 0.2);
+}
+
/* One accent in both themes (VS-1, VS-9): click-ui's dark theme paints the
primary button, the active tab, focus outlines, links and checked controls
in its brand yellow, next to the settings blue and the amber Edited state.
diff --git a/src/types/access.ts b/src/types/access.ts
index 8b8498a4..56d621a1 100644
--- a/src/types/access.ts
+++ b/src/types/access.ts
@@ -1,4 +1,4 @@
-import type { AdminGroup } from '@librechat/data-schemas';
+import type { AdminGroup, AdminMember } from '@librechat/data-schemas';
import type { Role, RolePermissions } from './role';
export interface AccessPageProps {
@@ -18,6 +18,12 @@ export interface ConfirmDialogProps {
error?: string;
onConfirm: () => void;
onCancel: () => void;
+ /**
+ * Where focus goes once Confirm closes the dialog, since the action removes
+ * the control that opened it (the deleted row's Delete), and on any close
+ * when that control is already gone.
+ */
+ returnFocusTo?: () => HTMLElement | null;
}
export type CreateGroupTab = 'details' | 'members';
@@ -68,3 +74,20 @@ export interface RenameProfileNoteProps {
/** LibreChat moves the profile with the role (`roleRenameMovesProfile`). */
moves: boolean;
}
+
+/** The current members of a role or group in its Edit dialog, with removals staged until Save. */
+export interface MemberListProps {
+ members: AdminMember[];
+ loading: boolean;
+ error: boolean;
+ fetching: boolean;
+ /** The user ids staged for removal. */
+ removalIds: ReadonlySet;
+ onRemove: (member: AdminMember) => void;
+ onUndoRemove: (userId: string) => void;
+ canManage: boolean;
+ total: number;
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}
diff --git a/src/types/grant.ts b/src/types/grant.ts
index f120e09a..0f897208 100644
--- a/src/types/grant.ts
+++ b/src/types/grant.ts
@@ -63,6 +63,8 @@ export interface AuditLogTableRowProps {
isLast: boolean;
/** Opens the entry's detail drawer. */
onOpen: () => void;
+ /** The row's button, which takes focus when a drawer opened from a permalink closes. */
+ buttonRef?: Ref;
}
export interface DateFilterProps {
@@ -74,3 +76,45 @@ export interface DateFilterProps {
resetKey: number;
onSelectDate: (date: Date) => void;
}
+
+export interface AuditLogDetailDrawerProps {
+ entry: AuditLogEntryWithDiff | null;
+ open: boolean;
+ onClose: () => void;
+ /** Resolves to `true` when the clipboard write succeeded so the drawer only
+ * flips to its "Copied!" affordance after a real success. */
+ onCopyPermalink: (entryId: string) => Promise;
+ /** Invoked with the copied field's name ("actor ID") after an inline copy
+ * succeeded, so the caller can announce it. */
+ onCopied?: (field: string) => void;
+ /** Invoked when any inline `CopyableMono` (timestamp, actor/target/entry IDs,
+ * capability) fails to write to the clipboard. The caller is responsible for
+ * surfacing the failure — typically via the same screen-reader announcement
+ * used for the permalink-copy failure path so the drawer's copy controls are
+ * not silent on clipboard error. */
+ onCopyFailed?: () => void;
+ /** Render a "no entry found" message instead of the detail body when the
+ * deep-linked id couldn't be located (e.g. the entry was purged). */
+ notFound?: boolean;
+ /** Render a loading shell while the single-entry deep-link query is in flight
+ * for an entry that is not on the current page. Without this, the drawer
+ * would either flicker shut during the fetch or never appear on cold load. */
+ loading?: boolean;
+ /** Render a load-error message + close affordance when the single-entry
+ * fetch failed for reasons other than not-found (network failure, 5xx). The
+ * caller is responsible for distinguishing this from `notFound` (which is
+ * the 404 case where the request itself succeeded but the entry is gone). */
+ loadError?: boolean;
+ /** Where focus goes on close when nothing on the page opened the drawer
+ * (it opened from a permalink). */
+ returnFocusTo?: () => HTMLElement | null;
+}
+
+/** A value in the audit log drawer that copies itself when pressed. */
+export interface CopyableMonoProps {
+ value: string;
+ /** What the value is, in a sentence ("actor ID"). */
+ field: string;
+ onCopied?: (field: string) => void;
+ onCopyFailed?: () => void;
+}
diff --git a/src/types/shared.ts b/src/types/shared.ts
index 4c54c003..438cb9dd 100644
--- a/src/types/shared.ts
+++ b/src/types/shared.ts
@@ -1,5 +1,6 @@
+import type { ReactNode, Ref, RefObject, MutableRefObject } from 'react';
import type { AdminUserSearchResult } from '@librechat/data-schemas';
-import type { ReactNode, RefObject, MutableRefObject } from 'react';
+import type { ButtonProps } from '@clickhouse/click-ui';
import type { MenuIconName } from './scope';
export interface ReorderableListHandle {
@@ -100,6 +101,7 @@ export interface SearchInputProps {
placeholder: string;
className?: string;
ariaLabel?: string;
+ inputRef?: Ref;
}
export interface SelectedMemberListProps {
@@ -155,6 +157,12 @@ export interface TrashButtonProps {
ariaLabel: string;
size?: 'xs' | 'sm';
disabled?: boolean;
+ buttonRef?: Ref;
+}
+
+export interface BusyButtonProps extends ButtonProps {
+ /** The button's action is running: it shows a spinner and ignores presses, but keeps focus. */
+ busy: boolean;
}
export interface PaginationProps {