From dccb0c6faf042a0f2e5801f059082bccb7b83ad7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 10:05:58 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=9A=91=20fix:=20Stop=20Configuration?= =?UTF-8?q?=20Page=20Data=20Loss=20(Wave=200=20Hotfix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements HF-1 to HF-11 of the configuration-page plan, plus HF-12 as limited by the product decisions (no data-provider bump; unknown keys per D8). - HF-1 Enter key: every click-ui Button/IconButton now sets an explicit htmlType (new ESLint rule click-ui/require-button-html-type); the tab form gets a disabled hidden default submit button and blocks implicit submission from inputs. Enter can no longer delete an endpoint/social login or add "". - HF-2 Delete buttons: TrashButton/EditButton are native buttons named "Delete "/"Edit "; an entry card ignores keydown from nested controls, so Enter on a card's trash button deletes instead of toggling. - HF-3 Blank values: buildSavePayload normalizes every value (blank list items dropped, emptied lists/records become "no value"), drops edits equal to the saved value, and turns an emptied override into a reset. Blank allowlist rows, empty header lists and stray [] / {} are never written. - HF-4 beforeunload: only prompts while there are unsaved edits. - HF-5 YAML import: validates a copy (version optional) but imports the raw YAML (no Zod defaults, unknown keys kept and listed), merges through the normal validated save path into the target picked in the dialog (base or a profile; the open profile is preselected). The full-replace PUT is gone. - HF-6 Dotted / $ keys: inline errors in key/value rows, record add-key and rename inputs; the review blocks the save and names each key; import and the server pre-flight reject them (BLOCK_UNSAFE_CONFIG_KEYS flag for BE-1). - HF-7 Atomic saves: validateConfigChangesFn validates every save (indexed array entries against the element schema, process-backed MCP fields, unsafe keys, capabilities) before any reset is written; queries always refetch. - HF-8 Base-only sections (filters) and YAML-only sections (cloudfront, rateLimits, turnstile, verified in LibreChat) render read-only with a banner, are stripped from saves, and a backend "No actionable" reply is reported as a warning instead of "Changes saved". - HF-9 Azure groups and Anthropic Vertex are read-only with a banner, group API keys are masked (also in the review dialog), and a stored override gets a "Remove stale override" action. - HF-10 The save error is cleared on open, cancel, discard and every edit. - HF-11 Scope mode reads collections from the merged tree when entries have pending edits, so sequential edits and "Add item" rows are kept; review BEFORE values come from the profile being edited, and the title names it. - HF-12 (D8) Settings unknown to the bundled schema are listed read-only per section and at top level on the System tab; unmapped sections get a readable title; test asserts every schema section has SECTION_META. Saves and imports send at most 100 entries per PATCH (the admin API limit). Tests: normalizeForSave, findUnsafeKeys, raw YAML import (3 keys -> 3 leaves), pre-flight checks, import target routing, HF-11 sequential scope edits, secret masking; Playwright spec for Enter on every tab and delete-button names (axe). --- e2e/config.spec.ts | 104 ++++ eslint.config.js | 10 +- src/components/AuthCard.tsx | 2 + src/components/access/CreateGroupDialog.tsx | 2 + src/components/access/CreateRoleDialog.tsx | 2 + src/components/access/EditGroupDialog.tsx | 2 + src/components/access/EditRoleDialog.tsx | 3 + src/components/access/GroupsTab.tsx | 1 + src/components/access/RolesTab.tsx | 1 + src/components/configuration/ConfigPage.tsx | 464 +++++++++++------- .../configuration/ConfigTabContent.tsx | 40 +- .../configuration/ConfigTableOfContents.tsx | 2 +- .../configuration/ConfirmSaveDialog.tsx | 81 ++- .../configuration/DeleteProfileValueModal.tsx | 2 + .../configuration/FieldRenderer.tsx | 95 ++-- .../configuration/ImportYamlDialog.tsx | 69 ++- .../configuration/ManagedNotice.tsx | 42 ++ .../configuration/ProfileValueModal.tsx | 3 + .../configuration/ResetBaseConfigDialog.tsx | 2 + .../configuration/ScopeSelector.tsx | 1 + .../configuration/UnknownSettings.tsx | 54 ++ .../__tests__/ImportYamlDialog.test.tsx | 127 +++++ .../configuration/configMeta.test.ts | 20 +- .../configuration/fields/KeyValueField.tsx | 135 ++--- .../configuration/fields/ObjectEntryCard.tsx | 69 ++- .../fields/RecordObjectField.tsx | 79 ++- .../sections/EndpointsRenderer.tsx | 2 + .../sections/LangfuseRenderer.tsx | 5 +- .../sections/McpServersRenderer.tsx | 8 +- src/components/configuration/utils.test.ts | 391 ++++++++++++++- src/components/configuration/utils.ts | 309 +++++++++++- .../grants/AuditLogDetailDrawer.tsx | 26 +- src/components/grants/AuditLogTab.tsx | 3 + .../grants/EditCapabilitiesDialog.tsx | 2 + src/components/shared/AddItemButton.tsx | 1 + src/components/shared/EditButton.tsx | 17 +- src/components/shared/FormDialog.tsx | 2 + src/components/shared/ReorderableList.tsx | 1 + src/components/shared/StickyActionBar.tsx | 4 +- src/components/shared/TrashButton.tsx | 21 +- src/components/users/UserDetailDialog.tsx | 8 +- src/constants/config.ts | 65 +++ src/constants/index.ts | 1 + src/locales/en/translation.json | 34 +- src/server/config.test.ts | 132 +++++ src/server/config.ts | 367 +++++++++----- src/server/scopes.ts | 53 +- src/styles.css | 39 ++ src/types/config-ui.ts | 96 +++- src/types/config.ts | 9 + src/types/fields.ts | 2 + src/utils/index.ts | 1 + src/utils/keys.test.ts | 48 ++ src/utils/keys.ts | 41 ++ src/utils/secrets.test.ts | 16 + src/utils/secrets.ts | 22 + src/utils/toast.ts | 4 + tools/eslint-plugin-click-ui/README.md | 14 + tools/eslint-plugin-click-ui/index.js | 3 + .../rules/require-button-html-type.js | 57 +++ .../tests/rules.test.js | 26 + 61 files changed, 2691 insertions(+), 551 deletions(-) create mode 100644 e2e/config.spec.ts create mode 100644 src/components/configuration/ManagedNotice.tsx create mode 100644 src/components/configuration/UnknownSettings.tsx create mode 100644 src/components/configuration/__tests__/ImportYamlDialog.test.tsx create mode 100644 src/constants/config.ts create mode 100644 src/utils/keys.test.ts create mode 100644 src/utils/keys.ts create mode 100644 tools/eslint-plugin-click-ui/rules/require-button-html-type.js diff --git a/e2e/config.spec.ts b/e2e/config.spec.ts new file mode 100644 index 00000000..d9329f4a --- /dev/null +++ b/e2e/config.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import type { Page } from '@playwright/test'; + +const TABS = ['providers', 'custom', 'specs', 'mcp', 'features', 'files', 'system']; +const TEXT_INPUTS = + 'form input[type=text]:visible:enabled, form input[type=number]:visible:enabled, form input:not([type]):visible:enabled'; +const MAX_INPUTS_PER_TAB = 30; + +async function openTab(page: Page, tab: string) { + await page.goto(`/configuration?tab=${tab}`); + await page.waitForLoadState('networkidle'); +} + +/** Opens collapsed sections and entry cards so their inputs can be reached (select triggers are left alone). */ +async function expandAll(page: Page) { + for (let round = 0; round < 2; round++) { + const toggles = await page + .locator('form button[aria-expanded=false]:not([aria-haspopup]):not([role=combobox])') + .all(); + for (const toggle of toggles.slice(0, 12)) { + await toggle.click({ timeout: 1_000 }).catch(() => undefined); + } + } + const cards = await page.locator('form [role=button][aria-expanded=false]').all(); + for (const card of cards.slice(0, 3)) await card.click({ timeout: 1_000 }).catch(() => undefined); +} + +const unsavedBar = (page: Page) => page.getByText('Unsaved changes'); + +test.describe('Configuration: Enter never edits another field', () => { + test.describe.configure({ timeout: 120_000 }); + + for (const tab of TABS) { + test(`Enter in each input on the ${tab} tab leaves the page clean`, async ({ page }) => { + await openTab(page, tab); + await expandAll(page); + await expect(unsavedBar(page)).toHaveCount(0); + const inputs = page.locator(TEXT_INPUTS); + const count = Math.min(await inputs.count(), MAX_INPUTS_PER_TAB); + const cardsBefore = await page.locator('form [role=button][aria-expanded]').count(); + for (let i = 0; i < count; i++) { + const input = inputs.nth(i); + await input.focus(); + await input.press('Enter'); + await expect(unsavedBar(page), `Enter in input #${i} made the page dirty`).toHaveCount(0); + } + await expect(page.locator('form [role=button][aria-expanded]')).toHaveCount(cardsBefore); + }); + } + + test('Enter after editing a value marks only that field', async ({ page }) => { + await openTab(page, 'system'); + const field = page.locator('#balance-startBalance'); + await field.fill('424242'); + await field.press('Enter'); + await expect(unsavedBar(page)).toHaveCount(1); + await page + .getByRole('button', { name: /^Save$/ }) + .last() + .click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('1 field will be updated'); + await expect(dialog).toContainText('balance.startBalance'); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Discard' }).click(); + }); +}); + +test.describe('Configuration: delete buttons', () => { + test('every delete button is announced as "Delete "', async ({ page }) => { + await openTab(page, 'custom'); + const deleteButtons = page.locator('form button.icon-btn-danger'); + test.skip((await deleteButtons.count()) === 0, 'No custom endpoints to delete'); + await expect(page.locator('form button[aria-label="trash"]')).toHaveCount(0); + for (const label of await deleteButtons.evaluateAll((els) => + els.map((el) => el.getAttribute('aria-label') ?? ''), + )) { + expect(label).toMatch(/^Delete \S/); + } + const results = await new AxeBuilder({ page }) + .include('form') + .withRules(['button-name']) + .analyze(); + expect(results.violations).toEqual([]); + }); + + test('keyboard Enter on a card trash button deletes the entry without toggling the card', async ({ + page, + }) => { + await openTab(page, 'custom'); + const headers = page.locator('form [role=button][aria-expanded]'); + const before = await headers.count(); + test.skip(before === 0, 'No custom endpoints to delete'); + const header = headers.first(); + const trash = header.locator('button.icon-btn-danger'); + await trash.focus(); + await page.keyboard.press('Enter'); + await expect(headers).toHaveCount(before - 1); + await expect(headers.first()).toHaveAttribute('aria-expanded', 'false'); + await expect(unsavedBar(page)).toHaveCount(1); + await page.getByRole('button', { name: 'Discard' }).click(); + }); +}); diff --git a/eslint.config.js b/eslint.config.js index 650b4120..157db180 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,6 +3,13 @@ import tsPlugin from '@typescript-eslint/eslint-plugin'; import importPlugin from 'eslint-plugin-import-x'; import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes'; import clickUiPlugin from 'eslint-plugin-click-ui'; +import requireButtonHtmlType from './tools/eslint-plugin-click-ui/rules/require-button-html-type.js'; + +/** Loads rules from the in-repo plugin source so a stale installed copy cannot hide new rules. */ +const clickUi = { + ...clickUiPlugin, + rules: { ...clickUiPlugin.rules, 'require-button-html-type': requireButtonHtmlType }, +}; export default [ { @@ -17,7 +24,7 @@ export default [ '@typescript-eslint': tsPlugin, 'import-x': importPlugin, 'tailwind-canonical-classes': tailwindCanonicalClasses, - 'click-ui': clickUiPlugin, + 'click-ui': clickUi, }, rules: { '@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': false }], @@ -53,6 +60,7 @@ export default [ ...clickUiPlugin.configs.recommended.rules, 'click-ui/require-provider': 'off', 'click-ui/select-requires-options': 'off', + 'click-ui/require-button-html-type': 'error', }, }, ]; diff --git a/src/components/AuthCard.tsx b/src/components/AuthCard.tsx index 756dd65c..eb11f62b 100644 --- a/src/components/AuthCard.tsx +++ b/src/components/AuthCard.tsx @@ -317,6 +317,7 @@ export function AuthCard({ /> {tocItems && tocItems.length > 0 && (
    diff --git a/src/components/configuration/ConfirmSaveDialog.tsx b/src/components/configuration/ConfirmSaveDialog.tsx index 6946b968..22b577a0 100644 --- a/src/components/configuration/ConfirmSaveDialog.tsx +++ b/src/components/configuration/ConfirmSaveDialog.tsx @@ -2,12 +2,17 @@ import yaml from 'js-yaml'; import { Badge, Button, Dialog } from '@clickhouse/click-ui'; import type { ReactNode } from 'react'; import type * as t from '@/types'; +import { SKIP_REASON_KEYS } from '@/constants'; +import { maskSecretValues } from '@/utils'; import { useLocalize } from '@/hooks'; export function ConfirmSaveDialog({ open, + title, editedValues, originalValues, + skipped = [], + unsafeKeys = [], saving, error, onConfirm, @@ -16,10 +21,12 @@ export function ConfirmSaveDialog({ const localize = useLocalize(); const entries = Object.entries(editedValues).sort(([a], [b]) => a.localeCompare(b)); const count = entries.length; - const countLabel = - count === 1 - ? localize('com_config_field_change_count', { count }) - : localize('com_config_field_change_count_plural', { count }); + const blocked = unsafeKeys.length > 0; + const countLabel = (() => { + if (count === 0) return localize('com_config_nothing_to_save_detail'); + if (count === 1) return localize('com_config_field_change_count', { count }); + return localize('com_config_field_change_count_plural', { count }); + })(); return ( - +

    {countLabel}

    {entries.map(([path, newValue]) => { - const oldValue = originalValues?.[path]; - return ; + const leafKey = path.slice(path.lastIndexOf('.') + 1); + return ( + + ); })}
    - {error &&

    {error}

    } + {skipped.length > 0 && ( +
    +

    + {localize('com_config_review_skipped', { count: skipped.length })} +

    +
      + {skipped.map((item) => ( +
    • + {item.fieldPath}: {localize(SKIP_REASON_KEYS[item.reason])} +
    • + ))} +
    +
    + )} + + {blocked && ( +
    +

    + {localize('com_config_unsafe_keys_blocked')} +

    +
      + {unsafeKeys.map(({ path, key }) => ( +
    • + {path} → {key} +
    • + ))} +
    +
    + )} + + {error && ( +

    + {error} +

    + )}
    @@ -66,13 +111,15 @@ export function ConfirmSaveDialog({ ); } + function resolvePathLabel(path: string, newValue: t.ConfigValue, oldValue: t.ConfigValue): string { const match = /^(.+)\.(\d+)$/.exec(path); if (!match) return path; const val = (newValue ?? oldValue) as Record | undefined; - const name = val && typeof val === 'object' && !Array.isArray(val) - ? (val as Record).name - : undefined; + const name = + val && typeof val === 'object' && !Array.isArray(val) + ? (val as Record).name + : undefined; return name ? `${match[1]}[${match[2]}] (${name})` : `${match[1]}[${match[2]}]`; } diff --git a/src/components/configuration/DeleteProfileValueModal.tsx b/src/components/configuration/DeleteProfileValueModal.tsx index f67ce196..26ebda12 100644 --- a/src/components/configuration/DeleteProfileValueModal.tsx +++ b/src/components/configuration/DeleteProfileValueModal.tsx @@ -39,12 +39,14 @@ export function DeleteProfileValueModal({
    )} + {unknownPaths.length > 0 && ( +
    +

    + {localize('com_config_import_unknown_keys', { count: unknownPaths.length })} +

    +

    {unknownPaths.slice(0, 10).join(', ')}

    +
    + )} )} @@ -423,6 +460,7 @@ export function ImportYamlDialog({
    {step === 'target' && (
    + )} + + ); +} diff --git a/src/components/configuration/ProfileValueModal.tsx b/src/components/configuration/ProfileValueModal.tsx index 560bbf65..af55eb87 100644 --- a/src/components/configuration/ProfileValueModal.tsx +++ b/src/components/configuration/ProfileValueModal.tsx @@ -58,12 +58,14 @@ export function ProfileValueModal({
    + ), + Icon: () => null, + Dialog: Object.assign( + ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
    {children}
    : null, + { + Content: ({ title, children }: { title: string; children: ReactNode }) => ( +
    + {children} +
    + ), + }, + ), + Tabs: Object.assign(({ children }: { children: ReactNode }) =>
    {children}
    , { + TriggersList: ({ children }: { children: ReactNode }) =>
    {children}
    , + Trigger: ({ children }: { children: ReactNode }) => {children}, + Content: ({ children }: { children: ReactNode }) =>
    {children}
    , + }), +})); + +const mockParse = vi.mocked(parseImportedYaml); +const userScope: t.ConfigScope = { + _id: 'cfg-user', + principalType: PrincipalType.ROLE, + principalId: 'USER', + name: 'USER', + priority: 10, + isActive: true, +}; +const parsed = { balance: { startBalance: 777 } }; + +function renderDialog(currentScope?: t.ConfigScope) { + const onImport = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return { onImport, onClose }; +} + +async function validate() { + fireEvent.change(screen.getByLabelText('com_config_import_paste'), { + target: { value: 'balance:\n startBalance: 777\n' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_import_validate' })); + await screen.findByRole('radiogroup'); +} + +describe('ImportYamlDialog target routing', () => { + beforeEach(() => { + const result: t.ImportParseResult = { success: true, appConfig: parsed, unknownPaths: [] }; + mockParse.mockResolvedValue(result); + }); + + it('imports into base when no profile is open', async () => { + const { onImport, onClose } = renderDialog(); + await validate(); + expect(screen.queryByText('com_config_import_as_current:USER')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'com_config_import_apply' })); + await waitFor(() => expect(onImport).toHaveBeenCalledWith(parsed, { type: 'base' })); + expect(onClose).toHaveBeenCalled(); + }); + + it('preselects the open profile as the target', async () => { + const { onImport } = renderDialog(userScope); + await validate(); + expect( + screen.getByRole('radio', { name: /com_config_import_as_current:USER/ }), + ).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'com_config_import_apply' })); + await waitFor(() => + expect(onImport).toHaveBeenCalledWith(parsed, { type: 'scope', scope: userScope }), + ); + }); + + it('writes to base when Base is chosen while a profile is open', async () => { + const { onImport } = renderDialog(userScope); + await validate(); + fireEvent.click(screen.getByRole('radio', { name: /^com_config_import_as_base/ })); + fireEvent.click(screen.getByRole('button', { name: 'com_config_import_apply' })); + await waitFor(() => expect(onImport).toHaveBeenCalledWith(parsed, { type: 'base' })); + }); + + it('keeps the dialog open and shows the reason when the import is rejected', async () => { + const { onImport, onClose } = renderDialog(); + onImport.mockRejectedValueOnce(new Error('Validation failed — balance.startBalance')); + await validate(); + fireEvent.click(screen.getByRole('button', { name: 'com_config_import_apply' })); + expect(await screen.findByText('Validation failed — balance.startBalance')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/configuration/configMeta.test.ts b/src/components/configuration/configMeta.test.ts index 05daff9c..e2a098da 100644 --- a/src/components/configuration/configMeta.test.ts +++ b/src/components/configuration/configMeta.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { CONFIG_TABS, OTHER_TAB, SECTION_META, splitCamelCase } from './configMeta'; +import { configSchema } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { + CONFIG_TABS, + OTHER_TAB, + SECTION_META, + HIDDEN_SECTIONS, + splitCamelCase, +} from './configMeta'; describe('splitCamelCase', () => { it('splits camelCase into words', () => { @@ -79,3 +87,13 @@ describe('Other tab routing', () => { expect(filterForTab(OTHER_TAB.id).map((s) => s.key)).toEqual(['unknownFeature']); }); }); + +describe('schema coverage', () => { + it('every top-level configSchema key has SECTION_META or is hidden', () => { + const shape = (configSchema as t.ZodSchemaLike).shape ?? {}; + const unmapped = Object.keys(shape).filter( + (key) => !HIDDEN_SECTIONS.has(key) && !Object.hasOwn(SECTION_META, key), + ); + expect(unmapped, `Sections without SECTION_META: ${unmapped.join(', ')}`).toEqual([]); + }); +}); diff --git a/src/components/configuration/fields/KeyValueField.tsx b/src/components/configuration/fields/KeyValueField.tsx index 965aaeaf..5896a34c 100644 --- a/src/components/configuration/fields/KeyValueField.tsx +++ b/src/components/configuration/fields/KeyValueField.tsx @@ -3,6 +3,8 @@ import TextareaAutosize from 'react-textarea-autosize'; import { useState, useEffect, useRef, useLayoutEffect } from 'react'; import type * as t from '@/types'; import { AddItemButton, TrashButton } from '@/components/shared'; +import { BLOCK_UNSAFE_CONFIG_KEYS } from '@/constants'; +import { isUnsafeConfigKey } from '@/utils'; import { useLocalize } from '@/hooks'; const DEFAULT_TYPES: t.KVValueType[] = ['string', 'number', 'boolean']; @@ -21,6 +23,8 @@ function LocalInput({ disabled, className, 'aria-label': ariaLabel, + 'aria-invalid': ariaInvalid, + 'aria-describedby': ariaDescribedBy, }: { value: string; onCommit: (value: string) => void; @@ -29,6 +33,8 @@ function LocalInput({ disabled?: boolean; className?: string; 'aria-label'?: string; + 'aria-invalid'?: boolean; + 'aria-describedby'?: string; }) { const [local, setLocal] = useState(value); const externalRef = useRef(value); @@ -53,10 +59,14 @@ function LocalInput({ value={local} onChange={(e) => setLocal(e.target.value)} onBlur={commit} - onKeyDown={(e) => { if (e.key === 'Enter') commit(); }} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + }} placeholder={placeholder} disabled={disabled} aria-label={ariaLabel} + aria-invalid={ariaInvalid || undefined} + aria-describedby={ariaDescribedBy} className={className} /> ); @@ -151,62 +161,77 @@ export function KeyValueField({ onChange(next); }; + const keyErrorId = (index: number) => `${id}-key-${index}-error`; + const hasKeyError = (pair: t.KeyValuePair) => + BLOCK_UNSAFE_CONFIG_KEYS && isUnsafeConfigKey(pair.key); + const deleteLabel = (pair: t.KeyValuePair, index: number) => + `${localize('com_ui_delete')} ${pair.key || `${localize('com_ui_entry')} ${index + 1}`}`; + const keyError = (pair: t.KeyValuePair, index: number) => + hasKeyError(pair) ? ( + + ) : null; + const renderPrimitiveRow = (vType: t.KVValueType, pair: t.KeyValuePair, index: number) => { const valueLabel = `${localize('com_ui_value')} ${index + 1}`; + const invalid = hasKeyError(pair); return ( -
    - handleChange(index, 'key', v)} - placeholder={keyPlaceholder ?? localize('com_ui_key')} - disabled={disabled} - aria-label={`${localize('com_ui_key')} ${index + 1}`} - className="config-input max-w-37.5 flex-1" - /> - {vType === 'boolean' ? ( -
    - -
    - ) : ( +
    +
    handleChange(index, 'value', v)} - placeholder={valuePlaceholder ?? localize('com_ui_value')} + value={pair.key} + onCommit={(v) => handleChange(index, 'key', v)} + placeholder={keyPlaceholder ?? localize('com_ui_key')} disabled={disabled} - aria-label={valueLabel} - className="config-input flex-2" + aria-label={`${localize('com_ui_key')} ${index + 1}`} + aria-invalid={invalid} + aria-describedby={invalid ? keyErrorId(index) : undefined} + className="config-input max-w-37.5 flex-1" /> - )} - {!disabled && availableTypes.length > 1 && ( -
    - -
    - )} - {!disabled && ( - handleRemove(index)} - ariaLabel={`${localize('com_ui_delete')} ${localize('com_ui_entry')} ${index + 1}`} - /> - )} + {vType === 'boolean' ? ( +
    + +
    + ) : ( + handleChange(index, 'value', v)} + placeholder={valuePlaceholder ?? localize('com_ui_value')} + disabled={disabled} + aria-label={valueLabel} + className="config-input flex-2" + /> + )} + {!disabled && availableTypes.length > 1 && ( +
    + +
    + )} + {!disabled && ( + handleRemove(index)} ariaLabel={deleteLabel(pair, index)} /> + )} +
    + {keyError(pair, index)}
    ); }; @@ -220,6 +245,8 @@ export function KeyValueField({ placeholder={keyPlaceholder ?? localize('com_ui_key')} disabled={disabled} aria-label={`${localize('com_ui_key')} ${index + 1}`} + aria-invalid={hasKeyError(pair)} + aria-describedby={hasKeyError(pair) ? keyErrorId(index) : undefined} className="config-input min-w-0 flex-1" /> {!disabled && availableTypes.length > 1 && ( @@ -238,12 +265,10 @@ export function KeyValueField({
    )} {!disabled && ( - handleRemove(index)} - ariaLabel={`${localize('com_ui_delete')} ${localize('com_ui_entry')} ${index + 1}`} - /> + handleRemove(index)} ariaLabel={deleteLabel(pair, index)} /> )}
    + {keyError(pair, index)} handleChange(index, 'value', v)} diff --git a/src/components/configuration/fields/ObjectEntryCard.tsx b/src/components/configuration/fields/ObjectEntryCard.tsx index 4e4a3121..b5abb06c 100644 --- a/src/components/configuration/fields/ObjectEntryCard.tsx +++ b/src/components/configuration/fields/ObjectEntryCard.tsx @@ -1,10 +1,11 @@ import { Icon } from '@clickhouse/click-ui'; import { useState, useCallback, useRef, useEffect } from 'react'; import type * as t from '@/types'; +import { BLOCK_UNSAFE_CONFIG_KEYS } from '@/constants'; import { TrashButton } from '@/components/shared'; +import { cn, isUnsafeConfigKey } from '@/utils'; import { CodeField } from './CodeField'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; export function ObjectEntryCard({ id, @@ -24,6 +25,7 @@ export function ObjectEntryCard({ const [hasEverExpanded, setHasEverExpanded] = useState(defaultExpanded); const [isEditing, setIsEditing] = useState(false); const [editKey, setEditKey] = useState(entryKey); + const [renameError, setRenameError] = useState(null); const [hasAddField, setHasAddField] = useState(false); const inputRef = useRef(null); const cardRef = useRef(null); @@ -75,14 +77,27 @@ export function ObjectEntryCard({ ); const commitRename = useCallback(() => { - setIsEditing(false); const trimmed = editKey.trim(); + if (BLOCK_UNSAFE_CONFIG_KEYS && trimmed !== entryKey && isUnsafeConfigKey(trimmed)) { + setRenameError(localize('com_config_unsafe_key_error')); + return; + } + setRenameError(null); + setIsEditing(false); if (trimmed && trimmed !== entryKey && onRename) { onRename(trimmed); } else { setEditKey(entryKey); } - }, [editKey, entryKey, onRename]); + }, [editKey, entryKey, onRename, localize]); + + const cancelRename = useCallback(() => { + setEditKey(entryKey); + setRenameError(null); + setIsEditing(false); + }, [entryKey]); + + const renameErrorId = `${(id ?? `entry-${entryKey}`).replace(/[^\w-]/g, '-')}-rename-error`; const summary = getSummary(value, { enabled: localize('com_ui_enabled'), @@ -108,6 +123,7 @@ export function ObjectEntryCard({ isEditing ? undefined : (e) => { + if (e.target !== e.currentTarget) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); @@ -126,22 +142,36 @@ export function ObjectEntryCard({ {isEditing && onRename ? ( - setEditKey(e.target.value)} - onBlur={commitRename} - onKeyDown={(e) => { - if (e.key === 'Enter') commitRename(); - if (e.key === 'Escape') { - setEditKey(entryKey); - setIsEditing(false); - } - }} - onClick={(e) => e.stopPropagation()} - className="config-input-ghost w-auto max-w-50 min-w-20 text-sm font-medium" - /> + + { + setEditKey(e.target.value); + setRenameError(null); + }} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') cancelRename(); + }} + onClick={(e) => e.stopPropagation()} + aria-label={localize('com_a11y_rename_entry', { name: entryKey })} + aria-invalid={renameError ? true : undefined} + aria-describedby={renameError ? renameErrorId : undefined} + className="config-input-ghost w-auto max-w-50 min-w-20 text-sm font-medium" + /> + {renameError && ( + + {renameError} + + )} + ) : ( <> { if (renamed === oldKey || renamed in record) return; + if (BLOCK_UNSAFE_CONFIG_KEYS && isUnsafeConfigKey(renamed)) return; const next: Record = {}; for (const [k, v] of Object.entries(record)) { next[k === oldKey ? renamed : k] = v; @@ -132,10 +135,15 @@ const AddKeyInput = memo(function AddKeyInput({ }) { const localize = useLocalize(); const [newKey, setNewKey] = useState(''); + const [error, setError] = useState(null); const handleAdd = () => { const trimmed = newKey.trim(); if (!trimmed || trimmed in existingKeys) return; + if (BLOCK_UNSAFE_CONFIG_KEYS && isUnsafeConfigKey(trimmed)) { + setError(localize('com_config_unsafe_key_error')); + return; + } // Blur before unmount so the browser doesn't move focus to the next // focusable element in DOM order while React removes this component. (document.activeElement as HTMLElement)?.blur(); @@ -143,31 +151,54 @@ const AddKeyInput = memo(function AddKeyInput({ }; return ( -
    - setNewKey(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleAdd(); - if (e.key === 'Escape') { +
    +
    + { + setNewKey(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleAdd(); + if (e.key === 'Escape') { + setNewKey(''); + onCancel(); + } + }} + placeholder={localize('com_ui_key')} + className="config-input max-w-50 px-2 py-1 text-sm" + autoFocus + /> +
    + {error && ( + + )}
    ); }); diff --git a/src/components/configuration/sections/EndpointsRenderer.tsx b/src/components/configuration/sections/EndpointsRenderer.tsx index d18c1806..41109cfd 100644 --- a/src/components/configuration/sections/EndpointsRenderer.tsx +++ b/src/components/configuration/sections/EndpointsRenderer.tsx @@ -24,6 +24,7 @@ import { useCollapsibleSection } from '../useCollapsibleSection'; import { ArrayObjectField } from '../fields/ArrayObjectField'; import { countConfigured, hasDescendant } from '../utils'; import { renderCollapsible } from '../renderCollapsible'; +import { UnknownSettings } from '../UnknownSettings'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -602,6 +603,7 @@ function ProviderSection({ ) : ( )} + ); } diff --git a/src/components/configuration/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx index 6bf966aa..4eb5c062 100644 --- a/src/components/configuration/sections/LangfuseRenderer.tsx +++ b/src/components/configuration/sections/LangfuseRenderer.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Select, TextField } from '@clickhouse/click-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type * as t from '@/types'; import type { LangfuseConnectionStatus } from '@/server'; +import type * as t from '@/types'; import { getLangfuseConnectionFn, LANGFUSE_CONNECTION_QUERY_KEY, @@ -423,12 +423,14 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr {isEditing ? ( <>