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({
/>
{roleDetail.isError && (
roleDetail.refetch()}
@@ -349,12 +350,14 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
)}
{
+ if (!isEditingScope || !baseActiveConfigValues || !configValues) return baseActiveConfigValues;
+ const withBase = { ...baseActiveConfigValues };
+ for (const section of READ_ONLY_CONFIG_SECTIONS.keys()) {
+ if (section in configValues) withBase[section] = configValues[section];
+ }
+ return withBase;
+ }, [isEditingScope, baseActiveConfigValues, configValues]);
+
const activeConfigValues = useMemo(() => {
- if (!baseActiveConfigValues) return baseActiveConfigValues;
+ if (!displayConfigValues) return displayConfigValues;
const indexedEdits = Object.entries(editedValues).filter(([k]) => /\.\d+$/.test(k));
- if (indexedEdits.length === 0) return baseActiveConfigValues;
- return mergeIndexedArrayEdits(baseActiveConfigValues, indexedEdits);
- }, [baseActiveConfigValues, editedValues]);
+ if (indexedEdits.length === 0) return displayConfigValues;
+ return mergeIndexedArrayEdits(displayConfigValues, indexedEdits);
+ }, [displayConfigValues, editedValues]);
const scopeConfiguredPaths = useMemo(() => {
if (!scopeChangedPaths) return new Set();
@@ -411,8 +439,13 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
return set;
}, [baseActiveConfigValues]);
+ const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
const handleFieldChange = useCallback(
(path: string, value: t.ConfigValue) => {
+ setSaveError(null);
setTouchedPaths((prev) => {
if (prev.has(path)) return prev;
const next = new Set(prev);
@@ -465,22 +498,26 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
return resets;
}, [editedValues]);
- useBlocker({
- shouldBlockFn: ({ current, next }) => {
+ const shouldBlockNavigation = useCallback(
+ ({ current, next }: { current: { pathname: string }; next: { pathname: string } }) => {
if (!isDirty) return false;
if (current.pathname === next.pathname) return false;
return !window.confirm(localize('com_config_unsaved_leave'));
},
- });
+ [isDirty, localize],
+ );
+ const shouldPromptBeforeUnload = useCallback(() => isDirty, [isDirty]);
- const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
- const [saving, setSaving] = useState(false);
- const [saveError, setSaveError] = useState(null);
+ useBlocker({
+ shouldBlockFn: shouldBlockNavigation,
+ enableBeforeUnload: shouldPromptBeforeUnload,
+ });
const handleDiscard = useCallback(() => {
setEditedValues({});
setTouchedPaths(new Set());
setEditSessionId((id) => id + 1);
+ setSaveError(null);
}, []);
const clearEdits = useCallback(() => {
@@ -490,26 +527,102 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
setConfirmSaveOpen(false);
setSaving(false);
setSaveError(null);
- notifySuccess(localize('com_config_saved'));
- }, [localize]);
+ }, []);
- const invalidateAndResetBase = useCallback(() => {
- queryClient.invalidateQueries({ queryKey: ['baseConfig'] });
- clearEdits();
- }, [queryClient, clearEdits]);
+ /** Toast for a finished write: a warning names anything the backend or panel did not store. */
+ const announceWrite = useCallback(
+ (skipped: t.SkippedChange[], successMessage: string) => {
+ if (skipped.length === 0) {
+ notifySuccess(successMessage);
+ return;
+ }
+ const paths = skipped
+ .map((s) => `${s.fieldPath} (${localize(SKIP_REASON_KEYS[s.reason])})`)
+ .join(', ');
+ notifyWarning(localize('com_config_saved_with_skipped', { count: skipped.length, paths }));
+ },
+ [localize],
+ );
- const invalidateAndResetScope = useCallback(() => {
- queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] });
- queryClient.invalidateQueries({ queryKey: ['profileMap'] });
- queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
- clearEdits();
- }, [queryClient, clearEdits]);
-
- const importMutation = useMutation({
- mutationFn: (config: Record) => importBaseConfigFn({ data: { config } }),
- onError: (err: Error) => notifyError(err.message),
- onSuccess: invalidateAndResetBase,
- });
+ const invalidateTarget = useCallback(
+ (target: t.ImportTarget) => {
+ if (target.type === 'base') {
+ return queryClient.invalidateQueries({ queryKey: ['baseConfig'] });
+ }
+ return Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }),
+ queryClient.invalidateQueries({ queryKey: ['profileMap'] }),
+ queryClient.invalidateQueries({ queryKey: ['availableScopes'] }),
+ ]);
+ },
+ [queryClient],
+ );
+
+ /**
+ * The one write path for saves and imports. Every entry is validated before
+ * anything is written, so a rejected batch never leaves resets half-applied;
+ * queries are refetched afterwards whether or not the write succeeded, so
+ * the page always shows what was actually persisted.
+ */
+ const writeChanges = useCallback(
+ async (
+ target: t.ImportTarget,
+ saves: t.SaveEntry[],
+ resets: string[],
+ inheritedMcpKeys: Set = new Set(),
+ ): Promise => {
+ try {
+ const check = await validateConfigChangesFn({
+ data: { target: target.type, saves, resets },
+ });
+ if (check.errors.length > 0) {
+ throw new Error(
+ `${localize('com_config_validation_failed')} — ${check.errors.map((e) => e.error).join('; ')}`,
+ );
+ }
+ const skippedPaths = new Set(check.skipped.map((s) => s.fieldPath));
+ const writableResets = resets.filter((p) => !skippedPaths.has(p));
+ const writableSaves = saves.filter((e) => !skippedPaths.has(e.fieldPath));
+
+ /** Resets land before saves so a delete-then-recreate at the same path (e.g. MCP entry replaced with different fields) wipes stale fields first and the new leaf PATCHes don't race against the DELETE. */
+ if (target.type === 'scope') {
+ const { principalType, principalId } = target.scope;
+ const { resetPaths, tombstonePaths } = partitionScopeResetPaths(
+ writableResets,
+ inheritedMcpKeys,
+ );
+ await Promise.all([
+ ...resetPaths.map((fieldPath) =>
+ removeFieldProfileValueFn({ data: { fieldPath, principalType, principalId } }),
+ ),
+ ...tombstonePaths.map((fieldPath) =>
+ tombstoneFieldProfileValueFn({ data: { fieldPath, principalType, principalId } }),
+ ),
+ ]);
+ } else {
+ await Promise.all(
+ writableResets.map((fieldPath) => resetBaseConfigFieldFn({ data: { fieldPath } })),
+ );
+ }
+
+ if (writableSaves.length === 0) return check.skipped;
+ const result =
+ target.type === 'scope'
+ ? await bulkSaveProfileValuesFn({
+ data: {
+ principalType: target.scope.principalType,
+ principalId: target.scope.principalId,
+ entries: writableSaves,
+ },
+ })
+ : await saveBaseConfigFn({ data: { entries: writableSaves } });
+ return [...check.skipped, ...result.skipped];
+ } finally {
+ await invalidateTarget(target);
+ }
+ },
+ [localize, invalidateTarget],
+ );
const [resetBaseOpen, setResetBaseOpen] = useState(false);
const [resettingBase, setResettingBase] = useState(false);
@@ -544,6 +657,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
}, [resettingBase, queryClient, localize]);
const handleResetField = useCallback((fieldPath: string) => {
+ setSaveError(null);
startTransition(() => {
setTouchedPaths((prev) => {
if (prev.has(fieldPath)) return prev;
@@ -555,10 +669,49 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
});
}, []);
+ const overridePaths = isEditingScope ? scopeConfiguredPaths : dbOverridePaths;
+
+ const saveContext = useMemo(
+ () => ({
+ fields: schemaTree,
+ baselineAt: (path) => getValueAtPath(baseActiveConfigValues, path),
+ hasOverride: (path) => hasPathOverlap(path, overridePaths),
+ }),
+ [schemaTree, baseActiveConfigValues, overridePaths],
+ );
+
+ /** What Save would actually send: normalized, unchanged edits dropped, read-only paths skipped. */
+ const savePlan = useMemo(
+ () => buildSavePayload(touchedPaths, editedValues, schemaPathSet, saveContext),
+ [touchedPaths, editedValues, schemaPathSet, saveContext],
+ );
+
+ const unsafeKeys = useMemo(
+ () =>
+ BLOCK_UNSAFE_CONFIG_KEYS
+ ? savePlan.saves.flatMap((entry) => findUnsafeKeys(entry.value, entry.fieldPath))
+ : [],
+ [savePlan],
+ );
+
+ const editTarget = useMemo(
+ () =>
+ isEditingScope && editingScope ? { type: 'scope', scope: editingScope } : { type: 'base' },
+ [isEditingScope, editingScope],
+ );
+
const handleConfirmSave = useCallback(async () => {
- if (saving) return;
- const { touched, saves, resets } = buildSavePayload(touchedPaths, editedValues, schemaPathSet);
- if (touched.length === 0) return;
+ if (saving || unsafeKeys.length > 0) return;
+ const { touched, saves, resets, skipped } = savePlan;
+ if (saves.length === 0 && resets.length === 0) {
+ clearEdits();
+ if (skipped.length > 0) {
+ announceWrite(skipped, localize('com_config_saved'));
+ } else {
+ notifyInfo(localize('com_config_nothing_to_save'));
+ }
+ return;
+ }
/** Per-leaf saves can land an MCP entry in a transport state whose required siblings are missing (e.g. type=stdio with no command/args). Server-side per-field validation only sees one path at a time, so do the cross-field check here against the merged effective entry before any PATCH fires. Use baseActiveConfigValues so scope-mode edits validate against the scope-resolved baseline (where prior scope overrides supply some required fields) instead of the base config alone. */
const mcpBaseline = (() => {
@@ -568,9 +721,11 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
}
return {};
})();
+ const savedValues = new Map(saves.map((e) => [e.fieldPath, e.value]));
+ const resetSet = new Set(resets);
const mcpEdits: Array<[string, t.ConfigValue]> = touched
- .filter((p) => p.startsWith('mcpServers.'))
- .map((p) => [p, editedValues[p]] as [string, t.ConfigValue]);
+ .filter((p) => p.startsWith('mcpServers.') && (savedValues.has(p) || resetSet.has(p)))
+ .map((p) => [p, savedValues.get(p)]);
/** A leaf reset (undefined write) removes the override and reveals the value of the next-lower layer. In scope mode that next layer is the base config; in base mode it is the un-merged YAML config (the baseOnly response). Feed whichever layer applies as the resetFallback so the cross-field validator does not falsely flag a reset-but-still-valid field as missing. */
const mcpResetFallback = (() => {
const source = isEditingScope ? configValues?.mcpServers : baseConfigData?.yamlMcpServers;
@@ -605,60 +760,9 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
setSaveError(null);
try {
- /** Resets must land before saves so a delete-then-recreate at the same path (e.g. MCP entry replaced with different fields) wipes stale fields first and the new leaf PATCHes don't race against the DELETE. */
- if (resets.length > 0) {
- const resetPromises = isEditingScope
- ? (() => {
- const { resetPaths, tombstonePaths } = partitionScopeResetPaths(
- resets,
- inheritedMcpKeys,
- );
- return [
- ...resetPaths.map((fieldPath) =>
- removeFieldProfileValueFn({
- data: {
- fieldPath,
- principalType: editingScope!.principalType,
- principalId: editingScope!.principalId,
- },
- }),
- ),
- ...tombstonePaths.map((fieldPath) =>
- tombstoneFieldProfileValueFn({
- data: {
- fieldPath,
- principalType: editingScope!.principalType,
- principalId: editingScope!.principalId,
- },
- }),
- ),
- ];
- })()
- : resets.map((fieldPath) => resetBaseConfigFieldFn({ data: { fieldPath } }));
- if (resetPromises.length > 0) {
- await Promise.all(resetPromises);
- }
- }
-
- if (saves.length > 0) {
- if (isEditingScope) {
- await bulkSaveProfileValuesFn({
- data: {
- principalType: editingScope!.principalType,
- principalId: editingScope!.principalId,
- entries: saves,
- },
- });
- } else {
- await saveBaseConfigFn({ data: { entries: saves } });
- }
- }
-
- if (isEditingScope) {
- invalidateAndResetScope();
- } else {
- invalidateAndResetBase();
- }
+ const writeSkipped = await writeChanges(editTarget, saves, resets, inheritedMcpKeys);
+ clearEdits();
+ announceWrite([...skipped, ...writeSkipped], localize('com_config_saved'));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setSaving(false);
@@ -666,52 +770,39 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
notifyError(message);
}
}, [
- touchedPaths,
- editedValues,
- schemaPathSet,
saving,
- isEditingScope,
+ unsafeKeys,
+ savePlan,
+ clearEdits,
+ announceWrite,
baseActiveConfigValues,
+ isEditingScope,
configValues,
baseConfigData,
localize,
- editingScope,
- invalidateAndResetScope,
- invalidateAndResetBase,
+ writeChanges,
+ editTarget,
]);
- const serializedEditedValues = useMemo(() => {
+ /** The review lists exactly what Save sends: a value for each save, "removed" for each reset. */
+ const reviewValues = useMemo(() => {
const result: t.FlatConfigMap = {};
- for (const [k, v] of Object.entries(editedValues)) {
- result[k] = stripSecretPreviewValues(deepSerializeKVPairs(v), k, schemaPathSet);
- }
+ for (const { fieldPath, value } of savePlan.saves) result[fieldPath] = value;
+ for (const fieldPath of savePlan.resets) result[fieldPath] = undefined;
return result;
- }, [editedValues, schemaPathSet]);
+ }, [savePlan]);
+ /** BEFORE values come from the target being edited (the open profile's own values in scope mode). */
const originalValuesForDialog = useMemo(() => {
- const baseline = isEditingScope ? scopeBaseline : flatBaseline;
- const result: t.FlatConfigMap = { ...baseline };
- for (const path of Object.keys(editedValues)) {
- if (path in result) {
- result[path] = stripSecretPreviewValues(result[path], path, schemaPathSet);
- continue;
- }
- const segments = path.split('.');
- let current: t.ConfigValue = configValues;
- for (const seg of segments) {
- if (current == null || typeof current !== 'object') {
- current = undefined;
- break;
- }
- current = Array.isArray(current)
- ? (current as t.ConfigValue[])[Number(seg)]
- : (current as Record)[seg];
+ const result: t.FlatConfigMap = {};
+ for (const path of Object.keys(reviewValues)) {
+ const before = getValueAtPath(baseActiveConfigValues, path);
+ if (before !== undefined) {
+ result[path] = stripSecretPreviewValues(before, path, schemaPathSet);
}
- if (current !== undefined)
- result[path] = stripSecretPreviewValues(current, path, schemaPathSet);
}
return result;
- }, [editedValues, flatBaseline, isEditingScope, scopeBaseline, configValues, schemaPathSet]);
+ }, [reviewValues, baseActiveConfigValues, schemaPathSet]);
const [importSuccessMessage, setImportSuccessMessage] = useState(null);
@@ -722,64 +813,28 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
dismissTimer.current = setTimeout(() => setImportSuccess(false), 4000);
}, []);
- const handleImportAsProfile = useCallback(
- async (appConfig: Record, scope: t.ConfigScope) => {
- const normalized = normalizeImportConfig(appConfig);
- const flat = flattenObject(normalized);
- const entries = Object.entries(flat)
- .filter(
- ([fieldPath, value]) =>
- value != null && secretPathForPreviewPath(fieldPath, schemaPathSet) == null,
- )
- .map(([fieldPath, value]) => ({
- fieldPath,
- value: stripSecretPreviewValues(value, fieldPath, schemaPathSet),
- }));
- await bulkSaveProfileValuesFn({
- data: {
- principalType: scope.principalType,
- principalId: scope.principalId,
- entries,
- },
- });
- queryClient.invalidateQueries({ queryKey: ['profileMap'] });
- queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] });
- queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
- queryClient.invalidateQueries({ queryKey: ['roles'] });
- queryClient.invalidateQueries({ queryKey: ['groups'] });
- showImportSuccess(
- localize('com_config_import_profile_success', {
- count: entries.length,
- name: scope.name,
- }),
- );
- },
- [queryClient, localize, showImportSuccess, schemaPathSet],
- );
-
+ /** Imports merge into the chosen target through the same validated write path as Save: only the keys present in the YAML are written, and nothing else is replaced. */
const handleImport = useCallback(
- (appConfig: Record) => {
- const normalized = normalizeImportConfig(appConfig);
- if (isEditingScope && editingScope) {
- handleImportAsProfile(normalized, editingScope).catch((err: Error) => {
- notifyError(err.message);
- });
- } else {
- const stripped = stripSecretPreviewValues(normalized, '', schemaPathSet) as Record<
- string,
- t.ConfigValue
- >;
- importMutation.mutate(stripped, { onSuccess: () => showImportSuccess() });
+ async (appConfig: Record, target: t.ImportTarget) => {
+ const entries = collectImportEntries(appConfig, schemaPathSet);
+ if (entries.length === 0) throw new Error(localize('com_config_import_nothing'));
+ const skipped = await writeChanges(target, entries, []);
+ if (target.type === 'scope') {
+ queryClient.invalidateQueries({ queryKey: ['roles'] });
+ queryClient.invalidateQueries({ queryKey: ['groups'] });
}
+ const written = entries.length - skipped.length;
+ const message =
+ target.type === 'scope'
+ ? localize('com_config_import_profile_success', {
+ count: written,
+ name: target.scope.name,
+ })
+ : localize('com_config_import_base_success', { count: written });
+ showImportSuccess(message);
+ if (skipped.length > 0) announceWrite(skipped, message);
},
- [
- isEditingScope,
- editingScope,
- importMutation,
- showImportSuccess,
- handleImportAsProfile,
- schemaPathSet,
- ],
+ [schemaPathSet, localize, writeChanges, queryClient, showImportSuccess, announceWrite],
);
const highlightRef = useHighlightRef(highlightField);
@@ -823,15 +878,26 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
const children = section.children ?? [];
const hasStructuredChildren =
(section.isObject || section.type === 'record') && children.length > 0;
+ const readOnlyReason = READ_ONLY_CONFIG_SECTIONS.get(section.key);
return {
id: section.key,
titleKey: meta?.titleKey ?? `com_config_section_${section.key}`,
+ title: meta ? undefined : splitCamelCase(section.key).join(' '),
+ isRecord: section.type === 'record',
descriptionKey: meta?.descriptionKey,
fields: hasStructuredChildren ? children : [],
...(!hasStructuredChildren && { sectionField: section }),
...(section.key === 'interface' && {
bannerText: localize('com_config_interface_permissions_info'),
}),
+ ...(readOnlyReason && {
+ readOnlyReason,
+ bannerText: localize(
+ readOnlyReason === 'baseOnly'
+ ? 'com_config_base_only_section'
+ : 'com_config_yaml_only_section',
+ ),
+ }),
};
});
@@ -930,6 +996,16 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
return allSections;
}, [schemaTree, activeTab, activeConfigValues, localize, sectionPermissions]);
+ /** Top-level settings the bundled schema doesn't know (newer LibreChat), listed read-only on System. */
+ const topLevelUnknown = useMemo(() => {
+ if (activeTab !== 'system' || !activeConfigValues) return undefined;
+ const value: Record = {};
+ for (const [key, entry] of Object.entries(activeConfigValues)) {
+ if (!APP_SERVICE_INTERNAL_KEYS.has(key)) value[key] = entry;
+ }
+ return { fields: schemaTree, value };
+ }, [activeTab, activeConfigValues, schemaTree]);
+
const renderBanner = () => {
if (importSuccess) {
return (
@@ -1027,6 +1103,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
baseRecordKeys={baseRecordKeys}
onValidationError={(message) => notifyError(message)}
editSessionId={editSessionId}
+ unknownSettings={topLevelUnknown}
/>
@@ -1046,18 +1123,31 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
discardLabel={localize('com_config_discard')}
saveLabel={localize('com_config_save')}
onDiscard={handleDiscard}
- onSave={() => setConfirmSaveOpen(true)}
+ onSave={() => {
+ setSaveError(null);
+ setConfirmSaveOpen(true);
+ }}
/>
)}
setConfirmSaveOpen(false)}
+ onCancel={() => {
+ setConfirmSaveOpen(false);
+ setSaveError(null);
+ }}
/>
setImportOpen(false)}
+ currentScope={editingScope}
onImport={handleImport}
- onImportAsProfile={handleImportAsProfile}
/>
) {
+ if (event.key === 'Enter' && event.target instanceof HTMLInputElement) event.preventDefault();
+}
+
function isSimpleScalar(f: t.SchemaField): boolean {
return !f.children?.length && !f.isArray && f.type !== 'record' && f.type !== 'object';
}
@@ -59,20 +66,13 @@ export function ConfigTabContent({
baseRecordKeys,
onValidationError,
editSessionId,
+ unknownSettings,
}: t.ConfigTabContentProps) {
const localize = useLocalize();
const fieldsDisabled = readOnly;
- const getValue = (path: string, fallback: t.ConfigValue): t.ConfigValue => {
- if (path in editedValues) {
- if (editedValues[path] === undefined) return schemaDefaults?.[path] ?? fallback;
- return editedValues[path];
- }
- if (resolvedValues && path in resolvedValues) {
- return resolvedValues[path];
- }
- return fallback;
- };
+ const getValue = (path: string, fallback: t.ConfigValue): t.ConfigValue =>
+ resolveFieldValue(path, fallback, editedValues, resolvedValues, schemaDefaults);
const filtering = showChangedOnly;
@@ -159,7 +159,9 @@ export function ConfigTabContent({
const renderSectionContent = (section: t.ConfigSectionConfig) => {
const dataKey = section.schemaKey ?? section.id;
const sectionValue = configValues?.[dataKey];
- const sectionDisabled = isSectionDisabled(!!fieldsDisabled, sectionPermissions, dataKey);
+ const sectionDisabled =
+ section.readOnlyReason != null ||
+ isSectionDisabled(!!fieldsDisabled, sectionPermissions, dataKey);
const CustomSectionRenderer =
section.fields.length > 0 ? SECTION_RENDERERS[section.id] : undefined;
const fieldRendererProps: t.FieldRendererProps = {
@@ -195,7 +197,7 @@ export function ConfigTabContent({
<>
{section.bannerText && (
-
+
)}
{section.fields.length > 0 &&
@@ -204,6 +206,9 @@ export function ConfigTabContent({
) : (
))}
+ {section.fields.length > 0 && !CustomSectionRenderer && !section.isRecord && (
+
+ )}
{section.fields.length === 0 && section.sectionField && (
e.preventDefault()}
+ onKeyDown={preventImplicitSubmit}
className="flex flex-col gap-6 py-4"
>
+
{groups.map((group) => {
if (group.kind === 'flat') {
return (
@@ -290,7 +297,7 @@ export function ConfigTabContent({
{renderSectionContent(section)}
@@ -326,6 +333,9 @@ export function ConfigTabContent({
);
})}
+ {unknownSettings && (
+
+ )}
);
}
diff --git a/src/components/configuration/ConfigTableOfContents.tsx b/src/components/configuration/ConfigTableOfContents.tsx
index 23813366..d49eb854 100644
--- a/src/components/configuration/ConfigTableOfContents.tsx
+++ b/src/components/configuration/ConfigTableOfContents.tsx
@@ -184,7 +184,7 @@ export const ConfigTableOfContents = memo(function ConfigTableOfContents({
onClick={() => handleClick(sectionDomId)}
className="toc-item toc-item-parent block w-full cursor-pointer truncate border-none bg-transparent py-1.5 pr-1 pl-3 text-left text-[13px] font-medium text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)"
>
- {localize(section.titleKey)}
+ {section.title ?? localize(section.titleKey)}
{tocItems && tocItems.length > 0 && (